diff --git a/docs/NEXT_PHASES.md b/docs/NEXT_PHASES.md new file mode 100644 index 00000000..e102a0b0 --- /dev/null +++ b/docs/NEXT_PHASES.md @@ -0,0 +1,29 @@ +# CAD/BIM Library Next Phases + +## Phase 1: Cleanup and classification + +Create a safe cleaned library outside the source repository. Copy only selected BIM-relevant assets, normalize folder names, classify file types, and generate first-pass `template.json` files plus metadata reports. + +## Phase 2: Parameter schema system + +Enrich cleaned `template.json` metadata with reusable parameter definitions, element-type schemas, validation rules, schema readiness flags, and floating-toolbar group hints. This phase prepares data for a future UI but does not edit geometry. + +## Phase 3: Geometry generator system + +Create backend placeholder generators that turn enriched template parameters into neutral mesh geometry, OBJ previews, STL previews, and generator manifests. This proves the parameter-to-geometry contract before building UI or real-time editing. + +## Phase 4: Floating parameter toolbar UI + +Create the floating parameter toolbar after stable schemas and generator interfaces exist. The toolbar should read Phase 2 toolbar groups, edit schema-backed parameters, and call Phase 3 generator APIs rather than directly mutating static reference meshes. + +## Phase 5: Real-time regeneration and viewport update + +Connect toolbar edits to real-time geometry regeneration, validation, preview updates, and viewport refresh. This phase should include performance testing and fallback behavior for assets that remain static references. + +## Phase 6: Save/export parametric project data + +Persist schema-backed parameters, generator references, user edits, and export mappings so projects can be reopened, versioned, and exported without losing parametric intent. + +## Phase 7: Advanced CAD kernel / FreeCAD / OpenCASCADE integration + +Replace or augment placeholder mesh generators with robust CAD-kernel-backed generation for production solids, booleans, constraints, IFC/BIM semantics, and high-quality import/export workflows. diff --git a/docs/PHASE_1_LIBRARY_CLEANUP.md b/docs/PHASE_1_LIBRARY_CLEANUP.md new file mode 100644 index 00000000..bb03a53b --- /dev/null +++ b/docs/PHASE_1_LIBRARY_CLEANUP.md @@ -0,0 +1,108 @@ +# Phase 1 CAD/BIM Library Cleanup + +Phase 1 creates a safe, curated CAD/BIM asset library from the broader FreeCAD-library repository. The goal is cleanup and classification only: no CAD editor, floating toolbar, parametric engine, or real-time 3D editing is built in this phase. + +## Why this cleanup exists + +The upstream library contains many useful BIM-adjacent objects, but it also includes electronics, robotics, sports, mechanical, PCB, and general-purpose models. A CAD/BIM product needs a smaller, predictable structure organized around building elements, MEP parts, site assets, and metadata that later systems can read. + +The cleanup script copies selected BIM-relevant files into a separate output folder and generates metadata beside those copied files. It intentionally does **not** modify the source library. + +## Safety model + +`tools/cleanup_phase1.py` is non-destructive: + +- it does not delete original files; +- it does not move original files; +- it does not overwrite source files; +- it refuses to place the cleaned output inside the source repository; +- it copies only files that match Phase 1 BIM category and file-type rules. + +Generated cleaned output should live outside the repository, for example `/tmp/cad_bim_library_cleaned` or `../cad_bim_library_cleaned`. + +## Dry-run command + +Use dry-run first to inspect what would be copied without creating output files: + +```bash +python tools/cleanup_phase1.py --source . --output ../cad_bim_library_cleaned --dry-run +``` + +A dry-run prints totals for files scanned, BIM-relevant files that would be copied, asset folders that would be created, backup files detected, and PCB/Gerber files detected. + +## Actual cleanup command + +After dry-run succeeds, run the actual copy: + +```bash +python tools/cleanup_phase1.py --source . --output ../cad_bim_library_cleaned +``` + +To create an archive placeholder for future non-BIM asset workflows, add: + +```bash +python tools/cleanup_phase1.py --source . --output ../cad_bim_library_cleaned --include-archive +``` + +To focus on one cleaned category while testing, add a category such as: + +```bash +python tools/cleanup_phase1.py --source . --output ../cad_bim_library_cleaned --limit-category architecture.doors +``` + +## Cleaned folder meanings + +The cleaned output has these top-level areas: + +- `00_metadata/` contains reports, file rules, and `library_index.json`. +- `architecture/` contains doors, windows, beams, roof assets, construction blocks, bathroom/kitchen fixtures, furniture, foundations, and architecture-related symbols. +- `mep/` contains HVAC ducts, HVAC pipes, plumbing pipes, hydro equipment, and electrical equipment relevant to building systems. +- `site/` contains topography and vegetation/site assets. +- `archive_non_bim/` is created only when `--include-archive` is passed. Phase 1 reports non-BIM candidates but does not blindly copy the entire archive. + +Asset folders use normalized `snake_case` names so downstream software can depend on stable paths. For example, a source group like: + +```text +Architectural Parts/Doors/Wood/Single door with trims.FCStd +Architectural Parts/Doors/Wood/Single door with trims.step +Architectural Parts/Doors/Wood/Single door with trims.stl +``` + +becomes: + +```text +architecture/doors/wood/single_door_with_trims/ + source.FCStd + reference.step + preview.stl + template.json +``` + +## Static reference geometry + +STEP, STL, BREP, image, and documentation files are useful as geometry references, previews, or documentation, but they are not treated as live parametric generators in Phase 1. STEP files can preserve exchange geometry, and STL files can provide preview meshes, but neither guarantees editable CAD/BIM parameters such as width, height, swing direction, glazing type, or duct bend angle. + +## FreeCAD source files + +`.FCStd` and `.fcstd` files are treated as possible parametric sources because they may contain FreeCAD-native model data. They are still marked as `static_reference` in Phase 1 because the cleanup script does not inspect constraints, object history, sketches, or generator logic. `parametricSourceAvailable` means a native source exists, not that real-time editing is already implemented. + +## Generated metadata + +Each cleaned asset folder receives a `template.json` with: + +- an asset ID; +- element type; +- cleaned category; +- display name; +- original source path; +- copied source file names; +- file type groups; +- editability status; +- placeholder parameters; +- notes explaining that generator work is still required. + +`00_metadata/library_index.json` lists every cleaned asset for product ingestion. Reports in `00_metadata/` summarize cleanup results, missing templates, excluded non-BIM candidates, and file rules. + +## Preparing for a later floating parameter toolbar + +Phase 1 provides consistent asset IDs, categories, templates, and placeholder parameter names. That structure lets Phase 2 refine parameter schemas before Phase 3 creates geometry generators. Only after generator logic exists should a floating parameter toolbar attempt real-time model regeneration. diff --git a/docs/PHASE_2_PARAMETER_SCHEMA_SYSTEM.md b/docs/PHASE_2_PARAMETER_SCHEMA_SYSTEM.md new file mode 100644 index 00000000..317a21d2 --- /dev/null +++ b/docs/PHASE_2_PARAMETER_SCHEMA_SYSTEM.md @@ -0,0 +1,98 @@ +# Phase 2 Parameter Schema System + +Phase 2 makes the cleaned CAD/BIM library metadata more structured and ready for future parameter-driven editing. It works on the cleaned output from Phase 1, not on the original FreeCAD-library source assets. + +This phase still does **not** build a CAD editor, floating toolbar UI, real-time modelling engine, or geometry generator. + +## How Phase 2 connects to Phase 1 + +Phase 1 copies selected BIM-relevant assets into a separate cleaned folder and creates one `template.json` for each cleaned asset. Those first templates contain useful starting metadata, but their parameters are intentionally broad placeholders. + +Phase 2 reads those `template.json` files and enriches them with consistent parameter definitions, validation metadata, toolbar group hints, schema status, and reports. The original `.FCStd`, `.step`, `.stl`, `.brep`, image, and documentation files are not edited. + +## What the Phase 2 script does + +`tools/parameter_schema_phase2.py` scans a cleaned library folder for asset-level `template.json` files. For each template it: + +- loads the existing template metadata; +- infers a standard BIM element type when needed; +- compares parameters against `element_type_schemas.json`; +- adds missing required parameters; +- enriches parameters from `parameter_catalog.json`; +- adds validation metadata; +- adds floating-toolbar grouping metadata for a future UI; +- normalizes `editability.status` to an allowed value; +- preserves existing data wherever possible; +- writes an updated template only when `--write` is passed. + +## Dry-run command + +Run dry-run first to validate and preview enrichment without updating templates: + +```bash +python tools/parameter_schema_phase2.py --library-root /tmp/cad_bim_library_cleaned --dry-run +``` + +Dry-run writes Phase 2 reports by default to: + +```text +/tmp/cad_bim_library_cleaned/00_metadata/phase2_schema_reports/ +``` + +## Apply schema enrichment + +After reviewing dry-run output, apply enrichment with: + +```bash +python tools/parameter_schema_phase2.py --library-root /tmp/cad_bim_library_cleaned --write --backup +``` + +`--backup` creates `.bak` copies of `template.json` files before writing updated metadata. This backup affects only cleaned metadata files in the Phase 1 output folder, not the original FreeCAD source assets. + +To process one category while testing, use: + +```bash +python tools/parameter_schema_phase2.py --library-root /tmp/cad_bim_library_cleaned --dry-run --limit-category architecture.doors +``` + +## Schema configuration files + +The schema configuration lives in `schemas/parameter_schema/`. + +### `parameter_catalog.json` + +`parameter_catalog.json` is the master dictionary of reusable parameters. Each parameter includes its name, label, description, type, unit, default value, min/max rules, options, UI-control hint, toolbar group, and validation metadata. + +Examples include `width`, `height`, `thickness`, `swingDirection`, `openingType`, `material`, `fireRating`, `flowDirection`, `ductShape`, `manufacturer`, and `sourceUrl`. + +### `element_type_schemas.json` + +`element_type_schemas.json` maps BIM element types to required parameters, optional parameters, allowed file types, and toolbar groups. For example, `Door` requires `width`, `height`, and `thickness`, while `Pipe` requires `diameter` and `length`. + +### `validation_rules.json` + +`validation_rules.json` documents the general rules for valid templates: required top-level fields, known parameter names, required parameters by element type, naming conventions, category dot notation, and allowed editability statuses. + +## Phase 2 reports + +Reports are written under `00_metadata/phase2_schema_reports/` unless `--report-dir` is supplied: + +- `phase2_schema_report.md` summarizes scanned templates, valid templates, enriched templates, missing required parameters, unsupported assets, custom parameters, processed categories, limitations, and the next recommended step. +- `phase2_validation_report.csv` lists validation issues or success rows per asset. +- `phase2_parameter_coverage.csv` summarizes parameter coverage by element type. +- `phase2_toolbar_groups.json` aggregates toolbar group definitions by element type. +- `phase2_unsupported_assets.csv` lists assets that could not be mapped to a useful schema. + +## How a future floating toolbar will use this schema + +The enriched templates include a `toolbar` block with `floating_parameter_panel` mode and grouped parameter names. A later UI can read these groups to decide which controls to show for dimensions, operation, materials, MEP settings, classification, or metadata. + +However, Phase 2 only prepares metadata for that UI. It does not create the UI itself. + +## Why STEP/STL files are still not truly editable + +STEP and STL files can describe reference geometry or preview meshes, but they do not automatically expose product parameters such as door swing direction, pipe schedule, duct shape, or window glazing type. Phase 2 can add clean parameter metadata beside those files, but it cannot make a static mesh or exchange file regenerate from parameter changes. + +## Why Phase 3 geometry generators are still required + +A future editor needs generator logic that consumes parameters and creates or updates real geometry. Phase 3 should define and implement those generators. Only after generator APIs exist should Phase 4 build the floating toolbar UI and Phase 5 connect toolbar edits to real-time viewport regeneration. diff --git a/docs/PHASE_3_GEOMETRY_GENERATOR_SYSTEM.md b/docs/PHASE_3_GEOMETRY_GENERATOR_SYSTEM.md new file mode 100644 index 00000000..57aa9d3d --- /dev/null +++ b/docs/PHASE_3_GEOMETRY_GENERATOR_SYSTEM.md @@ -0,0 +1,92 @@ +# Phase 3 Geometry Generator System + +Phase 3 creates a backend generator framework that turns enriched `template.json` metadata into simple parametric placeholder geometry. It proves this workflow: + +```text +template.json parameters -> generator -> geometry output +``` + +This phase is not the final CAD kernel, not a floating toolbar UI, and not real-time viewport editing. + +## How Phase 3 depends on Phase 1 and Phase 2 + +Phase 1 creates a cleaned BIM-oriented library outside the original FreeCAD-library repository. Phase 2 enriches each cleaned asset's `template.json` with consistent parameters and toolbar-ready metadata. Phase 3 reads those enriched templates and generates simple geometry from their parameters. + +The original FreeCAD source assets are not modified. Phase 3 does not edit `.FCStd`, `.step`, `.stl`, `.brep`, images, or documentation files. + +## Why generated geometry is placeholder geometry + +The goal is not to perfectly recreate the original FreeCAD assets. Instead, each element type gets a simple standard-library mesh: + +- doors become a panel and frame; +- windows become a frame and glass panel; +- beams become a box or simple I-beam approximation; +- roofs become a sloped slab; +- construction blocks become rectangular blocks; +- pipes and ducts become cylinder-like or rectangular placeholder meshes; +- furniture, fixtures, foundations, vegetation, and generic BIM objects become simple representative forms. + +This creates a safe backend contract for future UI work while keeping expectations clear: these meshes are not production CAD geometry. + +## Dry-run command + +Use dry-run to scan templates and write reports without writing geometry files: + +```bash +python tools/geometry_generator_phase3.py --library-root /tmp/cad_bim_library_cleaned --output /tmp/cad_bim_generated_geometry --dry-run +``` + +## Generate geometry + +Generate all supported output formats with: + +```bash +python tools/geometry_generator_phase3.py --library-root /tmp/cad_bim_library_cleaned --output /tmp/cad_bim_generated_geometry --write +``` + +Generate only one element type: + +```bash +python tools/geometry_generator_phase3.py --library-root /tmp/cad_bim_library_cleaned --output /tmp/cad_bim_generated_geometry --write --limit-element-type Door +``` + +Generate only one asset: + +```bash +python tools/geometry_generator_phase3.py --library-root /tmp/cad_bim_library_cleaned --output /tmp/cad_bim_generated_geometry --write --asset-id door_wood_single_door_with_trims +``` + +Use `--format json`, `--format obj`, `--format stl`, or `--format all` to choose outputs. + +## Output files + +Each generated asset folder can contain: + +- `geometry.json`: neutral mesh representation with asset id, element type, category, units, parameters used, vertices, faces, and generator metadata. +- `preview.obj`: Wavefront OBJ preview mesh. +- `preview.stl`: ASCII STL preview mesh. +- `generator_manifest.json`: manifest explaining which generator ran, which template was used, which outputs were written, and the known limitations. + +Reports are written to `/00_reports/` by default: + +- `phase3_generation_report.md` +- `phase3_generation_report.csv` +- `phase3_unsupported_assets.csv` +- `phase3_generator_coverage.csv` +- `phase3_generator_registry.json` + +## How a future floating toolbar will use this system + +A Phase 4 toolbar can edit schema-backed parameters from the enriched templates and then call these generator classes to regenerate placeholder geometry. Phase 5 can connect that regeneration loop to a viewport. Later phases can replace placeholder mesh generation with production-grade CAD-kernel operations. + +## Limitations + +- Placeholder geometry only. +- No original FreeCAD, STEP, STL, BREP, or image assets are modified. +- No dimensions are reverse-engineered from source CAD files. +- OBJ and STL outputs are preview meshes, not authoritative BIM/CAD models. +- Advanced constraints, boolean operations, openings, connectors, and parametric histories remain future work. + +## What Phase 4 should do next + +Phase 4 should build the floating parameter toolbar UI that reads Phase 2 schema metadata and calls Phase 3 generators through a stable backend interface. The UI should still communicate clearly when an asset is placeholder-only versus backed by a production generator. diff --git a/docs/PHASE_4_FLOATING_PARAMETER_TOOLBAR.md b/docs/PHASE_4_FLOATING_PARAMETER_TOOLBAR.md new file mode 100644 index 00000000..76fa176d --- /dev/null +++ b/docs/PHASE_4_FLOATING_PARAMETER_TOOLBAR.md @@ -0,0 +1,59 @@ +# Phase 4 Floating Parameter Toolbar Prototype + +Phase 4 creates a zero-build browser prototype and Python integration layer for editing schema-backed BIM parameters. It proves that a cleaned/enriched `template.json` can become a floating editable toolbar, validated object state, preview drawing, and exported JSON. + +## How it depends on earlier phases + +- Phase 1 creates the cleaned BIM library and first `template.json` files. +- Phase 2 enriches templates with parameters, validation metadata, and toolbar groups. +- Phase 3 can optionally provide generated placeholder geometry links for preview/export context. + +Phase 4 still does not edit `.FCStd`, `.step`, `.stl`, `.brep`, image, or source CAD files. + +## Why this is still a prototype + +The UI uses plain HTML, CSS, JavaScript, and a simple canvas preview. It is meant to validate workflow and state management before investing in a production CAD viewport or OpenCASCADE/FreeCAD kernel integration. + +## Dry-run + +```bash +python tools/floating_toolbar_phase4.py --library-root /tmp/cad_bim_library_cleaned --output /tmp/cad_bim_toolbar_demo --dry-run +``` + +Dry-run scans templates and writes reports only. + +## Generate toolbar demo data + +```bash +python tools/floating_toolbar_phase4.py --library-root /tmp/cad_bim_library_cleaned --generated-geometry-root /tmp/cad_bim_generated_geometry --output /tmp/cad_bim_toolbar_demo --write +``` + +This writes `assets.json`, `toolbar_config.json`, `object_instances.json`, `geometry_links.json`, reports, and a copy of the static UI under `/tmp/cad_bim_toolbar_demo/ui/`. + +## Open the UI + +Open this file in a browser: + +```text +/tmp/cad_bim_toolbar_demo/ui/index.html +``` + +Or run a local server: + +```bash +python tools/floating_toolbar_phase4.py --output /tmp/cad_bim_toolbar_demo --serve --port 8765 +``` + +Then open `http://localhost:8765/ui/index.html`. + +## How the floating toolbar works + +When an asset is selected, the UI creates an object instance with parameters initialized from template defaults or Phase 3 fallback defaults. It builds toolbar groups from `template.toolbar.groups`, falling back to each parameter's `toolbarGroup`. Number, select, checkbox, text, textarea, and color controls are generated dynamically. + +## Validation and preview updates + +Number inputs validate min/max and positive rules inline. Valid changes update the object-state JSON and redraw a simplified 2.5D canvas preview immediately after a short debounce. + +## What Phase 5 should do next + +Phase 5 should connect this state and toolbar workflow to real-time geometry regeneration and viewport integration. Later phases can replace placeholder preview drawing with CAD-kernel-backed model updates. diff --git a/generators/__init__.py b/generators/__init__.py new file mode 100644 index 00000000..41631e27 --- /dev/null +++ b/generators/__init__.py @@ -0,0 +1,4 @@ +"""Phase 3 placeholder geometry generators.""" +from .registry import GENERATOR_REGISTRY, get_generator, registry_summary + +__all__ = ["GENERATOR_REGISTRY", "get_generator", "registry_summary"] diff --git a/generators/base.py b/generators/base.py new file mode 100644 index 00000000..abec5e44 --- /dev/null +++ b/generators/base.py @@ -0,0 +1,63 @@ +"""Base generator class for Phase 3 placeholder geometry.""" +from __future__ import annotations + +from typing import Any, Dict, List + + +class BaseGenerator: + element_type = "GenericBIMObject" + generator_name = "BaseGenerator" + generator_version = "phase3.v1" + fallback_parameters: Dict[str, Any] = {"width": 1000, "depth": 1000, "height": 1000} + + def can_generate(self, template: dict) -> bool: + return True + + def template_parameters(self, template: dict) -> Dict[str, Any]: + values: Dict[str, Any] = {} + for parameter in template.get("parameters", []): + if isinstance(parameter, dict) and parameter.get("name"): + values[parameter["name"]] = parameter.get("default") + return values + + def default_parameters(self, template: dict) -> Dict[str, Any]: + params = dict(self.fallback_parameters) + for name, value in self.template_parameters(template).items(): + if value is not None: + params[name] = value + return params + + def positive_number(self, params: Dict[str, Any], name: str, fallback: float) -> float: + value = params.get(name, fallback) + try: + value = float(value) + except (TypeError, ValueError): + value = float(fallback) + return value if value > 0 else float(fallback) + + def validate_parameters(self, params: dict) -> List[str]: + issues = [] + for key, value in params.items(): + if isinstance(value, (int, float)) and value < 0: + issues.append(f"{key} should not be negative") + return issues + + def geometry_metadata(self, template: dict, params: dict, mesh: dict, notes: str = "Simple parametric placeholder generated from template parameters.") -> dict: + return { + "assetId": template.get("id", "unknown_asset"), + "elementType": template.get("elementType", self.element_type), + "category": template.get("category", "uncategorized"), + "units": "mm", + "parametersUsed": params, + "geometryType": "mesh", + "metadata": { + "generator": self.generator_name, + "generatorVersion": self.generator_version, + "phase": "phase3", + "isPlaceholderGeometry": True, + "notes": notes, + }, + } + + def generate(self, template: dict) -> dict: + raise NotImplementedError("Subclasses must implement generate().") diff --git a/generators/beam_generator.py b/generators/beam_generator.py new file mode 100644 index 00000000..e2c0bf8a --- /dev/null +++ b/generators/beam_generator.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from .base import BaseGenerator +from .mesh_utils import create_box, merge_meshes + +class BeamGenerator(BaseGenerator): + element_type = "Beam" + generator_name = "BeamGenerator" + fallback_parameters = {"length":3000,"width":200,"height":400,"profileType":"rectangular"} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + length = self.positive_number(params,"length",3000); width = self.positive_number(params,"width",200); height = self.positive_number(params,"height",400) + profile = str(params.get("profileType", "rectangular")).lower() + if any(token in profile for token in ["hea", "heb", "i-beam", "ibeam"]) or profile == "i": + flange = max(height * 0.18, 20); web = max(width * 0.35, 20) + mesh = merge_meshes([create_box(length, width, flange, (0,0,0)), create_box(length, web, height, (0,(width-web)/2,0)), create_box(length, width, flange, (0,0,height-flange))]) + else: + mesh = create_box(length, width, height) + result = self.geometry_metadata(template, params, mesh); result.update(mesh); return result diff --git a/generators/block_generator.py b/generators/block_generator.py new file mode 100644 index 00000000..2a7a47cf --- /dev/null +++ b/generators/block_generator.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from .primitive_generators import BoxPlaceholderGenerator + +class ConstructionBlockGenerator(BoxPlaceholderGenerator): + element_type = "ConstructionBlock" + generator_name = "ConstructionBlockGenerator" + fallback_parameters = {"length":390,"width":190,"height":190} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + result = self.generate_box(template, self.positive_number(params,"length",390), self.positive_number(params,"width",190), self.positive_number(params,"height",190), params) + text = f"{template.get('displayName','')} {template.get('originalPath','')}".lower() + if "hollow" in text or "canal" in text: + result["metadata"]["hollowDetail"] = "Metadata only in Phase 3; hollow geometry not modeled yet." + return result diff --git a/generators/door_generator.py b/generators/door_generator.py new file mode 100644 index 00000000..d37617cd --- /dev/null +++ b/generators/door_generator.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from .base import BaseGenerator +from .mesh_utils import create_box, create_rectangular_frame, merge_meshes + +class DoorGenerator(BaseGenerator): + element_type = "Door" + generator_name = "DoorGenerator" + fallback_parameters = {"width":900,"height":2100,"thickness":45,"frameWidth":100,"openingAngle":90,"swingDirection":"unknown"} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + width = self.positive_number(params,"width",900); height = self.positive_number(params,"height",2100) + thickness = self.positive_number(params,"thickness",45); frame = self.positive_number(params,"frameWidth",100) + panel = create_box(width, thickness, height) + frame_mesh = create_rectangular_frame(width, height, frame, max(thickness, frame/2)) + mesh = merge_meshes([panel, frame_mesh]) + result = self.geometry_metadata(template, params, mesh) + result["metadata"]["swingDirection"] = params.get("swingDirection", "unknown") + result["metadata"]["openingAngle"] = params.get("openingAngle", 90) + result.update(mesh) + return result diff --git a/generators/duct_generator.py b/generators/duct_generator.py new file mode 100644 index 00000000..b8899301 --- /dev/null +++ b/generators/duct_generator.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from .base import BaseGenerator +from .mesh_utils import create_pipe, create_rectangular_duct + +class DuctGenerator(BaseGenerator): + element_type = "Duct" + generator_name = "DuctGenerator" + fallback_parameters = {"length":1000,"width":400,"height":250,"thickness":10,"ductShape":"rectangular","diameter":300} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + length = self.positive_number(params,"length",1000); shape = str(params.get("ductShape","rectangular")).lower() + if shape in {"circular","round"}: + diameter = self.positive_number(params,"diameter",300); thickness = self.positive_number(params,"thickness",10) + mesh = create_pipe(diameter, max(diameter - 2*thickness, 1), length) + else: + mesh = create_rectangular_duct(self.positive_number(params,"width",400), self.positive_number(params,"height",250), length, self.positive_number(params,"thickness",10)) + result = self.geometry_metadata(template, params, mesh); result.update(mesh); return result diff --git a/generators/foundation_generator.py b/generators/foundation_generator.py new file mode 100644 index 00000000..5d87e7bf --- /dev/null +++ b/generators/foundation_generator.py @@ -0,0 +1,25 @@ +from __future__ import annotations +import re +from .base import BaseGenerator +from .mesh_utils import create_box, create_cylinder, merge_meshes + +class FoundationGenerator(BaseGenerator): + element_type = "Foundation" + generator_name = "FoundationGenerator" + fallback_parameters = {"length":1500,"width":1500,"height":500} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + length = self.positive_number(params,"length",1500); width = self.positive_number(params,"width",1500); height = self.positive_number(params,"height",500) + meshes = [create_box(length, width, height)] + match = re.search(r"(\d+)\s*piles?", str(template.get("displayName", "")).lower()) + if match: + count = max(1, int(match.group(1))); radius = min(length, width) / 16 + for i in range(count): + pile = create_cylinder(radius, height * 1.5, 16) + dx = (i + 1) * length / (count + 1) + for vertex in pile["vertices"]: + vertex[0] += dx; vertex[1] += width / 2; vertex[2] -= height * 1.5 + meshes.append(pile) + params["pileCount"] = count + mesh = merge_meshes(meshes) + result = self.geometry_metadata(template, params, mesh); result.update(mesh); return result diff --git a/generators/furniture_generator.py b/generators/furniture_generator.py new file mode 100644 index 00000000..d6320e18 --- /dev/null +++ b/generators/furniture_generator.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from .primitive_generators import BoxPlaceholderGenerator + +class FurnitureGenerator(BoxPlaceholderGenerator): + element_type = "Furniture" + generator_name = "FurnitureGenerator" + fallback_parameters = {"width":600,"depth":600,"height":900} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + return self.generate_box(template, self.positive_number(params,"width",600), self.positive_number(params,"depth",600), self.positive_number(params,"height",900), params) diff --git a/generators/generic_generator.py b/generators/generic_generator.py new file mode 100644 index 00000000..90fbb8da --- /dev/null +++ b/generators/generic_generator.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from .primitive_generators import BoxPlaceholderGenerator + +class GenericGenerator(BoxPlaceholderGenerator): + element_type = "GenericBIMObject" + generator_name = "GenericGenerator" + fallback_parameters = {"width": 1000, "depth": 1000, "height": 1000} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + return self.generate_box(template, self.positive_number(params,"width",1000), self.positive_number(params,"depth",1000), self.positive_number(params,"height",1000), params) diff --git a/generators/mesh_utils.py b/generators/mesh_utils.py new file mode 100644 index 00000000..42cba012 --- /dev/null +++ b/generators/mesh_utils.py @@ -0,0 +1,150 @@ +"""Small standard-library mesh helpers for Phase 3 placeholder geometry.""" +from __future__ import annotations + +import json +import math +import re +from pathlib import Path +from typing import Dict, Iterable, List, Sequence, Tuple + +Mesh = Dict[str, List[List[float]]] + + +def sanitize_filename(name: str) -> str: + clean = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name).strip()).strip("_") + return clean or "unnamed" + + +def create_box(width: float, depth: float, height: float, origin: Tuple[float, float, float] = (0, 0, 0)) -> Mesh: + x, y, z = origin + w, d, h = float(width), float(depth), float(height) + vertices = [ + [x, y, z], [x + w, y, z], [x + w, y + d, z], [x, y + d, z], + [x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h], + ] + faces = [ + [0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6], + [0, 4, 5], [0, 5, 1], [1, 5, 6], [1, 6, 2], + [2, 6, 7], [2, 7, 3], [3, 7, 4], [3, 4, 0], + ] + return {"vertices": vertices, "faces": faces} + + +def create_rectangular_frame(width: float, height: float, frame_width: float, depth: float) -> Mesh: + fw = min(float(frame_width), float(width) / 3, float(height) / 3) + w, h, d = float(width), float(height), float(depth) + return merge_meshes([ + create_box(w + 2 * fw, d, fw, (-fw, 0, -fw)), + create_box(w + 2 * fw, d, fw, (-fw, 0, h)), + create_box(fw, d, h, (-fw, 0, 0)), + create_box(fw, d, h, (w, 0, 0)), + ]) + + +def create_cylinder(radius: float, height: float, segments: int = 24) -> Mesh: + r, h = float(radius), float(height) + segments = max(8, int(segments)) + vertices: List[List[float]] = [] + for z in (0.0, h): + for i in range(segments): + angle = 2 * math.pi * i / segments + vertices.append([r * math.cos(angle), r * math.sin(angle), z]) + bottom_center = len(vertices); vertices.append([0, 0, 0]) + top_center = len(vertices); vertices.append([0, 0, h]) + faces: List[List[int]] = [] + for i in range(segments): + j = (i + 1) % segments + faces.append([i, j, segments + j]); faces.append([i, segments + j, segments + i]) + faces.append([bottom_center, j, i]); faces.append([top_center, segments + i, segments + j]) + return {"vertices": vertices, "faces": faces} + + +def create_pipe(outer_diameter: float, inner_diameter: float, length: float, segments: int = 24) -> Mesh: + outer_r = float(outer_diameter) / 2 + inner_r = max(0.0, min(float(inner_diameter) / 2, outer_r * 0.95)) + if inner_r <= 0: + return create_cylinder(outer_r, length, segments) + segments = max(8, int(segments)) + vertices: List[List[float]] = [] + for z in (0.0, float(length)): + for r in (outer_r, inner_r): + for i in range(segments): + angle = 2 * math.pi * i / segments + vertices.append([r * math.cos(angle), r * math.sin(angle), z]) + faces: List[List[int]] = [] + ob, ib, ot, it = 0, segments, 2 * segments, 3 * segments + for i in range(segments): + j = (i + 1) % segments + faces += [[ob+i, ob+j, ot+j], [ob+i, ot+j, ot+i]] + faces += [[ib+j, ib+i, it+i], [ib+j, it+i, it+j]] + faces += [[ot+i, ot+j, it+j], [ot+i, it+j, it+i]] + faces += [[ob+j, ob+i, ib+i], [ob+j, ib+i, ib+j]] + return {"vertices": vertices, "faces": faces} + + +def create_rectangular_duct(width: float, height: float, length: float, wall_thickness: float) -> Mesh: + # Phase 3 placeholder: use a solid bounding duct and record wall thickness in metadata upstream. + return create_box(float(length), float(width), float(height), (0, 0, 0)) + + +def merge_meshes(meshes: Iterable[Mesh]) -> Mesh: + vertices: List[List[float]] = [] + faces: List[List[int]] = [] + offset = 0 + for mesh in meshes: + mesh_vertices = mesh.get("vertices", []) + vertices.extend(mesh_vertices) + faces.extend([[int(index) + offset for index in face] for face in mesh.get("faces", [])]) + offset += len(mesh_vertices) + return {"vertices": vertices, "faces": faces} + + +def compute_bounding_box(mesh: Mesh) -> Dict[str, List[float]]: + vertices = mesh.get("vertices", []) + if not vertices: + return {"min": [0, 0, 0], "max": [0, 0, 0]} + return {"min": [min(v[i] for v in vertices) for i in range(3)], "max": [max(v[i] for v in vertices) for i in range(3)]} + + +def _normal(a: Sequence[float], b: Sequence[float], c: Sequence[float]) -> List[float]: + ux, uy, uz = b[0]-a[0], b[1]-a[1], b[2]-a[2] + vx, vy, vz = c[0]-a[0], c[1]-a[1], c[2]-a[2] + nx, ny, nz = uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx + length = math.sqrt(nx*nx + ny*ny + nz*nz) or 1.0 + return [nx/length, ny/length, nz/length] + + +def write_obj(mesh: Mesh, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = ["# Phase 3 placeholder OBJ"] + lines += [f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}" for v in mesh.get("vertices", [])] + lines += ["f " + " ".join(str(i + 1) for i in face) for face in mesh.get("faces", [])] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_ascii_stl(mesh: Mesh, path: Path, solid_name: str = "phase3_placeholder") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + vertices = mesh.get("vertices", []) + lines = [f"solid {sanitize_filename(solid_name)}"] + for face in mesh.get("faces", []): + if len(face) < 3: + continue + a, b, c = vertices[face[0]], vertices[face[1]], vertices[face[2]] + n = _normal(a, b, c) + lines.append(f" facet normal {n[0]:.6f} {n[1]:.6f} {n[2]:.6f}") + lines.append(" outer loop") + for vertex in (a, b, c): + lines.append(f" vertex {vertex[0]:.6f} {vertex[1]:.6f} {vertex[2]:.6f}") + lines.append(" endloop") + lines.append(" endfacet") + lines.append(f"endsolid {sanitize_filename(solid_name)}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_geometry_json(mesh: Mesh, metadata: Dict[str, object], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = dict(metadata) + data["vertices"] = mesh.get("vertices", []) + data["faces"] = mesh.get("faces", []) + data.setdefault("metadata", {})["boundingBox"] = compute_bounding_box(mesh) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") diff --git a/generators/pipe_generator.py b/generators/pipe_generator.py new file mode 100644 index 00000000..c3a5babf --- /dev/null +++ b/generators/pipe_generator.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from .base import BaseGenerator +from .mesh_utils import create_cylinder, create_pipe + +class PipeGenerator(BaseGenerator): + element_type = "Pipe" + generator_name = "PipeGenerator" + fallback_parameters = {"length":1000,"diameter":100,"innerDiameter":80} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + length = self.positive_number(params,"length",1000); diameter = self.positive_number(params,"diameter",100) + inner = params.get("innerDiameter") + mesh = create_pipe(diameter, self.positive_number(params,"innerDiameter",80), length) if inner is not None else create_cylinder(diameter/2, length) + result = self.geometry_metadata(template, params, mesh); result.update(mesh); return result diff --git a/generators/primitive_generators.py b/generators/primitive_generators.py new file mode 100644 index 00000000..ad7c20d7 --- /dev/null +++ b/generators/primitive_generators.py @@ -0,0 +1,16 @@ +"""Reusable primitive placeholder generator helpers.""" +from __future__ import annotations + +from .base import BaseGenerator +from .mesh_utils import create_box + + +class BoxPlaceholderGenerator(BaseGenerator): + element_type = "GenericBIMObject" + generator_name = "BoxPlaceholderGenerator" + + def generate_box(self, template: dict, width: float, depth: float, height: float, params: dict) -> dict: + mesh = create_box(width, depth, height) + result = self.geometry_metadata(template, params, mesh) + result.update(mesh) + return result diff --git a/generators/registry.py b/generators/registry.py new file mode 100644 index 00000000..5735cb1b --- /dev/null +++ b/generators/registry.py @@ -0,0 +1,41 @@ +"""Generator registry for Phase 3 placeholder geometry.""" +from __future__ import annotations + +from .beam_generator import BeamGenerator +from .block_generator import ConstructionBlockGenerator +from .door_generator import DoorGenerator +from .duct_generator import DuctGenerator +from .foundation_generator import FoundationGenerator +from .furniture_generator import FurnitureGenerator +from .generic_generator import GenericGenerator +from .pipe_generator import PipeGenerator +from .roof_generator import RoofGenerator +from .vegetation_generator import VegetationGenerator +from .window_generator import WindowGenerator + +GENERATOR_REGISTRY = { + "Door": DoorGenerator, + "Window": WindowGenerator, + "Beam": BeamGenerator, + "Roof": RoofGenerator, + "ConstructionBlock": ConstructionBlockGenerator, + "Pipe": PipeGenerator, + "Duct": DuctGenerator, + "Furniture": FurnitureGenerator, + "Fixture": FurnitureGenerator, + "Foundation": FoundationGenerator, + "Vegetation": VegetationGenerator, + "GenericBIMObject": GenericGenerator, +} + + +def get_generator(element_type: str): + return GENERATOR_REGISTRY.get(element_type, GenericGenerator)() + + +def registry_summary() -> list[dict[str, str]]: + rows = [] + for element_type, generator_class in sorted(GENERATOR_REGISTRY.items()): + generator = generator_class() + rows.append({"elementType": element_type, "generatorName": generator.generator_name, "generatorVersion": generator.generator_version}) + return rows diff --git a/generators/roof_generator.py b/generators/roof_generator.py new file mode 100644 index 00000000..8a67b931 --- /dev/null +++ b/generators/roof_generator.py @@ -0,0 +1,16 @@ +from __future__ import annotations +import math +from .base import BaseGenerator + +class RoofGenerator(BaseGenerator): + element_type = "Roof" + generator_name = "RoofGenerator" + fallback_parameters = {"length":4000,"width":3000,"thickness":150,"slope":15} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + length = self.positive_number(params,"length",4000); width = self.positive_number(params,"width",3000); t = self.positive_number(params,"thickness",150) + rise = math.tan(math.radians(float(params.get("slope",15) or 15))) * width + vertices = [[0,0,0],[length,0,0],[length,width,rise],[0,width,rise],[0,0,t],[length,0,t],[length,width,rise+t],[0,width,rise+t]] + faces = [[0,1,2],[0,2,3],[4,6,5],[4,7,6],[0,4,5],[0,5,1],[1,5,6],[1,6,2],[2,6,7],[2,7,3],[3,7,4],[3,4,0]] + mesh = {"vertices": vertices, "faces": faces} + result = self.geometry_metadata(template, params, mesh); result.update(mesh); return result diff --git a/generators/vegetation_generator.py b/generators/vegetation_generator.py new file mode 100644 index 00000000..b4c427d9 --- /dev/null +++ b/generators/vegetation_generator.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from .base import BaseGenerator +from .mesh_utils import create_box, create_cylinder, merge_meshes + +class VegetationGenerator(BaseGenerator): + element_type = "Vegetation" + generator_name = "VegetationGenerator" + fallback_parameters = {"height":3000,"diameter":1500} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + height = self.positive_number(params,"height",3000); diameter = self.positive_number(params,"diameter",1500) + trunk = create_cylinder(max(diameter*0.06, 40), height*0.45, 12) + canopy = create_box(diameter, diameter, height*0.55, (-diameter/2, -diameter/2, height*0.4)) + mesh = merge_meshes([trunk, canopy]) + result = self.geometry_metadata(template, params, mesh, "Simple trunk and canopy placeholder generated from template parameters."); result.update(mesh); return result diff --git a/generators/window_generator.py b/generators/window_generator.py new file mode 100644 index 00000000..c9826eb4 --- /dev/null +++ b/generators/window_generator.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from .base import BaseGenerator +from .mesh_utils import create_box, create_rectangular_frame, merge_meshes + +class WindowGenerator(BaseGenerator): + element_type = "Window" + generator_name = "WindowGenerator" + fallback_parameters = {"width":1200,"height":1200,"frameDepth":80,"frameWidth":60,"panelCount":2,"openingType":"unknown"} + def generate(self, template: dict) -> dict: + params = self.default_parameters(template) + width = self.positive_number(params,"width",1200); height = self.positive_number(params,"height",1200) + depth = self.positive_number(params,"frameDepth",80); frame = self.positive_number(params,"frameWidth",60) + glass = create_box(width, max(6, depth/8), height, (0, depth/2, 0)) + mesh = merge_meshes([create_rectangular_frame(width, height, frame, depth), glass]) + result = self.geometry_metadata(template, params, mesh) + result["metadata"]["panelCount"] = params.get("panelCount", 2) + result["metadata"]["openingType"] = params.get("openingType", "unknown") + result.update(mesh) + return result diff --git a/schemas/generator_schema/README.md b/schemas/generator_schema/README.md new file mode 100644 index 00000000..c71f3125 --- /dev/null +++ b/schemas/generator_schema/README.md @@ -0,0 +1,8 @@ +# Phase 3 Generator Schema + +This folder documents the metadata shape written by `tools/geometry_generator_phase3.py`. + +- `geometry_output_schema.json` describes the neutral mesh payload in `geometry.json`. +- `generator_manifest_schema.json` describes `generator_manifest.json`, including the generator used, requested outputs, status, and limitations. + +The schemas are intentionally lightweight JSON documentation files. They do not require external validation libraries and they do not describe final production CAD geometry. diff --git a/schemas/generator_schema/generator_manifest_schema.json b/schemas/generator_schema/generator_manifest_schema.json new file mode 100644 index 00000000..8baae5e9 --- /dev/null +++ b/schemas/generator_schema/generator_manifest_schema.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "phase3.v1", + "description": "Manifest describing how a Phase 3 placeholder geometry asset was generated.", + "requiredFields": [ + "assetId", + "elementType", + "generatorName", + "generatorVersion", + "inputTemplate", + "outputs", + "status", + "limitations" + ], + "allowedStatuses": [ + "generated", + "skipped", + "failed" + ], + "outputs": { + "geometryJson": "geometry.json when JSON output is requested.", + "obj": "preview.obj when OBJ output is requested.", + "stl": "preview.stl when STL output is requested." + } +} diff --git a/schemas/generator_schema/geometry_output_schema.json b/schemas/generator_schema/geometry_output_schema.json new file mode 100644 index 00000000..5eca5c0c --- /dev/null +++ b/schemas/generator_schema/geometry_output_schema.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": "phase3.v1", + "description": "Neutral placeholder mesh geometry output written by Phase 3 generators.", + "requiredFields": [ + "assetId", + "elementType", + "category", + "units", + "parametersUsed", + "geometryType", + "vertices", + "faces", + "metadata" + ], + "meshFormat": { + "vertices": "Array of [x, y, z] coordinates in millimeters.", + "faces": "Array of triangle index lists referencing zero-based vertices." + }, + "metadataRequiredFields": [ + "generator", + "phase", + "isPlaceholderGeometry", + "notes" + ] +} diff --git a/schemas/parameter_schema/README.md b/schemas/parameter_schema/README.md new file mode 100644 index 00000000..a376091f --- /dev/null +++ b/schemas/parameter_schema/README.md @@ -0,0 +1,13 @@ +# Phase 2 Parameter Schema Configuration + +This folder contains the reusable schema configuration used by `tools/parameter_schema_phase2.py`. + +## Files + +- `parameter_catalog.json` is the master dictionary of reusable parameter definitions. Each entry includes labels, descriptions, units, UI-control hints, toolbar grouping, and validation metadata. +- `element_type_schemas.json` maps BIM element types such as `Door`, `Window`, `Pipe`, and `Duct` to required parameters, optional parameters, allowed file types, and floating-toolbar groups. +- `validation_rules.json` records the general template validation rules used by Phase 2. + +## Scope + +These files describe metadata only. They do not modify FreeCAD source assets and do not make STEP, STL, or BREP files parametric. Phase 3 still needs geometry generators before real-time editing can work. diff --git a/schemas/parameter_schema/element_type_schemas.json b/schemas/parameter_schema/element_type_schemas.json new file mode 100644 index 00000000..ba995255 --- /dev/null +++ b/schemas/parameter_schema/element_type_schemas.json @@ -0,0 +1,893 @@ +{ + "Door": { + "elementType": "Door", + "displayName": "Door", + "description": "Parametric schema for architectural door assets.", + "requiredParameters": [ + "width", + "height", + "thickness" + ], + "optionalParameters": [ + "frameWidth", + "swingDirection", + "openingAngle", + "leafCount", + "hingeSide", + "material", + "finish", + "fireRating", + "acousticRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "width", + "height", + "thickness", + "frameWidth" + ] + }, + { + "groupId": "operation", + "label": "Operation", + "parameters": [ + "swingDirection", + "openingAngle", + "leafCount", + "hingeSide" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material", + "finish" + ] + } + ] + }, + "Window": { + "elementType": "Window", + "displayName": "Window", + "description": "Parametric schema for architectural window assets.", + "requiredParameters": [ + "width", + "height" + ], + "optionalParameters": [ + "frameDepth", + "sillHeight", + "headHeight", + "glazingType", + "openingType", + "panelCount", + "material", + "finish", + "thermalRating", + "acousticRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "width", + "height", + "frameDepth", + "sillHeight", + "headHeight" + ] + }, + { + "groupId": "operation", + "label": "Operation", + "parameters": [ + "openingType", + "panelCount" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material", + "finish", + "thermalRating" + ] + } + ] + }, + "Beam": { + "elementType": "Beam", + "displayName": "Beam", + "description": "Schema for horizontal structural framing members.", + "requiredParameters": [ + "length", + "width", + "height" + ], + "optionalParameters": [ + "profileType", + "structuralMaterial", + "loadBearing", + "fireRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "length", + "width", + "height" + ] + }, + { + "groupId": "classification", + "label": "Classification", + "parameters": [ + "profileType", + "loadBearing", + "ifcClass" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "structuralMaterial", + "fireRating" + ] + } + ] + }, + "Roof": { + "elementType": "Roof", + "displayName": "Roof", + "description": "Schema for roof components and roof reference assets.", + "requiredParameters": [ + "length", + "width", + "thickness" + ], + "optionalParameters": [ + "slope", + "material", + "thermalRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "length", + "width", + "thickness", + "slope" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material", + "thermalRating" + ] + } + ] + }, + "ConstructionBlock": { + "elementType": "ConstructionBlock", + "displayName": "Construction Block", + "description": "Schema for blocks and modular construction pieces.", + "requiredParameters": [ + "length", + "width", + "height" + ], + "optionalParameters": [ + "material", + "loadBearing", + "fireRating", + "categoryCode", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "length", + "width", + "height" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material", + "fireRating" + ] + }, + { + "groupId": "classification", + "label": "Classification", + "parameters": [ + "loadBearing", + "categoryCode", + "ifcClass" + ] + } + ] + }, + "Wall": { + "elementType": "Wall", + "displayName": "Wall", + "description": "Schema for wall objects prepared for future generators.", + "requiredParameters": [ + "length", + "height", + "thickness" + ], + "optionalParameters": [ + "material", + "loadBearing", + "fireRating", + "acousticRating", + "thermalRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "length", + "height", + "thickness" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material" + ] + }, + { + "groupId": "classification", + "label": "Classification", + "parameters": [ + "loadBearing", + "fireRating", + "acousticRating", + "thermalRating", + "ifcClass" + ] + } + ] + }, + "Slab": { + "elementType": "Slab", + "displayName": "Slab", + "description": "Schema for slab objects prepared for future generators.", + "requiredParameters": [ + "length", + "width", + "thickness" + ], + "optionalParameters": [ + "structuralMaterial", + "loadBearing", + "fireRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "length", + "width", + "thickness" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "structuralMaterial" + ] + }, + { + "groupId": "classification", + "label": "Classification", + "parameters": [ + "loadBearing", + "fireRating", + "ifcClass" + ] + } + ] + }, + "Floor": { + "elementType": "Floor", + "displayName": "Floor", + "description": "Schema for floor finish or floor assembly objects.", + "requiredParameters": [ + "length", + "width", + "thickness" + ], + "optionalParameters": [ + "material", + "finish", + "fireRating", + "acousticRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "length", + "width", + "thickness" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material", + "finish" + ] + }, + { + "groupId": "classification", + "label": "Classification", + "parameters": [ + "fireRating", + "acousticRating", + "ifcClass" + ] + } + ] + }, + "Column": { + "elementType": "Column", + "displayName": "Column", + "description": "Schema for vertical structural members.", + "requiredParameters": [ + "height", + "width", + "depth" + ], + "optionalParameters": [ + "diameter", + "profileType", + "structuralMaterial", + "loadBearing", + "fireRating", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "height", + "width", + "depth", + "diameter" + ] + }, + { + "groupId": "classification", + "label": "Classification", + "parameters": [ + "profileType", + "loadBearing", + "ifcClass" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "structuralMaterial", + "fireRating" + ] + } + ] + }, + "Pipe": { + "elementType": "Pipe", + "displayName": "Pipe", + "description": "Schema for plumbing or mechanical pipe assets.", + "requiredParameters": [ + "diameter", + "length" + ], + "optionalParameters": [ + "innerDiameter", + "outerDiameter", + "thickness", + "bendAngle", + "flowDirection", + "connectionType", + "pressureClass", + "pipeSchedule", + "insulationThickness", + "material", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "diameter", + "innerDiameter", + "outerDiameter", + "length", + "thickness", + "bendAngle" + ] + }, + { + "groupId": "mep", + "label": "MEP", + "parameters": [ + "flowDirection", + "connectionType", + "pressureClass", + "pipeSchedule", + "insulationThickness" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material" + ] + } + ] + }, + "Duct": { + "elementType": "Duct", + "displayName": "Duct", + "description": "Schema for HVAC duct assets.", + "requiredParameters": [ + "width", + "height", + "length" + ], + "optionalParameters": [ + "diameter", + "ductShape", + "bendAngle", + "flowDirection", + "connectionType", + "pressureClass", + "insulationThickness", + "material", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "width", + "height", + "diameter", + "length", + "bendAngle" + ] + }, + { + "groupId": "mep", + "label": "MEP", + "parameters": [ + "ductShape", + "flowDirection", + "connectionType", + "pressureClass", + "insulationThickness" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material" + ] + } + ] + }, + "Furniture": { + "elementType": "Furniture", + "displayName": "Furniture", + "description": "Schema for movable or built-in furniture assets.", + "requiredParameters": [ + "width", + "depth", + "height" + ], + "optionalParameters": [ + "material", + "finish", + "color", + "manufacturer", + "model", + "tags" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "width", + "depth", + "height" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material", + "finish", + "color" + ] + }, + { + "groupId": "metadata", + "label": "Metadata", + "parameters": [ + "manufacturer", + "model", + "tags" + ] + } + ] + }, + "Fixture": { + "elementType": "Fixture", + "displayName": "Fixture", + "description": "Schema for bathroom, kitchen, and equipment fixtures.", + "requiredParameters": [ + "width", + "depth", + "height" + ], + "optionalParameters": [ + "connectionType", + "flowDirection", + "material", + "finish", + "manufacturer", + "model", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "width", + "depth", + "height" + ] + }, + { + "groupId": "mep", + "label": "MEP", + "parameters": [ + "connectionType", + "flowDirection" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material", + "finish" + ] + } + ] + }, + "Foundation": { + "elementType": "Foundation", + "displayName": "Foundation", + "description": "Schema for foundation and footing assets.", + "requiredParameters": [ + "length", + "width", + "height" + ], + "optionalParameters": [ + "structuralMaterial", + "loadBearing", + "categoryCode", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "length", + "width", + "height" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "structuralMaterial" + ] + }, + { + "groupId": "classification", + "label": "Classification", + "parameters": [ + "loadBearing", + "categoryCode", + "ifcClass" + ] + } + ] + }, + "Vegetation": { + "elementType": "Vegetation", + "displayName": "Vegetation", + "description": "Schema for site vegetation and landscape symbols.", + "requiredParameters": [ + "height" + ], + "optionalParameters": [ + "width", + "depth", + "radius", + "species", + "color", + "tags" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "height", + "width", + "depth", + "radius" + ] + }, + { + "groupId": "materials", + "label": "Appearance", + "parameters": [ + "color" + ] + }, + { + "groupId": "metadata", + "label": "Metadata", + "parameters": [ + "species", + "tags" + ] + } + ] + }, + "GenericBIMObject": { + "elementType": "GenericBIMObject", + "displayName": "Generic BIM Object", + "description": "Fallback schema for useful BIM assets without a dedicated schema yet.", + "requiredParameters": [ + "width", + "depth", + "height" + ], + "optionalParameters": [ + "material", + "manufacturer", + "model", + "description", + "tags", + "sourceLicense", + "sourceUrl", + "ifcClass" + ], + "allowedFileTypes": [ + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool" + ], + "toolbarGroups": [ + { + "groupId": "dimensions", + "label": "Dimensions", + "parameters": [ + "width", + "depth", + "height" + ] + }, + { + "groupId": "materials", + "label": "Materials", + "parameters": [ + "material" + ] + }, + { + "groupId": "metadata", + "label": "Metadata", + "parameters": [ + "manufacturer", + "model", + "description", + "tags", + "sourceLicense", + "sourceUrl" + ] + } + ] + } +} diff --git a/schemas/parameter_schema/parameter_catalog.json b/schemas/parameter_schema/parameter_catalog.json new file mode 100644 index 00000000..896b95ca --- /dev/null +++ b/schemas/parameter_schema/parameter_catalog.json @@ -0,0 +1,851 @@ +{ + "width": { + "name": "width", + "label": "Width", + "description": "Overall horizontal size of the object.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "height": { + "name": "height", + "label": "Height", + "description": "Overall vertical size of the object.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "depth": { + "name": "depth", + "label": "Depth", + "description": "Overall front-to-back size of the object.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "length": { + "name": "length", + "label": "Length", + "description": "Overall longitudinal size of the object.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "thickness": { + "name": "thickness", + "label": "Thickness", + "description": "Material or assembly thickness.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "radius": { + "name": "radius", + "label": "Radius", + "description": "Radius of a curved or circular feature.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "diameter": { + "name": "diameter", + "label": "Diameter", + "description": "Overall outside diameter for a circular object.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "innerDiameter": { + "name": "innerDiameter", + "label": "Inner Diameter", + "description": "Inside diameter for hollow circular components.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "outerDiameter": { + "name": "outerDiameter", + "label": "Outer Diameter", + "description": "Outside diameter for hollow circular components.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "frameWidth": { + "name": "frameWidth", + "label": "Frame Width", + "description": "Width of a surrounding frame member.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "frameDepth": { + "name": "frameDepth", + "label": "Frame Depth", + "description": "Depth of a surrounding frame member.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "sillHeight": { + "name": "sillHeight", + "label": "Sill Height", + "description": "Height from floor datum to the window sill.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "headHeight": { + "name": "headHeight", + "label": "Head Height", + "description": "Height from floor datum to the top of an opening.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "slope": { + "name": "slope", + "label": "Slope", + "description": "Inclination angle or pitch of a sloped object.", + "type": "number", + "unit": "deg", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "angle": { + "name": "angle", + "label": "Angle", + "description": "General angular value.", + "type": "number", + "unit": "deg", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "bendAngle": { + "name": "bendAngle", + "label": "Bend Angle", + "description": "Angle of a bend in a pipe or duct.", + "type": "number", + "unit": "deg", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "dimensions", + "validation": { + "required": false, + "positive": true + } + }, + "swingDirection": { + "name": "swingDirection", + "label": "Swing Direction", + "description": "Direction a hinged leaf swings.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": [ + "left", + "right", + "inward", + "outward", + "unknown" + ], + "uiControl": "select", + "toolbarGroup": "operation", + "validation": { + "required": false, + "positive": false + } + }, + "openingType": { + "name": "openingType", + "label": "Opening Type", + "description": "Operating style for a door or window.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": [ + "fixed", + "swing", + "sliding", + "tilt", + "casement", + "unknown" + ], + "uiControl": "select", + "toolbarGroup": "operation", + "validation": { + "required": false, + "positive": false + } + }, + "slidingDirection": { + "name": "slidingDirection", + "label": "Sliding Direction", + "description": "Direction of sliding operation.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": [ + "left", + "right", + "bi_parting", + "unknown" + ], + "uiControl": "select", + "toolbarGroup": "operation", + "validation": { + "required": false, + "positive": false + } + }, + "hingeSide": { + "name": "hingeSide", + "label": "Hinge Side", + "description": "Side where hinges are located.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": [ + "left", + "right", + "unknown" + ], + "uiControl": "select", + "toolbarGroup": "operation", + "validation": { + "required": false, + "positive": false + } + }, + "openingAngle": { + "name": "openingAngle", + "label": "Opening Angle", + "description": "Maximum opening angle for hinged operation.", + "type": "number", + "unit": "deg", + "default": null, + "min": 0, + "max": 180, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "operation", + "validation": { + "required": false, + "positive": true + } + }, + "panelCount": { + "name": "panelCount", + "label": "Panel Count", + "description": "Number of visible panels.", + "type": "integer", + "unit": null, + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "operation", + "validation": { + "required": false, + "positive": false + } + }, + "leafCount": { + "name": "leafCount", + "label": "Leaf Count", + "description": "Number of door or gate leaves.", + "type": "integer", + "unit": null, + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "operation", + "validation": { + "required": false, + "positive": false + } + }, + "material": { + "name": "material", + "label": "Material", + "description": "Primary visible or functional material.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "materials", + "validation": { + "required": false, + "positive": false + } + }, + "finish": { + "name": "finish", + "label": "Finish", + "description": "Surface finish or coating.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "materials", + "validation": { + "required": false, + "positive": false + } + }, + "color": { + "name": "color", + "label": "Color", + "description": "Display or finish color.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "color_picker", + "toolbarGroup": "materials", + "validation": { + "required": false, + "positive": false + } + }, + "fireRating": { + "name": "fireRating", + "label": "Fire Rating", + "description": "Fire-resistance classification.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "classification", + "validation": { + "required": false, + "positive": false + } + }, + "acousticRating": { + "name": "acousticRating", + "label": "Acoustic Rating", + "description": "Sound reduction or acoustic performance rating.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "classification", + "validation": { + "required": false, + "positive": false + } + }, + "thermalRating": { + "name": "thermalRating", + "label": "Thermal Rating", + "description": "Thermal performance rating.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "classification", + "validation": { + "required": false, + "positive": false + } + }, + "loadBearing": { + "name": "loadBearing", + "label": "Load Bearing", + "description": "Whether the element can be load bearing.", + "type": "boolean", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "checkbox", + "toolbarGroup": "classification", + "validation": { + "required": false, + "positive": false + } + }, + "structuralMaterial": { + "name": "structuralMaterial", + "label": "Structural Material", + "description": "Primary structural material.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "materials", + "validation": { + "required": false, + "positive": false + } + }, + "categoryCode": { + "name": "categoryCode", + "label": "Category Code", + "description": "Internal or external classification code.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "classification", + "validation": { + "required": false, + "positive": false + } + }, + "ifcClass": { + "name": "ifcClass", + "label": "IFC Class", + "description": "Suggested IFC entity class for BIM export.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "classification", + "validation": { + "required": false, + "positive": false + } + }, + "flowDirection": { + "name": "flowDirection", + "label": "Flow Direction", + "description": "Direction of air or fluid flow.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": [ + "in", + "out", + "bidirectional", + "unknown" + ], + "uiControl": "select", + "toolbarGroup": "mep", + "validation": { + "required": false, + "positive": false + } + }, + "connectionType": { + "name": "connectionType", + "label": "Connection Type", + "description": "Connector or fitting style.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "mep", + "validation": { + "required": false, + "positive": false + } + }, + "pressureClass": { + "name": "pressureClass", + "label": "Pressure Class", + "description": "Pressure rating or class.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "mep", + "validation": { + "required": false, + "positive": false + } + }, + "ductShape": { + "name": "ductShape", + "label": "Duct Shape", + "description": "Duct cross-section shape.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": [ + "round", + "rectangular", + "oval", + "flex", + "unknown" + ], + "uiControl": "select", + "toolbarGroup": "mep", + "validation": { + "required": false, + "positive": false + } + }, + "pipeSchedule": { + "name": "pipeSchedule", + "label": "Pipe Schedule", + "description": "Pipe wall schedule or class.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "mep", + "validation": { + "required": false, + "positive": false + } + }, + "insulationThickness": { + "name": "insulationThickness", + "label": "Insulation Thickness", + "description": "Thickness of thermal or acoustic insulation.", + "type": "number", + "unit": "mm", + "default": null, + "min": 0, + "max": null, + "options": null, + "uiControl": "number_input", + "toolbarGroup": "mep", + "validation": { + "required": false, + "positive": true + } + }, + "manufacturer": { + "name": "manufacturer", + "label": "Manufacturer", + "description": "Manufacturer or content author.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "metadata", + "validation": { + "required": false, + "positive": false + } + }, + "model": { + "name": "model", + "label": "Model", + "description": "Manufacturer model name or number.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "metadata", + "validation": { + "required": false, + "positive": false + } + }, + "description": { + "name": "description", + "label": "Description", + "description": "Human-readable asset description.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "textarea", + "toolbarGroup": "metadata", + "validation": { + "required": false, + "positive": false + } + }, + "tags": { + "name": "tags", + "label": "Tags", + "description": "Search tags for filtering and discovery.", + "type": "list", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "tag_input", + "toolbarGroup": "metadata", + "validation": { + "required": false, + "positive": false + } + }, + "sourceLicense": { + "name": "sourceLicense", + "label": "Source License", + "description": "License information for the source asset.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "metadata", + "validation": { + "required": false, + "positive": false + } + }, + "sourceUrl": { + "name": "sourceUrl", + "label": "Source URL", + "description": "Original source or documentation URL.", + "type": "url", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "url_input", + "toolbarGroup": "metadata", + "validation": { + "required": false, + "positive": false + } + }, + "glazingType": { + "name": "glazingType", + "label": "Glazing Type", + "description": "Type of glazing or glass assembly.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "materials", + "validation": { + "required": false, + "positive": false + } + }, + "profileType": { + "name": "profileType", + "label": "Profile Type", + "description": "Structural or geometric profile type.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "classification", + "validation": { + "required": false, + "positive": false + } + }, + "species": { + "name": "species", + "label": "Species", + "description": "Plant species or vegetation type.", + "type": "text", + "unit": null, + "default": null, + "min": null, + "max": null, + "options": null, + "uiControl": "text_input", + "toolbarGroup": "metadata", + "validation": { + "required": false, + "positive": false + } + } +} diff --git a/schemas/parameter_schema/validation_rules.json b/schemas/parameter_schema/validation_rules.json new file mode 100644 index 00000000..03fa020c --- /dev/null +++ b/schemas/parameter_schema/validation_rules.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": "phase2.v1", + "requiredTemplateFields": [ + "id", + "elementType", + "category", + "displayName", + "originalPath", + "sourceFiles", + "fileTypes", + "editability", + "parameters" + ], + "parameterRules": { + "mustExistInCatalogUnlessCustom": true, + "requiredParametersMustExist": true, + "nameStyle": "camelCase" + }, + "namingRules": { + "assetIdStyle": "snake_case", + "categoryStyle": "dot_notation", + "categoryExample": "architecture.doors.wood" + }, + "editabilityStatusAllowedValues": [ + "static_reference", + "parametric_source_available", + "generator_ready", + "fully_parametric", + "unsupported" + ] +} diff --git a/schemas/toolbar_schema/README.md b/schemas/toolbar_schema/README.md new file mode 100644 index 00000000..5e7ed2de --- /dev/null +++ b/schemas/toolbar_schema/README.md @@ -0,0 +1,3 @@ +# Phase 4 Toolbar Schema + +These lightweight JSON files document the state and event shapes used by the floating parameter toolbar prototype. They are documentation/configuration only and require no external validation package. diff --git a/schemas/toolbar_schema/toolbar_event_schema.json b/schemas/toolbar_schema/toolbar_event_schema.json new file mode 100644 index 00000000..b81617aa --- /dev/null +++ b/schemas/toolbar_schema/toolbar_event_schema.json @@ -0,0 +1 @@ +{"schemaVersion":"phase4.v1","description":"Toolbar prototype event payloads.","eventTypes":["assetSelected","objectCreated","parameterChanged","validationFailed","previewRegenerated","objectStateExported"],"example":{"eventType":"parameterChanged","timestamp":"ISO-8601 string","instanceId":"object_001","assetId":"door_wood_single_door_with_trims","parameterName":"width","previousValue":900,"newValue":1000,"valid":true,"issues":[]}} diff --git a/schemas/toolbar_schema/toolbar_state_schema.json b/schemas/toolbar_schema/toolbar_state_schema.json new file mode 100644 index 00000000..6988dbd3 --- /dev/null +++ b/schemas/toolbar_schema/toolbar_state_schema.json @@ -0,0 +1 @@ +{"schemaVersion":"phase4.v1","description":"Object instance state edited by the floating parameter toolbar.","requiredFields":["instanceId","assetId","elementType","category","parameters","placement","editability"],"example":{"instanceId":"object_001","assetId":"door_wood_single_door_with_trims","elementType":"Door","category":"architecture.doors.wood","parameters":{"width":900,"height":2100},"placement":{"x":0,"y":0,"z":0,"rotation":0},"editability":{"schemaReady":true,"generatorAvailable":false,"status":"static_reference"}}} diff --git a/tests/test_cleanup_phase1.py b/tests/test_cleanup_phase1.py new file mode 100644 index 00000000..a6c5f469 --- /dev/null +++ b/tests/test_cleanup_phase1.py @@ -0,0 +1,33 @@ +import tempfile +import unittest +from pathlib import Path + +from tools import cleanup_phase1 + + +class CleanupPhase1Tests(unittest.TestCase): + def test_classify_common_extensions(self): + self.assertEqual(cleanup_phase1.classify_file_type("door.FCStd"), "source_model") + self.assertEqual(cleanup_phase1.classify_file_type("door.step"), "exchange_geometry") + self.assertEqual(cleanup_phase1.classify_file_type("door.STL"), "preview_mesh") + self.assertEqual(cleanup_phase1.classify_file_type("board.GTL"), "pcb_manufacturing_file") + self.assertIsNone(cleanup_phase1.classify_file_type("unknown.xyz")) + + def test_normalize_name(self): + self.assertEqual(cleanup_phase1.normalize_name("Single door with trims"), "single_door_with_trims") + self.assertEqual(cleanup_phase1.normalize_name(" HVAC/Pipe-90° Bend "), "hvac_pipe_90_bend") + + def test_dry_run_main_completes_on_minimal_fixture(self): + with tempfile.TemporaryDirectory() as source, tempfile.TemporaryDirectory() as parent: + source_root = Path(source) + asset_dir = source_root / "Architectural Parts" / "Doors" / "Wood" + asset_dir.mkdir(parents=True) + (asset_dir / "Single door with trims.FCStd").write_text("fixture", encoding="utf-8") + output_root = Path(parent) / "cleaned" + result = cleanup_phase1.main(["--source", str(source_root), "--output", str(output_root), "--dry-run"]) + self.assertEqual(result, 0) + self.assertFalse(output_root.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_geometry_generator_phase3.py b/tests/test_geometry_generator_phase3.py new file mode 100644 index 00000000..7b356aa6 --- /dev/null +++ b/tests/test_geometry_generator_phase3.py @@ -0,0 +1,87 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from generators.beam_generator import BeamGenerator +from generators.door_generator import DoorGenerator +from generators.mesh_utils import create_box, write_ascii_stl, write_obj +from generators.pipe_generator import PipeGenerator +from generators.registry import GENERATOR_REGISTRY +from generators.window_generator import WindowGenerator +from tools import geometry_generator_phase3 + + +def template(element_type="Door"): + return { + "id": f"{element_type.lower()}_sample", + "elementType": element_type, + "category": "architecture.doors" if element_type == "Door" else "generic.sample", + "displayName": f"{element_type} Sample", + "originalPath": "fixture", + "parameters": [], + } + + +class GeometryGeneratorPhase3Tests(unittest.TestCase): + def test_import_registry(self): + self.assertIn("Door", GENERATOR_REGISTRY) + + def test_create_box_returns_vertices_and_faces(self): + mesh = create_box(1, 2, 3) + self.assertEqual(len(mesh["vertices"]), 8) + self.assertGreater(len(mesh["faces"]), 0) + + def test_obj_writer_writes_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "preview.obj" + write_obj(create_box(1, 1, 1), path) + self.assertIn("v ", path.read_text(encoding="utf-8")) + + def test_ascii_stl_writer_writes_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "preview.stl" + write_ascii_stl(create_box(1, 1, 1), path, "box") + self.assertIn("solid box", path.read_text(encoding="utf-8")) + + def test_door_generator_generates_mesh(self): + result = DoorGenerator().generate(template("Door")) + self.assertGreater(len(result["vertices"]), 0) + self.assertEqual(result["metadata"]["generator"], "DoorGenerator") + + def test_window_generator_generates_mesh(self): + result = WindowGenerator().generate(template("Window")) + self.assertGreater(len(result["faces"]), 0) + + def test_beam_generator_generates_mesh(self): + result = BeamGenerator().generate(template("Beam")) + self.assertGreater(len(result["vertices"]), 0) + + def test_pipe_generator_generates_mesh(self): + result = PipeGenerator().generate(template("Pipe")) + self.assertGreater(len(result["faces"]), 0) + + def test_dry_run_does_not_create_geometry_output(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "library" + asset_dir = root / "architecture" / "doors" / "sample" + asset_dir.mkdir(parents=True) + (asset_dir / "template.json").write_text(json.dumps(template("Door")), encoding="utf-8") + output = Path(tmp) / "generated" + summary = geometry_generator_phase3.process_library(root, output, Path(tmp) / "reports", True, False, None, None, None, "all") + self.assertEqual(summary.generated, 1) + self.assertFalse((output / "architecture" / "doors" / "door_sample" / "geometry.json").exists()) + + def test_write_mode_creates_geometry_json(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "library" + asset_dir = root / "architecture" / "doors" / "sample" + asset_dir.mkdir(parents=True) + (asset_dir / "template.json").write_text(json.dumps(template("Door")), encoding="utf-8") + output = Path(tmp) / "generated" + geometry_generator_phase3.process_library(root, output, output / "00_reports", False, True, None, None, None, "json") + self.assertTrue((output / "architecture" / "doors" / "door_sample" / "geometry.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_parameter_schema_phase2.py b/tests/test_parameter_schema_phase2.py new file mode 100644 index 00000000..29074b4e --- /dev/null +++ b/tests/test_parameter_schema_phase2.py @@ -0,0 +1,105 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from tools import parameter_schema_phase2 + + +class ParameterSchemaPhase2Tests(unittest.TestCase): + def test_parameter_catalog_loads(self): + _schemas, catalog, _rules = parameter_schema_phase2.load_schema_config() + self.assertIn("width", catalog) + self.assertEqual(catalog["width"]["uiControl"], "number_input") + + def test_element_type_schema_loads(self): + schemas, _catalog, _rules = parameter_schema_phase2.load_schema_config() + self.assertIn("Door", schemas) + self.assertIn("width", schemas["Door"]["requiredParameters"]) + + def test_template_validation_catches_missing_id(self): + schemas, catalog, rules = parameter_schema_phase2.load_schema_config() + template = { + "elementType": "Door", + "category": "architecture.doors.wood", + "displayName": "Door", + "originalPath": "Architectural Parts/Doors/Wood", + "sourceFiles": {}, + "fileTypes": {}, + "editability": {"status": "static_reference"}, + "parameters": [], + } + issues = parameter_schema_phase2.validate_template(template, Path("template.json"), schemas, catalog, rules) + self.assertTrue(any(issue.issue_code == "missing_required_field" and "id" in issue.issue_message for issue in issues)) + + def test_element_type_inference_detects_doors(self): + template = {"category": "architecture.doors.wood", "originalPath": "Architectural Parts/Doors/Wood"} + result = parameter_schema_phase2.infer_element_type(template, Path("architecture/doors/wood/template.json")) + self.assertEqual(result, "Door") + + def test_enriching_door_adds_required_parameters(self): + schemas, catalog, rules = parameter_schema_phase2.load_schema_config() + template = { + "id": "door_sample", + "elementType": "Door", + "category": "architecture.doors", + "displayName": "Door Sample", + "originalPath": "Architectural Parts/Doors", + "sourceFiles": {}, + "fileTypes": {}, + "editability": {"status": "static_reference"}, + "parameters": [], + } + processed = parameter_schema_phase2.enrich_template(template, Path("template.json"), schemas, catalog, rules) + names = {parameter["name"] for parameter in processed.enriched["parameters"]} + self.assertTrue({"width", "height", "thickness"}.issubset(names)) + + def test_dry_run_does_not_modify_template(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + asset_dir = root / "architecture" / "doors" / "sample" + asset_dir.mkdir(parents=True) + template_path = asset_dir / "template.json" + original = { + "id": "door_sample", + "elementType": "Door", + "category": "architecture.doors", + "displayName": "Door Sample", + "originalPath": "Architectural Parts/Doors", + "sourceFiles": {}, + "fileTypes": {}, + "editability": {"status": "static_reference"}, + "parameters": [], + } + template_path.write_text(json.dumps(original, indent=2) + "\n", encoding="utf-8") + report_dir = root / "reports" + parameter_schema_phase2.process_library(root, report_dir, dry_run=True, write=False, backup=False, limit_category=None) + self.assertEqual(json.loads(template_path.read_text(encoding="utf-8")), original) + self.assertTrue((report_dir / "phase2_schema_report.md").exists()) + + def test_write_mode_updates_template(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + asset_dir = root / "architecture" / "doors" / "sample" + asset_dir.mkdir(parents=True) + template_path = asset_dir / "template.json" + template_path.write_text(json.dumps({ + "id": "door_sample", + "elementType": "Door", + "category": "architecture.doors", + "displayName": "Door Sample", + "originalPath": "Architectural Parts/Doors", + "sourceFiles": {}, + "fileTypes": {}, + "editability": {"status": "static_reference"}, + "parameters": [], + }, indent=2) + "\n", encoding="utf-8") + parameter_schema_phase2.process_library(root, root / "reports", dry_run=False, write=True, backup=True, limit_category=None) + updated = json.loads(template_path.read_text(encoding="utf-8")) + self.assertIn("schema", updated) + self.assertIn("toolbar", updated) + self.assertTrue((asset_dir / "template.json.bak").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/toolbar_runtime/__init__.py b/toolbar_runtime/__init__.py new file mode 100644 index 00000000..953fb53d --- /dev/null +++ b/toolbar_runtime/__init__.py @@ -0,0 +1 @@ +"""Phase 4 floating toolbar runtime helpers.""" diff --git a/toolbar_runtime/export.py b/toolbar_runtime/export.py new file mode 100644 index 00000000..cd610994 --- /dev/null +++ b/toolbar_runtime/export.py @@ -0,0 +1,21 @@ +"""JSON export helpers for Phase 4 toolbar demo data.""" +from __future__ import annotations +import json +from pathlib import Path +from typing import Any, Dict, List +from .schema_adapter import CONTROL_TYPES, FALLBACK_DEFAULTS + +def _write(path: Path, data: Any) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True); Path(path).write_text(json.dumps(data, indent=2)+"\n", encoding="utf-8") + +def export_assets_json(records: List[Dict[str, Any]], path: Path) -> None: _write(path, {"assets": records}) + +def export_toolbar_config(records: List[Dict[str, Any]], path: Path) -> None: + types=sorted({r.get("elementType") for r in records if r.get("elementType")}) + groups={} + for r in records: groups.setdefault(r.get("elementType","GenericBIMObject"), r.get("toolbar",{}).get("groups",[])) + _write(path,{"availableElementTypes":types,"controlTypes":CONTROL_TYPES,"toolbarGroupsByElementType":groups,"validationRules":{"positiveNumbers":True,"respectMinMax":True},"fallbackDefaults":FALLBACK_DEFAULTS}) + +def export_object_instances(instances: List[Dict[str, Any]], path: Path) -> None: _write(path, {"instances": instances}) + +def export_geometry_links(records: List[Dict[str, Any]], path: Path) -> None: _write(path, {r["id"]: r.get("geometry",{}) for r in records}) diff --git a/toolbar_runtime/schema_adapter.py b/toolbar_runtime/schema_adapter.py new file mode 100644 index 00000000..8b311a75 --- /dev/null +++ b/toolbar_runtime/schema_adapter.py @@ -0,0 +1,83 @@ +"""Adapters that turn Phase 2 template.json files into toolbar-ready records.""" +from __future__ import annotations +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +CONTROL_BY_TYPE = {"number":"number_input","integer":"number_input","boolean":"checkbox","text":"text_input","url":"text_input","list":"text_input"} +FALLBACK_DEFAULTS = { + "Door":{"width":900,"height":2100,"thickness":45,"frameWidth":100,"openingAngle":90}, + "Window":{"width":1200,"height":1200,"frameDepth":80,"frameWidth":60,"panelCount":2}, + "Beam":{"length":3000,"width":200,"height":400}, + "Roof":{"length":4000,"width":3000,"thickness":150,"slope":15}, + "ConstructionBlock":{"length":390,"width":190,"height":190}, + "Pipe":{"length":1000,"diameter":100,"innerDiameter":80}, + "Duct":{"length":1000,"width":400,"height":250,"thickness":10,"ductShape":"rectangular"}, + "Furniture":{"width":600,"depth":600,"height":900},"Fixture":{"width":600,"depth":600,"height":900}, + "Foundation":{"length":1500,"width":1500,"height":500},"Vegetation":{"height":3000,"diameter":1500}, + "GenericBIMObject":{"width":1000,"depth":1000,"height":1000}, +} +CONTROL_TYPES = ["number_input","select","checkbox","text_input","textarea","color_picker"] + +def load_template(path: Path) -> Dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + +def scan_templates(library_root: Path) -> Iterable[Path]: + for path in Path(library_root).rglob("template.json"): + if "00_metadata" not in path.parts and path.is_file(): + yield path + +def infer_ui_control(parameter: Dict[str, Any]) -> str: + if parameter.get("uiControl"): + return parameter["uiControl"] + if parameter.get("options"): + return "select" + return CONTROL_BY_TYPE.get(str(parameter.get("type","text")), "text_input") + +def normalize_parameter(parameter: Dict[str, Any]) -> Dict[str, Any]: + p = dict(parameter) + p.setdefault("name", "unnamed") + p.setdefault("label", p["name"]) + p.setdefault("description", "") + p.setdefault("type", "text") + p.setdefault("unit", None) + p.setdefault("default", None) + p.setdefault("min", None) + p.setdefault("max", None) + p.setdefault("options", None) + p["uiControl"] = infer_ui_control(p) + p.setdefault("toolbarGroup", "general") + p.setdefault("validation", {}) + return p + +def extract_toolbar_groups(template: Dict[str, Any]) -> List[Dict[str, Any]]: + toolbar = template.get("toolbar", {}) if isinstance(template.get("toolbar"), dict) else {} + groups = toolbar.get("groups") if isinstance(toolbar.get("groups"), list) else [] + if groups: + return groups + grouped: Dict[str, List[str]] = {} + for p in template.get("parameters", []): + if isinstance(p, dict) and p.get("name"): + grouped.setdefault(p.get("toolbarGroup", "general"), []).append(p["name"]) + return [{"groupId": gid, "label": gid.replace("_"," ").title(), "parameters": names} for gid, names in grouped.items()] + +def template_to_toolbar_config(template: Dict[str, Any]) -> Dict[str, Any]: + params = [normalize_parameter(p) for p in template.get("parameters", []) if isinstance(p, dict)] + return {"mode":"floating_parameter_panel","groups":extract_toolbar_groups({**template,"parameters":params})} + +def _geometry_links(asset_id: str, geometry_root: Optional[Path]) -> Dict[str, str]: + if not geometry_root: + return {} + matches = list(Path(geometry_root).rglob(f"{asset_id}/geometry.json")) + if not matches: + return {} + folder = matches[0].parent + links = {} + for key, name in [("geometryJson","geometry.json"),("obj","preview.obj"),("stl","preview.stl")]: + p = folder / name + if p.exists(): links[key] = p.as_posix() + return links + +def build_asset_record(template_path: Path, template: Dict[str, Any], geometry_root: Optional[Path] = None) -> Dict[str, Any]: + params = [normalize_parameter(p) for p in template.get("parameters", []) if isinstance(p, dict)] + return {"id":template.get("id"),"displayName":template.get("displayName", template.get("id")),"elementType":template.get("elementType","GenericBIMObject"),"category":template.get("category","uncategorized"),"templatePath":Path(template_path).as_posix(),"geometry":_geometry_links(str(template.get("id")), geometry_root),"toolbar":template_to_toolbar_config({**template,"parameters":params}),"parameters":params,"editability":template.get("editability",{})} diff --git a/toolbar_runtime/state_manager.py b/toolbar_runtime/state_manager.py new file mode 100644 index 00000000..72942601 --- /dev/null +++ b/toolbar_runtime/state_manager.py @@ -0,0 +1,31 @@ +"""Object instance state helpers for Phase 4.""" +from __future__ import annotations +import copy, json +from typing import Any, Dict +from .schema_adapter import FALLBACK_DEFAULTS + +_counter = 0 + +def apply_defaults(template: Dict[str, Any]) -> Dict[str, Any]: + element = template.get("elementType","GenericBIMObject") + fallbacks = FALLBACK_DEFAULTS.get(element, FALLBACK_DEFAULTS["GenericBIMObject"]) + values = {} + for p in template.get("parameters", []): + name = p.get("name") + if name: values[name] = p.get("default") if p.get("default") is not None else fallbacks.get(name) + for k,v in fallbacks.items(): values.setdefault(k,v) + return values + +def build_placement_default() -> Dict[str, float]: + return {"x":0,"y":0,"z":0,"rotation":0} + +def create_object_instance(asset_record: Dict[str, Any]) -> Dict[str, Any]: + global _counter + _counter += 1 + return {"instanceId":f"object_{_counter:03d}","assetId":asset_record["id"],"elementType":asset_record.get("elementType","GenericBIMObject"),"category":asset_record.get("category","uncategorized"),"parameters":apply_defaults(asset_record),"placement":build_placement_default(),"editability":copy.deepcopy(asset_record.get("editability",{}))} + +def update_parameter(object_state: Dict[str, Any], parameter_name: str, value: Any) -> Dict[str, Any]: + updated = copy.deepcopy(object_state); updated.setdefault("parameters",{})[parameter_name]=value; return updated + +def serialize_object_state(object_state: Dict[str, Any]) -> str: + return json.dumps(object_state, indent=2) diff --git a/toolbar_runtime/validation.py b/toolbar_runtime/validation.py new file mode 100644 index 00000000..3c92df7c --- /dev/null +++ b/toolbar_runtime/validation.py @@ -0,0 +1,29 @@ +"""Validation helpers for Phase 4 toolbar state and parameter edits.""" +from __future__ import annotations +from typing import Any, Dict, List + +REQUIRED_TEMPLATE_FIELDS = ["id","elementType","category","displayName","parameters"] + +def validate_template_for_toolbar(template: Dict[str, Any]) -> List[Dict[str, str]]: + issues=[] + for f in REQUIRED_TEMPLATE_FIELDS: + if f not in template: issues.append({"code":"missing_field","message":f"Missing {f}","severity":"error","recommendedFix":f"Add {f} to template.json."}) + if not isinstance(template.get("parameters", []), list): issues.append({"code":"parameters_not_list","message":"parameters must be a list","severity":"error","recommendedFix":"Use a list of parameter objects."}) + return issues + +def validate_parameter_value(parameter_definition: Dict[str, Any], value: Any) -> List[str]: + issues=[]; typ=parameter_definition.get("type") + if typ in {"number","integer"} or parameter_definition.get("uiControl") == "number_input": + try: number=float(value) + except (TypeError,ValueError): return ["Value must be numeric."] + if parameter_definition.get("validation",{}).get("positive") and number <= 0: issues.append("Value must be positive.") + if parameter_definition.get("min") is not None and number < float(parameter_definition["min"]): issues.append(f"Value must be >= {parameter_definition['min']}.") + if parameter_definition.get("max") is not None and number > float(parameter_definition["max"]): issues.append(f"Value must be <= {parameter_definition['max']}.") + if parameter_definition.get("options") and value not in parameter_definition["options"]: issues.append("Value must be one of the allowed options.") + return issues + +def validate_object_state(object_state: Dict[str, Any]) -> List[str]: + return [f"Missing {f}" for f in ["instanceId","assetId","elementType","parameters","placement","editability"] if f not in object_state] + +def get_validation_issues(template: Dict[str, Any]) -> List[Dict[str, str]]: + return validate_template_for_toolbar(template) diff --git a/tools/cleanup_phase1.py b/tools/cleanup_phase1.py new file mode 100755 index 00000000..cb149590 --- /dev/null +++ b/tools/cleanup_phase1.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +"""Phase 1 CAD/BIM library cleanup for FreeCAD-library. + +This script is intentionally conservative: +- it never deletes or moves source files; +- it copies only recognized BIM-relevant asset files; +- it writes all generated files to a separate output directory; +- dry-run mode scans and reports without creating output files. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import shutil +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +FILE_TYPE_RULES = { + ".FCStd": "source_model", + ".fcstd": "source_model", + ".step": "exchange_geometry", + ".stp": "exchange_geometry", + ".STEP": "exchange_geometry", + ".STP": "exchange_geometry", + ".brep": "kernel_geometry", + ".stl": "preview_mesh", + ".STL": "preview_mesh", + ".png": "preview_or_documentation_image", + ".jpg": "preview_or_documentation_image", + ".jpeg": "preview_or_documentation_image", + ".svg": "preview_or_documentation_image", + ".avif": "preview_or_documentation_image", + ".md": "documentation", + ".txt": "documentation", + ".pdf": "documentation", + ".FCMacro": "macro_or_parametric_tool", + ".FCBak": "backup_file", + ".GKO": "pcb_manufacturing_file", + ".GBO": "pcb_manufacturing_file", + ".GBL": "pcb_manufacturing_file", + ".GBS": "pcb_manufacturing_file", + ".GTL": "pcb_manufacturing_file", + ".GTP": "pcb_manufacturing_file", + ".GTO": "pcb_manufacturing_file", + ".GTS": "pcb_manufacturing_file", + ".DRL": "pcb_manufacturing_file", +} + +PCB_EXTENSIONS = {ext for ext, kind in FILE_TYPE_RULES.items() if kind == "pcb_manufacturing_file"} +COPYABLE_FILE_TYPES = { + "source_model", + "exchange_geometry", + "kernel_geometry", + "preview_mesh", + "preview_or_documentation_image", + "documentation", + "macro_or_parametric_tool", +} + +# Source prefix, cleaned category, element type, id prefix. +BIM_CATEGORY_RULES: Sequence[Tuple[str, str, str, str]] = ( + ("Architectural Parts/Doors", "architecture.doors", "Door", "door"), + ("Architectural Parts/Windows", "architecture.windows", "Window", "window"), + ("Architectural Parts/Beams", "architecture.beams", "Beam", "beam"), + ("Architectural Parts/Roof", "architecture.roof", "Roof", "roof"), + ("Architectural Parts/Construction blocks", "architecture.construction_blocks", "Construction block", "construction_block"), + ("Architectural Parts/Building Construction", "architecture.construction_blocks", "Construction block", "construction_block"), + ("Architectural Parts/Bathroom", "architecture.bathroom", "Furniture/fixture", "fixture"), + ("Architectural Parts/Kitchen", "architecture.kitchen", "Furniture/fixture", "fixture"), + ("Architectural Parts/Bedroom", "architecture.furniture", "Furniture/fixture", "furniture"), + ("Architectural Parts/Living room", "architecture.furniture", "Furniture/fixture", "furniture"), + ("Architectural Parts/Hydro equipment", "mep.hydro_equipment", "Pipe", "pipe"), + ("Architectural Parts/Electric equipment", "mep.electrical_equipment", "Electrical equipment", "electrical_equipment"), + ("Generic objects/Foundation", "architecture.foundations", "Foundation", "foundation"), + ("HVAC/Ducts", "mep.hvac_ducts", "Duct", "duct"), + ("HVAC/Pipes", "mep.hvac_pipes", "Pipe", "pipe"), + ("Pipes and tubes", "mep.plumbing_pipes", "Pipe", "pipe"), + ("Topography", "site.topography", "Topography", "topography"), + ("Architectural Parts/Symbols/Vegetation symbols", "site.vegetation", "Site symbol", "site_symbol"), +) + +NON_BIM_ROOTS = { + "Computing": "Computing hardware is outside Phase 1 BIM scope.", + "Electrical Parts": "Electrical/electronic components are archive candidates, except BIM electrical equipment under Architectural Parts.", + "Electronics Parts": "Electronics/PCB components are outside Phase 1 BIM scope.", + "Mechanical Parts": "Mechanical components are outside Phase 1 BIM scope.", + "Robots": "Robotics assets are outside Phase 1 BIM scope.", + "Sports": "Sports assets are outside Phase 1 BIM scope.", + "DummiesAndSculptures": "Character/sculpture assets are outside Phase 1 BIM scope.", +} + +BASE_OUTPUT_FOLDERS = [ + "00_metadata", + "architecture/doors", + "architecture/windows", + "architecture/beams", + "architecture/roof", + "architecture/construction_blocks", + "architecture/bathroom", + "architecture/kitchen", + "architecture/furniture", + "architecture/foundations", + "architecture/site_symbols", + "mep/hvac_ducts", + "mep/hvac_pipes", + "mep/plumbing_pipes", + "mep/hydro_equipment", + "mep/electrical_equipment", + "site/topography", + "site/vegetation", +] + + +@dataclass(frozen=True) +class CategoryRule: + source_prefix: PurePosixPath + category: str + element_type: str + id_prefix: str + + +@dataclass +class SourceFile: + path: Path + relative_path: PurePosixPath + file_type: str + cleaned_name: str = "" + + +@dataclass +class AssetGroup: + id: str + display_name: str + category: str + element_type: str + id_prefix: str + original_folder: PurePosixPath + cleaned_folder: PurePosixPath + files: List[SourceFile] = field(default_factory=list) + + +def normalize_name(value: str) -> str: + """Return a safe snake_case name for folders, IDs, and predictable paths.""" + normalized = value.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "_", normalized) + normalized = re.sub(r"_+", "_", normalized).strip("_") + return normalized or "unnamed" + + +def display_name_from_stem(stem: str) -> str: + words = re.split(r"[^A-Za-z0-9]+", stem.strip()) + return " ".join(word.capitalize() for word in words if word) or "Unnamed" + + +def classify_file_type(path: Path | str) -> Optional[str]: + return FILE_TYPE_RULES.get(Path(path).suffix) + + +def as_posix_relative(path: Path, source_root: Path) -> PurePosixPath: + return PurePosixPath(path.relative_to(source_root).as_posix()) + + +def build_category_rules() -> List[CategoryRule]: + rules = [CategoryRule(PurePosixPath(src), category, element, prefix) for src, category, element, prefix in BIM_CATEGORY_RULES] + return sorted(rules, key=lambda rule: len(rule.source_prefix.parts), reverse=True) + + +def path_starts_with(path: PurePosixPath, prefix: PurePosixPath) -> bool: + return path.parts[: len(prefix.parts)] == prefix.parts + + +def find_bim_rule(relative_path: PurePosixPath, rules: Sequence[CategoryRule]) -> Optional[CategoryRule]: + for rule in rules: + if path_starts_with(relative_path, rule.source_prefix): + return rule + return None + + +def non_bim_reason(relative_path: PurePosixPath, file_type: Optional[str]) -> Optional[str]: + if file_type == "pcb_manufacturing_file": + return "PCB/Gerber/drill manufacturing file is outside BIM scope." + if not relative_path.parts: + return None + root = relative_path.parts[0] + if root in NON_BIM_ROOTS: + return NON_BIM_ROOTS[root] + if root == "Generic objects" and not path_starts_with(relative_path, PurePosixPath("Generic objects/Foundation")): + return "Generic objects are archive candidates in Phase 1 except Foundation assets." + return None + + +def cleaned_file_name(source_file: Path, file_type: str, used_names: set[str]) -> str: + extension = source_file.suffix + base_by_type = { + "source_model": "source", + "exchange_geometry": "reference", + "kernel_geometry": "kernel", + "preview_mesh": "preview", + "preview_or_documentation_image": "image", + "documentation": "documentation", + "macro_or_parametric_tool": "macro", + "backup_file": "backup", + "pcb_manufacturing_file": "pcb", + } + base = base_by_type.get(file_type, normalize_name(source_file.stem)) + candidate = f"{base}{extension}" + counter = 2 + while candidate in used_names: + candidate = f"{base}_{counter}{extension}" + counter += 1 + used_names.add(candidate) + return candidate + + +def parameter(name: str, label: str, kind: str = "number", unit: Optional[str] = "mm") -> Dict[str, object]: + data: Dict[str, object] = {"name": name, "label": label, "type": kind, "unit": unit, "default": None} + if kind == "number": + data["min"] = None + data["max"] = None + return data + + +def default_parameters(element_type: str) -> List[Dict[str, object]]: + element = element_type.lower() + if element == "door": + return [ + parameter("width", "Width"), parameter("height", "Height"), parameter("thickness", "Thickness"), + parameter("frameWidth", "Frame Width"), parameter("swingDirection", "Swing Direction", "text", None), + parameter("openingAngle", "Opening Angle", "number", "deg"), parameter("material", "Material", "text", None), + ] + if element == "window": + return [ + parameter("width", "Width"), parameter("height", "Height"), parameter("frameDepth", "Frame Depth"), + parameter("glazingType", "Glazing Type", "text", None), parameter("openingType", "Opening Type", "text", None), + parameter("panelCount", "Panel Count", "integer", None), parameter("material", "Material", "text", None), + ] + if element == "beam": + return [parameter("length", "Length"), parameter("width", "Width"), parameter("height", "Height"), parameter("profileType", "Profile Type", "text", None), parameter("material", "Material", "text", None)] + if element == "roof": + return [parameter("length", "Length"), parameter("width", "Width"), parameter("thickness", "Thickness"), parameter("slope", "Slope", "number", "deg"), parameter("material", "Material", "text", None)] + if element == "construction block": + return [parameter("length", "Length"), parameter("width", "Width"), parameter("height", "Height"), parameter("material", "Material", "text", None)] + if element in {"duct", "pipe"}: + return [parameter("diameter", "Diameter"), parameter("width", "Width"), parameter("height", "Height"), parameter("length", "Length"), parameter("thickness", "Thickness"), parameter("bendAngle", "Bend Angle", "number", "deg"), parameter("material", "Material", "text", None)] + return [parameter("width", "Width"), parameter("depth", "Depth"), parameter("height", "Height"), parameter("material", "Material", "text", None)] + + +def source_files_map(files: Sequence[SourceFile]) -> Dict[str, str]: + result: Dict[str, str] = {} + key_by_type = { + "source_model": "freecad", + "exchange_geometry": "step", + "kernel_geometry": "brep", + "preview_mesh": "stl", + "preview_or_documentation_image": "image", + "documentation": "documentation", + "macro_or_parametric_tool": "macro", + } + for source in files: + key = key_by_type.get(source.file_type) + if key and key not in result: + result[key] = source.cleaned_name + return result + + +def file_types_map(files: Sequence[SourceFile]) -> Dict[str, List[str]]: + result: Dict[str, List[str]] = {} + for source in files: + result.setdefault(source.file_type, []).append(source.cleaned_name) + return result + + +def build_template(asset: AssetGroup) -> Dict[str, object]: + parametric = any(source.file_type == "source_model" for source in asset.files) + return { + "id": asset.id, + "elementType": asset.element_type, + "category": asset.category, + "displayName": asset.display_name, + "originalPath": asset.original_folder.as_posix(), + "sourceFiles": source_files_map(asset.files), + "fileTypes": file_types_map(asset.files), + "editability": { + "status": "static_reference", + "parametricSourceAvailable": parametric, + "generatorAvailable": False, + }, + "parameters": default_parameters(asset.element_type), + "notes": "Imported from FreeCAD-library. Needs parametric generator before real-time editing.", + } + + +def iter_source_files(source_root: Path) -> Iterable[Path]: + ignored_dirs = {".git", "__pycache__"} + for path in source_root.rglob("*"): + if any(part in ignored_dirs for part in path.relative_to(source_root).parts): + continue + if path.is_file(): + yield path + + +def scan_assets(source_root: Path, limit_category: Optional[str] = None) -> Tuple[Dict[Tuple[str, str], AssetGroup], Dict[str, object]]: + rules = build_category_rules() + assets: Dict[Tuple[str, str], AssetGroup] = {} + non_bim_rows: List[Dict[str, str]] = [] + total_files = 0 + backup_files = 0 + pcb_files = 0 + + for path in iter_source_files(source_root): + total_files += 1 + relative = as_posix_relative(path, source_root) + file_type = classify_file_type(path) + if file_type == "backup_file": + backup_files += 1 + if file_type == "pcb_manufacturing_file": + pcb_files += 1 + + rule = find_bim_rule(relative, rules) + reason = non_bim_reason(relative, file_type) + if reason: + non_bim_rows.append({"path": relative.as_posix(), "type": "file", "reason": reason}) + + if not rule or file_type not in COPYABLE_FILE_TYPES: + continue + if limit_category and rule.category != limit_category: + continue + + after_prefix = PurePosixPath(*relative.parts[len(rule.source_prefix.parts) :]) + source_subfolder = PurePosixPath(*after_prefix.parent.parts) + normalized_parts = [normalize_name(part) for part in source_subfolder.parts] + asset_slug = normalize_name(path.stem) + cleaned_folder = PurePosixPath(*rule.category.split("."), *normalized_parts, asset_slug) + category_suffix = ".".join(normalized_parts) + category = rule.category if not category_suffix else f"{rule.category}.{category_suffix}" + id_middle = "_".join(normalized_parts + [asset_slug]) + asset_id = f"{rule.id_prefix}_{id_middle}" if id_middle else f"{rule.id_prefix}_{asset_slug}" + original_folder = relative.parent + key = (category, cleaned_folder.as_posix()) + if key not in assets: + assets[key] = AssetGroup( + id=asset_id, + display_name=display_name_from_stem(path.stem), + category=category, + element_type=rule.element_type, + id_prefix=rule.id_prefix, + original_folder=original_folder, + cleaned_folder=cleaned_folder, + ) + assets[key].files.append(SourceFile(path=path, relative_path=relative, file_type=file_type)) + + for asset in assets.values(): + used_names: set[str] = set() + for source in sorted(asset.files, key=lambda item: item.relative_path.as_posix()): + source.cleaned_name = cleaned_file_name(source.path, source.file_type, used_names) + asset.files.sort(key=lambda item: item.cleaned_name) + + stats = { + "total_files": total_files, + "backup_files": backup_files, + "pcb_files": pcb_files, + "non_bim_rows": non_bim_rows, + } + return assets, stats + + +def ensure_output_structure(output_root: Path, include_archive: bool) -> None: + for folder in BASE_OUTPUT_FOLDERS: + (output_root / folder).mkdir(parents=True, exist_ok=True) + if include_archive: + (output_root / "archive_non_bim").mkdir(parents=True, exist_ok=True) + + +def copy_asset_files(output_root: Path, assets: Iterable[AssetGroup]) -> int: + copied = 0 + for asset in assets: + target_folder = output_root / asset.cleaned_folder + target_folder.mkdir(parents=True, exist_ok=True) + for source in asset.files: + shutil.copy2(source.path, target_folder / source.cleaned_name) + copied += 1 + with (target_folder / "template.json").open("w", encoding="utf-8") as handle: + json.dump(build_template(asset), handle, indent=2) + handle.write("\n") + return copied + + +def write_json(path: Path, data: object) -> None: + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +def write_metadata(output_root: Path, assets: Sequence[AssetGroup], stats: Dict[str, object], copied_count: int, include_archive: bool, missing_templates: Sequence[Dict[str, str]]) -> None: + metadata_root = output_root / "00_metadata" + index = [] + for asset in assets: + template = build_template(asset) + index.append({ + "id": asset.id, + "displayName": asset.display_name, + "elementType": asset.element_type, + "category": asset.category, + "cleanedPath": asset.cleaned_folder.as_posix(), + "originalPath": asset.original_folder.as_posix(), + "availableFormats": sorted(template["fileTypes"].keys()), + "editability.status": template["editability"]["status"], + "parametricSourceAvailable": template["editability"]["parametricSourceAvailable"], + }) + write_json(metadata_root / "library_index.json", index) + write_json(metadata_root / "file_rules.json", FILE_TYPE_RULES) + + non_bim_rows = stats["non_bim_rows"] + categories = sorted({asset.category for asset in assets}) + report = [ + "# Phase 1 CAD/BIM Library Cleanup Report", + "", + f"- Total files scanned: {stats['total_files']}", + f"- Total BIM-relevant files copied: {copied_count}", + f"- Total assets created: {len(assets)}", + f"- Categories created: {len(categories)}", + f"- Non-BIM folders/files detected: {len(non_bim_rows)}", + f"- Backup files detected: {stats['backup_files']}", + f"- PCB/Gerber files detected: {stats['pcb_files']}", + f"- Archive folder included: {'yes' if include_archive else 'no'}", + "", + "## Categories created", + "", + *[f"- {category}" for category in categories], + "", + "## Limitations", + "", + "- Phase 1 does not reverse-engineer dimensions or constraints from CAD files.", + "- STEP, STL, BREP, and image files are treated as static reference assets.", + "- FCStd files are marked as possible parametric sources, but not guaranteed editable.", + "- The script copies selected BIM-relevant files only and leaves the original repository untouched.", + "", + "## Next recommended step", + "", + "Phase 2 should refine parameter schemas and identify which assets need real generator logic before toolbar-driven editing.", + ] + (metadata_root / "cleanup_report.md").write_text("\n".join(report) + "\n", encoding="utf-8") + + with (metadata_root / "missing_template_report.csv").open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["asset_id", "cleaned_path", "reason"]) + writer.writeheader() + writer.writerows(missing_templates) + + with (metadata_root / "non_bim_assets_report.csv").open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["path", "type", "reason"]) + writer.writeheader() + writer.writerows(non_bim_rows) + + +def validate_templates(assets: Sequence[AssetGroup]) -> List[Dict[str, str]]: + missing = [] + required = {"id", "elementType", "category", "displayName", "originalPath", "sourceFiles", "fileTypes", "editability", "parameters", "notes"} + for asset in assets: + template = build_template(asset) + missing_keys = sorted(required - set(template.keys())) + if missing_keys or not asset.files: + reason = "missing keys: " + ", ".join(missing_keys) if missing_keys else "no source files grouped" + missing.append({"asset_id": asset.id, "cleaned_path": asset.cleaned_folder.as_posix(), "reason": reason}) + return missing + + +def print_summary(assets: Sequence[AssetGroup], stats: Dict[str, object], copied_count: int, dry_run: bool, output_root: Path) -> None: + action = "would copy" if dry_run else "copied" + print("Phase 1 cleanup scan complete") + print(f"Output: {output_root}") + print(f"Dry run: {'yes' if dry_run else 'no'}") + print(f"Total files scanned: {stats['total_files']}") + print(f"BIM-relevant files {action}: {copied_count}") + print(f"Assets created: {len(assets)}") + print(f"Backup files detected: {stats['backup_files']}") + print(f"PCB/Gerber files detected: {stats['pcb_files']}") + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Safely copy selected FreeCAD-library assets into a cleaned CAD/BIM structure.") + parser.add_argument("--source", required=True, help="Path to the FreeCAD-library source repository.") + parser.add_argument("--output", required=True, help="Path for the cleaned output folder.") + parser.add_argument("--dry-run", action="store_true", help="Scan and report without creating or copying files.") + parser.add_argument("--include-archive", action="store_true", help="Create archive_non_bim for future non-BIM archive workflows.") + parser.add_argument("--limit-category", help="Optional cleaned category filter such as architecture.doors or mep.hvac_pipes.") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + source_root = Path(args.source).resolve() + output_root = Path(args.output).resolve() + if not source_root.exists() or not source_root.is_dir(): + raise SystemExit(f"Source folder does not exist: {source_root}") + if output_root == source_root or source_root in output_root.parents: + raise SystemExit("Output must be outside the source repository to keep cleanup non-destructive.") + + assets_by_key, stats = scan_assets(source_root, args.limit_category) + assets = sorted(assets_by_key.values(), key=lambda asset: asset.cleaned_folder.as_posix()) + copied_count = sum(len(asset.files) for asset in assets) + missing_templates = validate_templates(assets) + + if not args.dry_run: + ensure_output_structure(output_root, args.include_archive) + copied_count = copy_asset_files(output_root, assets) + write_metadata(output_root, assets, stats, copied_count, args.include_archive, missing_templates) + + print_summary(assets, stats, copied_count, args.dry_run, output_root) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/floating_toolbar_phase4.py b/tools/floating_toolbar_phase4.py new file mode 100755 index 00000000..abc2a115 --- /dev/null +++ b/tools/floating_toolbar_phase4.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Build and serve Phase 4 floating parameter toolbar demo data.""" +from __future__ import annotations +import argparse, csv, json, shutil, sys +from dataclasses import dataclass, field +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) + +from toolbar_runtime.export import export_assets_json, export_geometry_links, export_object_instances, export_toolbar_config +from toolbar_runtime.schema_adapter import build_asset_record, load_template, scan_templates +from toolbar_runtime.state_manager import create_object_instance +from toolbar_runtime.validation import get_validation_issues + +UI_SOURCE = REPO_ROOT / "ui" / "floating_parameter_toolbar" +LIMITATIONS = ["Prototype UI only.", "Does not edit FCStd/STEP/STL/BREP/source CAD files.", "Canvas preview is simplified 2.5D placeholder drawing.", "Phase 5 is still required for real-time viewport integration."] + +@dataclass +class Row: + asset_id: str; display_name: str; element_type: str; category: str; template_path: str; toolbar_ready: bool; parameter_count: int; toolbar_group_count: int; issues: List[Dict[str,str]] = field(default_factory=list) + +@dataclass +class Summary: + rows: List[Row] = field(default_factory=list) + scanned: int = 0 + @property + def ready(self): return sum(1 for r in self.rows if r.toolbar_ready) + @property + def skipped(self): return self.scanned - len(self.rows) + @property + def unsupported(self): return sum(1 for r in self.rows if not r.toolbar_ready) + @property + def validation_issue_count(self): return sum(len(r.issues) for r in self.rows) + @property + def missing_toolbar_groups(self): return sum(1 for r in self.rows if r.toolbar_group_count == 0) + @property + def control_count(self): return sum(r.parameter_count for r in self.rows) + +def matches(record: Dict[str, Any], limit_category: Optional[str], limit_element_type: Optional[str], asset_id: Optional[str]) -> bool: + c = record.get("category", ""); e = record.get("elementType", "") + return not ((limit_category and not (c == limit_category or c.startswith(limit_category+"."))) or (limit_element_type and e != limit_element_type) or (asset_id and record.get("id") != asset_id)) + +def collect_records(library_root: Path, geometry_root: Optional[Path], limit_category=None, limit_element_type=None, asset_id=None) -> tuple[List[Dict[str,Any]], Summary]: + records=[]; summary=Summary() + for template_path in sorted(scan_templates(library_root)): + summary.scanned += 1 + template = load_template(template_path) + record = build_asset_record(template_path, template, geometry_root) + if not matches(record, limit_category, limit_element_type, asset_id): + continue + issues = get_validation_issues(template) + groups = record.get("toolbar",{}).get("groups",[]) + row = Row(str(record.get("id","")), str(record.get("displayName","")), str(record.get("elementType","")), str(record.get("category","")), template_path.as_posix(), not issues, len(record.get("parameters",[])), len(groups), issues) + summary.rows.append(row); records.append(record) + return records, summary + +def copy_ui(output_root: Path) -> None: + target = output_root / "ui" + if target.exists(): shutil.rmtree(target) + shutil.copytree(UI_SOURCE, target, ignore=shutil.ignore_patterns("sample_data")) + +def write_reports(report_dir: Path, summary: Summary) -> None: + report_dir.mkdir(parents=True, exist_ok=True) + types = sorted({r.element_type for r in summary.rows}) + lines=["# Phase 4 Floating Toolbar Report","",f"- Total templates scanned: {summary.scanned}",f"- Total toolbar-ready assets: {summary.ready}",f"- Total skipped assets: {summary.skipped}",f"- Total unsupported assets: {summary.unsupported}",f"- Element types covered: {', '.join(types) if types else 'none'}",f"- Parameter controls generated: {summary.control_count}",f"- Assets with missing toolbar groups: {summary.missing_toolbar_groups}",f"- Validation issues found: {summary.validation_issue_count}","","## Limitations","",*[f"- {x}" for x in LIMITATIONS],"","## Next recommended step","","Phase 5 should connect toolbar edits to real-time regeneration and viewport integration."] + (report_dir/"phase4_toolbar_report.md").write_text("\n".join(lines)+"\n", encoding="utf-8") + with (report_dir/"phase4_toolbar_report.csv").open("w",encoding="utf-8",newline="") as h: + w=csv.DictWriter(h, fieldnames=["asset_id","display_name","element_type","category","template_path","toolbar_ready","parameter_count","toolbar_group_count","issues"]); w.writeheader() + for r in summary.rows: w.writerow({"asset_id":r.asset_id,"display_name":r.display_name,"element_type":r.element_type,"category":r.category,"template_path":r.template_path,"toolbar_ready":str(r.toolbar_ready).lower(),"parameter_count":r.parameter_count,"toolbar_group_count":r.toolbar_group_count,"issues":"; ".join(i['code'] for i in r.issues)}) + with (report_dir/"phase4_toolbar_unsupported_assets.csv").open("w",encoding="utf-8",newline="") as h: + w=csv.DictWriter(h, fieldnames=["asset_id","element_type","category","template_path","reason"]); w.writeheader() + for r in summary.rows: + if not r.toolbar_ready: w.writerow({"asset_id":r.asset_id,"element_type":r.element_type,"category":r.category,"template_path":r.template_path,"reason":"; ".join(i['message'] for i in r.issues)}) + with (report_dir/"phase4_toolbar_validation_report.csv").open("w",encoding="utf-8",newline="") as h: + w=csv.DictWriter(h, fieldnames=["asset_id","template_path","issue_code","issue_message","severity","recommended_fix"]); w.writeheader() + for r in summary.rows: + for i in r.issues: w.writerow({"asset_id":r.asset_id,"template_path":r.template_path,"issue_code":i.get("code"),"issue_message":i.get("message"),"severity":i.get("severity"),"recommended_fix":i.get("recommendedFix")}) + +def write_demo(output_root: Path, records: List[Dict[str,Any]], summary: Summary, report_dir: Path) -> None: + output_root.mkdir(parents=True, exist_ok=True) + export_assets_json(records, output_root/"assets.json") + export_toolbar_config(records, output_root/"toolbar_config.json") + export_object_instances([create_object_instance(r) for r in records[:5]], output_root/"object_instances.json") + export_geometry_links(records, output_root/"geometry_links.json") + copy_ui(output_root); write_reports(report_dir, summary) + +def serve(output: Path, port: int) -> None: + import os + os.chdir(output) + print(f"Serving Phase 4 toolbar demo at http://localhost:{port}/ui/index.html") + ThreadingHTTPServer(("", port), SimpleHTTPRequestHandler).serve_forever() + +def process(library_root: Optional[Path], output: Path, geometry_root: Optional[Path], dry_run: bool, write: bool, report_dir: Path, **filters) -> Summary: + if not library_root: + raise SystemExit("--library-root is required for --dry-run or --write.") + records, summary = collect_records(library_root, geometry_root, **filters) + if write: write_demo(output, records, summary, report_dir) + else: write_reports(report_dir, summary) + return summary + +def parse_args(argv: Optional[Sequence[str]]=None): + p=argparse.ArgumentParser(description="Build or serve Phase 4 floating parameter toolbar demo data.") + p.add_argument("--library-root"); p.add_argument("--generated-geometry-root"); p.add_argument("--output", required=True) + mode=p.add_mutually_exclusive_group(required=True); mode.add_argument("--dry-run", action="store_true"); mode.add_argument("--write", action="store_true"); mode.add_argument("--serve", action="store_true") + p.add_argument("--port", type=int, default=8765); p.add_argument("--limit-category"); p.add_argument("--limit-element-type"); p.add_argument("--asset-id"); p.add_argument("--report-dir") + return p.parse_args(argv) + +def main(argv: Optional[Sequence[str]]=None) -> int: + a=parse_args(argv); out=Path(a.output).resolve(); report=Path(a.report_dir).resolve() if a.report_dir else out/"00_reports" + if a.serve: serve(out, a.port); return 0 + lib=Path(a.library_root).resolve() if a.library_root else None; geo=Path(a.generated_geometry_root).resolve() if a.generated_geometry_root else None + s=process(lib,out,geo,a.dry_run,a.write,report,limit_category=a.limit_category,limit_element_type=a.limit_element_type,asset_id=a.asset_id) + print("Phase 4 toolbar data processing complete"); print(f"Dry run: {'yes' if a.dry_run else 'no'}"); print(f"Write demo: {'yes' if a.write else 'no'}"); print(f"Templates scanned: {s.scanned}"); print(f"Toolbar-ready assets: {s.ready}"); print(f"Validation issues: {s.validation_issue_count}"); print(f"Output: {out}"); print(f"Reports: {report}") + return 0 +if __name__ == "__main__": raise SystemExit(main()) diff --git a/tools/geometry_generator_phase3.py b/tools/geometry_generator_phase3.py new file mode 100755 index 00000000..43522a60 --- /dev/null +++ b/tools/geometry_generator_phase3.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Generate Phase 3 placeholder geometry from enriched BIM template.json files.""" +from __future__ import annotations + +import argparse +import csv +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from generators.mesh_utils import sanitize_filename, write_ascii_stl, write_geometry_json, write_obj +from generators.registry import get_generator, registry_summary + +LIMITATIONS = [ + "Placeholder geometry only.", + "Does not modify or reverse-engineer original FCStd/STEP/STL files.", + "Not yet suitable as final production CAD geometry.", +] + + +@dataclass +class GenerationRow: + asset_id: str + element_type: str + category: str + template_path: str + output_path: str + status: str + generator: str + issues: List[str] = field(default_factory=list) + + +@dataclass +class Summary: + rows: List[GenerationRow] = field(default_factory=list) + formats: set[str] = field(default_factory=set) + scanned: int = 0 + + @property + def generated(self) -> int: + return sum(1 for row in self.rows if row.status in {"generated", "would_generate"}) + + @property + def skipped(self) -> int: + return sum(1 for row in self.rows if row.status == "skipped") + + @property + def failed(self) -> int: + return sum(1 for row in self.rows if row.status == "failed") + + @property + def unsupported(self) -> int: + return sum(1 for row in self.rows if "unsupported" in row.issues) + + +def load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def find_templates(library_root: Path) -> Iterable[Path]: + for path in library_root.rglob("template.json"): + if "00_metadata" in path.relative_to(library_root).parts: + continue + if path.is_file(): + yield path + + +def selected_formats(format_name: str) -> set[str]: + return {"json", "obj", "stl"} if format_name == "all" else {format_name} + + +def category_path(category: str) -> Path: + return Path(*[sanitize_filename(part) for part in category.split(".") if part]) + + +def asset_output_path(output_root: Path, template: Dict[str, Any]) -> Path: + category = str(template.get("category", "uncategorized")) + asset_id = sanitize_filename(str(template.get("id", "unknown_asset"))) + return output_root / category_path(category) / asset_id + + +def matches_filters(template: Dict[str, Any], limit_category: Optional[str], limit_element_type: Optional[str], asset_id: Optional[str]) -> bool: + category = str(template.get("category", "")) + element_type = str(template.get("elementType", "")) + if limit_category and not (category == limit_category or category.startswith(limit_category + ".")): + return False + if limit_element_type and element_type != limit_element_type: + return False + if asset_id and template.get("id") != asset_id: + return False + return True + + +def geometry_json_payload(generated: Dict[str, Any]) -> Dict[str, Any]: + return { + "assetId": generated.get("assetId"), + "elementType": generated.get("elementType"), + "category": generated.get("category"), + "units": generated.get("units", "mm"), + "parametersUsed": generated.get("parametersUsed", {}), + "geometryType": generated.get("geometryType", "mesh"), + "metadata": generated.get("metadata", {}), + } + + +def manifest_for(template: Dict[str, Any], template_path: Path, generated: Dict[str, Any], formats: set[str]) -> Dict[str, Any]: + outputs: Dict[str, str] = {} + if "json" in formats: + outputs["geometryJson"] = "geometry.json" + if "obj" in formats: + outputs["obj"] = "preview.obj" + if "stl" in formats: + outputs["stl"] = "preview.stl" + return { + "assetId": template.get("id"), + "elementType": template.get("elementType"), + "generatorName": generated.get("metadata", {}).get("generator"), + "generatorVersion": generated.get("metadata", {}).get("generatorVersion", "phase3.v1"), + "inputTemplate": template_path.as_posix(), + "outputs": outputs, + "status": "generated", + "limitations": LIMITATIONS, + } + + +def write_asset_outputs(asset_dir: Path, template: Dict[str, Any], template_path: Path, generated: Dict[str, Any], formats: set[str]) -> None: + mesh = {"vertices": generated.get("vertices", []), "faces": generated.get("faces", [])} + if "json" in formats: + write_geometry_json(mesh, geometry_json_payload(generated), asset_dir / "geometry.json") + if "obj" in formats: + write_obj(mesh, asset_dir / "preview.obj") + if "stl" in formats: + write_ascii_stl(mesh, asset_dir / "preview.stl", str(template.get("id", "asset"))) + write_json(asset_dir / "generator_manifest.json", manifest_for(template, template_path, generated, formats)) + + +def process_library(library_root: Path, output_root: Path, report_dir: Path, dry_run: bool, write: bool, limit_category: Optional[str], limit_element_type: Optional[str], asset_id: Optional[str], format_name: str) -> Summary: + formats = selected_formats(format_name) + summary = Summary(formats=formats) + for template_path in sorted(find_templates(library_root)): + template = load_json(template_path) + if not isinstance(template, dict): + continue + if not matches_filters(template, limit_category, limit_element_type, asset_id): + continue + summary.scanned += 1 + out_dir = asset_output_path(output_root, template) + element_type = str(template.get("elementType", "GenericBIMObject")) + generator = get_generator(element_type) + try: + if not generator.can_generate(template): + summary.rows.append(GenerationRow(str(template.get("id", "")), element_type, str(template.get("category", "")), template_path.as_posix(), out_dir.as_posix(), "skipped", generator.generator_name, ["unsupported"])) + continue + generated = generator.generate(template) + issues = generator.validate_parameters(generated.get("parametersUsed", {})) + if write: + write_asset_outputs(out_dir, template, template_path, generated, formats) + summary.rows.append(GenerationRow(str(template.get("id", "")), element_type, str(template.get("category", "")), template_path.as_posix(), out_dir.as_posix(), "would_generate" if dry_run else "generated", generator.generator_name, issues)) + except Exception as exc: # report per-asset failures without stopping the full batch + summary.rows.append(GenerationRow(str(template.get("id", "")), element_type, str(template.get("category", "")), template_path.as_posix(), out_dir.as_posix(), "failed", generator.generator_name, [str(exc)])) + write_reports(report_dir, summary) + return summary + + +def write_reports(report_dir: Path, summary: Summary) -> None: + report_dir.mkdir(parents=True, exist_ok=True) + write_markdown_report(report_dir / "phase3_generation_report.md", summary) + write_generation_csv(report_dir / "phase3_generation_report.csv", summary) + write_unsupported_csv(report_dir / "phase3_unsupported_assets.csv", summary) + write_coverage_csv(report_dir / "phase3_generator_coverage.csv", summary) + write_json(report_dir / "phase3_generator_registry.json", registry_summary()) + + +def write_markdown_report(path: Path, summary: Summary) -> None: + generated_types = sorted({row.element_type for row in summary.rows if row.status in {"generated", "would_generate"}}) + lines = [ + "# Phase 3 Geometry Generation Report", + "", + f"- Total templates scanned: {summary.scanned}", + f"- Total generated: {summary.generated}", + f"- Total skipped: {summary.skipped}", + f"- Total failed: {summary.failed}", + f"- Element types generated: {', '.join(generated_types) if generated_types else 'none'}", + f"- Output formats written: {', '.join(sorted(summary.formats))}", + f"- Unsupported assets: {summary.unsupported}", + "", + "## Limitations", + "", + *[f"- {item}" for item in LIMITATIONS], + "- Phase 3 proves template parameters can drive placeholder geometry; it is not the final CAD kernel.", + "", + "## Next recommended step", + "", + "Phase 4 should integrate these generators with a floating parameter toolbar UI.", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_generation_csv(path: Path, summary: Summary) -> None: + fieldnames = ["asset_id", "element_type", "category", "template_path", "output_path", "status", "generator", "issues"] + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames); writer.writeheader() + for row in summary.rows: + writer.writerow({"asset_id": row.asset_id, "element_type": row.element_type, "category": row.category, "template_path": row.template_path, "output_path": row.output_path, "status": row.status, "generator": row.generator, "issues": "; ".join(row.issues)}) + + +def write_unsupported_csv(path: Path, summary: Summary) -> None: + fieldnames = ["asset_id", "element_type", "category", "template_path", "reason"] + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames); writer.writeheader() + for row in summary.rows: + if "unsupported" in row.issues: + writer.writerow({"asset_id": row.asset_id, "element_type": row.element_type, "category": row.category, "template_path": row.template_path, "reason": "; ".join(row.issues)}) + + +def write_coverage_csv(path: Path, summary: Summary) -> None: + coverage: Dict[str, Dict[str, Any]] = {} + for row in summary.rows: + item = coverage.setdefault(row.element_type, {"templates_found": 0, "generated_count": 0, "skipped_count": 0, "failed_count": 0, "generator_name": row.generator}) + item["templates_found"] += 1 + if row.status in {"generated", "would_generate"}: item["generated_count"] += 1 + if row.status == "skipped": item["skipped_count"] += 1 + if row.status == "failed": item["failed_count"] += 1 + fieldnames = ["element_type", "templates_found", "generated_count", "skipped_count", "failed_count", "generator_name"] + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames); writer.writeheader() + for element_type, row in sorted(coverage.items()): + writer.writerow({"element_type": element_type, **row}) + + +def print_summary(summary: Summary, output_root: Path, report_dir: Path, dry_run: bool, write: bool) -> None: + print("Phase 3 geometry generation complete") + print(f"Dry run: {'yes' if dry_run else 'no'}") + print(f"Write geometry: {'yes' if write else 'no'}") + print(f"Templates scanned: {summary.scanned}") + print(f"Generated: {summary.generated}") + print(f"Skipped: {summary.skipped}") + print(f"Failed: {summary.failed}") + print(f"Output formats: {', '.join(sorted(summary.formats))}") + print(f"Output root: {output_root}") + print(f"Reports: {report_dir}") + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate Phase 3 placeholder geometry from enriched CAD/BIM template.json files.") + parser.add_argument("--library-root", required=True, help="Path to the cleaned BIM library output from Phase 1/2.") + parser.add_argument("--output", required=True, help="Path where generated geometry should be written.") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true", help="Scan templates and report what would be generated, without writing geometry files.") + mode.add_argument("--write", action="store_true", help="Actually generate output geometry files.") + parser.add_argument("--limit-category", help="Only process one category such as architecture.doors.") + parser.add_argument("--limit-element-type", help="Only process one element type such as Door or Duct.") + parser.add_argument("--asset-id", help="Generate geometry for only one asset id.") + parser.add_argument("--format", choices=["json", "obj", "stl", "all"], default="all", help="Output format to write. Default: all.") + parser.add_argument("--report-dir", help="Optional report directory. Default: /00_reports.") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + library_root = Path(args.library_root).resolve() + output_root = Path(args.output).resolve() + if not library_root.exists() or not library_root.is_dir(): + raise SystemExit(f"Library root does not exist: {library_root}") + report_dir = Path(args.report_dir).resolve() if args.report_dir else output_root / "00_reports" + summary = process_library(library_root, output_root, report_dir, args.dry_run, args.write, args.limit_category, args.limit_element_type, args.asset_id, args.format) + print_summary(summary, output_root, report_dir, args.dry_run, args.write) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/parameter_schema_phase2.py b/tools/parameter_schema_phase2.py new file mode 100755 index 00000000..1d87c655 --- /dev/null +++ b/tools/parameter_schema_phase2.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +"""Phase 2 parameter schema enrichment for cleaned CAD/BIM templates. + +The script operates on a cleaned library produced by Phase 1, or on any folder +that contains asset-level template.json files. It updates metadata only; it does +not open or edit CAD geometry files such as FCStd, STEP, STL, or BREP. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import shutil +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +SCHEMA_VERSION = "phase2.v1" +ALLOWED_EDITABILITY_STATUSES = { + "static_reference", + "parametric_source_available", + "generator_ready", + "fully_parametric", + "unsupported", +} +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SCHEMA_ROOT = REPO_ROOT / "schemas" / "parameter_schema" + + +@dataclass +class ValidationIssue: + asset_id: str + template_path: str + element_type: str + category: str + valid: bool + issue_code: str + issue_message: str + recommended_fix: str + + +@dataclass +class ProcessedTemplate: + path: Path + original: Dict[str, Any] + enriched: Dict[str, Any] + issues: List[ValidationIssue] + missing_required: List[str] + custom_parameters: List[str] + unsupported: bool + changed: bool + + +@dataclass +class RunSummary: + processed: List[ProcessedTemplate] = field(default_factory=list) + categories: set[str] = field(default_factory=set) + + @property + def scanned_count(self) -> int: + return len(self.processed) + + @property + def enriched_count(self) -> int: + return sum(1 for item in self.processed if item.changed) + + @property + def valid_count(self) -> int: + return sum(1 for item in self.processed if not item.issues) + + @property + def missing_required_count(self) -> int: + return sum(1 for item in self.processed if item.missing_required) + + @property + def unsupported_count(self) -> int: + return sum(1 for item in self.processed if item.unsupported) + + @property + def custom_parameter_count(self) -> int: + return sum(len(item.custom_parameters) for item in self.processed) + + +def load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def write_json(path: Path, data: Any) -> None: + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +def load_schema_config(schema_root: Path = DEFAULT_SCHEMA_ROOT) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]: + """Load element schemas, parameter catalog, and validation rules.""" + element_schemas = load_json(schema_root / "element_type_schemas.json") + parameter_catalog = load_json(schema_root / "parameter_catalog.json") + validation_rules = load_json(schema_root / "validation_rules.json") + return element_schemas, parameter_catalog, validation_rules + + +def is_snake_case(value: str) -> bool: + return bool(re.fullmatch(r"[a-z0-9]+(?:_[a-z0-9]+)*", value or "")) + + +def is_dot_notation(value: str) -> bool: + return bool(re.fullmatch(r"[a-z0-9_]+(?:\.[a-z0-9_]+)*", value or "")) + + +def is_camel_case(value: str) -> bool: + return bool(re.fullmatch(r"[a-z][A-Za-z0-9]*", value or "")) + + +def canonical_element_type(value: Optional[str]) -> Optional[str]: + if not value: + return None + compact = re.sub(r"[^A-Za-z0-9]+", "", value).lower() + aliases = { + "door": "Door", + "window": "Window", + "beam": "Beam", + "roof": "Roof", + "constructionblock": "ConstructionBlock", + "wall": "Wall", + "slab": "Slab", + "floor": "Floor", + "column": "Column", + "pipe": "Pipe", + "duct": "Duct", + "furniture": "Furniture", + "furniturefixture": "Fixture", + "fixture": "Fixture", + "foundation": "Foundation", + "vegetation": "Vegetation", + "sitesymbol": "Vegetation", + "topography": "GenericBIMObject", + "electricalequipment": "Fixture", + "genericbimobject": "GenericBIMObject", + } + return aliases.get(compact) + + +def infer_element_type(template: Dict[str, Any], template_path: Path) -> str: + """Infer a useful BIM element type from existing metadata and path text.""" + category = str(template.get("category", "")) + existing = canonical_element_type(str(template.get("elementType", ""))) + path_text = " ".join([category, str(template.get("originalPath", "")), template_path.as_posix()]).lower() + + if "doors" in path_text or ".door" in path_text: + return "Door" + if "windows" in path_text or ".window" in path_text: + return "Window" + if "beams" in path_text or ".beam" in path_text: + return "Beam" + if "roof" in path_text: + return "Roof" + if "construction_blocks" in path_text or "construction blocks" in path_text or "building construction" in path_text: + return "ConstructionBlock" + if "hvac_ducts" in path_text or " ducts" in path_text or "/duct" in path_text: + return "Duct" + if "pipes" in path_text or "tubes" in path_text or "hydro_equipment" in path_text: + return "Pipe" + if "foundation" in path_text: + return "Foundation" + if "bathroom" in path_text or "kitchen" in path_text: + return "Fixture" + if "furniture" in path_text or "living_room" in path_text or "living room" in path_text or "bedroom" in path_text: + return "Furniture" + if "vegetation" in path_text: + return "Vegetation" + return existing or "GenericBIMObject" + + +def normalize_editability(editability: Any, has_source_model: bool) -> Dict[str, Any]: + if not isinstance(editability, dict): + editability = {} + status = editability.get("status", "static_reference") + if status not in ALLOWED_EDITABILITY_STATUSES: + status = "static_reference" + normalized = dict(editability) + if status == "static_reference" and has_source_model and editability.get("status") == "parametric_source_available": + status = "parametric_source_available" + normalized["status"] = status + normalized["parametricSourceAvailable"] = bool(editability.get("parametricSourceAvailable", has_source_model)) + normalized["generatorAvailable"] = bool(editability.get("generatorAvailable", False)) + normalized["schemaReady"] = True + return normalized + + +def merge_parameter(existing: Optional[Dict[str, Any]], catalog_entry: Optional[Dict[str, Any]], required: bool) -> Dict[str, Any]: + if catalog_entry: + merged = dict(catalog_entry) + else: + existing_name = existing.get("name") if existing else "customParameter" + merged = { + "name": existing_name, + "label": existing_name, + "description": "Custom parameter not yet defined in the Phase 2 catalog.", + "type": "text", + "unit": None, + "default": None, + "min": None, + "max": None, + "options": None, + "uiControl": "text_input", + "toolbarGroup": "custom", + "validation": {"required": required}, + "custom": True, + } + if existing: + for key, value in existing.items(): + if key == "validation" and isinstance(value, dict): + validation = dict(merged.get("validation", {})) + validation.update({k: v for k, v in value.items() if v is not None}) + merged["validation"] = validation + elif key not in merged or value not in (None, "", []): + merged[key] = value + validation = dict(merged.get("validation", {})) + validation["required"] = required + if merged.get("type") == "number" and "positive" not in validation: + validation["positive"] = True + merged["validation"] = validation + return merged + + +def enrich_parameters(template: Dict[str, Any], element_schema: Dict[str, Any], catalog: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[str], List[str]]: + existing_parameters = template.get("parameters", []) + if not isinstance(existing_parameters, list): + existing_parameters = [] + existing_by_name = {param.get("name"): param for param in existing_parameters if isinstance(param, dict) and param.get("name")} + required = list(element_schema.get("requiredParameters", [])) + optional = list(element_schema.get("optionalParameters", [])) + ordered_names: List[str] = [] + for name in required + optional + list(existing_by_name.keys()): + if name not in ordered_names: + ordered_names.append(name) + enriched: List[Dict[str, Any]] = [] + custom: List[str] = [] + for name in ordered_names: + existing = existing_by_name.get(name) + catalog_entry = catalog.get(name) + if not catalog_entry and (not existing or not existing.get("custom")): + custom.append(name) + enriched.append(merge_parameter(existing, catalog_entry, name in required)) + missing_required = [name for name in required if name not in existing_by_name] + return enriched, missing_required, sorted(custom) + + +def build_toolbar(element_schema: Dict[str, Any], parameters: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + available = {param.get("name") for param in parameters} + groups = [] + for group in element_schema.get("toolbarGroups", []): + group_params = [name for name in group.get("parameters", []) if name in available] + if group_params: + copied = dict(group) + copied["parameters"] = group_params + groups.append(copied) + grouped = {name for group in groups for name in group["parameters"]} + custom_parameters = [param.get("name") for param in parameters if param.get("name") not in grouped] + if custom_parameters: + groups.append({"groupId": "custom", "label": "Custom", "parameters": custom_parameters}) + return {"mode": "floating_parameter_panel", "groups": groups} + + +def validation_issue(template: Dict[str, Any], template_path: Path, code: str, message: str, fix: str, valid: bool = False) -> ValidationIssue: + return ValidationIssue( + asset_id=str(template.get("id", "")), + template_path=template_path.as_posix(), + element_type=str(template.get("elementType", "")), + category=str(template.get("category", "")), + valid=valid, + issue_code=code, + issue_message=message, + recommended_fix=fix, + ) + + +def validate_template(template: Dict[str, Any], template_path: Path, element_schemas: Dict[str, Any], catalog: Dict[str, Any], rules: Dict[str, Any]) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + for field_name in rules.get("requiredTemplateFields", []): + if field_name not in template: + issues.append(validation_issue(template, template_path, "missing_required_field", f"template.json is missing '{field_name}'.", f"Add the '{field_name}' field.")) + asset_id = template.get("id") + if asset_id and not is_snake_case(str(asset_id)): + issues.append(validation_issue(template, template_path, "asset_id_not_snake_case", "Asset id should use snake_case.", "Rename the asset id with lowercase words separated by underscores.")) + category = template.get("category") + if category and not is_dot_notation(str(category)): + issues.append(validation_issue(template, template_path, "category_not_dot_notation", "Category should use dot notation such as architecture.doors.wood.", "Normalize the category path to dot notation.")) + element_type = template.get("elementType") + if element_type not in element_schemas: + issues.append(validation_issue(template, template_path, "unsupported_element_type", f"Element type '{element_type}' is not in element_type_schemas.json.", "Infer or map this asset to a supported BIM element type.")) + status = template.get("editability", {}).get("status") if isinstance(template.get("editability"), dict) else None + if status and status not in ALLOWED_EDITABILITY_STATUSES: + issues.append(validation_issue(template, template_path, "unsupported_editability_status", f"Editability status '{status}' is not allowed.", "Use a status from validation_rules.json.")) + parameters = template.get("parameters", []) + if not isinstance(parameters, list): + issues.append(validation_issue(template, template_path, "parameters_not_list", "Parameters must be a list.", "Replace parameters with a list of parameter objects.")) + parameters = [] + parameter_names = set() + for parameter in parameters: + if not isinstance(parameter, dict): + issues.append(validation_issue(template, template_path, "invalid_parameter", "Parameter entries must be objects.", "Replace invalid parameter entries with objects.")) + continue + name = parameter.get("name") + if not name: + issues.append(validation_issue(template, template_path, "parameter_missing_name", "A parameter is missing a name.", "Add a camelCase parameter name.")) + continue + parameter_names.add(name) + if not is_camel_case(str(name)): + issues.append(validation_issue(template, template_path, "parameter_name_not_camel_case", f"Parameter '{name}' should use camelCase.", "Rename the parameter using camelCase.")) + if name not in catalog and not parameter.get("custom"): + issues.append(validation_issue(template, template_path, "parameter_not_in_catalog", f"Parameter '{name}' is not in parameter_catalog.json.", "Add it to the catalog or mark it custom.")) + schema = element_schemas.get(element_type) + if schema: + for required_name in schema.get("requiredParameters", []): + if required_name not in parameter_names: + issues.append(validation_issue(template, template_path, "missing_required_parameter", f"Required parameter '{required_name}' is missing.", "Run Phase 2 enrichment or add the required parameter.")) + return issues + + +def enrich_template(template: Dict[str, Any], template_path: Path, element_schemas: Dict[str, Any], catalog: Dict[str, Any], rules: Dict[str, Any]) -> ProcessedTemplate: + original = json.loads(json.dumps(template)) + element_type = infer_element_type(template, template_path) + schema = element_schemas.get(element_type) or element_schemas["GenericBIMObject"] + enriched = dict(template) + enriched["elementType"] = schema["elementType"] + file_types = enriched.get("fileTypes", {}) if isinstance(enriched.get("fileTypes"), dict) else {} + has_source_model = "source_model" in file_types + enriched["editability"] = normalize_editability(enriched.get("editability", {}), has_source_model) + parameters, missing_required, custom_parameters = enrich_parameters(enriched, schema, catalog) + enriched["parameters"] = parameters + enriched["toolbar"] = build_toolbar(schema, parameters) + issues = validate_template(enriched, template_path, element_schemas, catalog, rules) + enriched["schema"] = { + "version": SCHEMA_VERSION, + "schemaReady": not issues, + "validatedAt": None, + "issues": [issue.issue_code for issue in issues], + } + notes = str(enriched.get("notes", "")).strip() + phase2_note = "Schema enriched in Phase 2." + if phase2_note not in notes: + notes = f"{notes} {phase2_note}".strip() + generator_note = "Needs parametric generator before real-time editing." + if generator_note not in notes: + notes = f"{notes} {generator_note}".strip() + enriched["notes"] = notes + # Validate again after schema metadata is present so issues reflect final output. + issues = validate_template(enriched, template_path, element_schemas, catalog, rules) + enriched["schema"]["schemaReady"] = not issues + enriched["schema"]["issues"] = [issue.issue_code for issue in issues] + unsupported = enriched.get("elementType") not in element_schemas + changed = enriched != original + return ProcessedTemplate(template_path, original, enriched, issues, missing_required, custom_parameters, unsupported, changed) + + +def find_template_files(library_root: Path) -> Iterable[Path]: + for path in library_root.rglob("template.json"): + if "00_metadata" in path.relative_to(library_root).parts: + continue + if path.is_file(): + yield path + + +def category_matches(template: Dict[str, Any], limit_category: Optional[str]) -> bool: + if not limit_category: + return True + category = str(template.get("category", "")) + return category == limit_category or category.startswith(limit_category + ".") + + +def backup_template(path: Path) -> Path: + candidate = path.with_suffix(path.suffix + ".bak") + counter = 2 + while candidate.exists(): + candidate = path.with_suffix(path.suffix + f".bak{counter}") + counter += 1 + shutil.copy2(path, candidate) + return candidate + + +def process_library(library_root: Path, report_dir: Path, dry_run: bool, write: bool, backup: bool, limit_category: Optional[str], schema_root: Path = DEFAULT_SCHEMA_ROOT) -> RunSummary: + element_schemas, catalog, rules = load_schema_config(schema_root) + summary = RunSummary() + for template_path in sorted(find_template_files(library_root)): + template = load_json(template_path) + if not isinstance(template, dict): + template = {} + if not category_matches(template, limit_category): + continue + processed = enrich_template(template, template_path, element_schemas, catalog, rules) + summary.processed.append(processed) + if processed.enriched.get("category"): + summary.categories.add(str(processed.enriched["category"])) + if write and processed.changed: + if backup: + backup_template(template_path) + write_json(template_path, processed.enriched) + write_reports(report_dir, summary, element_schemas) + return summary + + +def write_reports(report_dir: Path, summary: RunSummary, element_schemas: Dict[str, Any]) -> None: + report_dir.mkdir(parents=True, exist_ok=True) + write_schema_report(report_dir / "phase2_schema_report.md", summary) + write_validation_report(report_dir / "phase2_validation_report.csv", summary) + write_parameter_coverage(report_dir / "phase2_parameter_coverage.csv", summary, element_schemas) + write_toolbar_groups(report_dir / "phase2_toolbar_groups.json", summary) + write_unsupported_assets(report_dir / "phase2_unsupported_assets.csv", summary) + + +def write_schema_report(path: Path, summary: RunSummary) -> None: + lines = [ + "# Phase 2 Parameter Schema Report", + "", + f"- Total template.json files scanned: {summary.scanned_count}", + f"- Total templates valid: {summary.valid_count}", + f"- Total templates enriched: {summary.enriched_count}", + f"- Total templates with missing required parameters: {summary.missing_required_count}", + f"- Total templates with unsupported element type: {summary.unsupported_count}", + f"- Total custom parameters found: {summary.custom_parameter_count}", + "", + "## Categories processed", + "", + ] + lines.extend(f"- {category}" for category in sorted(summary.categories)) + lines.extend([ + "", + "## Limitations", + "", + "- Phase 2 enriches metadata only; it does not edit FreeCAD, STEP, STL, BREP, image, or documentation assets.", + "- Added parameters are schema placeholders and do not prove that geometry can regenerate from those values.", + "- Static exchange and mesh files still need generator work before real-time editing is possible.", + "", + "## Next recommended step", + "", + "Phase 3 should create geometry generators that consume these validated schemas.", + ]) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_validation_report(path: Path, summary: RunSummary) -> None: + fieldnames = ["asset id", "template path", "element type", "category", "valid true/false", "issue code", "issue message", "recommended fix"] + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for item in summary.processed: + if item.issues: + for issue in item.issues: + writer.writerow({ + "asset id": issue.asset_id, + "template path": issue.template_path, + "element type": issue.element_type, + "category": issue.category, + "valid true/false": "false", + "issue code": issue.issue_code, + "issue message": issue.issue_message, + "recommended fix": issue.recommended_fix, + }) + else: + writer.writerow({ + "asset id": item.enriched.get("id", ""), + "template path": item.path.as_posix(), + "element type": item.enriched.get("elementType", ""), + "category": item.enriched.get("category", ""), + "valid true/false": "true", + "issue code": "", + "issue message": "", + "recommended fix": "", + }) + + +def write_parameter_coverage(path: Path, summary: RunSummary, element_schemas: Dict[str, Any]) -> None: + totals: Dict[str, Dict[str, Any]] = defaultdict(lambda: {"assets": 0, "parameter_counts": [], "missing": 0}) + for item in summary.processed: + element_type = str(item.enriched.get("elementType", "GenericBIMObject")) + totals[element_type]["assets"] += 1 + totals[element_type]["parameter_counts"].append(len(item.enriched.get("parameters", []))) + totals[element_type]["missing"] += len(item.missing_required) + fieldnames = ["element type", "total assets", "required parameters", "optional parameters", "average parameter count", "missing required parameter count"] + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for element_type in sorted(totals): + schema = element_schemas.get(element_type, {}) + counts = totals[element_type]["parameter_counts"] + average = sum(counts) / len(counts) if counts else 0 + writer.writerow({ + "element type": element_type, + "total assets": totals[element_type]["assets"], + "required parameters": ";".join(schema.get("requiredParameters", [])), + "optional parameters": ";".join(schema.get("optionalParameters", [])), + "average parameter count": f"{average:.2f}", + "missing required parameter count": totals[element_type]["missing"], + }) + + +def write_toolbar_groups(path: Path, summary: RunSummary) -> None: + groups: Dict[str, Any] = {} + for item in summary.processed: + element_type = str(item.enriched.get("elementType", "GenericBIMObject")) + groups.setdefault(element_type, item.enriched.get("toolbar", {"mode": "floating_parameter_panel", "groups": []})) + write_json(path, groups) + + +def write_unsupported_assets(path: Path, summary: RunSummary) -> None: + fieldnames = ["asset id", "template path", "element type", "category", "reason"] + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for item in summary.processed: + if item.unsupported: + writer.writerow({ + "asset id": item.enriched.get("id", ""), + "template path": item.path.as_posix(), + "element type": item.enriched.get("elementType", ""), + "category": item.enriched.get("category", ""), + "reason": "No supported useful BIM schema could be assigned.", + }) + + +def print_summary(summary: RunSummary, report_dir: Path, dry_run: bool, write: bool, backup: bool) -> None: + print("Phase 2 parameter schema processing complete") + print(f"Dry run: {'yes' if dry_run else 'no'}") + print(f"Write templates: {'yes' if write else 'no'}") + print(f"Backup templates: {'yes' if backup else 'no'}") + print(f"Templates scanned: {summary.scanned_count}") + print(f"Templates valid after enrichment: {summary.valid_count}") + print(f"Templates enriched: {summary.enriched_count}") + print(f"Templates with missing required parameters before enrichment: {summary.missing_required_count}") + print(f"Unsupported element types: {summary.unsupported_count}") + print(f"Custom parameters found: {summary.custom_parameter_count}") + print(f"Reports: {report_dir}") + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate and enrich Phase 1 cleaned BIM template.json files with Phase 2 parameter schemas.") + parser.add_argument("--library-root", required=True, help="Path to the cleaned BIM library generated by Phase 1, or any folder containing template.json files.") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--dry-run", action="store_true", help="Validate and report only; do not update template.json files.") + mode.add_argument("--write", action="store_true", help="Update/enrich template.json files.") + parser.add_argument("--backup", action="store_true", help="Before writing, create .bak copies of template.json files.") + parser.add_argument("--limit-category", help="Only process a category such as architecture.doors or mep.hvac_ducts.") + parser.add_argument("--report-dir", help="Where to write Phase 2 reports. Defaults to /00_metadata/phase2_schema_reports.") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + library_root = Path(args.library_root).resolve() + if not library_root.exists() or not library_root.is_dir(): + raise SystemExit(f"Library root does not exist: {library_root}") + if args.backup and not args.write: + raise SystemExit("--backup can only be used with --write.") + report_dir = Path(args.report_dir).resolve() if args.report_dir else library_root / "00_metadata" / "phase2_schema_reports" + summary = process_library(library_root, report_dir, args.dry_run, args.write, args.backup, args.limit_category) + print_summary(summary, report_dir, args.dry_run, args.write, args.backup) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ui/floating_parameter_toolbar/README.md b/ui/floating_parameter_toolbar/README.md new file mode 100644 index 00000000..a2b308f7 --- /dev/null +++ b/ui/floating_parameter_toolbar/README.md @@ -0,0 +1,9 @@ +# Floating Parameter Toolbar Prototype + +Open `index.html` directly or serve the generated demo folder with: + +```bash +python tools/floating_toolbar_phase4.py --output /tmp/cad_bim_toolbar_demo --serve --port 8765 +``` + +The UI is plain HTML/CSS/JavaScript. It has no npm, bundler, CDN, or framework dependency. diff --git a/ui/floating_parameter_toolbar/app.js b/ui/floating_parameter_toolbar/app.js new file mode 100644 index 00000000..95682f20 --- /dev/null +++ b/ui/floating_parameter_toolbar/app.js @@ -0,0 +1,14 @@ +const FALLBACKS={Door:{width:900,height:2100,thickness:45,frameWidth:100,openingAngle:90},Window:{width:1200,height:1200,frameDepth:80,frameWidth:60,panelCount:2},Beam:{length:3000,width:200,height:400},Roof:{length:4000,width:3000,thickness:150,slope:15},ConstructionBlock:{length:390,width:190,height:190},Pipe:{length:1000,diameter:100,innerDiameter:80},Duct:{length:1000,width:400,height:250,thickness:10,ductShape:'rectangular'},Furniture:{width:600,depth:600,height:900},Fixture:{width:600,depth:600,height:900},Foundation:{length:1500,width:1500,height:500},Vegetation:{height:3000,diameter:1500},GenericBIMObject:{width:1000,depth:1000,height:1000}}; +let assets=[],selected=null,state=null,debounce=null; +async function loadData(){try{assets=(await (await fetch('../assets.json')).json()).assets}catch(e){assets=(await (await fetch('sample_data/sample_assets.json')).json()).assets}renderFilters();renderAssets();} +function renderFilters(){const types=[...new Set(assets.map(a=>a.elementType))].sort();typeFilter.innerHTML=''+types.map(t=>``).join('')} +function renderAssets(){const q=search.value.toLowerCase(),t=typeFilter.value;assetList.innerHTML='';assets.filter(a=>(!t||a.elementType===t)&&a.displayName.toLowerCase().includes(q)).forEach(a=>{const d=document.createElement('div');d.className='asset'+(selected&&selected.id===a.id?' active':'');d.innerHTML=`${a.displayName}${a.elementType}${a.category}`;d.onclick=()=>selectAsset(a);assetList.appendChild(d)})} +function defaults(asset){const f=FALLBACKS[asset.elementType]||FALLBACKS.GenericBIMObject, p={};(asset.parameters||[]).forEach(x=>p[x.name]=x.default??f[x.name]??null);Object.entries(f).forEach(([k,v])=>{if(p[k]===undefined)p[k]=v});return p} +function selectAsset(asset){selected=asset;state={instanceId:'object_'+Date.now(),assetId:asset.id,elementType:asset.elementType,category:asset.category,parameters:defaults(asset),placement:{x:0,y:0,z:0,rotation:0},editability:asset.editability||{}};renderAssets();renderToolbar();draw();renderState()} +function groups(asset){if(asset.toolbar&&asset.toolbar.groups&&asset.toolbar.groups.length)return asset.toolbar.groups;const g={};(asset.parameters||[]).forEach(p=>(g[p.toolbarGroup||'general']??=[]).push(p.name));return Object.entries(g).map(([k,v])=>({groupId:k,label:k,parameters:v}))} +function renderToolbar(){toolbar.classList.remove('hidden');toolbarBody.innerHTML='';const byName=Object.fromEntries((selected.parameters||[]).map(p=>[p.name,p]));groups(selected).forEach(g=>{const wrap=document.createElement('div');wrap.className='group';wrap.innerHTML=`

${g.label}

`;(g.parameters||[]).forEach(n=>{const p=byName[n];if(!p)return;const field=document.createElement('div');field.className='field';const val=state.parameters[n]??'';let input=p.uiControl==='select'?``:p.uiControl==='checkbox'?``:p.uiControl==='textarea'?``:``;field.innerHTML=`${input}
${p.description||''}${p.min!=null?' min '+p.min:''}${p.max!=null?' max '+p.max:''}
`;wrap.appendChild(field)});toolbarBody.appendChild(wrap)});toolbarBody.querySelectorAll('[data-param]').forEach(i=>i.oninput=()=>{clearTimeout(debounce);debounce=setTimeout(()=>update(i),180)})} +function validate(p,val){const e=[];if(p.uiControl==='number_input'){const n=Number(val);if(Number.isNaN(n))e.push('Must be numeric');if(p.validation&&p.validation.positive&&n<=0)e.push('Must be positive');if(p.min!=null&&nNumber(p.max))e.push('Above maximum')}return e} +function update(input){const p=selected.parameters.find(x=>x.name===input.dataset.param);let v=input.type==='checkbox'?input.checked:input.value;if(input.type==='number')v=Number(v);const errs=validate(p,v);document.getElementById('err_'+p.name).textContent=errs.join(', ');if(!errs.length){state.parameters[p.name]=v;draw();renderState()}} +function draw(){const c=preview,ctx=c.getContext('2d');ctx.clearRect(0,0,c.width,c.height);ctx.save();ctx.translate(390,280);ctx.strokeStyle='#1d3557';ctx.fillStyle='#8ecae6';const p=state.parameters,e=state.elementType;function box(w,h,d=60){ctx.fillRect(-w/2,-h/2,w,h);ctx.strokeRect(-w/2,-h/2,w,h);ctx.beginPath();ctx.moveTo(-w/2,-h/2);ctx.lineTo(-w/2+d,-h/2-d);ctx.lineTo(w/2+d,-h/2-d);ctx.lineTo(w/2,-h/2);ctx.stroke()} if(e==='Pipe'){ctx.beginPath();ctx.ellipse(0,0,(p.diameter||100)/2,60,0,0,7);ctx.fill();ctx.stroke()} else if(e==='Vegetation'){ctx.fillStyle='#8b5a2b';ctx.fillRect(-15,0,30,120);ctx.fillStyle='#3a7d44';ctx.beginPath();ctx.arc(0,-50,(p.diameter||1500)/20,0,7);ctx.fill();ctx.stroke()} else box(Math.min((p.width||p.length||1000)/5,500),Math.min((p.height||p.thickness||500)/5,420));ctx.restore()} +function renderState(){stateJson.textContent=JSON.stringify(state,null,2)} +downloadState.onclick=()=>{const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(state,null,2)],{type:'application/json'}));a.download=state.instanceId+'.json';a.click()};search.oninput=renderAssets;typeFilter.onchange=renderAssets;collapseBtn.onclick=()=>toolbar.classList.toggle('collapsed');let drag=false,ox=0,oy=0;toolbarHeader.onmousedown=e=>{drag=true;ox=e.clientX-toolbar.offsetLeft;oy=e.clientY-toolbar.offsetTop};document.onmouseup=()=>drag=false;document.onmousemove=e=>{if(drag){toolbar.style.left=e.clientX-ox+'px';toolbar.style.top=e.clientY-oy+'px'}};loadData(); diff --git a/ui/floating_parameter_toolbar/index.html b/ui/floating_parameter_toolbar/index.html new file mode 100644 index 00000000..50b53f0c --- /dev/null +++ b/ui/floating_parameter_toolbar/index.html @@ -0,0 +1 @@ +Phase 4 Floating Parameter Toolbar

CAD/BIM Floating Parameter Toolbar Prototype

Phase 4 static UI: select an asset, edit parameters, validate, preview, and export object state.

diff --git a/ui/floating_parameter_toolbar/sample_data/sample_assets.json b/ui/floating_parameter_toolbar/sample_data/sample_assets.json new file mode 100644 index 00000000..56299b4d --- /dev/null +++ b/ui/floating_parameter_toolbar/sample_data/sample_assets.json @@ -0,0 +1 @@ +{"assets":[{"id":"door_sample","displayName":"Sample Door","elementType":"Door","category":"architecture.doors.sample","templatePath":"sample_data/sample_templates/door_sample_template.json","geometry":{},"toolbar":{"mode":"floating_parameter_panel","groups":[{"groupId":"dimensions","label":"Dimensions","parameters":["width","height"]}]},"parameters":[{"name":"width","label":"Width","type":"number","unit":"mm","default":900,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}},{"name":"height","label":"Height","type":"number","unit":"mm","default":2100,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}}],"editability":{"status":"static_reference","schemaReady":true}},{"id":"window_sample","displayName":"Sample Window","elementType":"Window","category":"architecture.windows.sample","templatePath":"sample_data/sample_templates/window_sample_template.json","geometry":{},"toolbar":{"mode":"floating_parameter_panel","groups":[{"groupId":"dimensions","label":"Dimensions","parameters":["width","height"]}]},"parameters":[{"name":"width","label":"Width","type":"number","unit":"mm","default":1200,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}}],"editability":{"status":"static_reference","schemaReady":true}}]} diff --git a/ui/floating_parameter_toolbar/sample_data/sample_templates/beam_sample_template.json b/ui/floating_parameter_toolbar/sample_data/sample_templates/beam_sample_template.json new file mode 100644 index 00000000..b2b4e458 --- /dev/null +++ b/ui/floating_parameter_toolbar/sample_data/sample_templates/beam_sample_template.json @@ -0,0 +1 @@ +{"id":"beam_sample","displayName":"Sample Beam","elementType":"Beam","category":"architecture.beams.sample","parameters":[{"name":"length","label":"Length","type":"number","unit":"mm","default":3000,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}},{"name":"height","label":"Height","type":"number","unit":"mm","default":400,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}}],"toolbar":{"mode":"floating_parameter_panel","groups":[{"groupId":"dimensions","label":"Dimensions","parameters":["length","height"]}]},"editability":{"status":"static_reference","schemaReady":true}} diff --git a/ui/floating_parameter_toolbar/sample_data/sample_templates/door_sample_template.json b/ui/floating_parameter_toolbar/sample_data/sample_templates/door_sample_template.json new file mode 100644 index 00000000..12bfa890 --- /dev/null +++ b/ui/floating_parameter_toolbar/sample_data/sample_templates/door_sample_template.json @@ -0,0 +1 @@ +{"id":"door_sample","displayName":"Sample Door","elementType":"Door","category":"architecture.doors.sample","parameters":[{"name":"width","label":"Width","type":"number","unit":"mm","default":900,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}},{"name":"height","label":"Height","type":"number","unit":"mm","default":2100,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}}],"toolbar":{"mode":"floating_parameter_panel","groups":[{"groupId":"dimensions","label":"Dimensions","parameters":["width","height"]}]},"editability":{"status":"static_reference","schemaReady":true}} diff --git a/ui/floating_parameter_toolbar/sample_data/sample_templates/window_sample_template.json b/ui/floating_parameter_toolbar/sample_data/sample_templates/window_sample_template.json new file mode 100644 index 00000000..872fb1ff --- /dev/null +++ b/ui/floating_parameter_toolbar/sample_data/sample_templates/window_sample_template.json @@ -0,0 +1 @@ +{"id":"window_sample","displayName":"Sample Window","elementType":"Window","category":"architecture.windows.sample","parameters":[{"name":"width","label":"Width","type":"number","unit":"mm","default":1200,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}},{"name":"height","label":"Height","type":"number","unit":"mm","default":1200,"min":0,"uiControl":"number_input","toolbarGroup":"dimensions","validation":{"positive":true}}],"toolbar":{"mode":"floating_parameter_panel","groups":[{"groupId":"dimensions","label":"Dimensions","parameters":["width","height"]}]},"editability":{"status":"static_reference","schemaReady":true}} diff --git a/ui/floating_parameter_toolbar/styles.css b/ui/floating_parameter_toolbar/styles.css new file mode 100644 index 00000000..ece79231 --- /dev/null +++ b/ui/floating_parameter_toolbar/styles.css @@ -0,0 +1 @@ +:root{font-family:Inter,Arial,sans-serif;color:#172033;background:#f4f6fa}body{margin:0}header{padding:16px 22px;background:#1d3557;color:white}header h1{margin:0 0 6px}main{display:grid;grid-template-columns:300px 1fr 360px;gap:12px;padding:12px}.asset-panel,.preview-panel,.state-panel{background:white;border:1px solid #d8deea;border-radius:10px;padding:12px;min-height:560px}#search,#typeFilter{width:100%;box-sizing:border-box;margin-bottom:8px;padding:8px}.asset{border:1px solid #e1e6f0;border-radius:8px;padding:9px;margin:8px 0;cursor:pointer}.asset:hover,.asset.active{border-color:#2b6cb0;background:#edf6ff}.asset small{display:block;color:#5b677a}canvas{width:100%;height:560px;background:linear-gradient(#eef6ff,#ffffff);border-radius:8px}.state-panel pre{white-space:pre-wrap;font-size:12px;max-height:500px;overflow:auto;background:#0f172a;color:#dbeafe;padding:10px;border-radius:8px}.toolbar{position:fixed;left:340px;top:130px;width:360px;background:white;border:1px solid #9db4d7;border-radius:12px;box-shadow:0 14px 40px #0003;z-index:10}.hidden{display:none}#toolbarHeader{padding:10px 12px;background:#244c7c;color:white;border-radius:12px 12px 0 0;cursor:move;display:flex;justify-content:space-between}#toolbarBody{padding:12px;max-height:560px;overflow:auto}.group{border-top:1px solid #e5eaf2;padding-top:10px;margin-top:10px}.field{margin:8px 0}.field label{display:block;font-weight:600}.field input,.field select,.field textarea{width:100%;box-sizing:border-box;padding:7px}.help{font-size:12px;color:#64748b}.error{font-size:12px;color:#b42318}.collapsed #toolbarBody{display:none}