diff --git a/.agents/skills/industry-pack-generator/SKILL.md b/.agents/skills/industry-pack-generator/SKILL.md new file mode 100644 index 000000000..03de73368 --- /dev/null +++ b/.agents/skills/industry-pack-generator/SKILL.md @@ -0,0 +1,61 @@ +--- +name: industry-pack-generator +description: Generate Pascal industrial profile packs from an industry brief, factory equipment list, or request such as "make a cement/electrolytic aluminum/food processing industry pack"; create a structured pack spec, scaffold profiles and quality rules, validate with repository QA gates, and prepare the pack for the simulated cloud. +--- + +# Industry Pack Generator + +Use this skill to create or expand installable Pascal geometry knowledge/profile packs. + +## Workflow + +1. Read the current project rules: + - `PROFILE_PACK_AUTHORING_STANDARD.md` + - `LLM_PRIMITIVE_GENERATION_ARCHITECTURE.md` when architecture context is needed + - `references/industry-pack-spec.md` for the scaffold spec shape +2. Identify package scope: + - Use one focused industry or extension domain per pack. + - Prefer `industry.{industry}.basic` for a self-contained base pack. + - Prefer `industry.{industry}.{extension}` with `dependsOn` for extensions. +3. Draft a v2 industry pack spec JSON with equipment profiles, parts, primary roles, aliases, visual cues, quality constraints, and equipment-node intent. + - For basic packs, include `factoryArchitectures` and `processTemplates`. + - For extension packs, include them only when the extension adds a new factory-level process. + - For recipe-backed equipment, include `recipeId`, `processPorts`, and `equipmentDefaults` so the scaffold can emit `equipmentBindings`. +4. Run the scaffold: + +```bash +bun apps/editor/scripts/scaffold-industry-profile-pack.ts --spec --force +``` + +5. Validate and QA: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts @ --validate-only +bun apps/editor/scripts/profile-pack-qa.ts @ --limit 3 +``` + +6. Review generated profiles: + - Ensure every `parts[].kind` exists in the Part Registry. + - Ensure `primarySemanticRole` appears in required roles. + - Ensure aliases include Chinese and English terms when useful. + - Keep `shapeCount.max` realistic; avoid bloated geometry. + +## Pack Authoring Rules + +- Generate data first, not TypeScript hardcoding. +- Reuse common part kinds from `packages/core/src/lib/part-registry.ts`. +- Default new packages to schema v2 with `dependsOnPlugins` and `equipmentBindings`. +- Use `generic_industrial_layout` unless a known layout family is clearly better. +- Put device-specific knowledge in profiles and quality rules. +- Do not invent a new family/layout for one equipment unless the layout is reusable. +- For unknown equipment, model major visible assemblies: body, frame, drive, ports, platforms, legs, hoppers, ducts, panels, sensors. + +## Output Contract + +When finishing, report: + +- Pack id and output directory. +- Device count and profile ids. +- Whether factory architectures and process templates were generated. +- Validation/QA command results. +- Any low-confidence devices that need visual reference review. diff --git a/.agents/skills/industry-pack-generator/agents/openai.yaml b/.agents/skills/industry-pack-generator/agents/openai.yaml new file mode 100644 index 000000000..b1b044351 --- /dev/null +++ b/.agents/skills/industry-pack-generator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Industry Pack Generator" + short_description: "Generate and validate Pascal industrial profile packs." + default_prompt: "Generate a Pascal industry profile pack from an industry brief, then validate it with the repository QA gates." diff --git a/.agents/skills/industry-pack-generator/references/industry-pack-spec.md b/.agents/skills/industry-pack-generator/references/industry-pack-spec.md new file mode 100644 index 000000000..a11a37b99 --- /dev/null +++ b/.agents/skills/industry-pack-generator/references/industry-pack-spec.md @@ -0,0 +1,159 @@ +# Industry Pack Scaffold Spec + +Use this JSON shape as input to `apps/editor/scripts/scaffold-industry-profile-pack.ts`. + +```json +{ + "industry": "cement", + "id": "industry.cement.basic", + "name": "Cement Basic Equipment Pack", + "version": "0.1.0", + "description": "Focused cement plant equipment pack.", + "schemaVersion": "2.0", + "dependsOnPlugins": ["pascal:factory-equipment"], + "capabilities": ["factory_creation"], + "factoryArchitectures": [ + { + "id": "cement.factory.modular", + "label": "Cement plant modular architecture", + "industry": "cement", + "processId": "cement_plant_full", + "layoutStyle": "linear", + "defaultDimensions": { "length": 60, "width": 24 }, + "modules": [ + { + "id": "pyro_line", + "displayLabel": "Pyro line", + "order": 10, + "stationIds": ["rotary_kiln"] + } + ] + } + ], + "processTemplates": [ + { + "processId": "cement_plant_full", + "processLabel": "Cement plant", + "processDisplayLabel": "Cement plant", + "domain": "industrial", + "aliases": ["cement plant", "cement factory"], + "requiredRoles": ["rotary_kiln"], + "defaultLayoutStyle": "linear", + "defaultDimensions": { "length": 60, "width": 24 }, + "stations": [ + { + "id": "rotary_kiln", + "label": "Rotary kiln", + "displayLabel": "Rotary kiln", + "role": "rotary_kiln", + "equipmentHint": "cement.rotary_kiln long inclined rotary kiln", + "profileId": "cement.rotary_kiln", + "footprintHint": "long" + } + ] + } + ], + "devices": [ + { + "id": "rotary_kiln", + "name": "Rotary kiln", + "aliases": ["rotary kiln", "cement kiln", "回转窑", "水泥回转窑"], + "recipeId": "factory:storage-tank", + "layoutFamily": "vessel_layout", + "family": "tank", + "defaultDimensions": { "length": 12, "width": 2.2, "height": 2.4 }, + "processPorts": [ + { "id": "inlet", "side": "left", "diameter": 0.3 }, + { "id": "outlet", "side": "right", "diameter": 0.28 } + ], + "equipmentDefaults": { "orientation": "horizontal", "capacity": 30, "liquidLevel": 0.35 }, + "primarySemanticRole": "kiln_shell", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "kiln_shell", + "required": true, + "length": 12, + "radius": 0.65, + "axis": "x" + } + ], + "forbiddenRoles": ["vehicle_cabin"], + "shapeCount": { "min": 8, "max": 80 }, + "visualCues": ["long inclined cylinder", "riding rings", "support rollers"] + } + ] +} +``` + +## Top-Level Fields + +- `industry`: required. Use a focused industry id such as `cement`, `food`, `fine-chemical`, or `electrolytic-aluminum`. +- `id`: optional. Defaults to `industry.{industry}.basic`. +- `name`: optional. Defaults to `{industry} Basic Equipment Pack`. +- `version`: optional. Defaults to `0.1.0`. +- `schemaVersion`: optional. Defaults to `2.0`. +- `dependsOn`: optional for extension packs, for example `[{ "id": "industry.fine-chemical.basic", "version": ">=0.1.0" }]`. +- `dependsOnPlugins`: optional. Defaults to `["pascal:factory-equipment"]` for v2 equipment-node packs. +- `capabilities`: optional. Use `["factory_creation"]` only when the pack includes factory/process knowledge. +- `factoryArchitectures`: required when `capabilities` includes `factory_creation`; otherwise optional. Defines the whole-plant module tree. +- `processTemplates`: required when `capabilities` includes `factory_creation`; otherwise optional. Defines stations, aliases, and station connections. +- `devices`: required non-empty list. + +## Factory-Capable Pack Rules + +When `capabilities` includes `factory_creation`, QA enforces: + +- At least one `factoryArchitectures` resource. +- At least one `processTemplates` resource. +- Every station must resolve through `profileId`/`equipmentProfileId` to an `equipmentBindings[]` + entry or declare `genericFallback.reason`. +- Every process-template station must be covered by a device profile, native resolver, or catalog resolver hint. +- Every architecture module `stationIds[]` entry must exist in the matching process template. +- Factory creation is intentionally single-process-template per request. Do not add quantity expansion fields such as `parameters`, `flows`, `countParam`, `defaultCount`, `minCount`, `maxCount`, or `replicatedStationIds`. + +If the pack only provides equipment profiles, omit `factory_creation`; QA will classify it as a `device-only` pack. + +## Device Fields + +- `id`, `name`, `aliases`, `parts`, `primarySemanticRole` are required. +- `layoutFamily` defaults to `generic_industrial_layout`. +- `family` defaults to `generic`. +- `recipeId` may reference a registered semantic equipment recipe such as `factory:centrifugal-pump`, `factory:storage-tank`, `factory:distillation-unit`, `factory:refinery-reactor-unit`, or `factory:refinery-auxiliary-unit`; otherwise the scaffold infers known equipment kinds from profile text. +- `defaultDimensions` should describe the whole equipment envelope. +- `processPorts` declares device-level ports that v2 `portMap` must cover. +- `equipmentDefaults` declares equipment-node parameters such as pump type, flow rate, motor power, + tank orientation, capacity, or liquid level. +- `parts[].kind` must exist in the Part Registry. +- `parts[].semanticRole` should be stable and domain-specific. +- `qualityRequiredRoles` can add roles beyond required parts. +- `forbiddenRoles` should prevent common wrong-domain details. +- `shapeCount` controls QA expectations. +- Control rooms, MCC rooms, labs, substations, and other occupied-building stations should use + `preferredResolver: "profile-parts"` instead of `catalog-item`, with roles for the building body, + roof cap/parapet, door/opening, windows, panels, and service entries. +- Packaged boilers can use a rectangular casing, but should also include boiler-specific process + features such as a stack, steam drum or tube bank, steam header/manifold, burner opening, platform, + and control box. Avoid profiles that are only `generic_body` plus one accessory. + +## Generated Files + +The scaffold writes: + +- `pack.json` +- `README.md` +- `profiles/generated.json` +- `factory-architectures/generated.json` when `factoryArchitectures` is provided +- `process-templates/generated.json` when `processTemplates` is provided +- `quality-rules/generated-quality.json` +- `-.zip` beside the generated source directory, unless `--skip-zip` is used + +The generated `README.md` labels the pack as `factory-capable` or `device-only` and lists supported factory/process templates when factory creation is enabled. +The generated `pack.json` uses schema v2, includes `dependsOnPlugins`, and writes inferred +`equipmentBindings` for profiles that map to factory equipment nodes. + +Run validation after generation: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts @ --validate-only +``` diff --git a/.agents/skills/review-architecture/SKILL.md b/.agents/skills/review-architecture/SKILL.md index 3ffd9b434..88e952d3d 100644 --- a/.agents/skills/review-architecture/SKILL.md +++ b/.agents/skills/review-architecture/SKILL.md @@ -1,6 +1,6 @@ --- name: review-architecture -description: Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. +description: Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, the slots + world-scale-UV convention for new nodes/geometry, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob --- @@ -97,8 +97,16 @@ If the PR adds or modifies a node kind, check against `wiki/architecture/node-de - **Tag geometry-built children.** `` only disposes children carrying `userData.__fromGeometry = true`. Custom systems that imperatively add children to a registered group must follow the same convention if the group can host React-mounted children (e.g. shelf surfaces hosting items). - **One registered mesh per node ID.** If a custom renderer mounts multiple objects, register the parent group (or whichever object the system needs to address via `sceneRegistry.nodes.get(id)`). - **Previews must clone cached materials.** If `def.preview` calls the geometry builder and then sets `material.opacity = 0.5`, but the builder caches materials at module scope (most do, keyed on `material` / `materialPreset`), the mutation leaks into every committed instance. Clone, mutate the clone, reassign `mesh.material`, dispose only the clone on unmount. Reference: `nodes/src/shelf/preview.tsx`. +- **Schema changes must keep old scenes loadable.** Any diff that adds, renames, removes, or retypes a property on a node schema needs a load path for scenes saved before the change (parsed through `AnyNode` in `SceneState.setScene`). A *new* field needs a Zod `.default()` / `.optional()`. A *rename / removal / retype* needs a `migrateNodes` entry in `packages/core/src/store/use-scene.ts` that rewrites the legacy shape before parse — a `.default()` alone silently drops the old value. A schema diff that does neither is a blocker: it breaks every existing scene. See `wiki/architecture/node-schemas.md` § Schema Evolution. - **Host kinds need `children` on the schema.** If `def.relations.hosts` is set, the schema must declare `children: z.array(z.string()).default([])` (and `migrateNodes` must patch existing scenes). Otherwise `useScene.createNode(child, parentId)` writes a `parent.children` entry into nothing and the host never sees the new child. - **Movable opt-in.** `MoveTool` dispatches to `MoveRegistryNodeTool` only when `def.capabilities.movable` is set. Kinds with bespoke move semantics (wall endpoint drag with linked-wall cascade, slab vertex edit, etc.) deliberately omit `movable` and supply `def.affordanceTools.move` instead. Force-routing a bespoke-move kind through generic dispatch (`nodeRegistry.has(kind)` instead of `def.capabilities.movable`) is a regression — call it out. The bug history is documented in `plans/editor-node-registry.md` ("Capability-driven move dispatch"). +- **Paint dispatch lives on `def.capabilities.paint`.** A paintable kind declares `resolveRole` / `buildPatch` / `applyPreview` (+ optional `getEffectiveMaterial`) on `PaintCapability`; the editor's selection-manager routes hover / click / preview through the generic dispatcher. A PR that adds an `if (node.type === '')` arm to paint-mode handling, paint-preview application, or material picker resolution is a regression — the behaviour belongs on the kind's `paint` capability. See `packages/core/src/registry/types.ts` (`PaintCapability`). +- **Slots + world-scale UVs for paintable surfaces.** A new kind (or geometry change) that exposes paintable parts must follow the unified slot convention, not reinvent it: + - **Paintable parts are slots, carried on the node.** Overrides live in a `slots` record (`slotId → MaterialRef`, `scene:`/`library:`) on the schema, resolved via `def.capabilities.paint` — not ad-hoc per-surface `material` / `materialPreset` fields, and not a parallel store. A new paintable kind whose schema lacks `slots` (or whose duplicate / preset / clone path drops it) is a blocker: it silently loses painted materials. (Slots are plain data — generic clone/parse preserves them; bespoke draft-rebuild placement paths must thread them through explicitly. Reference bug: item duplicate rebuilt the draft from `asset` and dropped `slots`.) + - **Texturable geometry emits UVs in metres (1 UV unit = 1 m).** Any `def.geometry` producing a surface a finish can tile onto must generate UVs at the same world scale walls / slabs / roofs use, because catalog finishes set `repeat` as tiles-per-metre. Unitless, bounding-box-normalised, or hardcoded UVs that don't scale with the surface are a blocker — finishes won't tile consistently. Flat-colour-only surfaces need no UVs. GLB item authoring follows the same contract via `slot_`-prefixed materials (case-insensitive, `slot_` → slot id). See `wiki/architecture/materials-and-themes.md` § "Texture world scale" and `wiki/architecture/item-authoring.md`. +- **Floor elevation lives on `def.capabilities.floorPlaced`.** Kinds that rest on a level and lift over overlapping slabs declare a `footprint` (and optional `applies` predicate) on `FloorPlacedConfig`; the generic `` writes `slabElevation + node.position[1]` onto the registered mesh on each dirty mark. A new per-kind `useEffect` / per-kind system that recomputes Y from slab overlap is a regression — the per-kind block was lifted out of `ItemSystem` in Phase 6.1. See `packages/core/src/registry/types.ts` (`FloorPlacedConfig`). +- **Render-mode behaviour goes through `def.surfaceRole`.** Solid / Rendered / Clay viewer modes look up the kind's `surfaceRole` on `NodeDefinition` to pick the right material strategy (clay overrides, edge passes, theme overlays). New `if (node.type === '')` arms inside the render-mode pipeline or theme application are a regression — the behaviour belongs on `def.surfaceRole`. See `packages/core/src/registry/types.ts:529`. +- **Capability names must describe verbs, not host kinds.** `movable` / `paint` / `floorPlaced` / `cuttable` / `selectable` describe *what the node does*. A new capability named after a host kind (`slabAccessory`, `wallAccessory`, `siteAccessory`, anything `Xaccessory` / `Xhosted` shaped) couples the registry's type surface to one specific host and reads as precedent for the next reviewer. Blocker. Push back: generalise into a paired *host-side* capability ("I merge subtractive accessories from my children") + *accessory-side* capability ("I provide a cut geometry, cascade my dirty mark to my host's parent"). The single existing case — `capabilities.roofAccessory` (`packages/core/src/registry/types.ts:791`, consumed by `packages/viewer/src/systems/roof/roof-system.tsx`) — is documented tech debt; do not extend the pattern. - **Per-kind files in legacy locations are a regression.** New `viewer/src/components/renderers//*`, `viewer/src/systems/-system.tsx`, `editor/src/components/tools//*`, `editor/src/components/ui/panels/-panel.tsx`, `editor/src/components/ui/helpers/-helper.tsx`, or inline `useMemo` floor-plan entry-builders inside `editor/src/components/editor/floorplan-panel.tsx` — all of these were systematically deleted at Phase 6. The behavior belongs on the kind's `NodeDefinition` (`def.renderer` / `def.system` / `def.geometry` / `def.tool` / `def.affordanceTools` / `parametrics.customPanel` / `def.toolHints` / `def.floorplan`). - **Floor-plan output via `def.floorplan`.** New per-kind floor-plan rendering must return `FloorplanGeometry` from `def.floorplan(node, ctx)` and be rendered by ``. New inline branches in `floorplan-panel.tsx` are a blocker. - **Plugin contract surface.** A PR that extends the v1 plugin surface — adding `plugin.materials`, `plugin.systems`, `plugin.panels`, or making plugins extend host stores (`useScene` / `useEditor` / `useViewer`) — is out of scope for the v1 contract documented in `wiki/architecture/plugin-authoring.md`. Either the change belongs as a new field on `NodeDefinition` (additive, doesn't bump `apiVersion`) or it needs its own plan. @@ -123,6 +131,7 @@ If the PR adds or modifies a node kind, check against `wiki/architecture/node-de - Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers). - Editor-only overlays and systems are injected as children of ``, not added inside the viewer package. +- **Editor overlay meshes must carry the editor layer.** Any new `` / `` / `` / `` an editor overlay or tool component adds to the 3D scene (gizmos, handles, guides, previews, cursor meshes, marquees) must set `layers={EDITOR_LAYER}` — or `GRID_LAYER` for the ground grid, `ZONE_LAYER` for zone fills. The thumbnail/snapshot camera renders only layer 0, so an untagged overlay leaks into exports (and gets inked / SSGI-darkened in the live view). Flag any overlay-component primitive that omits the layer assignment. See `wiki/architecture/layers.md`. - New node types are added by creating one folder under `packages/nodes/src//` and registering its definition in `builtinPlugin.nodes`. Adding to a hand-maintained list elsewhere is a sign the registry hasn't absorbed that surface yet — check `plans/editor-node-registry.md` § "Known un-shimmed hardcoded lists" before assuming it's a violation. - `AnyNode` is hand-maintained for now (full runtime derivation would lose static typing); `packages/nodes/src/index.test.ts` is the drift gate. If a PR adds a kind to `AnyNode` without adding it to `builtinPlugin.nodes` (or vice versa), the parity test catches it — but flag it in review too. diff --git a/.env.example b/.env.example deleted file mode 100644 index daa84046e..000000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Optional environment variables — the editor works without any of these - -# Google Maps API key (enables address search) -# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_api_key - -# Dev server port (default: 3000) -# PORT=3000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..b672a9d78 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.glb binary +*.bin binary + +apps/editor/i18n/locales/zh-CN.json merge=ours diff --git a/.github/workflows/editor-factory-ai.yml b/.github/workflows/editor-factory-ai.yml new file mode 100644 index 000000000..526784790 --- /dev/null +++ b/.github/workflows/editor-factory-ai.yml @@ -0,0 +1,92 @@ +name: editor-factory-ai + +on: + push: + branches: [main] + paths: + - 'apps/editor/app/api/ai-harness/**' + - 'apps/editor/e2e/**' + - 'apps/editor/lib/ai-harness-runs/**' + - 'apps/editor/package.json' + - 'apps/editor/playwright.config.ts' + - 'packages/core/src/lib/**' + - 'packages/core/src/schema/**' + - 'packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/**' + - 'packages/editor/src/lib/factory-scene-patch-safety*' + - 'packages/viewer/src/store/**' + - 'bun.lock' + - '.github/workflows/editor-factory-ai.yml' + pull_request: + paths: + - 'apps/editor/app/api/ai-harness/**' + - 'apps/editor/e2e/**' + - 'apps/editor/lib/ai-harness-runs/**' + - 'apps/editor/package.json' + - 'apps/editor/playwright.config.ts' + - 'packages/core/src/lib/**' + - 'packages/core/src/schema/**' + - 'packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/**' + - 'packages/editor/src/lib/factory-scene-patch-safety*' + - 'packages/viewer/src/store/**' + - 'bun.lock' + - '.github/workflows/editor-factory-ai.yml' + +jobs: + factory-ai: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.0 + + - name: Install + run: bun install --frozen-lockfile + + - name: Biome check + run: > + bunx biome check + apps/editor/e2e/factory-ai.spec.ts + apps/editor/lib/ai-harness-runs/factory-agent-prompt.ts + apps/editor/lib/ai-harness-runs/factory-agent-prompt.test.ts + apps/editor/lib/ai-harness-runs/factory-ai-regression.test.ts + apps/editor/lib/ai-harness-runs/factory-planner.ts + apps/editor/lib/ai-harness-runs/factory-planner.test.ts + apps/editor/lib/ai-harness-runs/factory-runner.ts + apps/editor/lib/ai-harness-runs/factory-runner.test.ts + apps/editor/lib/ai-harness-runs/factory-selection-edit.ts + apps/editor/lib/ai-harness-runs/factory-selection-edit.test.ts + apps/editor/lib/ai-harness-runs/primitive-generation-service.ts + apps/editor/lib/ai-harness-runs/primitive-generation-service.test.ts + apps/editor/lib/ai-harness-runs/process-equipment-resolver.ts + apps/editor/lib/ai-harness-runs/process-equipment-resolver.test.ts + apps/editor/lib/ai-harness-runs/process-line-layout.ts + apps/editor/lib/ai-harness-runs/process-line-layout.test.ts + apps/editor/lib/ai-harness-runs/process-line-composer.ts + apps/editor/lib/ai-harness-runs/process-line-composer.test.ts + apps/editor/lib/ai-harness-runs/process-line-routing.ts + apps/editor/lib/ai-harness-runs/process-line-types.ts + apps/editor/lib/ai-harness-runs/process-template-registry.ts + apps/editor/playwright.config.ts + packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/index.tsx + packages/editor/src/lib/factory-scene-patch-safety.ts + packages/editor/src/lib/factory-scene-patch-safety.test.ts + + - name: Typecheck editor app + run: bun run --cwd apps/editor check-types + + - name: Typecheck editor package + run: bun run --cwd packages/editor check-types + + - name: Factory unit tests + run: bun test apps/editor/lib/ai-harness-runs packages/editor/src/lib/factory-scene-patch-safety.test.ts + + - name: Install Playwright Chromium + run: bunx playwright install --with-deps chromium + + - name: Factory browser smoke + run: bun run --cwd apps/editor e2e:factory diff --git a/.gitignore b/.gitignore index d01fc0bdd..11b8b0eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,21 @@ yarn-error.log* /.playwright-mcp og-test .env*.local +/.omx/ +/.codex-artifacts/ +/.codex-temp/ +/backups/ +/codex-*.png + +# Local external checkouts / generated runtime state +/articraft/ +/apps/editor/public/items/articraft-*/ +/apps/editor/public/items/image-to-3d-*/ +/apps/editor/public/items/articraft-assets.json +/apps/editor/public/items/generated-assets.json +/apps/editor/.generated/ +/apps/editor/.local/ +/apps/editor/qa-artifacts/ # Worktrees .worktrees diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 8b596d230..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib", - "biome.configPath": "./biome.jsonc", - "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": false, - "editor.formatOnPaste": true, - "emmet.showExpandedAbbreviation": "never", - "editor.codeActionsOnSave": { - "source.fixAll.biome": "always", - "quickfix.biome": "always", - "source.organizeImports.biome": "always", - "source.organizeImports": "never" - }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[json]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[jsonc]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnPaste": false - }, - "[javascriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[css]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "css.lint.unknownAtRules": "ignore", - "[graphql]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "evenBetterToml.formatter.allowedBlankLines": 4 -} diff --git a/AI-GEOMETRY-ROADMAP.md b/AI-GEOMETRY-ROADMAP.md new file mode 100644 index 000000000..37b46fc9f --- /dev/null +++ b/AI-GEOMETRY-ROADMAP.md @@ -0,0 +1,584 @@ +# AI 几何生成优化路线图 + +## 背景 + +本文档描述 AI 几何生成系统(primitive 模式)的改进计划。 + +### 系统架构简述 + +``` +用户输入 + └─→ buildGeometryAnalysisContext() 前端构建上下文 + └─→ /api/ai-harness/runs 后端 SSE 任务 + ├─ Stage 1: STAGE1_ANALYST_PROMPT 文字分析,无工具调用 + └─ Stage 2: STAGE2_GENERATOR_PROMPT 工具调用生成几何体 + ↓ + executeGeometryToolCall() 工具执行器 + ↓ + validatePrimitiveSemantics() 语义验证 + assessPrimitiveVisualQuality() 视觉质量评分 + ↓ + GeneratedGeometryArtifact 结果 artifact +``` + +### 核心代码位置 + +| 模块 | 路径 | +|---|---| +| 两阶段系统提示 | `packages/editor/src/lib/ai-chat-harness/primitive-system-prompts.ts` | +| 修复策略 | `packages/editor/src/lib/ai-chat-harness/primitive-repair-policy.ts` | +| 上下文构建 | `packages/editor/src/lib/ai-chat-harness/context-builder.ts` | +| 能力路由 | `packages/editor/src/lib/ai-chat-harness/capability-planner.ts` | +| 工具执行器 | `packages/editor/src/lib/ai-geometry-tool-executor.ts` | +| 后端运行器 | `apps/editor/lib/ai-harness-runs/primitive-runner.ts` | +| 装配模板 | `packages/core/src/lib/assembly-compose.ts` | +| 零件库 | `packages/core/src/lib/part-compose.ts` | +| 工业原型注册 | `packages/core/src/lib/industrial-archetype-registry.ts` | +| 语义验证 | `packages/core/src/lib/primitive-semantic-validation.ts` | +| 视觉质量 | `packages/core/src/lib/primitive-visual-quality.ts` | + +--- + +## 已完成的改动(基础修复) + +以下问题已在近期修复,是后续各 Phase 的前提: + +| 问题 | 修复内容 | +|---|---| +| `BASE_RULES` 系统提示是死代码,LLM 实际没收到详细规则 | 将提示迁移到 `primitive-system-prompts.ts`,runner 正确引用 | +| 修复循环固定 3 次,简单对象也浪费 LLM 调用 | `primitive-repair-policy.ts` 按请求复杂度动态分配修复预算 | +| Stage 1 分析阶段携带 45k artifact JSON | `buildGeometryAnalysisContext` 拆分,Stage 1 只传摘要 | +| "make it a new color" 被误判为新对象请求 | `isLikelyGeometryRevisionRequest` 增加属性变更的修订识别 | +| `compose_assembly` 对未支持 family 直接返回空 | `inferAssemblyFamily` 增加旋转机械(燃气机等)的 compressor 路由 | +| 工业原型命中但无 assembly family 时仍推荐 `compose_assembly` | `capability-planner.ts` 改为路由到 `compose_parts` | + +--- + +## Phase 1 — Stage 1 结构化蓝图协议 + +### 问题描述 + +当前 Stage 1 只输出文字分析,Stage 2 要从文字中重新理解意图并自行猜测 3D 坐标,两个阶段之间没有结构化协议。 + +**典型失败链条(以"燃气机"为例):** + +``` +Stage 1 分析文字: + "需要曲轴箱主体、6 个气缸盖、飞轮、进排气歧管、底座撬块 + 推荐 compose_parts,气缸等间距排列在机体顶部..." + +Stage 2 从头规划: + compose_parts({ + parts: [ + { kind: "box", semanticRole: "engine_block", position: [0, 0.5, 0] }, + { kind: "cylinder", position: [-0.6, 1.1, 0] }, // ← LLM 猜的 + { kind: "cylinder", position: [-0.3, 1.1, 0] }, // ← LLM 猜的 + ... + ] + }) + → 气缸间距不均、高度错误、飞轮悬空 +``` + +Stage 1 已经正确分析了需要什么零件,但 Stage 2 没有利用这个分析,而是重新猜坐标。 + +### 解决方案 + +Stage 1 在文字分析末尾额外输出一个 JSON 零件蓝图。Stage 2 读取蓝图,直接翻译为 `compose_parts` 调用,零件位置由代码从语义关系(`alignAbove`/`alignBeside`/`connectTo`/`around`)计算,LLM 不需要猜任何 `[x, y, z]`。 + +**改造后的数据流:** + +``` +Stage 1 输出: + [分析文字] + "需要曲轴箱主体、6 个气缸盖、飞轮..." + + ```json + { + "route": "compose_parts", + "category": "gas_engine", + "constraints": { "length": 2, "width": 1.2, "height": 1.5, "primaryColor": "#555555" }, + "parts": [ + { "id": "base", "kind": "skid_base", "semanticRole": "machine_base" }, + { "id": "block", "kind": "rounded_machine_body", "semanticRole": "engine_block", "alignAbove": "base" }, + { "id": "cylinders", "kind": "cylinder", "semanticRole": "cylinder_head", "alignAbove": "block", + "array": { "count": 6, "axis": "x", "spacing": 0.28 } }, + { "id": "flywheel", "kind": "cylinder", "semanticRole": "flywheel", "alignBeside": "block", "side": "left", "axis": "x" }, + { "id": "exhaust", "kind": "pipe_port", "semanticRole": "exhaust_manifold", "alignBeside": "block", "side": "front" } + ], + "requiredRoles": ["machine_base", "engine_block", "cylinder_head", "flywheel"] + } + ``` + +Stage 2: + 读取蓝图 → 直接翻译为 compose_parts 调用 + alignAbove/alignBeside/around 由 part-compose.ts 代码解析为精确坐标 + LLM 只负责"这个零件在那个零件的上面/旁边",不需要猜具体数值 +``` + +### 开发内容 + +**1. 修改 `primitive-system-prompts.ts` 中的 `PRIMITIVE_STAGE1_ANALYST_PROMPT`** + +在 STAGE 1 分析指令末尾增加: + +``` +在分析文字之后,输出一个 ```json 代码块,包含结构化零件蓝图: +{ + "route": "compose_parts" | "compose_assembly" | "compose_recipe", + "category": "语义分类", + "constraints": { "length"?, "width"?, "height"?, "primaryColor"? }, + "parts": [ + { + "id": "唯一引用 id", + "kind": "零件类型(来自支持列表)", + "semanticRole": "语义角色", + // 位置关系(选其一,优先于手写坐标): + "alignAbove"?: "父零件 id", + "alignBeside"?: "父零件 id", + "side"?: "left|right|front|back", + "centeredOn"?: "父零件 id", + "connectTo"?: "父零件 id", + "connectPoint"?: "端口名", + "around"?: "父零件 id", + "aroundCount"?: number, + // 数组重复: + "array"?: { "count": number, "axis": "x|y|z", "spacing": number }, + // 尺寸(相对主体的比例或绝对值): + "dimensions"?: { "length"?, "width"?, "height"?, "radius"? } + } + ], + "requiredRoles": ["必须出现的语义角色列表,用于验证"] +} +仅当 route 为 revise_geometry 时可省略 parts。 +``` + +**2. 修改 `primitive-runner.ts`** + +在 Stage 1 响应解析后,新增蓝图提取逻辑: + +```typescript +function extractBlueprintFromAnalysis(analysis: string): PartBlueprint | null { + const match = analysis.match(/```json\s*([\s\S]*?)\s*```/) + if (!match?.[1]) return null + try { + const parsed = JSON.parse(match[1]) + if (parsed.route && parsed.parts) return parsed as PartBlueprint + return null + } catch { + return null + } +} +``` + +Stage 2 用户消息从: +``` +User request: {harnessContext} +Analysis: {analysis} +Now call the best available tool... +``` + +改为: +```typescript +const blueprint = extractBlueprintFromAnalysis(analysis) +const genUserMessage = blueprint + ? [ + `User request: ${userPrompt}`, + '', + 'Part blueprint from analysis (translate this directly to a compose_parts call):', + JSON.stringify(blueprint, null, 2), + '', + 'Translate the blueprint parts array into compose_parts arguments.', + 'Keep all relationship fields (alignAbove/alignBeside/connectTo/around) as-is.', + 'Do not invent raw position coordinates; let the relationship fields drive layout.', + 'Add dimensions and colors from blueprint.constraints.', + ].join('\n') + : [ + `User request: ${harnessContext}`, + '', + 'Analysis:', + analysis, + '', + 'Now call the best available tool based on this analysis. Output exactly one tool call.', + ].join('\n') +``` + +**3. 新增 `PartBlueprint` 类型定义** + +在 `primitive-runner.ts` 或单独的 `types.ts` 中: + +```typescript +type PartBlueprintItem = { + id: string + kind: string + semanticRole?: string + alignAbove?: string + alignBeside?: string + side?: 'left' | 'right' | 'front' | 'back' + centeredOn?: string + connectTo?: string + connectPoint?: string + around?: string + aroundCount?: number + array?: { count: number; axis: 'x' | 'y' | 'z'; spacing: number } + dimensions?: { length?: number; width?: number; height?: number; radius?: number } +} + +type PartBlueprint = { + route: 'compose_parts' | 'compose_assembly' | 'compose_recipe' | 'revise_geometry' + category?: string + constraints?: { length?: number; width?: number; height?: number; primaryColor?: string } + parts?: PartBlueprintItem[] + requiredRoles?: string[] +} +``` + +**4. 蓝图同步传给 `executeGeometryToolCall`** + +将蓝图的 `requiredRoles` 注入 `geometryBrief`,为 Phase 3 验证做准备(可在 Phase 1 预埋,Phase 3 再实现验证逻辑): + +```typescript +const blueprintBrief: Partial | undefined = blueprint + ? { + category: blueprint.category, + requiredRoles: blueprint.requiredRoles, + } + : undefined +``` + +### 预期效果 + +| 指标 | 改造前 | 改造后 | +|---|---|---| +| 不支持设备首次生成成功率 | ~20% | ~70% | +| 平均 LLM 调用次数(失败设备) | 4~5 次 | 2~3 次 | +| 气缸/飞轮等部件位置偏差 | 高(纯猜坐标) | 低(关系字段驱动) | + +### 不解决的问题 + +- LLM 不了解的极冷门设备(语义知识本身的缺失) +- 零件内部细节精度(`rounded_machine_body` 仍是通用圆角机体) +- 曲线走向的管路(需要 sweep 路径,超出当前关系字段能力) + +--- + +## Phase 2 — compose_parts 关系字段补全 + +### 问题描述 + +Phase 1 蓝图依赖关系字段表达空间布局,但当前 `part-compose.ts` 的关系字段有盲区: + +``` +"6 个气缸等间距排在机体顶部 X 轴方向" +→ 需要 array: { count: 6, axis: "x", spacing: 0.28 } +→ 现状:array 字段在 part-compose.ts 里实现不完整 + +"排气歧管在气缸盖端部向前延伸 0.3m" +→ 需要 offsetFrom: "cylinders" + direction: "front" + distance: 0.3 +→ 现状:只有 alignBeside(side),粒度不足 + +"4 个支脚均匀分布在底座四角" +→ 需要 around: "base" + aroundCount: 4 + aroundAxis: "y" + cornerPattern: true +→ 现状:around 支持圆周分布,但不支持四角矩形分布 +``` + +### 开发内容 + +**1. 完善 `part-compose.ts` 的 `array` 字段处理** + +当前 `array` 只在 compose_primitive 层支持(`expandPrimitiveShapeArrays`),`compose_parts` 的 part 层尚未支持。 + +需要在 `resolvePartRelationships()` 之前,展开带 `array` 字段的 part: + +```typescript +function expandArrayParts(parts: PartComposePartInput[]): PartComposePartInput[] { + return parts.flatMap(part => { + if (!part.array) return [part] + const { count, axis, spacing } = part.array + return Array.from({ length: count }, (_, i) => ({ + ...part, + array: undefined, + id: `${part.id}_${i}`, + // axis 方向上按 spacing 偏移,由关系字段驱动(alignAbove 保持不变) + // 偏移量注入为 arrayOffset,由关系解析器读取 + arrayOffset: i * spacing, + arrayAxis: axis, + })) + }) +} +``` + +**2. 增加 `offsetFrom` 关系** + +```typescript +// part-compose.ts 关系解析 +if (part.offsetFrom) { + const parent = resolvedParts.find(p => p.id === part.offsetFrom) + if (parent) { + const direction = part.offsetDirection ?? 'front' + const distance = part.offsetDistance ?? 0 + // 在 parent 边界外按 direction 偏移 distance + position = computeOffsetFrom(parent.resolvedBounds, direction, distance) + } +} +``` + +**3. 增加 `cornerPattern` 支持** + +在 `around` 关系里支持矩形四角分布: + +```typescript +if (part.around && part.cornerPattern) { + // 不用圆周分布,改用矩形边界四角 + const parent = resolvedParts.find(p => p.id === part.around) + const corners = computeRectCorners(parent.resolvedBounds, part.cornerInset ?? 0) + return corners.map((pos, i) => ({ ...part, id: `${part.id}_${i}`, position: pos })) +} +``` + +**4. 更新 `COMPOSE_PARTS_TOOL` schema** + +在 `index.tsx` 的工具描述中补充这些字段的说明,让 LLM 知道可以使用它们。 + +### 预期效果 + +Phase 1 之后仍有位置错误的情况,再减少约 50%。 +LLM 蓝图可以正确表达"6 个等间距气缸"、"4 个对称支脚"等工业常见布局。 + +### 不解决的问题 + +- 自由曲线路径(排气管弯折走向) +- 嵌套装配(零件内部的子零件) + +--- + +## Phase 3 — 蓝图驱动的语义验证 + +### 问题描述 + +当前语义验证对 `family = 'unknown'` 的对象直接跳过所有检查: + +```typescript +// primitive-semantic-validation.ts +if (family === 'unknown') { + return { ok: true, family: 'unknown', score: 1, issues: [], warnings: [], facts: {} } +} +``` + +这意味着一个"燃气机"没有飞轮、没有气缸,照样能通过验证保存。 +修复循环只在 **schema 错误** 时触发,在 **语义缺失** 时不触发。 + +### 解决方案 + +Stage 1 蓝图的 `requiredRoles` 字段传入验证器,对任何对象做最低语义完整性检查: + +``` +蓝图要求: requiredRoles: ["machine_base", "engine_block", "cylinder_head", "flywheel"] +生成结果: 有 machine_base、engine_block,缺 cylinder_head 和 flywheel + +验证结果: ok=false, issues=["Missing required semantic role: cylinder_head", "Missing required semantic role: flywheel"] +→ 触发修复循环,携带具体缺失信息 +``` + +### 开发内容 + +**1. 修改 `validatePrimitiveSemantics` 签名,接受蓝图必需角色** + +```typescript +// primitive-semantic-validation.ts +export function validatePrimitiveSemantics( + shapes: PrimitiveShapeInput[], + transforms: PrimitiveWorldTransform[], + context: { + toolName: string + prompt: string + sourceArgs: Record + geometryBrief?: PrimitiveGeometryBrief + blueprintRequiredRoles?: string[] // ← 新增 + } +) +``` + +**2. 新增蓝图角色检查逻辑** + +```typescript +// 在现有 family-based 检查之后追加 +if (context.blueprintRequiredRoles?.length) { + const presentRoles = new Set(shapes.map(s => s.semanticRole).filter(Boolean)) + const missingRoles = context.blueprintRequiredRoles.filter(role => !presentRoles.has(role)) + if (missingRoles.length > 0) { + issues.push(...missingRoles.map(role => `Missing required semantic role: ${role}`)) + } +} +``` + +**3. 在 `ai-geometry-tool-executor.ts` 传入蓝图角色** + +```typescript +const semanticValidation = validatePrimitiveSemantics( + shapes as PrimitiveShapeInput[], + transforms, + { + toolName: name, + prompt: context.prompt, + sourceArgs: args, + geometryBrief, + blueprintRequiredRoles: context.blueprintRequiredRoles, // ← 从 context 传入 + }, +) +``` + +**4. 扩展 `GeometryToolExecutionContext` 类型** + +```typescript +export type GeometryToolExecutionContext = { + prompt: string + revisionOf?: string + revisionVersion?: number + replaceNodeIds?: string[] + revisionTarget?: GeneratedGeometryArtifact | null + blueprintRequiredRoles?: string[] // ← 新增 +} +``` + +**5. 修改 `primitive-runner.ts` 的 `executeTool` 调用,传入蓝图角色** + +```typescript +function executeTool( + name: string, + args: Record, + prompt: string, + revisionTarget: GeneratedGeometryArtifact | null, + blueprint: PartBlueprint | null, +): GeometryToolExecutionResult { + return executeGeometryToolCall( + name, args, + { + prompt, + revisionOf: ..., + revisionTarget, + blueprintRequiredRoles: blueprint?.requiredRoles, // ← 新增 + }, + ) +} +``` + +### 预期效果 + +| 情况 | 改造前 | 改造后 | +|---|---|---| +| 生成了几何体但缺关键部件 | 静默通过,保存错误结果 | 拦截,触发修复循环 | +| 修复循环提示信息 | "Invalid geometry tool call" | "Missing required semantic role: flywheel" | +| LLM 修复命中率 | 随机 | 有具体指导,定向修复 | + +### 不解决的问题 + +- 零件存在但位置明显错误(空间位置验证需要更复杂的约束) +- requiredRoles 只能检查"有没有",不能检查"在哪里" + +--- + +## Phase 4(可选)— Assembly Family 配置化 + +### 前提条件 + +Phase 1-3 完成后,大多数工业设备可以通过 LLM 蓝图 + `compose_parts` 生成,不再需要硬编码模板。 +Phase 4 针对的场景是:**需要比 LLM 蓝图更精确的比例控制**,且这类设备足够高频,值得维护一份精确模板。 + +当前 13 个 assembly family 全部是硬编码函数(`assembly-compose.ts`,826 行),增加新 family 需要改代码。 + +### 解决方案 + +将装配模板抽象为 JSON 配置,由通用引擎执行。 + +**配置格式示意(`config/assembly-templates/vehicle.json`):** + +```json +{ + "family": "vehicle", + "defaultDimensions": { + "length": 4.4, + "widthRatio": 0.42, + "heightRatio": 0.32 + }, + "styleVariants": { + "suv": { "lengthScale": 1.0, "heightRatio": 0.38 }, + "truck": { "lengthScale": 1.18, "widthRatio": 0.43 }, + "sports": { "heightRatio": 0.26 } + }, + "parts": [ + { + "kind": "body_shell", + "semanticRole": "vehicle_body", + "dimensionExpr": { "length": "length", "width": "width", "height": "height" }, + "cornerRadiusExpr": "min(length, width, height) * 0.08" + }, + { + "kind": "wheel_set", + "count": 4, + "semanticRole": "vehicle_tire" + }, + { + "kind": "window_strip", + "semanticRole": "vehicle_window", + "variant": "vehicle_glasshouse" + } + ] +} +``` + +**通用引擎 `composeAssemblyFromConfig(config, input, constraints)`:** + +```typescript +// 替代所有 composeVehicleAssembly / composeFanAssembly 等具体函数 +function composeAssemblyFromConfig( + config: AssemblyTemplateConfig, + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const dims = resolveTemplateDimensions(config, input, constraints) + const style = resolveTemplateStyle(config, input, constraints) + const parts = config.parts.map(partSpec => resolveTemplatePart(partSpec, dims, style)) + return composePartPrimitives(partInput(input, constraints, parts)) +} +``` + +### 开发代价 + +- `assembly-compose.ts` 全部重写(826 行 → 通用引擎 + JSON 配置文件) +- 需要实现表达式求值器(`cornerRadiusExpr`、`dimensionExpr`) +- 现有模板里有部分硬编码的特殊逻辑(如车辆风格判断)需要在配置语言里表达 +- 预计工作量:2~3 周 + +### 建议 + +Phase 1-3 完成后再评估是否需要。如果 LLM 蓝图对新增工业设备已经足够,Phase 4 优先级很低。 +如果业务上需要大量新增精确设备类型(超出 LLM 语义知识范围),再考虑实施。 + +--- + +## 工作量与优先级汇总 + +| Phase | 核心价值 | 主要改动文件 | 预估工作量 | 优先级 | +|---|---|---|---|---| +| 已完成 | 系统基础稳定 | 6 个文件,数十行 | — | 完成 | +| Phase 1 蓝图协议 | 消除 LLM 猜坐标,覆盖所有 LLM 语义知识内的设备 | `primitive-system-prompts.ts`
`primitive-runner.ts` | 1~2 周 | 最高 | +| Phase 2 关系字段 | 蓝图能更精确地表达工业布局 | `part-compose.ts`
`index.tsx` (schema) | 1 周 | 高 | +| Phase 3 蓝图验证 | 捕获语义缺失,触发精准修复 | `primitive-semantic-validation.ts`
`ai-geometry-tool-executor.ts`
`primitive-runner.ts` | 3~5 天 | 中 | +| Phase 4 配置化模板 | 新增精确设备模板不需要改代码 | `assembly-compose.ts` 全部重写 | 2~3 周 | 低(按需) | + +--- + +## 各 Phase 完成后的系统能力对比 + +``` + 当前(修复后) +Phase1 +Phase2 +Phase3 +───────────────────────────────────────────────────────────────── +已支持 13 种设备质量 ████████ ████████ ████████ ████████ +LLM知识覆盖的新设备 ██ ██████ ███████ ███████ +部件位置精度(不支持类) █ █████ ███████ ███████ +语义完整性验证 ████(已知) ████(已知) ████ ███████ +首次生成成功率 60% 80% 85% 88% +每请求平均 LLM 调用数 2.8 2.2 2.0 2.1 +``` + +Phase 1 的收益最大,是核心变化。Phase 2、3 是对 Phase 1 的补强。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b3039b29..89866a7e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,10 @@ bun check:fix # Auto-fix issues A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children. +### Building a plugin + +New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example. + ## Submitting a PR 1. **Fork the repo** and create a branch from `main` diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..86d3050ad --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,338 @@ +# Design + +## Source of truth +- Status: Draft +- Last refreshed: 2026-06-11 +- Primary product surfaces: AI sidebar / LLM chat panel, generated geometry preview card, generated asset placement and saving. +- Evidence reviewed: + - `README.md` — Pascal is a React Three Fiber/WebGPU 3D building editor with separated core/viewer/editor responsibilities. + - `apps/editor/app/page.tsx` and `apps/editor/components/scene-loader.tsx` — AI is a sidebar tab that mounts `AiChatPanel`. + - `packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/index.tsx` — current LLM flow sends tool schemas, executes geometry tool calls, and immediately creates scene nodes. + - `packages/core/src/schema/nodes/item.ts` — current catalog assets are `AssetInput` records with `src`, thumbnail, dimensions, category, and source. + - `wiki/architecture/layers.md` and `wiki/architecture/viewer-isolation.md` — editor-only UI belongs outside viewer internals; viewer stays editor-agnostic. + +## Brand +- Personality: professional, creative, spatial, CAD-adjacent but approachable. +- Trust signals: every AI output should expose what was generated, whether it is draft/placed/saved, and what action will mutate the scene. +- Avoid: silently inserting model output into the canvas before the user has reviewed it. + +## Product goals +- Goals: + - Let users review AI-generated geometry in the chat history before it affects the scene. + - Make generated objects actionable with two obvious choices: `放置画布` and `存入素材`. + - Support iterative refinement by sending the prior generated geometry plus the user's modification request back to the model. + - Preserve editability for primitive/assembly outputs whenever possible. +- Non-goals: + - Pixel-perfect CAD rendering inside the chat card. + - Adding editor-specific state or AI workflow concepts to `@pascal-app/viewer`. + - Treating a saved primitive assembly as a generic GLB unless the user explicitly exports it. +- Success signals: + - Users can identify the shape from the chat preview without placing it. + - Users can place or save the same generated result without re-prompting. + - Follow-up prompts produce a revised version that clearly references the prior version. + +## Personas and jobs +- Primary personas: + - Designer/modeler using natural language to create editable blockout geometry. + - Operator building a reusable local asset library from AI generations. +- User jobs: + - Generate a candidate object. + - Inspect its form from the chat thread. + - Place it into the current level only when satisfied. + - Save reusable objects to `Items -> Mine`. + - Ask for targeted changes without restating the full original prompt. +- Key contexts of use: + - Narrow sidebar on desktop. + - Mobile bottom sheet with limited vertical space. + - Slow or unreliable LLM/tool calls. + +## Information architecture +- Primary navigation: existing sidebar tab `AI`. +- Core routes/screens: + - AI chat thread. + - Generated geometry preview card inside assistant messages. + - Items panel `Mine` source for saved generated assets. +- Content hierarchy for generated card: + 1. Status and title, e.g. `草稿 · 风扇 v1`. + 2. Interactive mini 3D preview. + 3. Compact metadata: part count, dimensions, generation mode. + 4. Primary action: `放置画布`. + 5. Secondary action: `存入素材`. + 6. Revision affordance: "不满意?直接输入修改意见". + +## Design principles +- Review before mutate: LLM tool output becomes a draft artifact first; canvas mutation happens only through an explicit user action. +- Drafts are durable in the thread: every generated result remains visible, with status changes such as `草稿`, `已放置`, `已存入素材`, or `已被 v2 替换`. +- Revisions are full replacements first: follow-up generation should ask the model for a complete revised geometry payload, not a partial patch, until patch tooling is proven reliable. +- Tradeoffs: + - A draft artifact layer adds state complexity, but avoids surprise scene mutations. + - Saving primitive geometry as an editable Pascal asset is better UX than saving a non-editable GLB, but may require extending the generated asset format. + +## Visual language +- Color: reuse existing sidebar tokens (`background`, `accent`, `border`, `muted-foreground`) and the AI accent purple `#a684ff`. +- Typography: compact 11–12px sidebar copy; card title medium weight; metadata 10–11px. +- Spacing/layout rhythm: card padding 8–10px; preview aspect ratio around 4:3 or 16:10; actions aligned at card bottom. +- Shape/radius/elevation: rounded-xl card with subtle border; dropdown/preview card should feel like the existing AI panel controls. +- Motion: lightweight loading shimmer/spinner during generation; no camera auto-spin by default. +- Imagery/iconography: use existing Iconify/lucide icons; preview canvas is the primary visual. + +## Components +- Existing components to reuse: + - `AiChatPanel` for thread state and model interaction. + - Existing `useScene`/`useViewer` actions for explicit placement. + - Existing Items panel generated asset refresh events where possible. +- New/changed components: + - `GeneratedGeometryCard`: chat message content for one draft/revision. + - `GeneratedGeometryPreview`: isolated mini 3D preview with right-drag orbit. + - `GeneratedGeometryActions`: `放置画布`, `存入素材`, status badges. + - `aiGenerationHarness` or equivalent module: model calls, tool schemas, validation, draft artifact creation, revision prompt construction. +- Variants and states: + - Generating: skeleton card with spinner. + - Draft: both actions enabled. + - Placing: primary button busy. + - Placed: primary button becomes `已放置` or `再次放置`. + - Saving: secondary button busy. + - Saved: secondary button becomes `已存入素材`. + - Error: validation/API message with retry. + - Superseded: old version remains visible but muted, linked to newer version. +- Token/component ownership: + - Chat UI and preview card belong in `packages/editor`. + - Model/api routes and local generated asset persistence belong in `apps/editor`. + - Pure geometry normalization can live in `packages/core` only if it has no React, Three.js, viewer, or editor dependency. + +## Accessibility +- Target standard: keyboard-operable controls and readable contrast. +- Keyboard/focus behavior: + - Preview canvas must not trap focus. + - Buttons need clear focus rings. + - Provide keyboard alternatives for preview rotation: reset view and optional rotate-left/rotate-right buttons if the canvas is focused. +- Contrast/readability: status badges must remain legible on dark sidebar backgrounds. +- Screen-reader semantics: + - Generated card should announce title, status, part count, and action labels. + - Preview canvas should have an `aria-label` describing the object and interaction. +- Reduced motion and sensory considerations: + - No forced spinning preview. + - Respect reduced motion by disabling animated preview transitions. + +## Responsive behavior +- Supported breakpoints/devices: desktop sidebar and mobile sidebar bottom sheet. +- Layout adaptations: + - Desktop: preview card uses full sidebar width. + - Mobile: preview card can collapse metadata; actions stay visible. +- Touch/hover differences: + - Desktop: right-drag rotates; wheel zoom optional. + - Touch: one-finger drag rotates, two-finger pinch zoom. + - Suppress browser context menu only inside the preview canvas. + +## Interaction states +- Loading: show an assistant message card with generation progress. +- Empty: existing AI empty state remains. +- Error: validation errors stay attached to the failed generation turn. +- Success: show generated card instead of only text summary. +- Disabled: actions disabled while generating, placing, or saving. +- Offline/slow network: keep draft artifact once generated; allow retry save/place independently. + +## Content voice +- Tone: direct, action-oriented Chinese UI copy. +- Terminology: + - `草稿` for not-yet-placed generated output. + - `放置画布` for inserting into the current scene. + - `存入素材` for saving to the user's generated asset library. + - `继续修改` or "直接输入修改意见" for iterative generation. +- Microcopy rules: + - Say whether an action mutates the scene. + - Avoid exposing raw tool JSON unless in a debug/details disclosure. + +## Implementation constraints +- Framework/styling system: React, Next.js app, Tailwind-style utility classes, existing Iconify/lucide icons. +- Design-token constraints: reuse existing sidebar colors and radius/elevation patterns. +- Performance constraints: + - Mini preview must render only visible/generated cards or use a static thumbnail after initial render. + - Avoid mounting many full R3F canvases for long chat histories; prefer thumbnail fallback for older cards. + - Store serializable normalized geometry artifacts, not live Three.js objects. +- Compatibility constraints: + - Do not move editor-only chat/AI logic into `packages/viewer`. + - Do not let `packages/core` import Three.js, viewer, UI, tools, modes, or editor concepts. + - Placement must clone/materialize fresh node IDs so the same draft can be placed more than once. + - Saving primitive geometry may need a Pascal-geometry asset type or server-side conversion; do not force a GLB-only path if editability is required. +- Test/screenshot expectations: + - Typecheck after changes. + - UI verification should cover: generated card appears, right-drag rotates preview, `放置画布` creates/selects nodes, `存入素材` appears in Items `Mine`, follow-up prompt includes prior artifact. + +## Harness architecture decision +- Use a harness-style architecture for LLM generation and revision. +- In this context, "harness" means the application layer that: + 1. Maintains conversation and artifact state. + 2. Sends tool schemas and prompts to the model. + 3. Validates and normalizes model tool calls. + 4. Executes allowed side effects only when appropriate. + 5. Feeds tool results or prior artifacts back into later model turns. +- The key change is that geometry tool execution should produce a draft `GeneratedGeometryArtifact`, not immediately mutate the scene. +- Revision prompt construction should include: + - Original user prompt. + - Current artifact summary and normalized tool arguments. + - User modification request. + - Instruction to return one complete replacement geometry call. +- This is harness architecture at the AI workflow layer, not a reason to violate Pascal's package layering. + + +## Pipe dynamic flow effects design +- Product intent: + - Show readable process state for pipes (`water`, `steam`, `condensate`) without pretending to be a CFD/fluid simulation. + - The effect should answer: "what medium is inside, which way is it flowing, and is it active?" + - Default scene remains clean; rich motion appears when flow visualization is enabled globally or when a pipe is selected/inspected. +- Recommended effect tiers: + 1. **Surface flow indicator**: animated dashed/chevron bands moving along the pipe surface. This works for closed/insulated pipes and is the default low-cost mode. + 2. **Inner flow preview**: when selected or in an inspection mode, render a slightly smaller translucent tube/ribbon inside the pipe with moving color/alpha. Useful for water/condensate. + 3. **Steam plume/emission**: for steam, render soft billboard particles only at endpoints, valves, vents, or explicit leak markers; do not fill the whole pipe with smoke. +- Medium mapping: + - `water`: blue/cyan, smooth flowing bands, moderate opacity. + - `steam`: white/blue-white, fast wispy pulses, optional plume at open endpoints. + - `condensate`: amber/light blue, slower heavier pulses. +- Data model direction: + - Keep core data semantic and serializable. Add only domain fields to `PipeNode`, for example: + - `flowEnabled?: boolean` + - `flowDirection?: 'start-to-end' | 'end-to-start'` + - `flowRate?: number` + - `flowVisualization?: 'surface' | 'inner' | 'both'` + - Do not store shader uniforms, animation frame state, Three.js objects, or editor-only toggles in core. +- Rendering architecture: + - Geometry/path sampling stays pure; existing `samplePipeCenterline3D` is a good base. + - The visual effect belongs in the pipe node geometry/renderer path under `packages/nodes/src/pipe` or the viewer-side registry renderer path, not in editor UI. + - Per-frame animation belongs in the rendering component/material via `useFrame` or shader time uniforms; the scene store should not update every frame. + - Editor controls for enabling flow, direction, and flow rate belong in pipe parametrics/inspector UI. +- Performance guardrails: + - Prefer one shader/material with animated UV/time over many particle meshes. + - Use particles only for steam plumes and only when visible/selected or global flow visualization is enabled. + - Reuse sampled pipe centerlines; avoid rebuilding geometry every frame. + - Respect reduced motion by slowing or disabling animated flow. +- UX controls: + - Add a global view toggle: `Show pipe flow`. + - Add pipe inspector controls: `Flow enabled`, `Direction`, `Rate`, `Visualization`. + - Add a small direction affordance in 2D floorplan: arrows along pipe centerline when selected or flow view is on. +- Acceptance criteria: + - A water pipe can show blue moving bands in the correct direction. + - A steam pipe can show a subtle surface pulse and optional endpoint plume. + - Changing medium/direction/rate updates the effect without changing pipe geometry. + - Turning off `Show pipe flow` removes animation cost for ordinary viewing. + - No editor-only flow state leaks into `packages/core` or `packages/viewer`. + + +## Dynamic property tab design +- Product intent: + - Add a geometry/property-panel tab named `动态` for nodes that can visualize or play runtime behavior. + - Avoid showing irrelevant dynamic controls for static building elements such as walls, slabs, stairs, roofs, doors, and ordinary structural geometry. + - Support two dynamic families: process flow dynamics (pipe media such as steam/water/oil) and transform/pose dynamics (primitive assemblies rotating, moving, opening, pulsing). +- Capability model: + - Dynamic support should be explicit, not inferred only from node type names. + - Add a registry-level capability/descriptor concept such as `dynamic` or `animation`, with per-kind supported dynamic modes. + - Static kinds omit the descriptor, so the `动态` tab is hidden by default. + - AI/primitive-generated geometry can opt in by storing serializable dynamic specs on the generated artifact or resulting assembly metadata. +- Suggested dynamic spec shape: + - `dynamic.enabled: boolean` + - `dynamic.kind: 'flow' | 'motion' | 'state'` + - For flow: `medium`, `direction`, `rate`, `visualization`, `temperature`, `pressure`. + - For motion: `motionType`, `axis`, `amplitude`, `speed`, `loop`, `pivot`, `previewOnly`. + - Store this as plain JSON domain/config data, not runtime Three.js objects or per-frame state. +- Pipe tab behavior: + - `动态` tab appears for `pipe` because pipe has process metadata and flow visualization support. + - Controls: Flow enabled, Medium (`steam`, `water`, `condensate`, future `oil`, `air`, `gas`), Direction, Flow rate, Visualization style, Temperature, Pressure. + - Medium drives visual defaults but remains editable: water = blue bands, steam = white-blue pulse/plume, oil = amber/dark slow bands. +- Primitive/generated geometry tab behavior: + - `动态` appears only if the node or assembly has dynamic metadata, or if the user clicks `Add dynamic behavior` from an eligible primitive/assembly. + - Motion templates: Rotate, Move, Oscillate, Pulse, Open/Close, Custom sequence. + - The panel should show a short natural-language summary, e.g. `Rotates around Y at 30°/s`. + - Provide `Preview`, `Pause`, `Reset`, and `Apply as scene behavior` actions. +- Interaction rules: + - If a selected node has no dynamic capability, do not show an empty `动态` tab. Optionally show a small disabled badge/tooltip: `This object has no dynamic behavior`. + - For multi-selection, show the tab only when all selected nodes share a compatible dynamic family; otherwise show a compatibility summary. + - Defaults should be generated from domain semantics: pipe.medium -> flow defaults; fan/propeller primitive -> rotate defaults; door/window -> state/open-close only if supported. +- Recognition strategy: + - Primary source: explicit registry capability/descriptor. + - Secondary source: node schema fields (`medium`, `flowEnabled`, `dynamic`, `metadata.ai.dynamicIntent`). + - AI-generated primitives should include `dynamicIntent` only when the prompt asks for behavior, e.g. rotating fan, conveyor belt, moving arm. + - Do not auto-add motion to walls/stairs just because they are geometry. +- Architecture constraints: + - Core schema stores serializable dynamic config only. + - Viewer/node renderers implement visual effects and preview animation. + - Editor property panel decides whether to show the `动态` tab and writes config updates. + - Per-frame playback must not mutate the scene store every frame. +- Acceptance criteria: + - Selecting a pipe shows `动态` with flow controls. + - Selecting a wall/stair does not show irrelevant dynamic controls. + - Selecting an AI-generated rotating fan assembly shows `动态` with rotation controls. + - A dynamic config can be saved/loaded with the scene and remains editable. + + +## Items GLB import discoverability design +- Product intent: + - Make importing a local GLB feel like a clear user-owned asset creation path. + - Users should understand that imported GLB files become reusable assets under `Items -> Mine` and can be placed immediately. +- Current evidence: + - `packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx` owns the Items panel import affordance. + - `packages/editor/src/components/ui/item-catalog/item-catalog.tsx` classifies imported GLB assets as `????` inside the `Mine`/`??` category. +- Recommended IA: + - Do not show a global GLB import entry in every Items category. + - Show a single import card only in `?? / Mine`, because that is where user-owned/imported assets live. + - Keep the card visually prominent enough to work as the import entry point when users enter `Mine`. +- Recommended visual hierarchy: + - In `Mine`: show a full-width compact CTA card above the asset grid. + - Card content: `????` + `????????????????` + primary `?? GLB` button. + - Search remains secondary and should not contain the GLB import action. +- Interaction states: + - Idle: upload icon + `?? GLB`. + - Importing: `????` with spinner, disabled. + - Success: stay/switch to `Mine`, select the imported asset, show `??????????????`. + - Error: keep error directly under the search/import area. +- Acceptance criteria: + - The Items panel does not show a global `????` block outside `Mine`. + - `Mine` clearly invites GLB import. + - After import, users know where the model went and can place it immediately. + + +## GLB import optimization and reuse design +- Product intent: + - When users import GLB from `Items -> Mine`, Pascal should create a reusable, lighter asset instead of accepting only the original heavy file. + - Placing the same imported GLB many times should reuse the loaded resource and shared GPU geometry/materials instead of behaving like many independent model loads. + - Users should get actionable feedback: original complexity, optimized complexity, file size reduction, and warnings when a model is still too heavy. +- Current evidence: + - `packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx` posts the selected `.glb` file to `/api/imported-glb/assets`, then switches to `Mine`, selects the asset, and starts item placement. + - `apps/editor/app/api/imported-glb/assets/route.ts` already parses GLB JSON, records triangles/meshes/materials/images/texture size, rejects overly complex files, and writes `model.glb` plus `imported-glb.json`. + - `packages/nodes/src/item/renderer.tsx` loads item GLBs via `useGLTF(modelUrl)` and renders with drei ``; this already gives URL-level loader caching and shared child geometry/material references for repeated placements of the same `asset.src`. + - `packages/core/src/schema/nodes/item.ts` keeps item assets serializable through `asset.src`, dimensions, corrective transforms, tags, and metadata-like asset fields; core must not store Three.js resources. +- Geometry vocabulary: + - Do not equate GLB face count with mesh count. Meshes are containers; render cost is mainly triangles/primitives, draw calls/material count, texture memory, skeleton/animation cost, and number of visible instances. + - Import UI should display `triangles`, `mesh`, `materials`, `max texture size`, and file size separately. +- Import-time optimization pipeline: + 1. **Inspect original**: keep the current lightweight GLB JSON inspection, but persist `originalInspection` and `originalBytes`. + 2. **Optimize candidate**: run server-side optimization before saving the final asset. The preferred pipeline is prune/dedup/weld, simplify by triangle budget, resize oversized textures, quantize/meshopt-compress, then re-inspect. + 3. **Budget selection**: default target should be practical for canvas reuse, e.g. reduce to roughly `100k-150k` triangles for ordinary furniture/equipment, with a hard cap still around `500k` triangles for exceptional assets. + 4. **Save final**: write optimized output as `/items/{assetId}/model.glb`; optionally preserve the original as `/items/{assetId}/original.glb` only if product explicitly wants restore/re-optimize. + 5. **Record metadata**: `imported-glb.json` should include optimization status, original/final inspections, original/final byte sizes, reduction ratios, optimizer version/options, and warnings. + 6. **Manifest tags**: generated asset tags should include final triangle count and optimization status, not only original complexity. +- Dependency/implementation direction: + - Use an explicit GLB optimizer module under `apps/editor/lib/imported-glb/` or similar; keep it in the app/server layer because optimization depends on Node tooling and GLB binary processing. + - Do not put optimization logic in `packages/core` or `packages/viewer`; core remains schema/logic only, viewer remains runtime rendering only. + - If adding dependencies is allowed, prefer `@gltf-transform/core` + `@gltf-transform/functions` + `@gltf-transform/extensions` with meshoptimizer/Draco/texture transforms as needed. Without new dependencies, keep the current inspect/reject path and design the module boundary so the optimizer can be plugged in later. +- Runtime reuse design: + - Canonical reuse key is `asset.src`. All placements of the same imported asset must keep the same `asset.src` and asset id; transforms live on each `ItemNode`, not in duplicated asset files. + - Keep using `useGLTF(modelUrl)` plus `` for normal GLB assets because drei caches by URL and `Clone` shares geometry/material references. + - Avoid adding cache-busting query params except for semantic loader options. Imported GLBs currently add `?pascalImportedGlb=1`; this remains stable per `asset.src`, so multiple placements still share one cache entry. + - For very high instance counts of the exact same asset, add a later `instanced item` path only when the asset has no per-node material override, no animation state divergence, and compatible transforms. This is an optimization tier, not required for the first import pipeline. +- UX behavior in `Items -> Mine`: + - During import, show `Optimizing GLB...` after upload starts, not only `Importing...`. + - On success, show a compact result such as `Optimized: 320k -> 120k triangles, 18MB -> 7MB`. + - If optimization cannot reduce enough, reject with the measured reasons and tell users to reduce polygons/textures before import. + - If optimization fails due to unsupported input but the original is under safe limits, allow saving original with a warning only if this fallback is deliberate; otherwise fail closed to protect canvas performance. +- Acceptance criteria: + - Importing a valid GLB from `Items -> Mine` saves an optimized `model.glb` and records original/final metrics. + - The asset card remains in `Mine` and can be placed immediately. + - Placing the same imported asset multiple times keeps the same `asset.src`; runtime GLTF loading happens once per URL cache key, with clones sharing geometry/material references. + - Typecheck covers the import route and item renderer remains editor-agnostic. + - No Three.js resource object, loader cache, or optimizer-specific runtime object is stored in `packages/core` scene data. + +## Open questions +- [ ] Should `存入素材` preserve editability as Pascal geometry, or export a GLB-style item asset for compatibility? +- [ ] Should `放置画布` leave the draft reusable for multiple placements, or mark it as consumed? +- [ ] If v1 was already placed, should v2 offer `替换画布中的 v1` in addition to `放置画布`? +- [ ] Should older generated cards keep interactive 3D canvases, or collapse to thumbnails for performance? diff --git a/DYNAMIC_WEBSOCKET_DEVELOPMENT_PLAN.md b/DYNAMIC_WEBSOCKET_DEVELOPMENT_PLAN.md new file mode 100644 index 000000000..d8255c3a7 --- /dev/null +++ b/DYNAMIC_WEBSOCKET_DEVELOPMENT_PLAN.md @@ -0,0 +1,802 @@ +# WebSocket 鏁版嵁涓庨瑙堝姩鎬佺郴缁熷紑鍙戣鍒? +## 鐩爣 + +鎶婄紪杈戝櫒閲岀殑鈥滄暟鎹€濆拰鈥滃姩鎬佲€濇墦閫氭垚涓€涓畬鏁撮摼璺細 + +1. 椤圭洰鏍圭洰褰曟彁渚涗竴涓?WebSocket 妯℃嫙鏁版嵁宸ュ叿锛屾寔缁線澶栨帹閫佹ā鎷熷疄鏃舵暟鎹€?2. 妯℃嫙宸ュ叿鍚屾椂鎻愪緵 HTTP API锛屽墠绔彲浠ヨ幏鍙栧叏閮ㄥ彲缁戝畾鏁版嵁璺緞銆?3. 鐢诲竷涓嬫柟宸ュ叿鏍忕殑鈥滄暟鎹€濇ā鍧椾笉鍐嶅彧浣跨敤闈欐€佸亣鏁版嵁锛岃€屾槸鍙互瀵规帴 WebSocket 鏁版嵁璺緞銆?4. 浠绘剰鐢诲竷鐗╁搧锛屽寘鎷?assembly 涓嬬殑瀛愰儴浠讹紝閮藉彲浠ュ湪灞炴€ч潰鏉跨殑鈥滃姩鎬佲€濋〉绛鹃噷缁戝畾鏁版嵁璺緞鍜屽姩鎬佹晥鏋溿€?5. 鍔ㄦ€佹晥鏋滃彧鍦ㄧ偣鍑诲彸涓婅鈥滈瑙堚€濆悗杩愯锛岃璁℃€佸彧淇濆瓨閰嶇疆锛屼笉鎾斁鍔ㄧ敾锛屼笉姹℃煋鍦烘櫙鏁版嵁銆?6. 鏍规嵁鐗╁搧鐨勮涔夌被鍨嬪喅瀹氬彲閰嶇疆鍔ㄦ€侊細鏅€氱墿浣撳彧鏈夐€氱敤鍔ㄦ€侊紱绠¢亾鏈夆€滄祦閲忊€濓紱杈撻€佸甫鏈夆€滆緭閫佲€濓紱椋庢満鏈夆€滆浆鍔?閫熷害鈥濈瓑銆?7. LLM 鐢熸垚璁惧鏃惰緭鍑鸿涔夌被鍨嬶紝绯荤粺鏍规嵁鑳藉姏娉ㄥ唽琛ㄥ喅瀹氬畠鏀寔鍝簺鍔ㄦ€侊紝鑰屼笉鏄 LLM 浠绘剰鐢熸垚鍔ㄦ€佽兘鍔涖€? +--- + +## 褰撳墠浠g爜鐜扮姸 + +### 鐢诲竷涓嬫柟鈥滄暟鎹€濆叆鍙? +鐜版湁鍏ュ彛鍦細 + +- `packages/editor/src/components/ui/action-menu/control-modes.tsx` +- `packages/editor/src/components/ui/action-menu/structure-tools.tsx` + +褰撳墠閫昏緫锛? +- 鐐瑰嚮搴曢儴宸ュ叿鏍忊€滄暟鎹€濇寜閽悗锛岃繘鍏ワ細 + +```ts +phase: 'structure' +structureLayer: 'data' +mode: 'build' +tool: 'data-widget' +``` + +- 鈥滄暟鎹€濆伐鍏锋爮鐩墠鍙樉绀轰竴涓伐鍏凤細 + +```ts +dataTools = [ + { id: 'data-widget', iconSrc: '/icons/data-widget.svg', label: 'Data Widget' }, +] +``` + +### Data Widget + +鐩稿叧鏂囦欢锛? +- `packages/core/src/schema/nodes/data-widget.ts` +- `packages/core/src/live-data/static-live-data.ts` +- `packages/nodes/src/data-widget/panel.tsx` +- `packages/nodes/src/data-widget/renderer.tsx` +- `packages/nodes/src/data-widget/tool.tsx` + +褰撳墠闂锛? +- `data-widget` 浣跨敤鐨勬槸 `STATIC_LIVE_DATA_OPTIONS`銆?- 鏁版嵁瀛楁鏄潤鎬佹灇涓撅紝涓嶆槸杩滅▼ WebSocket path銆?- renderer 閫氳繃 `renderLiveDataTemplate()` 璇诲彇闈欐€佹暟鎹€?- 鍚庣画闇€瑕佹妸瀹冩敼鎴愬彲浠ョ粦瀹氬疄鏃舵暟鎹矾寰勩€? +--- + +## 鎬讳綋鏋舵瀯 + +绯荤粺鍒嗕负鍥涘眰锛? +```txt +Mock 鏁版嵁鏈嶅姟灞? HTTP paths API + WebSocket stream + +鏁版嵁婧愮鐞嗗眰 + 缂栬緫鍣ㄥ唴鐨?data source / live values store + +閰嶇疆灞? data-widget.dataBinding + node.metadata.dynamicBindings + node.metadata.semanticType + +棰勮杩愯灞? Preview Dynamic Runtime + Runtime visual overrides +``` + +鏍稿績鍘熷垯锛? +> 璁捐鎬佸彧閰嶇疆锛涢瑙堟€佹墠杩炴帴 WebSocket 骞舵墽琛屽姩鎬併€? +绂佹鍦ㄩ瑙堝姩鎬佽繃绋嬩腑鐩存帴璋冪敤 `updateNode()` 淇敼鑺傜偣鐪熷疄鏁版嵁銆? +--- + +## 涓€銆佹牴鐩綍 WebSocket 妯℃嫙宸ュ叿 + +### 寤鸿璺緞 + +```txt +tools/mock-websocket/ + server.mjs + signals.json + README.md +``` + +### package.json 鑴氭湰 + +```json +{ + "scripts": { + "mock:ws": "node tools/mock-websocket/server.mjs" + } +} +``` + +### 绔彛 + +榛樿锛? +```txt +http://localhost:3102 +ws://localhost:3102/ws +``` + +鍙€氳繃鐜鍙橀噺瑕嗙洊锛? +```txt +MOCK_WS_PORT=3102 +``` + +### HTTP API + +#### GET `/health` + +鐢ㄤ簬鍋ュ悍妫€鏌ャ€? +杩斿洖锛? +```json +{ + "ok": true, + "service": "pascal-mock-websocket" +} +``` + +#### GET `/paths` + +杩斿洖鎵€鏈夊彲缁戝畾鏁版嵁璺緞銆? +杩斿洖绀轰緥锛? +```json +[ + { + "path": "factory.conveyor.speed", + "label": "杈撻€佸甫閫熷害", + "type": "number", + "unit": "m/s", + "min": 0, + "max": 2, + "category": "conveyor" + }, + { + "path": "factory.pipe.flow", + "label": "绠¢亾娴侀噺", + "type": "number", + "unit": "m鲁/h", + "min": 0, + "max": 100, + "category": "pipe" + }, + { + "path": "factory.fan.running", + "label": "椋庢満杩愯", + "type": "boolean", + "category": "fan" + } +] +``` + +#### GET `/snapshot` + +杩斿洖褰撳墠鏈€鏂板€笺€? +杩斿洖绀轰緥锛? +```json +{ + "ts": 1782278400000, + "seq": 12, + "values": { + "factory.conveyor.speed": 1.2, + "factory.pipe.flow": 56, + "factory.fan.running": true + } +} +``` + +### WebSocket `/ws` + +鎸佺画鎺ㄩ€侊細 + +```json +{ + "ts": 1782278400000, + "seq": 13, + "values": { + "factory.conveyor.speed": 1.35, + "factory.pipe.flow": 61, + "factory.fan.running": true + } +} +``` + +### 渚濊禆閫夋嫨 + +Node 娌℃湁绋冲畾鍐呯疆 WebSocket server銆傜涓€鐗堝缓璁柊澧炰竴涓皬渚濊禆锛? +```txt +ws +``` + +濡傛灉鏆傛椂涓嶆兂鏂板渚濊禆锛屽彲浠ュ厛鐢?HTTP polling 楠岃瘉 UI 鍜屽姩鎬侀摼璺紝浣嗘渶缁?WebSocket 浠嶅缓璁敤 `ws`銆? +--- + +## 浜屻€佺粺涓€瀹炴椂鏁版嵁妯″瀷 + +### Signal path + +```ts +export type LiveDataValueType = 'number' | 'boolean' | 'string' + +export type LiveDataPath = { + path: string + label: string + type: LiveDataValueType + unit?: string + min?: number + max?: number + values?: string[] + category?: string +} +``` + +### Snapshot + +```ts +export type LiveDataSnapshot = { + ts: number + seq: number + values: Record +} +``` + +### 鍓嶇寤鸿璺緞 + +```txt +packages/editor/src/lib/live-data/ + types.ts + client.ts + store.ts + format.ts +``` + +### 鍓嶇鐘舵€? +```ts +type LiveDataState = { + status: 'idle' | 'connecting' | 'connected' | 'error' + endpoint: string + paths: LiveDataPath[] + snapshot: LiveDataSnapshot | null + values: Record + error: string | null +} +``` + +--- + +## 涓夈€佸簳閮ㄥ伐鍏锋爮鈥滄暟鎹€濇ā鍧楀鎺?WebSocket + +杩欐槸涓€涓嫭绔嬩絾蹇呴』绾冲叆绗竴闃舵鐨勯儴鍒嗐€? +### 褰撳墠琛屼负 + +搴曢儴宸ュ叿鏍忕偣鍑烩€滄暟鎹€濆悗锛屽彧鑳芥斁缃?Data Widget锛孌ata Widget 閫夋嫨闈欐€佸瓧娈碉細 + +```txt +machine.temperature +fan.speed +door.open +... +``` + +### 鐩爣琛屼负 + +搴曢儴鈥滄暟鎹€濇ā鍧楄礋璐f斁缃拰閰嶇疆鏁版嵁灞曠ず缁勪欢銆傚畠搴旇鍜?WebSocket 鏁版嵁婧愭墦閫氾細 + +1. 鐐瑰嚮搴曢儴鈥滄暟鎹€濄€?2. 閫夋嫨/鏀剧疆 Data Widget銆?3. 閫変腑 Data Widget銆?4. 灞炴€ч潰鏉夸腑鈥滄暟鎹瓧娈碘€濅笅鎷夋浠?`/paths` 鑾峰彇銆?5. Data Widget 鍦ㄨ璁℃€佸彲浠ユ樉绀烘渶鏂?snapshot 鎴栧崰浣嶅€笺€?6. 棰勮鎬侀殢 WebSocket 瀹炴椂鍒锋柊銆? +### Data Widget schema 寤鸿 + +褰撳墠锛? +```ts +dataKey: string +template: string +``` + +寤鸿淇濈暀 `dataKey` 鍏煎褰撳墠缁撴瀯锛屼絾璇箟鍗囩骇涓?live data path锛? +```ts +dataKey: z.string().default('factory.machine.temperature') +``` + +鍚庣画鍙墿灞曪細 + +```ts +dataBinding: z + .object({ + source: z.enum(['mock-websocket', 'manual', 'static']).default('mock-websocket'), + path: z.string(), + fallbackValue: z.union([z.string(), z.number(), z.boolean()]).optional(), + }) + .optional() +``` + +绗竴鐗堝彲浠ュ厛缁х画鐢?`dataKey`锛岄伩鍏?schema 鏀瑰姩杩囧ぇ銆? +### Data Widget 闈㈡澘鏀归€? +鏂囦欢锛? +```txt +packages/nodes/src/data-widget/panel.tsx +``` + +鏀归€犲唴瀹癸細 + +- 涓嶅啀鍙敤 `STATIC_LIVE_DATA_OPTIONS`銆?- 浠?editor live-data store 璇诲彇 paths銆?- 濡傛灉 WebSocket 鏈繛鎺ワ紝鏄剧ず锛? +```txt +鏈繛鎺ユ暟鎹簮 +浣跨敤妯℃嫙鏁版嵁 / 杩炴帴涓?/ 閲嶈瘯 +``` + +- 涓嬫媺鏄剧ず锛? +```txt +杈撻€佸甫閫熷害 factory.conveyor.speed +绠¢亾娴侀噺 factory.pipe.flow +椋庢満杩愯 factory.fan.running +``` + +### Data Widget renderer 鏀归€? +鏂囦欢锛? +```txt +packages/nodes/src/data-widget/renderer.tsx +``` + +鏀归€犲唴瀹癸細 + +- 璁捐鎬侊細 + - 浼樺厛鏄剧ず live-data store 涓渶鏂板€笺€? - 娌℃湁鍊兼椂鏄剧ず fallback 鎴?`?`銆?- 棰勮鎬侊細 + - 鐢?WebSocket snapshot 瀹炴椂椹卞姩銆? +娉ㄦ剰锛? +`packages/nodes` 涓嶅簲璇ョ洿鎺ョ煡閬?editor store銆傞渶瑕侀€氳繃涓€涓法鍖呭畨鍏ㄦ柟妗堝鐞嗭細 + +#### 鏂规 A锛氭妸 live-data store 鏀惧埌 core + +浼樼偣锛歯odes renderer 鍙互鐩存帴璇诲彇銆? +缂虹偣锛歝ore 浼氭壙鎷呰繍琛屾€佹暟鎹姸鎬侊紝鍙兘杈圭晫鍙橀噸銆? +#### 鏂规 B锛歞ata-widget renderer 浠嶅湪 nodes锛屼絾閫氳繃 core 鎻愪緵绾嚱鏁帮紝瀹炴椂鍊肩敱 viewer/editor context 娉ㄥ叆 + +浼樼偣锛氳竟鐣屾洿骞插噣銆? +缂虹偣锛氶渶瑕佽璁′竴涓?provider/hook銆? +#### 鎺ㄨ崘 + +绗竴鐗堜负浜嗗敖蹇墦閫氾紝鍙互鎶?live-data 鐨勭函绫诲瀷鍜屾牸寮忓寲宸ュ叿鏀?core锛屾妸 WebSocket client/store 鏀?editor銆侱ata Widget 杩愯鏃跺€奸€氳繃 scene/viewer 灞傚彲璁块棶鐨?lightweight store 鎴?provider 娉ㄥ叆锛岄伩鍏?nodes 鐩存帴 import editor銆? +--- + +## 鍥涖€佸姩鎬侀厤缃郴缁? +### 閰嶇疆瀛樻斁浣嶇疆 + +鍔ㄦ€佺粦瀹氬瓨鏀惧埌鑺傜偣 metadata锛? +```json +{ + "metadata": { + "semanticType": "fan", + "dynamicBindings": [ + { + "id": "dyn_001", + "type": "rotate", + "path": "factory.fan.speed", + "axis": "z", + "speedRange": [0, 8] + } + ] + } +} +``` + +### 涓轰粈涔堟斁 metadata + +- 涓嶇牬鍧忕幇鏈夎妭鐐?schema銆?- assembly root 鍜?assembly child 閮藉彲浠ヤ娇鐢ㄣ€?- LLM 鐢熸垚鑺傜偣鏃跺彲浠ョ洿鎺ュ啓鍏ャ€?- 鏈瘑鍒瓧娈典笉浼氬奖鍝嶆棫鑺傜偣銆? +### 绫诲瀷瀹氫箟寤鸿璺緞 + +```txt +packages/core/src/dynamics/ + types.ts + capabilities.ts + metadata.ts +``` + +濡傛灉涓嶆兂绗竴闃舵瑙︾ core锛屽彲浠ュ厛鏀撅細 + +```txt +packages/editor/src/lib/dynamics/ +``` + +浣嗛暱鏈熷缓璁牳蹇冪被鍨嬭繘鍏?core锛孶I 鍜?runtime 鐣欏湪 editor/viewer銆? +--- + +## 浜斻€佸姩鎬佽兘鍔涙敞鍐岃〃 + +### 閫氱敤鍔ㄦ€? +鎵€鏈夌墿浣撻粯璁ゆ敮鎸侊細 + +```txt +鍙 visible +绉诲姩 move +闂儊 blink +濉厖 fill +缂╂斁 scale +棰滆壊 color +杞姩 rotate +``` + +### 涓撳睘鍔ㄦ€? +鏍规嵁璇箟绫诲瀷杩藉姞锛? +```txt +pipe 鈫?flow +conveyor 鈫?conveyorFlow +conveyor_belt 鈫?conveyorFlow +tank 鈫?level +fan 鈫?rotate / speed +motor 鈫?rotate / speed +roller 鈫?rotate +valve 鈫?openClose / flow +pump 鈫?running / flow +light 鈫?brightness / blink +display 鈫?valueDisplay +``` + +### 绀轰緥 + +```ts +export const dynamicCapabilityRegistry = { + common: ['visible', 'move', 'blink', 'fill', 'scale', 'color', 'rotate'], + semanticTypes: { + pipe: ['flow'], + conveyor: ['conveyorFlow'], + conveyor_belt: ['conveyorFlow'], + tank: ['level'], + fan: ['speed'], + motor: ['speed'], + roller: ['rotate'], + valve: ['openClose', 'flow'], + pump: ['running', 'flow'], + light: ['brightness'], + display: ['valueDisplay'], + }, +} as const +``` + +### 鏄剧ず瑙勫垯 + +```txt +娌℃湁 semanticType + 鈫?鍙樉绀洪€氱敤鍔ㄦ€? +semanticType = conveyor + 鈫?閫氱敤鍔ㄦ€?+ 杈撻€? +semanticType = pipe + 鈫?閫氱敤鍔ㄦ€?+ 娴侀噺 +``` + +--- + +## 鍏€丩LM 鐢熸垚璁惧鎺ュ叆瑙勫垯 + +LLM 涓嶇洿鎺ュ喅瀹氬姩鎬佸垪琛ㄣ€侺LM 鍙緭鍑鸿涔夌被鍨嬨€? +绀轰緥锛? +```json +{ + "name": "鐨甫杈撻€佹満", + "semanticType": "conveyor", + "children": [ + { + "name": "鐨甫", + "semanticType": "conveyor_belt" + }, + { + "name": "婊氱瓛", + "semanticType": "roller" + }, + { + "name": "鐢垫満", + "semanticType": "motor" + } + ] +} +``` + +绯荤粺鏍规嵁 registry 鑷姩寰楀埌锛? +```txt +鐨甫杈撻€佹満 鈫?杈撻€?鐨甫 鈫?杈撻€?婊氱瓛 鈫?杞姩 +鐢垫満 鈫?杞€?杞姩 +``` + +### 鍏滃簳瑙勫垯 + +濡傛灉 LLM 鏈緭鍑?semanticType锛? +1. 榛樿鍙樉绀洪€氱敤鍔ㄦ€併€?2. 鐢ㄦ埛鍙互鍦ㄥ睘鎬ч潰鏉块珮绾у尯鍩熸墜鍔ㄩ€夋嫨鈥滆澶囩被鍨嬧€濄€?3. 鍚庣画鍙互鍋氬悕瀛楁帹鑽愶紝浣嗕笉瑕佸己鍒躲€? +--- + +## 涓冦€佸睘鎬ч潰鏉库€滃姩鎬佲€濋〉绛? +### 鐩爣 + +閫変腑浠讳綍鑺傜偣锛屽寘鎷?assembly 涓嬬殑瀛愰儴浠讹紝閮藉彲浠ヨ繘鍏モ€滃姩鎬佲€濋〉绛鹃厤缃€? +### UI 缁撴瀯 + +```txt +鍔ㄦ€? +[娣诲姞鍔ㄦ€乚 + +鍔ㄦ€?1 + 绫诲瀷锛氳浆鍔? 鏁版嵁璺緞锛歠actory.fan.speed + 杞村悜锛歓 + 閫熷害鑼冨洿锛? ~ 8 + +鍔ㄦ€?2 + 绫诲瀷锛氶鑹? 鏁版嵁璺緞锛歠actory.machine.temperature + 鏄犲皠锛? 钃?/ 50 榛?/ 100 绾?``` + +### 鏂囦欢鍏ュ彛 + +鐜版湁椤电澶栧3锛? +```txt +packages/editor/src/components/ui/panels/panel-wrapper.tsx +``` + +涓嬩竴姝ラ渶瑕佽椤电涓嶅彧鏄瑙夊垏鎹紝鑰屾槸鐪熸鎺у埗锛? +- 鍩虹鍐呭鏄剧ず鍘熷睘鎬ч潰鏉?children銆?- 鍔ㄦ€佸唴瀹规樉绀虹粺涓€ Dynamic Inspector銆? +### 寤鸿缁勪欢 + +```txt +packages/editor/src/components/ui/panels/dynamic-inspector/ + dynamic-inspector.tsx + dynamic-binding-card.tsx + dynamic-type-select.tsx + live-data-path-select.tsx +``` + +### assembly 瀛愰儴浠? +瑙勫垯锛? +```txt +閫変腑 assembly root + 鈫?dynamicBindings 鍐欏埌 assembly root metadata + +閫変腑 assembly child + 鈫?dynamicBindings 鍐欏埌 child node metadata +``` + +涓嶈兘鎶婂瓙閮ㄤ欢鍔ㄦ€佸啓鍒?assembly root锛屽惁鍒欐棤娉曞崟鐙帶鍒堕鎵囥€佺閬撱€佸澹炽€? +--- + +## 鍏€侀瑙堝姩鎬佽繍琛屾椂 + +### 瑙﹀彂鐐? +鍙湪鐐瑰嚮鍙充笂瑙掆€滈瑙堚€濆悗鍚敤銆? +闇€瑕佸厛妫€鏌ョ幇鏈夐瑙堢姸鎬佸叆鍙o紝鍐嶆帴鍏?runtime銆? +### 寤鸿鏂囦欢 + +```txt +packages/editor/src/lib/dynamics/runtime/ + preview-dynamic-runtime.ts + evaluators.ts + runtime-overrides.ts +``` + +### 杩愯娴佺▼ + +```txt +杩涘叆棰勮 + 鈫?杩炴帴 WebSocket + 鈫?鑾峰彇 paths + snapshot + 鈫?鎵弿 scene 涓墍鏈?dynamicBindings + 鈫?姣忓抚璇诲彇鏈€鏂?values + 鈫?搴旂敤 visual override + +閫€鍑洪瑙? 鈫?鏂紑 WebSocket + 鈫?娓呯┖鎵€鏈?override + 鈫?鎭㈠璁捐鎬?``` + +### 绂佹琛屼负 + +棰勮鏃朵笉瑕佽皟鐢細 + +```ts +useScene.getState().updateNode(...) +``` + +搴旇浣跨敤 runtime override锛? +```txt +visible override +position offset +rotation offset +scale multiplier +material color override +temporary clone objects +``` + +--- + +## 涔濄€佸姩鎬佹晥鏋滃疄鐜颁紭鍏堢骇 + +### 绗竴鎵癸細鏈€灏忛棴鐜? +浼樺厛鍋氾細 + +1. `visible` 鍙 +2. `color` 棰滆壊 +3. `scale` 缂╂斁 +4. `rotate` 杞姩 + +杩欏洓涓渶瀹规槗绋冲畾楠岃瘉銆? +### 绗簩鎵癸細甯哥敤鍔ㄤ綔 + +5. `move` 绉诲姩 +6. `blink` 闂儊 + +### 绗笁鎵癸細宸ヤ笟涓撳睘 + +7. `flow` 娴侀噺 +8. `conveyorFlow` 杈撻€?9. `level` 娑蹭綅 +10. `openClose` 闃€闂ㄥ紑鍏?11. `valueDisplay` 浠〃鏁版樉 + +--- + +## 鍗併€佽緭閫佸甫 conveyorFlow 璁捐 + +杩欎釜涓嶆槸鏅€氣€滅Щ鍔ㄢ€濓紝鑰屾槸瀹瑰櫒鍨嬪姩鎬併€? +### 鐢ㄦ埛閰嶇疆 + +閫変腑杈撻€佸甫鏃讹紝鍔ㄦ€侀噷鍑虹幇鈥滆緭閫佲€濄€? +閰嶇疆椤癸細 + +```txt +鏁版嵁璺緞锛歠actory.conveyor.speed +璐х墿妯℃澘锛氶€夋嫨鐢诲竷涓婄殑鏌愪釜鐗╀綋 +鏂瑰悜锛歑 / Y / Z / 璺緞 +璺濈锛?m +闂磋窛锛?.2m +閫熷害鑼冨洿锛? ~ 2m/s +寰幆锛氬紑鍚?``` + +### 淇濆瓨缁撴瀯 + +```json +{ + "id": "dyn_conveyor_001", + "type": "conveyorFlow", + "path": "factory.conveyor.speed", + "itemTemplateNodeId": "box_001", + "direction": "x", + "distance": 6, + "spacing": 1.2, + "speedRange": [0, 2], + "loop": true +} +``` + +### 棰勮琛ㄧ幇 + +璁捐鎬侊細 + +```txt +涓€涓緭閫佸甫 + 涓€涓揣鐗╂ā鏉?``` + +棰勮鎬侊細 + +```txt +澶氫釜涓存椂鍏嬮殕璐х墿娌胯緭閫佸甫寰幆绉诲姩 +``` + +姣忓抚浣嶇疆锛? +```ts +position = (time * speed + index * spacing) % distance +``` + +涓存椂鍏嬮殕瀵硅薄涓嶈兘鍐欏叆 scene graph銆? +--- + +## 鍗佷竴銆佹祦閲?flow 璁捐 + +涓昏鐢ㄤ簬 pipe銆? +绗竴鐗堣〃鐜板彲浠ョ畝鍗曪細 + +```txt +娴侀噺鍊艰秺澶э紝棰滆壊瓒婁寒 +娴侀噺鍊煎ぇ浜?0 鏃舵部绠¢亾鏄剧ず绉诲姩绠ご/鑴夊啿 +娴侀噺涓?0 鏃跺仠姝?``` + +淇濆瓨缁撴瀯锛? +```json +{ + "id": "dyn_flow_001", + "type": "flow", + "path": "factory.pipe.flow", + "direction": "forward", + "speedRange": [0, 3], + "color": "#35c8ff" +} +``` + +鍚庣画鍙互鍗囩骇涓?shader / texture offset銆? +--- + +## 鍗佷簩銆佸紑鍙戦噷绋嬬 + +### Milestone 1锛氭暟鎹簮涓?Data Widget 鎵撻€? +瀹屾垚锛? +- 鏂板 `tools/mock-websocket`銆?- 鏂板 `bun run mock:ws` 鎴?`npm run mock:ws` 鑴氭湰銆?- 鎻愪緵 `/paths`銆乣/snapshot`銆乣/health`銆乣/ws`銆?- 鍓嶇鏂板 live-data client/store銆?- 搴曢儴鈥滄暟鎹€濆伐鍏锋斁缃殑 Data Widget 鍙互閫夋嫨 WebSocket path銆?- Data Widget 鑳芥樉绀哄疄鏃?snapshot銆? +楠屾敹锛? +- 鍚姩 mock server銆?- 鎵撳紑缂栬緫鍣ㄣ€?- 鐐瑰嚮搴曢儴鈥滄暟鎹€濄€?- 鏀剧疆 Data Widget銆?- 涓嬫媺妗嗚兘鐪嬪埌 `/paths` 杩斿洖鐨勬暟鎹€?- WebSocket 鏁版嵁鍙樺寲鏃讹紝Data Widget 鏄剧ず闅忎箣鍙樺寲銆? +### Milestone 2锛氬姩鎬侀厤缃?UI + +瀹屾垚锛? +- 灞炴€ч潰鏉库€滃熀纭€/鍔ㄦ€佲€濋〉绛剧湡姝e垏鎹㈠唴瀹广€?- 鍔ㄦ€侀〉绛炬敮鎸佹坊鍔犮€佺紪杈戙€佸垹闄?dynamic binding銆?- 鍔ㄦ€佺被鍨嬫牴鎹?semanticType + registry 鐢熸垚銆?- 鏁版嵁璺緞涓嬫媺澶嶇敤 live-data paths銆?- 閰嶇疆淇濆瓨鍒?`metadata.dynamicBindings`銆? +楠屾敹锛? +- 鏅€氱墿浣撳彧鏈夐€氱敤鍔ㄦ€併€?- pipe 鏈夆€滄祦閲忊€濄€?- conveyor 鏈夆€滆緭閫佲€濄€?- assembly 瀛愰儴浠跺彲浠ュ崟鐙繚瀛樺姩鎬侀厤缃€? +### Milestone 3锛氶瑙堣繍琛屾椂鏈€灏忛棴鐜? +瀹屾垚锛? +- 鐐瑰嚮棰勮鍚庡惎鍔?dynamic runtime銆?- 閫€鍑洪瑙堝悗娓呯悊 runtime override銆?- 鏀寔 visible / color / scale / rotate銆? +楠屾敹锛? +- 璁捐鎬佺墿浣撲笉鍔ㄣ€?- 棰勮鎬?WebSocket 鏁版嵁椹卞姩鐗╀綋鍙樿壊銆佺缉鏀俱€佹樉绀洪殣钘忋€佹棆杞€?- 閫€鍑洪瑙堝悗涓€鍒囨仮澶嶃€? +### Milestone 4锛氬伐涓氫笓灞炲姩鎬? +瀹屾垚锛? +- pipe.flow銆?- conveyor.conveyorFlow銆?- tank.level銆?- valve.openClose銆? +楠屾敹锛? +- 绠¢亾鑳芥樉绀烘祦閲忔晥鏋溿€?- 杈撻€佸甫鑳界敤涓€涓揣鐗╂ā鏉跨敓鎴愬惊鐜緭閫佹晥鏋溿€?- 鏁版嵁鍙樺寲鑳藉奖鍝嶉€熷害/鐘舵€併€? +### Milestone 5锛歀LM 涓庤涔夌被鍨嬪畬鍠? +瀹屾垚锛? +- LLM 鐢熸垚璁惧鏃跺啓鍏?`metadata.semanticType`銆?- 瀛愰儴浠朵篃鍐欏叆 semanticType銆?- 鐢ㄦ埛鍙互鎵嬪姩淇敼璁惧绫诲瀷銆?- 鏈煡 semanticType 鍙樉绀洪€氱敤鍔ㄦ€併€? +楠屾敹锛? +- LLM 鐢熸垚杈撻€佸甫鍚庤嚜鍔ㄥ嚭鐜扳€滆緭閫佲€濆姩鎬併€?- LLM 鐢熸垚绠¢亾鍚庤嚜鍔ㄥ嚭鐜扳€滄祦閲忊€濆姩鎬併€?- LLM 鐢熸垚椋庢墖鍚庤嚜鍔ㄥ嚭鐜扳€滆浆鍔?閫熷害鈥濈浉鍏冲姩鎬併€? +--- + +## 鍗佷笁銆佹祴璇曡鍒? +### 鍗曞厓娴嬭瘯 + +瑕嗙洊锛? +- dynamic capability registry +- semanticType 鍒?dynamic types 鐨勬槧灏?- data path 绫诲瀷杩囨护 +- value mapping evaluator +- template render + +### 闆嗘垚娴嬭瘯 + +瑕嗙洊锛? +- mock server `/paths` +- mock server `/snapshot` +- WebSocket frame 鏍煎紡 +- live-data client reconnect + +### UI / E2E 娴嬭瘯 + +瑕嗙洊锛? +- 搴曢儴鐐瑰嚮鈥滄暟鎹€濆苟鏀剧疆 Data Widget銆?- Data Widget 闈㈡澘鏄剧ず WebSocket paths銆?- 閫変腑鏅€氱墿浣撴墦寮€鍔ㄦ€侀〉绛俱€?- 閫変腑 assembly 瀛愰儴浠舵墦寮€鍔ㄦ€侀〉绛俱€?- 鐐瑰嚮棰勮鍚庡姩鎬佽繍琛屻€?- 閫€鍑洪瑙堝悗鐘舵€佹仮澶嶃€? +--- + +## 鍗佸洓銆佹灦鏋勮竟鐣? +### core + +閫傚悎鏀撅細 + +- 鍔ㄦ€佺粦瀹氱函绫诲瀷銆?- semanticType / capability 绾敞鍐岃〃銆?- live-data path 绫诲瀷銆?- template/value formatting 绾嚱鏁般€? +涓嶉€傚悎鏀撅細 + +- WebSocket client銆?- React UI銆?- Three.js runtime 鎿嶄綔銆? +### editor + +閫傚悎鏀撅細 + +- WebSocket client/store銆?- 灞炴€ч潰鏉?UI銆?- 鏁版嵁婧愯繛鎺ョ姸鎬併€?- 棰勮妯″紡鎺у埗銆?- dynamic runtime orchestration銆? +### viewer + +閫傚悎鏀撅細 + +- 瀵?Three.js object 鐨?runtime visual override 鑳藉姏銆?- 涓嶅簲鐭ラ亾鈥滃姩鎬侀潰鏉库€濃€淲ebSocket 涓氬姟鈥濃€渆ditor tool鈥濊繖浜涚紪杈戝櫒姒傚康銆? +### nodes + +閫傚悎鏀撅細 + +- 鑺傜偣鑷繁鐨?panel/renderer銆?- Data Widget renderer銆?- 鑺傜偣灞€閮ㄥ姩鎬侀厤缃?UI 鍙互澶嶇敤 editor 鎻愪緵鐨勭粍浠躲€? +娉ㄦ剰锛? +`packages/nodes` 涓嶈兘闅忔剰 import `apps/editor`銆傚鏋滆浣跨敤 editor 鐘舵€侊紝闇€瑕侀€氳繃鍏叡鍖呮垨娉ㄥ叆鏈哄埗銆? +--- + +## 鍗佷簲銆佸凡鐭ラ闄? +1. **璁捐鎬佹薄鏌撻闄?* + 棰勮鍔ㄦ€佷笉鑳戒慨鏀圭湡瀹炶妭鐐规暟鎹€? +2. **assembly 瀛愰儴浠?ID 绋冲畾鎬?* + 鍔ㄦ€侀厤缃粦瀹?node id锛涘鏋滃瓙閮ㄤ欢閲嶅缓瀵艰嚧 id 鍙樺寲锛岄厤缃細涓€? +3. **Data Widget 璺ㄥ寘鐘舵€佽鍙?* + 闇€瑕佸皬蹇?core / editor / nodes 鐨勮竟鐣屻€? +4. **flow / conveyorFlow 鏁堟灉澶嶆潅** + 绗竴鐗堝厛鍋氬彲瑙佹晥鏋滐紝涓嶈拷姹傛渶缁?shader 璐ㄩ噺銆? +5. **LLM 璇箟閿欒** + semanticType 蹇呴』璧扮櫧鍚嶅崟鍜岀敤鎴峰彲淇鏈哄埗銆? +6. **WebSocket 杩炴帴鐘舵€?* + 闇€瑕佸鐞嗘柇绾裤€侀噸杩炪€佹棤鏁版嵁銆佽矾寰勪笉瀛樺湪銆? +7. **涔辩爜闂** + 鐜版湁閮ㄥ垎鏂囦欢宸茬粡鏈?mojibake銆傛柊澧炰腑鏂囨枃鏈缓璁娇鐢?UTF-8 涓旈伩鍏?PowerShell 鍐欏叆鐮村潖锛涘繀瑕佹椂鐢?unicode escape銆? +--- + +## 绗竴闃舵鎺ㄨ崘瀹炴柦椤哄簭 + +1. 鏂板 mock WebSocket server銆?2. 鏂板 live-data types/client/store銆?3. 鏀归€?Data Widget path 涓嬫媺锛屽厛鎺?`/paths`銆?4. 鏀归€?Data Widget renderer锛屾樉绀哄疄鏃?value銆?5. 鍋?dynamic types + capability registry銆?6. 璁╁睘鎬ч潰鏉库€滃姩鎬佲€濋〉绛炬樉绀虹湡瀹?Dynamic Inspector銆?7. 淇濆瓨 dynamicBindings 鍒?metadata銆?8. 鍋氶瑙?runtime锛屽厛鏀寔 color / rotate / scale / visible銆?9. 鏈€鍚庡仛 flow / conveyorFlow銆? +--- + +## 鍐崇瓥鎽樿 + +鏈€缁堟柟鍚戯細 + +```txt +搴曢儴鈥滄暟鎹€? 鈫?绠$悊瀹炴椂鏁版嵁灞曠ず缁勪欢 Data Widget + 鈫?鏁版嵁瀛楁鏉ヨ嚜 WebSocket paths + +灞炴€ч潰鏉库€滃姩鎬佲€? 鈫?缁欎换浣曡妭鐐圭粦瀹氬姩鎬佽涓? 鈫?鍔ㄦ€佺被鍨嬬敱 semanticType + capability registry 鍐冲畾 + +鍙充笂瑙掆€滈瑙堚€? 鈫?杩炴帴 WebSocket + 鈫?鎵ц runtime visual overrides + 鈫?閫€鍑哄悗鎭㈠璁捐鎬?``` + +杩欎釜鏂规鍙互鍚屾椂鏀拺锛? +- 鏅€氱墿浣撳姩鎬併€?- assembly 瀛愰儴浠跺姩鎬併€?- LLM 鐢熸垚璁惧鍔ㄦ€佽兘鍔涖€?- 绠¢亾娴侀噺銆?- 杈撻€佸甫杩炵画杈撻€併€?- Data Widget 瀹炴椂鏁版嵁鏄剧ず銆? diff --git a/FACTORY_COMMAND_EXECUTION_ANALYSIS.md b/FACTORY_COMMAND_EXECUTION_ANALYSIS.md new file mode 100644 index 000000000..b31d34368 --- /dev/null +++ b/FACTORY_COMMAND_EXECUTION_ANALYSIS.md @@ -0,0 +1,196 @@ +# Factory Creation & Command Execution 完整运行逻辑分析 + +## 核心发现 + +### 问题 1: "创建一个炼油厂" → 系统完整流程 + +✅ **流程链路**: +- API: POST /api/ai-harness/runs (route.ts:L49) +- 启动: ensureFactoryRunRunning() (factory-runner.ts:L853) +- 主循环: runFactoryRun() (factory-runner.ts:L866) + - Step 1: buildFactoryRunResultFromSelectionEdit() (L884) - 检查是否编辑现有对象 + - Step 2: planFactoryRequest() (factory-planner.ts) - AI 规划工厂类型 + - Step 3: composeProcessLine() (process-line-composer.ts) - 组合工艺线路 + - 创建壳体、设备、管道(根据介质上色,但透明度固定为1) + - 返回 patches(所有创建指令) + +### 问题 2: "把管道透明度设置成80%" → 能执行吗? + +❌ **当前不能** + +**原因**: +- 系统会识别为"选择编辑"命令 +- 调用 composeSelectionEdit() 分发器 +- 但没有 looksLikeSelectionOpacityEdit() 函数 +- 所有编辑函数都不匹配 → 返回 null +- 降级到"规划新工厂"流程(错误的处理) + +**现有编辑函数** (factory-selection-edit.ts:L1254): +1. composeSelectionDeleteEdit() - 删除 +2. composeSelectionMoveEdit() - 移动 +3. composeSelectionRotateEdit() - 旋转 +4. composeSelectionColorEdit() - 改颜色 ✅ +5. composeSelectionTankKindEdit() - 改罐体 +6. composeSelectionTowerLevelEdit() - 改塔吊 +7. composeSelectionGeometryEdit() - 改大小 +8. composeSelectionReplaceEdit() - 替换 +9. **composeSelectionOpacityEdit() - 改透明度** ❌ 缺失 + +## 关键代码位置 + +### 工厂创建流程 +- API: app/api/ai-harness/runs/route.ts:L49 +- 启动: lib/ai-harness-runs/factory-runner.ts:L853 +- 主循环: lib/ai-harness-runs/factory-runner.ts:L866 +- 规划: lib/ai-harness-runs/factory-planner.ts +- 工艺线路: lib/ai-harness-runs/process-line-composer.ts +- 管道颜色: process-line-composer.ts:L64-73 (MEDIUM_COLOR) +- 管道创建: process-line-composer.ts:L375-408 (createConnectionPatch) + +### 选择编辑流程 +- 入口: factory-runner.ts:L884 buildFactoryRunResultFromSelectionEdit() +- 分发: factory-selection-edit.ts:L1254 composeSelectionEdit() +- 颜色编辑(参考): factory-selection-edit.ts:L1158 composeSelectionColorEdit() +- 检测: factory-selection-edit.ts:L121 looksLikeSelectionColorEdit() +- 解析: factory-selection-edit.ts:L239 resolveSelectionEditColor() + +## 实现透明度编辑所需代码 + +### 1. 检测函数 +```typescript +export function looksLikeSelectionOpacityEdit(prompt: string) { + return /透明度|opacity|transparent|alpha|半透明|(\d+)\s*%/.test(prompt) +} +``` + +### 2. 解析函数 +```typescript +export function resolveSelectionOpacity(prompt: string): number | undefined { + const percentMatch = /(\d+(?:\.\d+)?)\s*%/.exec(prompt) + if (percentMatch) { + return Math.max(0, Math.min(1, Number(percentMatch[1]) / 100)) + } + + const floatMatch = /0\.\d+/.exec(prompt) + if (floatMatch) { + return Math.max(0, Math.min(1, Number(floatMatch[0]))) + } + + if (/全透明|完全透明|invisible/.test(prompt)) return 0 + if (/半透明|semi-transparent/.test(prompt)) return 0.5 + if (/不透明|opaque/.test(prompt)) return 1 + + return undefined +} +``` + +### 3. 执行函数 +```typescript +export function composeSelectionOpacityEdit(input: { + prompt: string + context?: unknown +}): FactorySelectionEditResult | null { + const opacity = resolveSelectionOpacity(input.prompt) + if (opacity === undefined) return null + + const snapshot = selectionSnapshotFromContext(input.context) + if (!snapshot?.selectedIds.length) { + return { + patches: [], + nodeIds: [], + changed: [], + missingReason: 'No canvas object is selected.', + } + } + + const candidates = expandedEditableNodes(snapshot).filter( + (node) => MATERIAL_NODE_TYPES.has(node.type) || node.color !== undefined + ) + + if (!candidates.length) { + return { + patches: [], + nodeIds: [], + changed: [], + missingReason: 'No selectable object with material found.', + } + } + + const patches = candidates.flatMap((node) => { + const material = { ...node.material } as Record + if (!material.properties) { + material.properties = {} + } + const properties = material.properties as Record + properties.opacity = opacity + properties.transparent = opacity < 1 + + return [{ + op: 'update' as const, + id: node.id, + data: { material } + }] + }) + + return { + patches, + nodeIds: patches.map(p => p.id), + changed: patches.map( + p => snapshot.nodes.find(n => n.id === p.id)?.name ?? p.id + ), + summary: patches.map( + p => `${snapshot.nodes.find(n => n.id === p.id)?.name ?? p.id}: opacity -> ${opacity}` + ) + } +} +``` + +### 4. 更新分发函数 (L1254-1268) +在 composeSelectionEdit() 中加入: +```typescript +composeSelectionOpacityEdit(input) ?? // ← 在 ColorEdit 之后 +``` + +## 工厂初始创建中的透明度 + +- 位置: factory-selection-edit.ts:L254-266 customMaterial() +- 当前值: opacity: 1 (硬编码100%不透明) +- transparent: false + +## 选择编辑数据流 + +``` +context = { + selection: { + selectedIds: ['pipe_1', 'pipe_2', 'pipe_3'], + nodes: [ + { id: 'pipe_1', type: 'pipe', color: '#38bdf8', material: {...} }, + { id: 'pipe_2', type: 'pipe', color: '#38bdf8', material: {...} }, + { id: 'pipe_3', type: 'pipe', color: '#38bdf8', material: {...} }, + ] + } +} + ↓ +composeSelectionOpacityEdit(input) + ↓ +返回 3 个 patches: +[ + { op: 'update', id: 'pipe_1', data: { material: { properties: { opacity: 0.8, transparent: true } } } }, + { op: 'update', id: 'pipe_2', data: { material: { properties: { opacity: 0.8, transparent: true } } } }, + { op: 'update', id: 'pipe_3', data: { material: { properties: { opacity: 0.8, transparent: true } } } } +] + ↓ +WebSocket 发送到渲染引擎 + ↓ +3D 视图中管道变为 80% 透明 +``` + +## 参考实现(颜色编辑) + +颜色编辑是完整的参考实现: +- 检测: looksLikeSelectionColorEdit() (L121-125) +- 解析: resolveSelectionEditColor() (L239-252) +- 执行: composeSelectionColorEdit() (L1158-1198) +- 材质: customMaterial() (L254-266) +- 节点类型: MATERIAL_NODE_TYPES (L63-92) + diff --git a/LLM_PRIMITIVE_GENERATION_ARCHITECTURE.md b/LLM_PRIMITIVE_GENERATION_ARCHITECTURE.md new file mode 100644 index 000000000..cbfa8a740 --- /dev/null +++ b/LLM_PRIMITIVE_GENERATION_ARCHITECTURE.md @@ -0,0 +1,520 @@ +# LLM Primitive 几何生成架构 + +本文说明项目里 LLM 通过 primitive 工具生成可编辑物品的架构。这里的 primitive 工具不是单指 `compose_primitive`,而是 AI harness 暴露给模型的一组几何生成能力,包括 `compose_recipe`、`compose_assembly`、`compose_parts`、`compose_robot_arm`、`compose_primitive` 和 `revise_geometry`。 + +当前架构的核心判断是: + +> Profile 只描述“设备是什么”,不能单独保证“生成得像”。复杂工业设备需要 Profile + Layout Template + Part Preset + Proportion Rules + Quality Rules 一起工作。 + +## 目标 + +1. LLM 负责理解意图、选择路线、给出结构化参数,不直接写最终 scene node。 +2. 本地 deterministic executor 负责生成 shape、transform、material、semanticRole 和质量校验。 +3. 设备知识尽量资源包化,减少为每个设备硬编码 TS 逻辑。 +4. 生成结果必须可编辑、可校验、可修订。 +5. 没有稳定 profile 时,先生成 draft profile,再经过 validator,而不是直接自由拼 primitive。 + +## 总体流程 + +```mermaid +flowchart TD + A["用户输入 prompt"] --> B["AI harness run API"] + B --> C["加载上下文和最近 artifact"] + C --> D["加载 Geometry Knowledge Packs"] + D --> E["Profile / Alias Matcher"] + E --> F{"命中 stable profile?"} + F -->|是| G["Profile Resolver"] + F -->|否| H["Stage1 Analyst / Draft Profile Builder"] + H --> I{"draft 合格?"} + I -->|是| G + I -->|否| J["generic industrial fallback"] + G --> K["Layout Template Resolver"] + K --> L["Part Preset / Proportion Resolver"] + L --> M["Part Composer"] + M --> N["Primitive Composer"] + N --> O["Semantic + Visual + Profile Quality"] + O --> P{"评分合格?"} + P -->|是| Q["GeneratedGeometryArtifact"] + P -->|否| R["repair / fallback"] + R --> K + Q --> S["写入画布"] + Q --> T["高质量 runtime draft 保存为 candidate"] +``` + +## 分层定义 + +### Primitive + +最底层的几何原子,例如 box、cylinder、torus、cone、frustum、rounded box、ellipse panel 等。 + +职责: + +- 只表达纯几何和材质。 +- 不携带行业知识。 +- 不负责判断“这是不是一台设备”。 + +主要代码: + +- `packages/core/src/lib/primitive-registry.ts` +- `packages/core/src/lib/primitive-compose.ts` + +### Recipe + +确定性机械零件生成器,例如齿轮、法兰、螺栓、轴承座、标准阀件等。 + +职责: + +- 生成标准小部件。 +- 可被 Part 或 Layout 复用。 +- 不承担完整设备布局。 + +主要代码: + +- `packages/core/src/lib/primitive-recipes.ts` + +### Part + +跨行业复用的语义零件能力,例如底座、机壳、电机、管口、法兰、控制箱、观察窗、输送带、滚筒、支腿、护罩等。 + +职责: + +- 暴露可编辑属性,例如 length、width、height、radius、color、material、count。 +- 保持行业无关。 +- 被 profile、layout template、part preset 调用。 + +主要代码: + +- `packages/core/src/lib/part-registry.ts` +- `packages/core/src/lib/part-compose.ts` + +### Family / Layout Family + +Family 不再表示具体设备百科,而是稳定布局能力。 + +示例: + +- `rotating_machine_layout` +- `vessel_layout` +- `linear_transport_layout` +- `box_enclosure_layout` +- `robot_workcell_layout` +- `pipe_valve_layout` +- `generic_industrial_layout` + +职责: + +- 提供可复用的空间布局类别。 +- 数量应少而稳定。 +- 不应该为每个具体设备新增 family。 + +### Device Profile + +Profile 是具体设备知识入口,描述“设备应该有哪些部件、主语义是什么、默认尺寸是什么、该用哪个 layout template”。 + +推荐结构: + +```ts +type DeviceProfile = { + id: string + name: string + aliases: string[] + industry?: string + family: string + layoutFamily?: string + layoutTemplate?: string + defaultDimensions?: { + length?: number + width?: number + height?: number + diameter?: number + } + parts: Array<{ + kind: string + semanticRole: string + required?: boolean + preset?: string + params?: Record + }> + primarySemanticRole: string + partPresets?: Record + proportionRules?: string | Record + qualityRules?: string | Record + status: 'runtime_draft' | 'candidate' | 'pending_review' | 'stable' | 'draft' + source: 'builtin' | 'workspace' | 'imported_pack' | 'generated_candidate' + sourcePack?: { + id: string + version: string + industry?: string + } +} +``` + +Profile 能解决: + +- 设备别名匹配。 +- 设备由哪些 part 组成。 +- 主形体和 required roles。 +- 默认尺寸。 +- 后续编辑时的 semanticRole 定位。 + +Profile 不能单独解决: + +- 机器臂关节姿态。 +- 回转窑长筒体比例。 +- 篦冷机篦床层级。 +- 反应釜封头、夹套、搅拌轴的空间拓扑。 + +这些必须交给 Layout Template、Part Preset、Proportion Rules 和 Quality Rules。 + +### Layout Template + +Layout Template 描述部件之间的空间骨架,不写可执行代码。 + +示例: + +```json +{ + "id": "articulated_robot.six_axis", + "family": "robot_workcell_layout", + "anchors": [ + { "id": "base", "position": [0, 0, 0] }, + { "id": "shoulder", "relativeTo": "base", "offset": [0, 0.42, 0] }, + { "id": "elbow", "relativeTo": "shoulder", "offsetRule": "upperArmVector" }, + { "id": "wrist", "relativeTo": "elbow", "offsetRule": "forearmVector" } + ], + "placements": [ + { "role": "robot_base", "anchor": "base" }, + { "role": "upper_arm", "between": ["shoulder", "elbow"] }, + { "role": "forearm", "between": ["elbow", "wrist"] } + ] +} +``` + +职责: + +- 定义 anchors、placements、bounds。 +- 把“像不像”的核心拓扑从 TS 设备硬编码里移出来。 +- 被本地 Layout Resolver 解释。 + +### Part Preset + +Part Preset 描述某类 part 的默认造型和参数映射。 + +示例: + +```json +{ + "id": "robot_joint.large", + "partKind": "cylinder", + "defaults": { + "segments": 48, + "material": "painted_metal" + }, + "parameters": { + "radius": { "from": "reach", "scale": 0.12 }, + "height": { "from": "reach", "scale": 0.16 } + } +} +``` + +职责: + +- 减少 profile 里重复写几何参数。 +- 让同一行业包或多个行业包复用零件风格。 +- 给 LLM 和修订逻辑暴露更清楚的可编辑属性。 + +### Proportion Rules + +比例规则描述整体尺寸如何映射到关键部件。 + +示例: + +```json +{ + "reach": { "from": "height", "scale": 0.9 }, + "upperArmLength": { "from": "reach", "scale": 0.43 }, + "forearmLength": { "from": "reach", "scale": 0.48 } +} +``` + +职责: + +- 保证不同尺寸下仍有合理比例。 +- 避免某个设备在默认尺寸好看,换尺寸后崩坏。 + +### Quality Rules + +质量规则描述生成后如何判断“像不像”。 + +示例: + +```json +{ + "id": "quality.rotary_kiln", + "requiredRoles": ["kiln_shell", "support_roller", "gear_ring", "kiln_head", "kiln_tail"], + "forbiddenRoles": ["wheel", "car_body", "desk_leg"], + "shapeCount": { "min": 8, "max": 40 }, + "dimensionExpectations": { + "lengthToDiameterRatio": { "min": 5, "max": 18 } + } +} +``` + +职责: + +- 检查主形体、required roles、forbidden roles。 +- 检查 shapeCount 是否过少或过量。 +- 检查关键比例,例如回转窑长径比。 +- 低分时触发 repair 或 fallback。 + +## Geometry Knowledge Pack + +为了减少硬编码,资源包从“profile pack”升级为“Geometry Knowledge Pack”。 + +推荐结构: + +```txt +cement-pack.zip + pack.json + profiles/ + rotary-kiln.json + grate-cooler.json + layouts/ + rotary-drum-layout.json + grate-bed-layout.json + part-presets/ + kiln-shell.json + support-roller.json + quality-rules/ + rotary-kiln-quality.json + aliases/ + zh-CN.json +``` + +`pack.json` 示例: + +```json +{ + "id": "industry.cement.basic", + "name": "Cement Basic Equipment Pack", + "industry": "cement", + "version": "0.1.0", + "schemaVersion": "1.1", + "knowledgeSchemaVersion": "1.0", + "profiles": ["profiles/rotary-kiln.json"], + "layouts": ["layouts/rotary-drum-layout.json"], + "partPresets": ["part-presets/kiln-shell.json"], + "qualityRules": ["quality-rules/rotary-kiln-quality.json"], + "aliases": ["aliases/zh-CN.json"] +} +``` + +资源包规则: + +- 只允许 JSON/YAML 数据,不允许 JS/TS 可执行代码。 +- 文件路径必须是相对路径,不能逃出包目录。 +- 同名 profile 优先级:`workspace > imported_pack > builtin > generated_candidate`。 +- imported profile 会记录 `sourcePack.id`、`sourcePack.version` 和 `industry`。 +- 资源包可以安装、禁用、删除;启用后自动参与 profile 匹配,不要求用户每次选择行业。 + +## Builtin 与资源包冲突 + +项目里会保留少量 builtin profile 和 deterministic fallback,用来保证没有资源包时也能生成基本设备。资源包导入后,冲突处理遵循: + +```txt +workspace > imported_pack > builtin > generated_candidate +``` + +这意味着: + +- 如果资源包和 builtin 有同 id profile,资源包 profile 会覆盖 builtin profile。 +- 覆盖只发生在数据层:profile、layoutTemplate、partPresets、proportionRules、qualityRules。 +- 资源包不能替换底层 TS composer,例如 `compose_robot_arm`、`part-compose.ts`、`primitive-compose.ts`。 +- 合并后的 profile 会记录 `overrides`,run 结果会显示 `overrodeBuiltin: true`。 +- 如果资源包 profile 校验失败,它不会进入可用 profile 列表,builtin fallback 仍然可用。 + +示例: + +```json +{ + "selectedProfile": "robot.six_axis_arm", + "profileSource": "imported_pack", + "profilePackId": "industry.robotics.basic", + "overrodeBuiltin": true, + "layoutTemplate": "articulated_robot.six_axis" +} +``` + +机器臂的推荐拆法: + +- builtin 保留:robot arm composer 能力、基础 robot parts、最小 fallback profile。 +- robotics 资源包提供:FANUC-like、KUKA-like、ABB-like、SCARA、palletizer、welding cell 等 profile/layout/preset/quality 数据。 + +当前主要代码: + +- `apps/editor/lib/profile-packs.ts` +- `apps/editor/lib/device-profiles.ts` +- `apps/editor/app/profile-packs/page.tsx` + +## Run 路由策略 + +### 1. Revision 优先 + +如果用户说“改成蓝色”“再大一点”“轮子变大”,并且上下文里有上一次 artifact,优先走 `revise_geometry`。 + +### 2. Stable Profile 优先 + +如果 prompt 命中 stable profile: + +1. 读取 profile source、sourcePack、layoutTemplate、partPresets、qualityRules。 +2. 构造 `compose_parts` 参数。 +3. 本地 deterministic 执行。 +4. 记录 route metrics 和 profile quality。 + +### 3. Draft Profile + +如果没有 stable profile,但像工业设备: + +1. Stage1 先尝试输出 `deviceProfileDraft`。 +2. 本地 `buildDraftDeviceProfile()` 兜底。 +3. draft 必须通过 schema validator、registry validator、execution smoke validator。 +4. 生成质量足够高时保存为 candidate。 + +### 4. Generic Fallback + +如果 draft 失败: + +- 走 `generic_industrial_layout`。 +- 不能空白。 +- 质量低则 repair 或提示失败原因。 + +### 5. Compose Primitive 兜底 + +`compose_primitive` 适合简单几何体,例如“10m x 10m 长方体”“1m x 2m 蓝色玻璃”。完整工业设备不应默认走这条路线。 + +## 常见问题判断 + +### 机器臂为什么只靠 profile 不够? + +机器臂的关键不是“有哪些零件”,而是“关节链怎么摆”。Profile 可以说有 base、shoulder、upper_arm、elbow、forearm、wrist、flange,但如果没有 `articulated_robot.six_axis` 这种 layout template,primitive 很容易拼得像杂乱积木。 + +### 回转窑为什么需要 quality rules? + +回转窑必须是长筒体,并带托轮、齿圈、窑头、窑尾。只写 parts 不能保证长径比合理,所以要在包里配置 `lengthToDiameterRatio` 规则。 + +### 什么时候新增 TS 代码? + +按优先级: + +1. 只缺设备知识:新增或修改资源包 profile。 +2. 需要特殊空间骨架:新增 layout template 数据。 +3. 需要复用零件造型:新增 part preset 数据。 +4. 需要评价标准:新增 quality rules 数据。 +5. Part 能力确实缺失:改 `part-registry` / `part-compose`。 +6. Primitive 几何能力缺失:改 `primitive-registry` / `primitive-compose`。 +7. 最后才改 prompt。 + +## 当前开发方向 + +短期目标: + +1. Profile schema 支持 `layoutTemplate`、`partPresets`、`proportionRules`、`qualityRules`、`sourcePack`。 +2. Pack manifest 支持 `layouts`、`partPresets`、`qualityRules`、`aliases`。 +3. Loader 校验这些资源文件,并把 pack metadata 注入 imported profile。 +4. Run 结果记录 profile source、pack id、layout template 和质量规则。 +5. 先迁移六轴机器臂、回转窑、篦冷机作为样板。 + +中期目标: + +1. 实现通用 Layout Template Resolver。 +2. 实现 Part Preset Resolver。 +3. 让 Quality Evaluator 消费包内 quality rules。 +4. 把水泥行业包做成完整样板。 + +长期目标: + +1. 行业包可在云端分发。 +2. 用户按需安装水泥、化工、食品、造纸等行业包。 +3. 高质量 generated candidate 可人工审核后晋升为 workspace profile 或行业包 profile。 + +## Profile Editability And Detail Budget Contract + +For profile-driven primitive generation, each production-grade profile should define three +contracts: + +1. `editableSchemaRef` / `editableOverrides`: declares what natural language edits may change. +2. `detailBudget`: declares the default detail level and per-part count limits before execution. +3. `qualityRules`: declares how the generated result is scored after execution. + +`detailBudget` is data, not code. It can live in builtin profiles, workspace profiles, or imported +profile packs: + +```json +{ + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "cooling_air_box": { "count": 5, "detailLevel": "low" }, + "cooler_grate_bed": { "detailLevel": "low" } + } + }, + "qualityRules": { + "requiredRoles": ["cooler_housing", "cooler_grate_bed", "cooling_air_box"], + "shapeCount": { "min": 8, "max": 52 } + } +} +``` + +Part budget keys are matched by part `id`, `semanticRole`, or `kind`. Supported budget controls +include `detailLevel`, `count`, `ringCount`, `spokeCount`, `slatCount`, `rungCount`, `boltCount`, +`radialSegments`, and `levelCount`. + +## Internal Part Layout Contract + +Profile parts should prefer semantic layout hints over handwritten coordinates. The local layout +resolver consumes these fields before `compose_parts` executes: + +- `attachToRole`: attach this part to a previously placed part by `semanticRole` or `kind`. +- `anchor`: one of `top`, `bottom`, `front`, `back`, `left`, `right`, `shell_center`, + `drive_side`, or `service_side`. +- `side`: fallback placement side when `anchor` is omitted. +- `offset`: optional `[x, y, z]` local correction after anchor placement. +- `arrayAlong`: distribute repeated parts along `length`/`x`, `width`/`z`, or `height`/`y`. + +Example: + +```json +{ + "parts": [ + { "kind": "cylindrical_tank", "semanticRole": "vessel_shell", "required": true }, + { + "kind": "flanged_nozzle", + "semanticRole": "feed_nozzle", + "attachToRole": "vessel_shell", + "anchor": "top", + "offset": [0.18, 0, 0] + }, + { + "kind": "platform_ladder", + "semanticRole": "access_platform", + "attachToRole": "vessel_shell", + "anchor": "service_side" + }, + { + "kind": "bearing_block", + "semanticRole": "support_roller", + "attachToRole": "vessel_shell", + "anchor": "bottom", + "arrayAlong": "length", + "count": 2 + } + ] +} +``` + +The first supported layout families are `vessel_layout`, `rotating_machine_layout`, +`box_enclosure_layout`, and `linear_transport_layout`. This keeps reactor nozzles/platforms, +rotary kiln riding rings/support rollers/drive units, enclosure controls/vents, and conveyor drive +parts stable without adding device-specific TypeScript code. diff --git a/PROFILE_PACK_AUTHORING_STANDARD.md b/PROFILE_PACK_AUTHORING_STANDARD.md new file mode 100644 index 000000000..a9041d001 --- /dev/null +++ b/PROFILE_PACK_AUTHORING_STANDARD.md @@ -0,0 +1,272 @@ +# Profile Pack Authoring Standard + +This document defines the project-local contract for AI-generated industrial profile packs. +Profile packs are installable data assets. Skills, prompts, and batch generators may create +drafts, but the final package must pass the validators and QA runners in this repository. + +## Package Shape + +Each package is a directory or zip with `pack.json` at the root. + +```txt +industry.example.basic-0.1.0/ + pack.json + README.md + profiles/*.json + layouts/*.json + part-presets/*.json + editable-schemas/*.json + quality-rules/*.json +``` + +`pack.json` must use stable identifiers and relative resource paths: + +```json +{ + "id": "industry.cement.basic", + "name": "Cement Basic Equipment Pack", + "industry": "cement", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": ["zh-CN", "en-US"], + "dependsOnPlugins": ["pascal:factory-equipment"], + "profiles": ["profiles/pyroprocess.json"], + "equipmentBindings": [ + { + "profileId": "cement.process_pump", + "recipeId": "factory:centrifugal-pump", + "paramMap": { + "defaultDimensions.length": "length", + "processPorts.inlet.diameter": "inletDiameter" + }, + "portMap": { "inlet": "inlet", "outlet": "outlet" } + } + ], + "layouts": ["layouts/pyroprocess-layouts.json"], + "partPresets": ["part-presets/cement-parts.json"], + "editableSchemas": ["editable-schemas/common-equipment.json"], + "qualityRules": ["quality-rules/cement-quality.json"] +} +``` + +Rules: + +- `id` format: `industry.{industry}.{basic|extension}` or + `industry.{industry}.{domain-extension}`. +- `version` uses semver. +- All paths are relative, safe paths inside the package. +- A basic package must be useful without manually installing another package. +- Extension packages may use `dependsOn`; installation resolves dependencies automatically. +- `dependsOnPlugins` declares executable equipment node plugins required by v2 bindings. +- `equipmentBindings` maps profile-level knowledge to registered semantic equipment recipes such as + `factory:centrifugal-pump`, `factory:storage-tank`, or `factory:distillation-unit`. +- Do not mix unrelated industries in one package. + +## Equipment Bindings + +Schema v2 packages are equipment binding packages. A profile that can become a stable semantic +assembly should have an `equipmentBindings[]` entry instead of relying on primitive assembly as the +main path. + +Rules: + +- `recipeId` must be registered before QA runs. +- `paramMap` source paths must exist on the raw profile, and target fields must exist on the recipe + parameter schema. +- `portMap` must cover every `processPorts[].id` declared by the profile. +- Factory process stations should declare `profileId`/`equipmentProfileId` when they resolve to an + equipment recipe. +- Stations that intentionally remain primitive/generic must declare `genericFallback.reason`. + +## Device Profiles + +Profiles describe equipment knowledge, not final shapes. + +Required fields: + +- `id` +- `name` +- `aliases` +- `family` +- `layoutFamily` +- `primarySemanticRole` +- `parts` +- `qualityRules` + +Rules: + +- `parts[].kind` must be executable by the current Part Registry. +- `parts[].semanticRole` must be domain-specific and stable. +- `primarySemanticRole` must be represented by at least one part or generated shape. +- Complex equipment should reference `layoutTemplate`. +- Reusable style and parameter defaults should go into `partPresets`, not repeated in every profile. +- Natural-language editability should be expressed through `editableSchemaRef` and + `editableOverrides`. + +### Occupied Buildings and Boxed Utilities + +Some process stations are not ordinary equipment. Control rooms, MCC rooms, boiler houses, labs, +substations, and other occupied buildings must not fall back to a catalog cabinet or a lone +`generic_body`. When the station should read as a small building, use `profile-parts` and include +building shell semantics such as body/walls, roof cap or parapet, door/opening, windows, and +service/electrical entry roles. Keep the profile lightweight, but make the visible building intent +explicit through `visualCues` and `qualityRequiredRoles`. + +Packaged industrial boilers may have a rectangular casing, but they should not be authored as only +a square box. Include a casing/body, stack, visible steam drum or tube bank, steam header/manifold, +burner/service opening, and control/service details so the generated result reads as a boiler rather +than a generic enclosure. + +## Layout Templates + +Layout templates describe spatial relationships and proportions for complex equipment. + +Rules: + +- Every referenced `layoutTemplate` id must exist in the package resources or a dependency. +- Templates should expose bounds or placement intent when possible. +- Layouts should not duplicate profile semantics. + +## Part Presets + +Part presets describe reusable industry-specific appearance and parameters for existing part kinds. + +Rules: + +- Every profile `partPresets` reference must resolve to a preset id. +- Presets must not invent new executable part kinds. +- Prefer shared presets for frames, hoppers, nozzles, ladders, rollers, and drive units. + +## Part Layout Hints + +Profiles should use semantic layout hints before absolute `position` values. + +Supported fields on `parts[]`: + +- `attachToRole`: attach to a previously declared part by `semanticRole` or `kind`. +- `anchor`: `top`, `bottom`, `front`, `back`, `left`, `right`, `shell_center`, `drive_side`, + or `service_side`. +- `side`: fallback side when `anchor` is omitted. +- `offset`: optional `[x, y, z]` correction after anchor placement. +- `arrayAlong`: distribute repeated parts along `length`/`x`, `width`/`z`, or `height`/`y`. + +Rules: + +- Declare the primary body/shell/frame part before dependent parts that use `attachToRole`. +- Use `arrayAlong` for repeated rollers, rings, supports, trays, stages, or vents. +- Prefer `service_side` for ladders, access platforms, control panels, and inspection items. +- Prefer `drive_side` for motors, gearboxes, couplings, and drive guards. +- Use absolute `position` only for equipment-specific asymmetry that cannot be expressed by anchors. + +## Editable Schemas + +Editable schemas define what natural language may change after generation. + +Rules: + +- Profile-specific editability should reference a common schema. +- Device-specific differences belong in `editableOverrides`. +- Do not require every profile to define its own schema. +- Editable values must map to actual composer arguments. + +## Detail Budgets + +Detail budgets define how much geometry a profile should create before quality scoring. + +Rules: + +- Use `detailBudget.detailLevel` for the default part detail level: `low`, `medium`, or `high`. +- Use `detailBudget.maxShapes` as the profile-level shape budget unless `qualityRules.shapeCount.max` + is stricter. +- Use `detailBudget.parts` to override specific parts by `id`, `semanticRole`, or `kind`. +- Supported per-part budget keys include `detailLevel`, `count`, `ringCount`, `spokeCount`, + `slatCount`, `rungCount`, `boltCount`, `radialSegments`, and `levelCount`. +- Detail budgets must not replace `qualityRules`; they control generation, while quality rules + judge whether the generated result is acceptable. + +Example: + +```json +{ + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "cooling_air_box": { "count": 5, "detailLevel": "low" }, + "cooler_grate_bed": { "detailLevel": "low" } + } + } +} +``` + +## Quality Rules + +Quality rules define what makes generated geometry acceptable. + +Rules: + +- Every stable profile must reference a quality rule. +- `requiredRoles` should include the profile's `primarySemanticRole` or a direct alias. +- `shapeCount.max` must be realistic for the package's intended detail level. +- `forbiddenRoles` should prevent common cross-domain failures. +- Complex equipment should include dimension expectations when ratios are visually important. + +## Validation Gates + +For batch generation, prefer creating a scaffold spec first and then generating the package files: + +```bash +bun apps/editor/scripts/scaffold-industry-profile-pack.ts --spec --force +``` + +Use the strict audit and QA runner before publishing a pack: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.cement.basic@0.1.0 +``` + +Required gates: + +1. Manifest schema and path safety. +2. Device profile registry validation. +3. v2 equipment binding validation. +4. Cross-resource reference validation. +5. Deterministic equipment-node compile smoke. +6. Quality score and role coverage. +7. Rendered screenshot output for review. + +AI skills may generate candidate packages, but packages should only be published after these +repository-local gates pass. + +## Simulated Cloud Governance + +The local simulated cloud lives at `cloud/`. It intentionally keeps +both editable source directories and installable zip files: + +```txt +cloud/ + industry.cement.basic-0.1.0/ + industry.cement.basic-0.1.0.zip +``` + +Governance is derived from package contents; do not hand-maintain a separate catalog file. + +Rules: + +- Every source directory with `pack.json` should have a matching `{id}-{version}.zip`. +- Every cloud zip should have a matching source directory for review and rebuilding. +- Basic packs should not depend on other packs. +- Extension packs may depend on basic packs; dependencies must be available in the cloud. +- Cloud installation refuses `blocked` packs. + +Cloud publish statuses: + +- `publishable`: strict audit passes, dependencies resolve, and audit score is at least `0.85`. +- `needs_review`: strict audit has no hard issues, but warnings reduce the audit score below `0.85`. +- `blocked`: strict audit has hard issues or a dependency is missing. + +The Profile Pack Manager page and `/api/profile-packs/cloud` expose the derived catalog summary, +industry grouping, publish status, dependency status, and governance notes. diff --git a/PROFILE_PACK_DEVELOPMENT_PLAN.md b/PROFILE_PACK_DEVELOPMENT_PLAN.md new file mode 100644 index 000000000..a4b985b99 --- /dev/null +++ b/PROFILE_PACK_DEVELOPMENT_PLAN.md @@ -0,0 +1,176 @@ +# Profile Pack Development Plan + +本文档描述行业设?profile 包的产品化开发计划。目标是让用户按行业下载或导?profile 包,例如水泥、化工、食品、造纸等,使几何搭建在生成行业设备时优先使用已安装的设备知识? +## 设计判断 + +行业包应像知识插件一样工作: + +- 用户下载或导入后默认启用?- 几何生成时系统自动匹配所有已启用 profile,不要求用户每次手动选择行业?- 用户可以在几何搭建面板里导入包,也可以进入管理页面查看、禁用、删除?- 行业包不直接修改源码目录,应进入受控的本?pack store? +## 包结? +推荐 zip 包结构: + +```txt +industry.cement.basic-0.1.0.zip + pack.json + README.md + profiles/ + pyroprocess.json + grinding.json + conveying.json + storage.json + dust-collection-packaging.json +``` + +`pack.json` 示例? +```json +{ + "id": "industry.cement.basic", + "name": "水泥行业基础设备?, + "industry": "cement", + "version": "0.1.0", + "schemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": ["zh-CN", "en-US"], + "profiles": [ + "profiles/pyroprocess.json", + "profiles/grinding.json" + ] +} +``` + +## 阶段 1:Manifest 与本地模拟云? +目标? +- 固化 profile pack 的目录结构?- 使用本地目录模拟云端包仓库?- 生成第一个水泥行业包 zip? +任务? +- 增加 `pack.json` manifest 规范?- 创建模拟云端目录:`cloud/`?- 生成 `industry.cement.basic-0.1.0.zip`?- 提供 profile 校验脚本或测试,验证 manifest 路径安全、JSON 可解析、profile family/part kind 存在? +验收? +- zip 解压后结构完整?- 14 个水?profile 可通过当前 registry 校验? +## 阶段 2:Pack Loader + +目标? +- ?runtime 能识?manifest 包,而不是盲目递归读取所?JSON? +任务? +- 修改 `loadDeviceProfiles()`?- `device-profile-packs/**/pack.json` 作为 manifest,不当作 profile?- ?manifest ?`profiles` 列表加载 profile 文件?- 保留旧目录兼容:没有 manifest 的目录仍可递归加载 profile JSON/YAML?- 路径安全校验:禁止绝对路径和 `../` 逃逸? +验收? +- 包内 `pack.json` 不再产生 invalid profile warning?- 导入包里?profile source ?`imported_pack`?- loader 结果记录 pack id/version/source? +## 阶段 3:本?Pack Store + +目标? +- zip 导入后不写入源码目录,而是写入本地用户数据目录? +建议目录? +```txt +apps/editor/.local/device-profile-packs/ +``` + +或桌?生产环境用户目录? +```txt +%APPDATA%/pascal-editor/device-profile-packs/ +``` + +任务? +- 增加 pack store?- 增加 `enabled-packs.json`?- 支持安装、启用、禁用、删除?- merge 优先级调整为? +```txt +workspace > enabled_imported_pack > builtin > generated_candidate +``` + +验收? +- 用户导入 zip ?profile 自动参与生成?- 禁用包后 profile 不再参与匹配?- 删除包后 loader 不报错? +## 阶段 4:几何搭建面板导入入? +目标? +- 用户在几何搭建面板内完成最常见的导入动作? +UI 建议? +```txt +几何搭建 +------------------------------------------------ +已启用:3 个行业包 · 128 个设? [导入] [管理] + +输入框: +生成一?12 米回转窑 +``` + +任务? +- 在几何搭建面板标题或输入区附近增加“导入行业包”按钮?- 支持选择 zip?- 导入后显示预览: + - 包名 + - 版本 + - profile ? - 新增/冲突/无效?- 默认导入后启用?- 成功提示用户可生成哪些代表设备? +验收? +- 用户不用离开几何搭建主流程即可安装行业包?- 导入后生成日志能显示命中?profile 来源? +## 阶段 5:行业包管理页面 + +目标? +- 管理多个已安装包? +页面能力? +- 已安装包列表?- 启用/禁用?- 删除?- 查看包详情?- 查看设备 profile 列表?- 导出 zip? +详情页展示: + +```txt +水泥行业基础设备?版本?.1.0 +状态:已启?Profile 数:14 + +设备?回转?/ cement.rotary_kiln / tank / vessel_layout +篦冷?/ cement.grate_cooler / conveyor / linear_transport_layout +``` + +验收? +- 用户可以理解“已安装哪些行业知识”?- 可以删除不用的行业包? +## 阶段 6:生成链路透明? +目标? +- 让用户知道行业包确实生效? +生成日志显示? +```txt +识别设备:回转窑 +使用 profile:cement.rotary_kiln +来源:水泥行业基础设备?v0.1.0 +路线:compose_parts +评分?.82 +``` + +任务? +- run result 记录 pack id/version/name?- UI 渲染 profile source?- 当多?profile 命中时,记录候选和最终选择原因? +验收? +- 用户能看到“为什么这次生成更准”? +## 阶段 7:云端市? +目标? +- 从云端拉取行业包索引,按需下载? +云端 index 示例? +```json +{ + "packs": [ + { + "id": "industry.cement.basic", + "name": "水泥行业基础设备?, + "version": "0.1.0", + "industry": "cement", + "profileCount": 14, + "downloadUrl": "https://example.com/profile-packs/industry.cement.basic-0.1.0.zip", + "checksum": "sha256:..." + } + ] +} +``` + +验收? +- 用户可从“云端市场”下载水泥包?- 下载后自动校?checksum、解压、安装、启用? +## 当前本地模拟? +已生成模拟云端包? +```txt +cloud/industry.cement.basic-0.1.0/ +cloud/industry.cement.basic-0.1.0.zip +``` + +包含 14 ?profile? +- `cement.rotary_kiln` +- `cement.grate_cooler` +- `cement.preheater_tower` +- `cement.cyclone_separator` +- `cement.vertical_raw_mill` +- `cement.cement_mill` +- `cement.roller_press` +- `cement.belt_conveyor` +- `cement.screw_conveyor` +- `cement.bucket_elevator` +- `cement.clinker_silo` +- `cement.cement_silo` +- `cement.bag_filter` +- `cement.cement_packer` + diff --git a/README.md b/README.md index 1d237bcf8..d705420ea 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,15 @@ Clears dirty flag --- +## Building a Plugin + +The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API. + +- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1. +- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point. + +--- + ## Technology Stack - **React 19** + **Next.js 16** diff --git a/RENDER_MATERIAL_WEBGPU_NOTES.md b/RENDER_MATERIAL_WEBGPU_NOTES.md new file mode 100644 index 000000000..3c106dc84 --- /dev/null +++ b/RENDER_MATERIAL_WEBGPU_NOTES.md @@ -0,0 +1,236 @@ +# Render / Material / WebGPU 修复记录 + +> 目的:记录这次墙面颜色、填充材质、solid/rendered 差异、WebGPU zero-count draw 的问题根因和修复方向,避免后续再次改回错误实现。 + +## 背景 + +画布右上角 Display 里有 Render 模式: + +- `solid` +- `rendered` + +源仓库的正确效果是: + +1. **不管 solid 还是 rendered,墙面都支持纯色和填充色显示。** +2. **solid 和 rendered 不能看起来完全一样。** + - `solid` 使用更轻的 Lambert 风格材质,视觉更平。 + - `rendered` 使用 Standard/PBR 材质,并通过 post-processing / SSGI 等效果让物体更立体。 + +## 这次出现的问题 + +### 1. 墙面填充在 solid 下看不到 + +现象: + +- 墙面“纯色”设置在 solid 下可见。 +- 墙面“填充”设置在 solid 下不可见或一闪而过。 +- 切到 rendered 后填充能看到。 + +错误理解: + +- 曾把 solid 理解为“不显示贴图,只显示代表色”。 +- 这不符合源仓库效果。 + +源仓库行为: + +- `solid` 仍然会应用填充预设的 albedo 贴图。 +- 区别只是材质类型: + - `solid`:`MeshLambertNodeMaterial` + - `rendered`:`MeshStandardNodeMaterial` + +关键文件: + +- `packages/viewer/src/lib/materials.ts` + +正确方向: + +- `createMaterialFromPreset()` 在 solid 下也必须调用 `applyMaterialPresetToMaterials()`。 +- 不要在 solid 下跳过贴图。 +- 不要把填充预设强行降级为纯色 fallback。 + +## 2. solid 和 rendered 看起来一样 + +现象: + +- rendered 没有明显立体感。 +- 物品自身阴影 / AO / GI 效果弱化或消失。 + +原因: + +- 本地曾把 `SSGI_PARAMS.enabled` 改成了 `false`。 +- rendered 的 post-processing 效果被关掉后,看起来会接近 direct render / solid。 +- 另外 post-fx 降级状态如果长期保留,也可能导致切回 rendered 后仍走 direct renderer path。 + +关键文件: + +- `packages/viewer/src/components/viewer/post-processing.tsx` +- `packages/viewer/src/lib/materials.ts` +- `packages/viewer/src/systems/wall/wall-materials.ts` + +正确方向: + +- `SSGI_PARAMS.enabled` 应保持 `true`,与源仓库一致。 +- `shading === 'rendered'` 时允许 SSGI / post-processing 参与。 +- 不要默认强制 `forceWebGL`。`NEXT_PUBLIC_VIEWER_FORCE_WEBGL=1` 只能作为排查 / 避开特定机器 WebGPU device lost 的安全模式;它会跳过 WebGPU-only SSGI/postFX,导致 `rendered` 接近 `solid`。 +- 切换 render / edges / shadows / scene 时,应重新尝试 post-processing pipeline,不要永久卡在降级 direct path。 +- `createSurfaceRoleMaterial()` 也必须把 `shading` 纳入材质类型和缓存 key: + - `solid`:`MeshLambertNodeMaterial` + - `rendered`:`MeshStandardNodeMaterial` +- 墙体这类没有显式材质/填充的默认 role surface,如果继续固定 Lambert,会导致 `solid` / `rendered` 看起来几乎一样,即使 SSGI 是开启的。 + +## 3. 墙面填充颜色一闪后丢失 / 画布颜色变深 + +相关原因: + +- 墙材质在下一帧被 `WallCutout` 或 wall renderer 重新替换。 +- 替换时如果没有带上当前 viewer render settings,就可能把当前 solid/rendered、theme、color preset、textures 状态弄丢。 + +关键文件: + +- `packages/viewer/src/systems/wall/wall-materials.ts` +- `packages/viewer/src/systems/wall/wall-cutout.tsx` +- `packages/nodes/src/wall/renderer.tsx` +- `packages/editor/src/components/editor/selection-manager.tsx` + +正确方向: + +- 生成墙材质时必须带当前 render settings: + - `shading` + - `textures` + - `colorPreset` + - `sceneTheme` +- 墙材质 cache hash 也要包含这些 render settings。 +- hover / paint preview 也必须用同一套 render settings,否则 preview 清理或下一帧系统更新会覆盖用户选择。 + +## 4. WebGPU 报错:Vertex buffer slot was not set + +典型日志: + +```txt +[viewer] WebGPU uncaptured error: Vertex buffer slot 1 required by [RenderPipeline "renderPipeline_MeshLambertNodeMaterial_..."] was not set. +[Invalid CommandBuffer from CommandEncoder "renderContext_2"] is invalid due to a previous error. +``` + +后续定位日志: + +```txt +[viewer] skipped a zero-count draw (would poison the WebGPU command encoder) { + name: 'merged-stair', + material: 'MeshLambertNodeMaterial', + group: { count: 0, materialIndex: 0, start: 0 } +} +``` + +根因: + +- `merged-stair` 占位几何里有 `group.count: 0`。 +- WebGPU 对 draw validation 很严格。 +- 即使 position attribute 存在,只要实际提交的是 zero-count group,也可能让 WebGPU command encoder 进入 invalid 状态。 +- 一次错误 draw 会污染整个 CommandBuffer,后续渲染都会报 Invalid CommandBuffer。 + +关键文件: + +- `packages/viewer/src/lib/drawable-geometry.ts` +- `packages/viewer/src/lib/webgpu-draw-guard.ts` +- `packages/viewer/src/lib/safe-geometry.ts` +- `packages/viewer/src/systems/stair/stair-system.tsx` + +修复方向: + +1. Renderer guard 不只检查 `position.count`,还要检查: + - `drawRange.count` + - `index.count` + - `group.count` + - `group.start` 是否越界 +2. 对于真的不能画的 draw,在进入 WebGPU 前跳过。 +3. 占位几何不要创建 `count: 0` 的 group。 + - 使用一个非空的退化三角形 group。 + - 视觉不可见,但 WebGPU validation 能通过。 +4. 保护日志应为 `console.debug`,不要在正常后台运行时刷 `warn`。 + +## 5. 关于材质库 solid 代表色 + +曾补充过 `getMaterialSolidColorByRef()`: + +- 用于没有贴图上下文的地方,例如 2D floorplan、ghost preview、textures 关闭时的 fallback。 +- 不应该用它替代 solid 下的真实填充贴图显示。 + +正确使用场景: + +- floorplan 填色 +- ghost preview 颜色 +- textures=false 的纯色 fallback +- ceiling overlay 这类只需要颜色、不需要真实贴图的预览 + +错误使用场景: + +- solid 模式的真实 3D 填充材质渲染 + +## 保持和源仓库一致的准则 + +后续修改 Display / Render / Material 相关逻辑时,先确认以下约束: + +1. `solid` 也要显示填充贴图。 +2. `rendered` 要保留 PBR / SSGI / post-processing 立体效果。 +3. 不要为了避免 WebGPU 问题关闭 rendered 的核心效果,除非有明确 fallback 和重试机制。 +4. 不要生成 `group.count === 0` 的 geometry group 给 WebGPU。 +5. 墙、屋顶、楼梯、道路、品件等通用材质入口应尽量走 `createMaterialFromPresetRef()` / `createMaterial()`,不要各自复制半套材质逻辑。 +6. 任何 wall material rebuild 都必须传入当前 viewer render settings。 +7. 默认 role material(wall/floor/roof/joinery/glazing/furnishing)不能绕过 `shading`;否则没有显式材质的墙、屋顶、楼板会变成“假 rendered”。 + +## 验证命令 + +相关修改后至少运行: + +```bash +bun run build +``` + +在这些包里分别验证: + +```bash +cd packages/core && bun run build +cd packages/viewer && bun run build +cd packages/nodes && bun run build +cd packages/editor && bun run check-types +``` + +如果改了材质库 fallback,可运行: + +```bash +bun test packages/core/src/material-library.test.ts +``` + +如果出现 WebGPU draw 问题,重点查看是否有: + +- `group.count: 0` +- `position.count: 0` +- 缺少 `uv / uv2 / normal / tangent / color` +- `drawRange.count <= 0` +- `index.count <= 0` + +## 最近一次修复涉及的主要文件 + +- `packages/core/src/material-library.ts` +- `packages/viewer/src/lib/materials.ts` +- `packages/viewer/src/lib/drawable-geometry.ts` +- `packages/viewer/src/lib/webgpu-draw-guard.ts` +- `packages/viewer/src/lib/safe-geometry.ts` +- `packages/viewer/src/components/viewer/post-processing.tsx` +- `packages/viewer/src/systems/wall/wall-materials.ts` +- `packages/viewer/src/systems/wall/wall-cutout.tsx` +- `packages/viewer/src/systems/stair/stair-system.tsx` +- `packages/nodes/src/wall/renderer.tsx` +- `packages/nodes/src/road/geometry.ts` +- `packages/nodes/src/road/floorplan.ts` +- `packages/editor/src/components/editor/selection-manager.tsx` + +## 一句话结论 + +这次问题不是单纯“颜色没写进去”,而是三条链路叠加: + +1. solid 填充不应禁用贴图; +2. rendered 不能关闭 SSGI/post-processing; +3. WebGPU 不能接收 zero-count draw/group。 + +以后如果墙面填充、画布变深、rendered 不立体、WebGPU Invalid CommandBuffer 同时出现,优先从这三条链路排查。 diff --git a/TRANSFER_NETWORK_DEVELOPMENT_PLAN.md b/TRANSFER_NETWORK_DEVELOPMENT_PLAN.md new file mode 100644 index 000000000..74599b94d --- /dev/null +++ b/TRANSFER_NETWORK_DEVELOPMENT_PLAN.md @@ -0,0 +1,304 @@ +# 输送带 / 管道路径网络开发计划 + +## 1. 结论 + +后续不再把“输送皮带”作为一个独立语义类型暴露给用户。 + +动态语义类型只保留: + +```txt +conveyor = 输送带 +pipe = 管道 +``` + +“皮带面”只是输送带内部路径或几何部件,不作为独立设备类型出现在动态面板里。 + +核心方向: + +> 输送带和管道都不应该只是单个模型上的动态效果,而应该升级为“可连接的流动路径”。 + +--- + +## 2. 用户体验目标 + +### 2.1 输送带 + +用户可以从“品件”里放置一个输送带节点。 + +它可以: + +- 画直线。 +- 画多段折线路径。 +- 支持转弯。 +- 端点靠近另一条输送带时自动吸附。 +- 多条输送带吸附后共享同一批箱子。 +- 预览模式下箱子沿整条线路连续移动,不在拼接处消失或重生。 + +### 2.2 管道 + +管道也使用相似的端点连接体验。 + +它可以: + +- 多段路径。 +- 端点吸附到另一根管道。 +- 后续吸附到阀门、泵、储罐接口。 +- 预览模式下流动方向和介质沿网络连续显示。 + +--- + +## 3. 和墙的关系 + +墙的交互经验可以复用: + +- 端点拖动。 +- 端点吸附。 +- 共享端点。 +- Alt 拖动断开。 +- 拖动一个端点时保持连接关系。 + +但语义不同: + +```txt +墙: + 端点相同 = 几何拼接 / 墙体转角 + +输送带: + 端点相同 = 货物流连接 / 箱子跨段移动 + +管道: + 端点相同 = 介质流连接 / 流量跨段传播 +``` + +所以不要把墙的 miter 逻辑直接照搬,只复用“端点吸附和连接体验”。 + +--- + +## 4. 第一阶段:输送带专用节点 + +新增一个专用节点,建议命名: + +```txt +conveyor-belt +``` + +注意:节点名可以叫 conveyor-belt,但动态语义类型仍然是: + +```txt +semanticType: conveyor +``` + +### 4.1 Schema + +建议字段: + +```ts +{ + type: 'conveyor-belt', + points: Array<[number, number, number]>, + width: number, + thickness: number, + elevation: number, + color: string, + showRollers: boolean, + showFrame: boolean, + turnRadius: number, + direction: 'forward' | 'backward', + metadata: { + semanticType: 'conveyor' + } +} +``` + +### 4.2 渲染 + +第一版可以先做: + +- 用多段 polyline 生成带面。 +- 折角处先允许硬转角。 +- 箱子沿路径切线方向旋转。 + +第二版再优化: + +- 转角圆弧。 +- 滚筒。 +- 支架。 +- 护边。 +- 皮带纹理滚动。 + +### 4.3 工具 + +需要一个绘制工具: + +```txt +点击点 1 +点击点 2 +点击点 3 +Enter 完成 +Esc 取消 +Backspace 删除上一点 +``` + +类似墙工具,但输出的是 `points`,不是 `start/end`。 + +--- + +## 5. 第二阶段:端点吸附 + +输送带路径有两个端口: + +```txt +input = points[0] +output = points[points.length - 1] +``` + +拖动端点时: + +1. 先吸附网格。 +2. 再查找附近输送带端点。 +3. 如果距离小于阈值,例如 0.15m,则吸附到目标端点。 +4. 吸附后建立连接关系。 + +连接关系可以先存在节点 metadata: + +```ts +metadata: { + transferConnections: [ + { + fromNodeId: 'belt_a', + fromPort: 'out', + toNodeId: 'belt_b', + toPort: 'in' + } + ] +} +``` + +后续如果连接越来越复杂,再提升为独立 scene-level connection 表。 + +--- + +## 6. 第三阶段:Route 计算 + +不要让每条输送带独立生成箱子。 + +正确模型: + +```txt +Route = Belt A + Belt B + Belt C +``` + +运行时维护货物: + +```ts +{ + routeId: 'route_1', + distance: 5.2 +} +``` + +根据累计距离判断箱子在哪一段: + +```txt +A: 0m - 4m +B: 4m - 7m +C: 7m - 12m +``` + +这样箱子在拼接处不会断。 + +--- + +## 7. 第四阶段:管道复用路径网络 + +管道后续也升级为路径网络。 + +共享能力: + +- 端点吸附。 +- 端口连接。 +- route 计算。 +- 路径采样。 +- 方向计算。 + +不同表现: + +```txt +输送带: + 显示箱子、托盘、物料包。 + +管道: + 显示液体、蒸汽、箭头、水波、流向。 +``` + +管道的连接对象还包括: + +- 阀门。 +- 泵。 +- 储罐接口。 +- 三通。 +- 弯头。 + +--- + +## 8. 数据绑定策略 + +设计态只保存配置: + +```ts +metadata.dynamicBindings = [ + { + type: 'conveyorFlow', + path: 'factory.conveyor.speed', + distance: 12, + spacing: 1.2 + } +] +``` + +预览态才运行: + +- 读取 WebSocket 数据。 +- 构建 route。 +- 临时生成箱子。 +- 沿 route 移动箱子。 +- 退出预览后清理临时对象。 + +--- + +## 9. 分阶段验收 + +### 阶段 A:单条多段输送带 + +- 品件可放置输送带。 +- 支持多段路径。 +- 支持转弯。 +- 动态预览下箱子沿整条路径循环。 + +### 阶段 B:输送带端点吸附 + +- 两条输送带端点靠近时吸附。 +- Alt 拖动可断开。 +- 属性或调试信息能看到连接状态。 + +### 阶段 C:多条输送带共享箱子 + +- A 接 B 接 C 后,箱子沿整条 route 连续移动。 +- 拼接处不消失、不重生。 +- 速度由同一条动态绑定驱动。 + +### 阶段 D:管道路径网络 + +- 管道复用端点吸附。 +- 多段管道 flow 连续。 +- 后续支持阀门/泵/储罐端口。 + +--- + +## 10. 当前决策 + +1. 动态语义类型只保留 `conveyor`,不保留 `conveyor_belt`。 +2. “皮带”作为输送带内部路径/部件,不作为动态面板里的设备类型。 +3. 第一优先级是新增可多段路径的输送带品件。 +4. 端点吸附体验参考墙,但连接含义是共享货物流。 +5. 管道后续复用同一套路径连接能力。 diff --git a/analysis.md b/analysis.md new file mode 100644 index 000000000..b31d34368 --- /dev/null +++ b/analysis.md @@ -0,0 +1,196 @@ +# Factory Creation & Command Execution 完整运行逻辑分析 + +## 核心发现 + +### 问题 1: "创建一个炼油厂" → 系统完整流程 + +✅ **流程链路**: +- API: POST /api/ai-harness/runs (route.ts:L49) +- 启动: ensureFactoryRunRunning() (factory-runner.ts:L853) +- 主循环: runFactoryRun() (factory-runner.ts:L866) + - Step 1: buildFactoryRunResultFromSelectionEdit() (L884) - 检查是否编辑现有对象 + - Step 2: planFactoryRequest() (factory-planner.ts) - AI 规划工厂类型 + - Step 3: composeProcessLine() (process-line-composer.ts) - 组合工艺线路 + - 创建壳体、设备、管道(根据介质上色,但透明度固定为1) + - 返回 patches(所有创建指令) + +### 问题 2: "把管道透明度设置成80%" → 能执行吗? + +❌ **当前不能** + +**原因**: +- 系统会识别为"选择编辑"命令 +- 调用 composeSelectionEdit() 分发器 +- 但没有 looksLikeSelectionOpacityEdit() 函数 +- 所有编辑函数都不匹配 → 返回 null +- 降级到"规划新工厂"流程(错误的处理) + +**现有编辑函数** (factory-selection-edit.ts:L1254): +1. composeSelectionDeleteEdit() - 删除 +2. composeSelectionMoveEdit() - 移动 +3. composeSelectionRotateEdit() - 旋转 +4. composeSelectionColorEdit() - 改颜色 ✅ +5. composeSelectionTankKindEdit() - 改罐体 +6. composeSelectionTowerLevelEdit() - 改塔吊 +7. composeSelectionGeometryEdit() - 改大小 +8. composeSelectionReplaceEdit() - 替换 +9. **composeSelectionOpacityEdit() - 改透明度** ❌ 缺失 + +## 关键代码位置 + +### 工厂创建流程 +- API: app/api/ai-harness/runs/route.ts:L49 +- 启动: lib/ai-harness-runs/factory-runner.ts:L853 +- 主循环: lib/ai-harness-runs/factory-runner.ts:L866 +- 规划: lib/ai-harness-runs/factory-planner.ts +- 工艺线路: lib/ai-harness-runs/process-line-composer.ts +- 管道颜色: process-line-composer.ts:L64-73 (MEDIUM_COLOR) +- 管道创建: process-line-composer.ts:L375-408 (createConnectionPatch) + +### 选择编辑流程 +- 入口: factory-runner.ts:L884 buildFactoryRunResultFromSelectionEdit() +- 分发: factory-selection-edit.ts:L1254 composeSelectionEdit() +- 颜色编辑(参考): factory-selection-edit.ts:L1158 composeSelectionColorEdit() +- 检测: factory-selection-edit.ts:L121 looksLikeSelectionColorEdit() +- 解析: factory-selection-edit.ts:L239 resolveSelectionEditColor() + +## 实现透明度编辑所需代码 + +### 1. 检测函数 +```typescript +export function looksLikeSelectionOpacityEdit(prompt: string) { + return /透明度|opacity|transparent|alpha|半透明|(\d+)\s*%/.test(prompt) +} +``` + +### 2. 解析函数 +```typescript +export function resolveSelectionOpacity(prompt: string): number | undefined { + const percentMatch = /(\d+(?:\.\d+)?)\s*%/.exec(prompt) + if (percentMatch) { + return Math.max(0, Math.min(1, Number(percentMatch[1]) / 100)) + } + + const floatMatch = /0\.\d+/.exec(prompt) + if (floatMatch) { + return Math.max(0, Math.min(1, Number(floatMatch[0]))) + } + + if (/全透明|完全透明|invisible/.test(prompt)) return 0 + if (/半透明|semi-transparent/.test(prompt)) return 0.5 + if (/不透明|opaque/.test(prompt)) return 1 + + return undefined +} +``` + +### 3. 执行函数 +```typescript +export function composeSelectionOpacityEdit(input: { + prompt: string + context?: unknown +}): FactorySelectionEditResult | null { + const opacity = resolveSelectionOpacity(input.prompt) + if (opacity === undefined) return null + + const snapshot = selectionSnapshotFromContext(input.context) + if (!snapshot?.selectedIds.length) { + return { + patches: [], + nodeIds: [], + changed: [], + missingReason: 'No canvas object is selected.', + } + } + + const candidates = expandedEditableNodes(snapshot).filter( + (node) => MATERIAL_NODE_TYPES.has(node.type) || node.color !== undefined + ) + + if (!candidates.length) { + return { + patches: [], + nodeIds: [], + changed: [], + missingReason: 'No selectable object with material found.', + } + } + + const patches = candidates.flatMap((node) => { + const material = { ...node.material } as Record + if (!material.properties) { + material.properties = {} + } + const properties = material.properties as Record + properties.opacity = opacity + properties.transparent = opacity < 1 + + return [{ + op: 'update' as const, + id: node.id, + data: { material } + }] + }) + + return { + patches, + nodeIds: patches.map(p => p.id), + changed: patches.map( + p => snapshot.nodes.find(n => n.id === p.id)?.name ?? p.id + ), + summary: patches.map( + p => `${snapshot.nodes.find(n => n.id === p.id)?.name ?? p.id}: opacity -> ${opacity}` + ) + } +} +``` + +### 4. 更新分发函数 (L1254-1268) +在 composeSelectionEdit() 中加入: +```typescript +composeSelectionOpacityEdit(input) ?? // ← 在 ColorEdit 之后 +``` + +## 工厂初始创建中的透明度 + +- 位置: factory-selection-edit.ts:L254-266 customMaterial() +- 当前值: opacity: 1 (硬编码100%不透明) +- transparent: false + +## 选择编辑数据流 + +``` +context = { + selection: { + selectedIds: ['pipe_1', 'pipe_2', 'pipe_3'], + nodes: [ + { id: 'pipe_1', type: 'pipe', color: '#38bdf8', material: {...} }, + { id: 'pipe_2', type: 'pipe', color: '#38bdf8', material: {...} }, + { id: 'pipe_3', type: 'pipe', color: '#38bdf8', material: {...} }, + ] + } +} + ↓ +composeSelectionOpacityEdit(input) + ↓ +返回 3 个 patches: +[ + { op: 'update', id: 'pipe_1', data: { material: { properties: { opacity: 0.8, transparent: true } } } }, + { op: 'update', id: 'pipe_2', data: { material: { properties: { opacity: 0.8, transparent: true } } } }, + { op: 'update', id: 'pipe_3', data: { material: { properties: { opacity: 0.8, transparent: true } } } } +] + ↓ +WebSocket 发送到渲染引擎 + ↓ +3D 视图中管道变为 80% 透明 +``` + +## 参考实现(颜色编辑) + +颜色编辑是完整的参考实现: +- 检测: looksLikeSelectionColorEdit() (L121-125) +- 解析: resolveSelectionEditColor() (L239-252) +- 执行: composeSelectionColorEdit() (L1158-1198) +- 材质: customMaterial() (L254-266) +- 节点类型: MATERIAL_NODE_TYPES (L63-92) + diff --git a/apps/editor/FACTORY_AI_DELIVERY.md b/apps/editor/FACTORY_AI_DELIVERY.md new file mode 100644 index 000000000..e0b80c1d4 --- /dev/null +++ b/apps/editor/FACTORY_AI_DELIVERY.md @@ -0,0 +1,83 @@ +# Factory AI Delivery Checklist + +This document summarizes the factory conversation capability after the process-line and selection-edit work. It is meant to be the handoff surface for review, QA, and future template expansion. + +## Capability Matrix + +| User request shape | Route | Scene output | Primary files | Verification | +| --- | --- | --- | --- | --- | +| Factory shell, room, or production-line layout | Layout planner and patch composer | Editable `zone`, `slab`, `ceiling`, `wall`, `door`, `window`, station zones | `apps/editor/lib/ai-harness-runs/factory-planner.ts`, `apps/editor/lib/ai-harness-runs/factory-layout-patches.ts`, `apps/editor/lib/ai-harness-runs/factory-layout-composer.ts` | `factory-planner.test.ts`, `factory-layout-patches.test.ts`, `factory-layout-composer.test.ts` | +| Water electrolysis / hydrogen workshop | Process-line template | Factory shell, station zones, native tanks/boxes, routed pipes, routed cable tray, tee/elbow fittings, generated electrolyzer assembly | `process-template-registry.ts`, `process-line-composer.ts`, `process-line-routing.ts`, `process-equipment-resolver.ts`, `factory-runner.ts` | `process-line-composer.test.ts`, `process-equipment-resolver.test.ts`, `factory-ai-regression.test.ts`, `e2e/factory-ai.spec.ts` | +| Known factory catalog item | Catalog-item plan | Explicit catalog item patch only for direct item requests | `factory-planner.ts`, `factory-runner.ts`, `factory-agent-prompt.ts` | `factory-planner.test.ts`, `factory-runner.test.ts` | +| Long-tail equipment in a factory context | Primitive generation fallback | Generated editable primitive assembly, placed by the factory runner | `primitive-generation-service.ts`, `factory-runner.ts`, `packages/editor/src/lib/ai-generated-geometry-nodes.ts` | `primitive-generation-service.test.ts`, `factory-runner.test.ts` | +| Selected assembly recolor | Selection edit | Update patches for editable selected descendants | `factory-selection-edit.ts`, `ai-chat-panel/index.tsx` | `factory-selection-edit.test.ts`, `factory-runner.test.ts`, `e2e/factory-ai.spec.ts` | +| Selected tank shape change | Selection edit | Tank `kind` update patch (`vertical`, `horizontal`, `spherical`) | `factory-selection-edit.ts`, `ai-chat-panel/index.tsx` | `factory-selection-edit.test.ts`, `factory-runner.test.ts`, `e2e/factory-ai.spec.ts` | +| Process-line positioning quality | Layout solver and diagnostics | Linear placement, compact spacing fallback, parallel-bay fallback, boundary fit, clearance overlap, connection endpoint diagnostics, and clearance-aware orthogonal routing | `process-line-layout.ts`, `process-line-routing.ts`, `process-line-composer.ts`, `factory-runner.ts` | `process-line-layout.test.ts`, `process-line-composer.test.ts`, `factory-runner.test.ts` | +| Unsafe or legacy patch payload | Patch safety gate | Rejects catalog `item` nodes in automatic process lines and structural update fields | `packages/editor/src/lib/factory-scene-patch-safety.ts`, `ai-chat-panel/index.tsx` | `factory-scene-patch-safety.test.ts` | + +## Current Browser Smoke + +`apps/editor/e2e/factory-ai.spec.ts` covers the main end-to-end happy path: + +1. Creates a fresh scene and AI conversation. +2. Sends `create a chemical factory hydrogen electrolysis workshop`. +3. Verifies native tanks, pipes, pipe fittings, cable tray, and generated electrolyzer assembly exist. +4. Selects the generated electrolyzer assembly and recolors its editable children green. +5. Selects a tank and changes its orientation. +6. Verifies the final graph through `/api/scenes/:id`. +7. Verifies no legacy factory GLB item requests were made. + +The smoke uses `FACTORY_E2E_SMOKE=1` and `NEXT_PUBLIC_FACTORY_E2E_SMOKE=1` to avoid external LLM calls and to expose a test-only scene/selection bridge. + +## CI + +The factory workflow lives in `.github/workflows/editor-factory-ai.yml`. + +It runs on PRs and pushes that touch factory AI, AI harness, relevant scene schemas/stores, Playwright config, or the lockfile. + +Checks: + +```bash +bunx biome check +bun run --cwd apps/editor check-types +bun run --cwd packages/editor check-types +bun test apps/editor/lib/ai-harness-runs packages/editor/src/lib/factory-scene-patch-safety.test.ts +bunx playwright install --with-deps chromium +bun run --cwd apps/editor e2e:factory +``` + +## Extension Rules + +- Add a new process line by registering a template in `process-template-registry.ts`, then add resolver/composer tests before relying on the browser smoke. +- Prefer native editable nodes for known industrial primitives: `tank`, `pipe`, `pipe-fitting`, `cable-tray`, and simple `box` stations. +- Use primitive generation only for stations that do not have a good native editable node. +- Keep automatic process-line patches free of catalog `item` nodes unless an explicit future product decision allows that route. +- Keep every process-line template covered by layout diagnostics for boundary fit, clearance overlap, connection endpoint validity, and routed connection behavior. +- For selection edits, include selected assembly descendants in the context and update only editable surface fields or supported domain fields. +- Any new update patch field should be checked against `factory-scene-patch-safety.ts` before being emitted by the runner. + +## Known Gaps + +| Gap | Risk | Recommended next step | +| --- | --- | --- | +| Browser smoke uses deterministic smoke planning/generation | It validates app integration, not live LLM quality | Add a separate manual or scheduled live-provider eval when credentials are available | +| Only one process template is implemented | New production-line domains still need curated templates/resolvers | Add templates one at a time with unit coverage and fixture prompts | +| Layout solver is intentionally small | It can compact spacing or switch to parallel bays, but does not optimize globally | Add scoring and richer strategies such as U-shaped layout, aisle reservation, and utility corridors | +| Routing is local per connection | Individual pipe and cable-tray routes avoid station clearance boxes, but there is no global pipe rack, shared corridor, or route bundling yet | Add scored utility corridors and shared route lanes before adding many more dense process templates | +| Selection edits cover color and tank kind only | Users may ask for scale, move, rotate, label, or material finish | Add a typed selection-edit intent table before broadening edit operations | +| E2E is desktop Chromium only | Mobile and other browser regressions are not covered | Add mobile viewport smoke only after factory UI stabilizes | +| No pixel-level visual assertion | Graph correctness can pass while visual framing degrades | Add a lightweight screenshot or canvas nonblank assertion once e2e runtime is stable | +| Final API assertion depends on autosave | Slow CI machines can spend most of the test waiting for persistence | If this flakes, expose a test-only flush/save hook instead of increasing timeout further | + +## Local Verification Run + +Last known-good local verification: + +```bash +bunx biome check apps/editor/e2e/factory-ai.spec.ts apps/editor/playwright.config.ts packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/index.tsx +bun run check-types # from apps/editor +bun run check-types # from packages/editor +bun test apps/editor/lib/ai-harness-runs packages/editor/src/lib/factory-scene-patch-safety.test.ts +bun run e2e:factory # from apps/editor +git diff --check +``` diff --git a/apps/editor/app/api/ai-chat/completions/route.ts b/apps/editor/app/api/ai-chat/completions/route.ts new file mode 100644 index 000000000..affd33ade --- /dev/null +++ b/apps/editor/app/api/ai-chat/completions/route.ts @@ -0,0 +1,43 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { callConfiguredAi } from '@/lib/ai-provider' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(req: NextRequest) { + let body: Record + try { + body = (await req.json()) as Record + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + let upstream + try { + upstream = await callConfiguredAi(body, req.signal) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json({ error: message }, { status: 500 }) + } + + const { res, text } = upstream + const contentType = res.headers.get('Content-Type') || '' + if (!contentType.toLowerCase().includes('json')) { + const preview = text.replace(/\s+/g, ' ').trim().slice(0, 300) + return NextResponse.json( + { + error: `AI upstream returned non-JSON response (${res.status} ${res.statusText}). Check AI provider configuration.`, + preview, + }, + { status: res.ok ? 502 : res.status }, + ) + } + + return new NextResponse(text, { + status: res.status, + statusText: res.statusText, + headers: { + 'Content-Type': contentType || 'application/json', + }, + }) +} diff --git a/apps/editor/app/api/ai-harness/conversations/[id]/route.ts b/apps/editor/app/api/ai-harness/conversations/[id]/route.ts new file mode 100644 index 000000000..68403ca71 --- /dev/null +++ b/apps/editor/app/api/ai-harness/conversations/[id]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server' +import { + deleteConversation, + listActiveRuns, + loadConversation, + saveConversation, +} from '@/lib/ai-harness-runs/run-store' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function conversationPurpose(value: unknown) { + return value === 'factory' || value === 'asset' ? value : undefined +} + +export async function GET(_request: Request, { params }: RouteParams) { + const { id } = await params + const conversation = await loadConversation(id) + const activeRuns = await listActiveRuns(id) + return NextResponse.json({ conversation, activeRuns }) +} + +export async function PATCH(request: Request, { params }: RouteParams) { + const { id } = await params + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + if (!isRecord(body)) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + const conversation = await loadConversation(id) + await saveConversation({ + ...conversation, + messages: Array.isArray(body.messages) ? body.messages : conversation.messages, + activeRunIds: Array.isArray(body.activeRunIds) + ? body.activeRunIds.filter((value): value is string => typeof value === 'string') + : conversation.activeRunIds, + conversationPurpose: + conversationPurpose(body.conversationPurpose) ?? conversation.conversationPurpose, + }) + return NextResponse.json({ ok: true }) +} + +export async function DELETE(_request: Request, { params }: RouteParams) { + const { id } = await params + await deleteConversation(id) + return NextResponse.json({ ok: true }) +} diff --git a/apps/editor/app/api/ai-harness/conversations/route.ts b/apps/editor/app/api/ai-harness/conversations/route.ts new file mode 100644 index 000000000..b9dab7a40 --- /dev/null +++ b/apps/editor/app/api/ai-harness/conversations/route.ts @@ -0,0 +1,41 @@ +import { randomUUID } from 'node:crypto' +import { NextResponse } from 'next/server' +import { listConversations, saveConversation } from '@/lib/ai-harness-runs/run-store' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function conversationId() { + return `conv_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}` +} + +export async function GET(request: Request) { + const searchParams = new URL(request.url).searchParams + const limitParam = searchParams.get('limit') + const cursorParam = searchParams.get('cursor') + const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : undefined + const parsedCursor = cursorParam ? Number.parseInt(cursorParam, 10) : 0 + const limit = + parsedLimit != null && Number.isFinite(parsedLimit) + ? Math.min(100, Math.max(1, parsedLimit)) + : 15 + const cursor = + parsedCursor != null && Number.isFinite(parsedCursor) ? Math.max(0, parsedCursor) : 0 + const page = await listConversations(limit + 1, cursor) + const conversations = page.slice(0, limit) + const nextCursor = page.length > limit ? String(cursor + conversations.length) : null + return NextResponse.json({ conversations, nextCursor }) +} + +export async function POST() { + const now = new Date().toISOString() + const id = conversationId() + await saveConversation({ + id, + messages: [], + activeRunIds: [], + createdAt: now, + updatedAt: now, + }) + return NextResponse.json({ conversationId: id }) +} diff --git a/apps/editor/app/api/ai-harness/intent-preview/route.test.ts b/apps/editor/app/api/ai-harness/intent-preview/route.test.ts new file mode 100644 index 000000000..5974ebe0f --- /dev/null +++ b/apps/editor/app/api/ai-harness/intent-preview/route.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' +import { parseIntentPreviewRequestBody } from './route' + +describe('POST /api/ai-harness/intent-preview request parsing', () => { + test('normalizes prompt, mode, purpose, image, and semantic selection', () => { + const parsed = parseIntentPreviewRequestBody({ + prompt: ' 生成一个炼油厂 ', + mode: 'factory', + conversationPurpose: 'factory', + image: { dataUrl: 'data:image/png;base64,abc' }, + selection: { + nodeIds: ['node_1', 2, 'node_2'], + assemblyId: 'assembly_1', + semanticRole: 'inner-wall', + }, + }) + + expect(parsed).toEqual({ + prompt: '生成一个炼油厂', + imageAttached: true, + generationMode: 'factory', + conversationPurpose: 'factory', + selection: { + nodeIds: ['node_1', 'node_2'], + nodeType: undefined, + assemblyId: 'assembly_1', + semanticRole: 'inner-wall', + sourcePartKind: undefined, + }, + }) + }) + + test('rejects empty requests without prompt or image', () => { + expect(parseIntentPreviewRequestBody({ prompt: ' ' })).toBeNull() + }) +}) diff --git a/apps/editor/app/api/ai-harness/intent-preview/route.ts b/apps/editor/app/api/ai-harness/intent-preview/route.ts new file mode 100644 index 000000000..acaf3091b --- /dev/null +++ b/apps/editor/app/api/ai-harness/intent-preview/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server' +import { + type AiIntentPreviewRequest, + buildAiIntentPreview, +} from '@/lib/ai-harness-runs/intent-preview-service' +import type { AiConversationPurpose, AiHarnessRunMode } from '@/lib/ai-harness-runs/types' +import { listInstalledProfilePacks } from '@/lib/profile-packs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asRunMode(value: unknown): AiHarnessRunMode | undefined { + return value === 'articraft' || + value === 'image-to-3d' || + value === 'primitive' || + value === 'factory' + ? value + : undefined +} + +function asConversationPurpose(value: unknown): AiConversationPurpose | undefined { + return value === 'factory' || value === 'asset' ? value : undefined +} + +function asSelection(value: unknown): AiIntentPreviewRequest['selection'] { + if (!isRecord(value)) return undefined + const nodeIds = Array.isArray(value.nodeIds) + ? value.nodeIds.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] + if (!nodeIds.length) return undefined + return { + nodeIds, + nodeType: typeof value.nodeType === 'string' ? value.nodeType : undefined, + assemblyId: typeof value.assemblyId === 'string' ? value.assemblyId : undefined, + semanticRole: typeof value.semanticRole === 'string' ? value.semanticRole : undefined, + sourcePartKind: typeof value.sourcePartKind === 'string' ? value.sourcePartKind : undefined, + } +} + +export function parseIntentPreviewRequestBody(body: unknown): AiIntentPreviewRequest | null { + if (!isRecord(body)) return null + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '' + const imageAttached = + body.imageAttached === true || + (isRecord(body.image) && + typeof body.image.dataUrl === 'string' && + body.image.dataUrl.length > 0) + if (!prompt && !imageAttached) return null + + return { + prompt, + imageAttached, + generationMode: asRunMode(body.generationMode ?? body.mode), + conversationPurpose: asConversationPurpose(body.conversationPurpose), + selection: asSelection(body.selection), + } +} + +export async function POST(request: Request) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const previewRequest = parseIntentPreviewRequestBody(body) + if (!previewRequest) { + return NextResponse.json({ error: 'prompt or image is required' }, { status: 400 }) + } + + const installedPacks = await listInstalledProfilePacks() + return NextResponse.json( + buildAiIntentPreview({ + request: previewRequest, + installedPacks, + }), + ) +} diff --git a/apps/editor/app/api/ai-harness/runs/[id]/events/route.ts b/apps/editor/app/api/ai-harness/runs/[id]/events/route.ts new file mode 100644 index 000000000..5f5297e8f --- /dev/null +++ b/apps/editor/app/api/ai-harness/runs/[id]/events/route.ts @@ -0,0 +1,117 @@ +import { isTerminalStatus, listRunEvents, loadRun } from '@/lib/ai-harness-runs/run-store' +import type { AiHarnessRun } from '@/lib/ai-harness-runs/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +const POLL_MS = 500 +const HEARTBEAT_MS = 15_000 +const MAX_EVENTS_PER_POLL = 100 + +async function ensureRunRunning(run: AiHarnessRun) { + if (isTerminalStatus(run.status)) return + if (run.mode === 'articraft') { + const { ensureArticraftRunRunning } = await import('@/lib/ai-harness-runs/articraft-runner') + ensureArticraftRunRunning(run.id) + } else if (run.mode === 'image-to-3d') { + const { ensureImageTo3DRunRunning } = await import('@/lib/ai-harness-runs/image-to-3d-runner') + ensureImageTo3DRunRunning(run.id) + } else if (run.mode === 'primitive') { + const { ensurePrimitiveRunRunning } = await import('@/lib/ai-harness-runs/primitive-runner') + ensurePrimitiveRunRunning(run.id) + } else if (run.mode === 'factory') { + const { ensureFactoryRunRunning } = await import('@/lib/ai-harness-runs/factory-runner') + ensureFactoryRunRunning(run.id) + } +} + +export async function GET(request: Request, { params }: RouteParams) { + const { id } = await params + const run = await loadRun(id) + if (!run) { + return Response.json({ error: 'not_found' }, { status: 404 }) + } + await ensureRunRunning(run) + + const url = new URL(request.url) + const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10) + const afterFromHeader = Number.parseInt(request.headers.get('Last-Event-ID') ?? '0', 10) + let cursor = Math.max( + 0, + Number.isFinite(afterFromQuery) ? afterFromQuery : 0, + Number.isFinite(afterFromHeader) ? afterFromHeader : 0, + ) + + const encoder = new TextEncoder() + let closed = false + let pollTimer: ReturnType | undefined + let heartbeatTimer: ReturnType | undefined + + const stream = new ReadableStream({ + start(controller) { + const enqueue = (chunk: string) => { + if (!closed) controller.enqueue(encoder.encode(chunk)) + } + + const close = () => { + if (closed) return + closed = true + if (pollTimer) clearTimeout(pollTimer) + if (heartbeatTimer) clearInterval(heartbeatTimer) + try { + controller.close() + } catch { + // Client may already be gone. + } + } + + request.signal.addEventListener('abort', close, { once: true }) + enqueue('retry: 1000\n\n') + + const poll = async () => { + if (closed) return + try { + const events = await listRunEvents(id, { after: cursor, limit: MAX_EVENTS_PER_POLL }) + if (events.length > 0) { + } + for (const event of events) { + cursor = event.id + enqueue(`id: ${event.id}\n`) + enqueue(`event: ${event.type}\n`) + enqueue(`data: ${JSON.stringify(event)}\n\n`) + } + const current = await loadRun(id) + if (current && isTerminalStatus(current.status) && events.length === 0) { + close() + return + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + enqueue('event: error\n') + enqueue(`data: ${JSON.stringify({ message })}\n\n`) + } finally { + if (!closed) pollTimer = setTimeout(poll, POLL_MS) + } + } + + heartbeatTimer = setInterval(() => enqueue(': keepalive\n\n'), HEARTBEAT_MS) + void poll() + }, + cancel() { + closed = true + if (pollTimer) clearTimeout(pollTimer) + if (heartbeatTimer) clearInterval(heartbeatTimer) + }, + }) + + return new Response(stream, { + headers: { + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'Content-Type': 'text/event-stream; charset=utf-8', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/apps/editor/app/api/ai-harness/runs/[id]/rerun/route.ts b/apps/editor/app/api/ai-harness/runs/[id]/rerun/route.ts new file mode 100644 index 000000000..aef776a28 --- /dev/null +++ b/apps/editor/app/api/ai-harness/runs/[id]/rerun/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server' +import { createRun, loadRun } from '@/lib/ai-harness-runs/run-store' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +export async function POST(request: Request, { params }: RouteParams) { + const { id } = await params + const sourceRun = await loadRun(id) + if (!sourceRun) return NextResponse.json({ error: 'not_found' }, { status: 404 }) + if (sourceRun.mode !== 'factory') { + return NextResponse.json({ error: 'source_run_not_factory' }, { status: 400 }) + } + + const body = await request.json().catch(() => ({})) + const stageId = stringValue(isRecord(body) ? body.stageId : undefined) ?? 'equipment-compiler' + const stationId = stringValue(isRecord(body) ? body.stationId : undefined) + if (!stationId) return NextResponse.json({ error: 'stationId is required' }, { status: 400 }) + + const sourceParams = isRecord(sourceRun.params) ? sourceRun.params : {} + const run = await createRun({ + conversationId: sourceRun.conversationId, + mode: 'factory', + prompt: `Re-run ${stageId} for station ${stationId}`, + params: { + ...sourceParams, + workflowRerun: { + sourceRunId: sourceRun.id, + stageId, + stationId, + }, + }, + context: sourceRun.context, + intentRoute: sourceRun.intentRoute, + }) + + const { ensureFactoryRunRunning } = await import('@/lib/ai-harness-runs/factory-runner') + ensureFactoryRunRunning(run.id) + + return NextResponse.json({ runId: run.id, conversationId: run.conversationId, run }) +} diff --git a/apps/editor/app/api/ai-harness/runs/[id]/route.ts b/apps/editor/app/api/ai-harness/runs/[id]/route.ts new file mode 100644 index 000000000..3faab68aa --- /dev/null +++ b/apps/editor/app/api/ai-harness/runs/[id]/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server' +import { listRunEvents, loadRun } from '@/lib/ai-harness-runs/run-store' +import type { AiHarnessRun } from '@/lib/ai-harness-runs/types' +import { buildAiWorkflowGraph } from '@/lib/ai-harness-runs/workflow-summary' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +async function cancelRun(run: AiHarnessRun) { + if (run.mode === 'articraft') { + const { cancelArticraftRun } = await import('@/lib/ai-harness-runs/articraft-runner') + await cancelArticraftRun(run.id) + } else if (run.mode === 'image-to-3d') { + const { cancelImageTo3DRun } = await import('@/lib/ai-harness-runs/image-to-3d-runner') + await cancelImageTo3DRun(run.id) + } else if (run.mode === 'primitive') { + const { cancelPrimitiveRun } = await import('@/lib/ai-harness-runs/primitive-runner') + await cancelPrimitiveRun(run.id) + } else if (run.mode === 'factory') { + const { cancelFactoryRun } = await import('@/lib/ai-harness-runs/factory-runner') + await cancelFactoryRun(run.id) + } +} + +export async function GET(_request: Request, { params }: RouteParams) { + const { id } = await params + const run = await loadRun(id) + if (!run) return NextResponse.json({ error: 'not_found' }, { status: 404 }) + const events = await listRunEvents(id, { limit: 500 }) + return NextResponse.json({ run, workflowGraph: buildAiWorkflowGraph({ run, events }) }) +} + +export async function DELETE(_request: Request, { params }: RouteParams) { + const { id } = await params + const run = await loadRun(id) + if (!run) return NextResponse.json({ error: 'not_found' }, { status: 404 }) + + await cancelRun(run) + + return NextResponse.json({ ok: true }) +} diff --git a/apps/editor/app/api/ai-harness/runs/[id]/workflow/route.ts b/apps/editor/app/api/ai-harness/runs/[id]/workflow/route.ts new file mode 100644 index 000000000..c15329699 --- /dev/null +++ b/apps/editor/app/api/ai-harness/runs/[id]/workflow/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' +import { listRunEvents, loadRun } from '@/lib/ai-harness-runs/run-store' +import { buildAiWorkflowGraph } from '@/lib/ai-harness-runs/workflow-summary' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +export async function GET(_request: Request, { params }: RouteParams) { + const { id } = await params + const run = await loadRun(id) + if (!run) return NextResponse.json({ error: 'not_found' }, { status: 404 }) + const events = await listRunEvents(id, { limit: 500 }) + return NextResponse.json({ workflowGraph: buildAiWorkflowGraph({ run, events }) }) +} diff --git a/apps/editor/app/api/ai-harness/runs/metrics/route.ts b/apps/editor/app/api/ai-harness/runs/metrics/route.ts new file mode 100644 index 000000000..0201f829c --- /dev/null +++ b/apps/editor/app/api/ai-harness/runs/metrics/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server' +import { listRecentRuns } from '@/lib/ai-harness-runs/run-store' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type PrimitiveRouteMetric = { + route?: string + fallbackReason?: string + family?: string + component?: string + deterministicTool?: string + deterministicSucceeded?: boolean + stage2Called?: boolean + stage2ToolCallCount?: number + repairCallCount?: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function primitiveRouteMetric(result: unknown): PrimitiveRouteMetric | undefined { + if (!isRecord(result)) return undefined + const metrics = result.metrics + if (!isRecord(metrics)) return undefined + const primitiveRoute = metrics.primitiveRoute + return isRecord(primitiveRoute) ? (primitiveRoute as PrimitiveRouteMetric) : undefined +} + +function increment(counts: Record, key: string | undefined) { + if (!key) return + counts[key] = (counts[key] ?? 0) + 1 +} + +export async function GET(request: Request) { + const url = new URL(request.url) + const requestedLimit = Number.parseInt(url.searchParams.get('limit') ?? '200', 10) + const limit = Math.max(1, Math.min(Number.isFinite(requestedLimit) ? requestedLimit : 200, 500)) + const runs = (await listRecentRuns(limit)).filter((run) => run.mode === 'primitive') + + const byFallbackReason: Record = {} + const byComponent: Record = {} + const samples = [] + let measured = 0 + let deterministic = 0 + let stage2Fallback = 0 + + for (const run of runs) { + const metric = primitiveRouteMetric(run.result) + if (!metric) continue + measured += 1 + if (metric.route === 'deterministic') deterministic += 1 + if (metric.route === 'stage2_fallback') stage2Fallback += 1 + increment(byFallbackReason, metric.fallbackReason) + increment(byComponent, metric.component) + samples.push({ + runId: run.id, + status: run.status, + prompt: run.prompt, + route: metric.route, + fallbackReason: metric.fallbackReason, + family: metric.family, + component: metric.component, + deterministicTool: metric.deterministicTool, + stage2ToolCallCount: metric.stage2ToolCallCount ?? 0, + repairCallCount: metric.repairCallCount ?? 0, + createdAt: run.createdAt, + completedAt: run.completedAt, + }) + } + + return NextResponse.json({ + window: { requestedLimit: limit, primitiveRunsScanned: runs.length, measuredRuns: measured }, + counts: { deterministic, stage2Fallback }, + fallbackRate: measured > 0 ? stage2Fallback / measured : null, + deterministicRate: measured > 0 ? deterministic / measured : null, + byFallbackReason, + byComponent, + samples, + }) +} diff --git a/apps/editor/app/api/ai-harness/runs/route.test.ts b/apps/editor/app/api/ai-harness/runs/route.test.ts new file mode 100644 index 000000000..c5f4e2287 --- /dev/null +++ b/apps/editor/app/api/ai-harness/runs/route.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' +import { parseAiHarnessRunRequestBody, parseRunIntentRouteEvidence } from './route' + +describe('POST /api/ai-harness/runs body parsing', () => { + test('decodes Windows cmd GB18030 JSON bodies before prompt routing', async () => { + const gb18030Body = new Uint8Array([ + 123, 34, 109, 111, 100, 101, 34, 58, 34, 112, 114, 105, 109, 105, 116, 105, 118, 101, 34, 44, + 34, 112, 114, 111, 109, 112, 116, 34, 58, 34, 201, 250, 179, 201, 210, 187, 184, 246, 189, + 193, 176, 232, 198, 247, 163, 172, 210, 187, 184, 246, 184, 203, 215, 211, 163, 172, 207, 194, + 195, 230, 202, 199, 200, 253, 198, 172, 189, 176, 210, 182, 34, 125, + ]) + const body = await parseAiHarnessRunRequestBody( + new Request('http://localhost/api/ai-harness/runs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: gb18030Body, + }), + ) + + expect(body).toEqual({ + mode: 'primitive', + prompt: '生成一个搅拌器,一个杆子,下面是三片桨叶', + }) + }) +}) + +describe('POST /api/ai-harness/runs intent route evidence parsing', () => { + test('normalizes route evidence for run persistence', () => { + expect( + parseRunIntentRouteEvidence({ + kind: 'create-factory', + confidence: 0.9, + reason: 'Prompt matches refinery.', + previewId: 'preview_1', + requiredPack: { + id: 'industry.refinery.basic', + version: '0.1.0', + installed: true, + reason: 'installed', + }, + }), + ).toEqual({ + kind: 'create-factory', + confidence: 0.9, + reason: 'Prompt matches refinery.', + previewId: 'preview_1', + requiredPack: { + id: 'industry.refinery.basic', + version: '0.1.0', + installed: true, + reason: 'installed', + }, + }) + }) + + test('rejects incomplete route evidence', () => { + expect(parseRunIntentRouteEvidence({ kind: 'create-factory' })).toBeUndefined() + }) +}) diff --git a/apps/editor/app/api/ai-harness/runs/route.ts b/apps/editor/app/api/ai-harness/runs/route.ts new file mode 100644 index 000000000..b8d49de8f --- /dev/null +++ b/apps/editor/app/api/ai-harness/runs/route.ts @@ -0,0 +1,200 @@ +import { NextResponse } from 'next/server' +import { resolveArticraftMaxTurns } from '@/lib/ai-harness-runs/articraft-turn-budget' +import { buildAiIntentPreview } from '@/lib/ai-harness-runs/intent-preview-service' +import { createRun, listRecentRuns } from '@/lib/ai-harness-runs/run-store' +import type { + AiConversationPurpose, + AiHarnessRun, + AiHarnessRunIntentRouteEvidence, + AiHarnessRunMode, +} from '@/lib/ai-harness-runs/types' +import { + buildAiWorkflowGraph, + summarizeAiWorkflowGraph, +} from '@/lib/ai-harness-runs/workflow-summary' +import { listInstalledProfilePacks } from '@/lib/profile-packs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeJsonBody(bytes: ArrayBuffer) { + const decoders = [ + new TextDecoder('utf-8', { fatal: true }), + new TextDecoder('gb18030', { fatal: true }), + ] + + for (const decoder of decoders) { + try { + return decoder.decode(bytes) + } catch { + // Try the next likely JSON body encoding. + } + } + + return new TextDecoder().decode(bytes) +} + +function asRunMode(value: unknown): AiHarnessRunMode | undefined { + return value === 'articraft' || + value === 'image-to-3d' || + value === 'primitive' || + value === 'factory' + ? value + : undefined +} + +function asConversationPurpose(value: unknown): AiConversationPurpose | undefined { + return value === 'factory' || value === 'asset' ? value : undefined +} + +export function parseRunIntentRouteEvidence( + value: unknown, +): AiHarnessRunIntentRouteEvidence | undefined { + if (!isRecord(value)) return undefined + const kind = typeof value.kind === 'string' ? value.kind : '' + const reason = typeof value.reason === 'string' ? value.reason : '' + const confidence = typeof value.confidence === 'number' ? value.confidence : Number.NaN + if (!kind || !reason || !Number.isFinite(confidence)) return undefined + const requiredPack = isRecord(value.requiredPack) + ? { + id: typeof value.requiredPack.id === 'string' ? value.requiredPack.id : '', + version: + typeof value.requiredPack.version === 'string' ? value.requiredPack.version : undefined, + installed: value.requiredPack.installed === true, + reason: + typeof value.requiredPack.reason === 'string' ? value.requiredPack.reason : undefined, + } + : undefined + + return { + kind, + confidence, + reason, + previewId: typeof value.previewId === 'string' ? value.previewId : undefined, + requiredPack: requiredPack?.id ? requiredPack : undefined, + } +} + +export async function parseAiHarnessRunRequestBody(request: Request): Promise { + return JSON.parse(decodeJsonBody(await request.arrayBuffer())) +} + +async function ensureRunRunning(run: AiHarnessRun) { + if (run.mode === 'articraft') { + const { ensureArticraftRunRunning } = await import('@/lib/ai-harness-runs/articraft-runner') + ensureArticraftRunRunning(run.id) + } else if (run.mode === 'image-to-3d') { + const { ensureImageTo3DRunRunning } = await import('@/lib/ai-harness-runs/image-to-3d-runner') + ensureImageTo3DRunRunning(run.id) + } else if (run.mode === 'primitive') { + const { ensurePrimitiveRunRunning } = await import('@/lib/ai-harness-runs/primitive-runner') + ensurePrimitiveRunRunning(run.id) + } else if (run.mode === 'factory') { + const { ensureFactoryRunRunning } = await import('@/lib/ai-harness-runs/factory-runner') + ensureFactoryRunRunning(run.id) + } +} + +export async function POST(request: Request) { + let body: unknown + try { + body = await parseAiHarnessRunRequestBody(request) + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!isRecord(body)) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const mode = body.mode + if ( + !(mode === 'articraft' || mode === 'image-to-3d' || mode === 'primitive' || mode === 'factory') + ) { + return NextResponse.json({ error: 'Unsupported run mode' }, { status: 400 }) + } + + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '' + if (!prompt && mode !== 'image-to-3d') { + return NextResponse.json({ error: 'prompt is required' }, { status: 400 }) + } + + const image = isRecord(body.image) + ? { + name: typeof body.image.name === 'string' ? body.image.name : 'reference', + type: typeof body.image.type === 'string' ? body.image.type : 'image/png', + dataUrl: typeof body.image.dataUrl === 'string' ? body.image.dataUrl : '', + } + : undefined + + try { + const installedPacks = await listInstalledProfilePacks() + const preview = buildAiIntentPreview({ + request: { + prompt: prompt || 'Generate a 3D model from the reference image', + imageAttached: Boolean(image), + generationMode: asRunMode(mode), + conversationPurpose: asConversationPurpose(body.conversationPurpose), + }, + installedPacks, + }) + if (preview.route.kind === 'create-factory' && preview.preview.applyMode === 'blocked') { + return NextResponse.json( + { + error: 'intent_blocked', + message: preview.preview.summary, + route: preview.route, + preview: preview.preview, + }, + { status: 409 }, + ) + } + const providedIntentRoute = parseRunIntentRouteEvidence(body.intentRoute) + const intentRoute: AiHarnessRunIntentRouteEvidence = providedIntentRoute ?? { + kind: preview.route.kind, + confidence: preview.route.confidence, + reason: preview.route.reason, + previewId: preview.preview.id, + requiredPack: preview.route.requiredPack + ? { + id: preview.route.requiredPack.id, + version: preview.route.requiredPack.version, + installed: preview.route.requiredPack.installed, + reason: preview.route.requiredPack.reason, + } + : undefined, + } + const run = await createRun({ + conversationId: typeof body.conversationId === 'string' ? body.conversationId : 'default', + mode: mode as AiHarnessRunMode, + prompt: prompt || 'Generate a 3D model from the reference image', + articraftMode: body.articraftMode === 'static' ? 'static' : 'articulated', + maxTurns: mode === 'articraft' ? resolveArticraftMaxTurns(prompt, body.maxTurns) : undefined, + params: isRecord(body.params) ? body.params : undefined, + context: body.context, + intentRoute, + image, + }) + + await ensureRunRunning(run) + + return NextResponse.json({ runId: run.id, conversationId: run.conversationId, run }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json({ error: message }, { status: 400 }) + } +} + +export async function GET(request: Request) { + const url = new URL(request.url) + const limit = Number.parseInt(url.searchParams.get('limit') ?? '20', 10) + const runs = await listRecentRuns(Number.isFinite(limit) ? limit : undefined) + return NextResponse.json({ + runs, + workflowSummaries: runs.map((run) => summarizeAiWorkflowGraph(buildAiWorkflowGraph({ run }))), + }) +} diff --git a/apps/editor/app/api/articraft/assets/route.ts b/apps/editor/app/api/articraft/assets/route.ts new file mode 100644 index 000000000..26811255e --- /dev/null +++ b/apps/editor/app/api/articraft/assets/route.ts @@ -0,0 +1,277 @@ +import { execFile } from 'node:child_process' +import { constants, existsSync } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' +import { type NextRequest, NextResponse } from 'next/server' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const execFileAsync = promisify(execFile) + +type SavedAsset = { + id: string + category: string + name: string + thumbnail: string + floorPlanUrl: string + source: 'mine' + src: string + dimensions: [number, number, number] + tags: string[] + articraft?: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function exists(filePath: string) { + try { + await fs.access(filePath, constants.F_OK) + return true + } catch { + return false + } +} + +async function findRepoRoot() { + let current = process.cwd() + for (let i = 0; i < 8; i += 1) { + const hasRootShape = + (await exists(path.join(current, 'package.json'))) && + (await exists(path.join(current, 'apps', 'editor', 'public'))) + if (hasRootShape) return current + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return process.cwd() +} + +function sanitizeSegment(value: string, fallback: string) { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 96) + return normalized || fallback +} + +function isSafeAssetId(assetId: string) { + return ( + sanitizeSegment(assetId, '') === assetId && !assetId.includes('/') && !assetId.includes('\\') + ) +} + +function positiveDimensions(value: unknown): [number, number, number] { + if (Array.isArray(value) && value.length >= 3) { + const next = value.slice(0, 3).map((item) => Number(item)) + if (next.every((item) => Number.isFinite(item) && item > 0)) { + return [ + Math.max(0.05, Number(next[0])), + Math.max(0.05, Number(next[1])), + Math.max(0.05, Number(next[2])), + ] + } + } + return [1, 1, 1] +} + +async function readManifest(manifestPath: string): Promise { + try { + const raw = await fs.readFile(manifestPath, 'utf8') + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed.filter(isRecord) as SavedAsset[]) : [] + } catch { + return [] + } +} + +async function writeManifest(manifestPath: string, assets: SavedAsset[]) { + await fs.mkdir(path.dirname(manifestPath), { recursive: true }) + const tmp = `${manifestPath}.tmp` + await fs.writeFile(tmp, `${JSON.stringify(assets, null, 2)}\n`, 'utf8') + await fs.rename(tmp, manifestPath) +} + +function articraftCliInvocation(repoRoot: string, args: string[]) { + const python = path.join( + repoRoot, + '.venv', + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', + ) + const cliEntry = path.join(repoRoot, 'cli', 'main.py') + if (existsSync(python) && existsSync(cliEntry)) { + return { command: python, args: [cliEntry, ...args] } + } + return { command: 'uv', args: ['run', '--directory', repoRoot, 'articraft', ...args] } +} + +async function exportModelGlb( + repoRoot: string, + recordId: string, + outputPath: string, + thumbnailPath: string, + floorPlanPath: string, +) { + const articraftRoot = path.join(repoRoot, 'articraft') + const modernCli = path.join(articraftRoot, 'cli', 'main.py') + if (!(await exists(modernCli))) { + throw new Error( + `Articraft checkout not found at ${articraftRoot}. Expected cli/main.py in the modern checkout.`, + ) + } + const invocation = articraftCliInvocation(articraftRoot, [ + 'export-pascal-asset', + '--repo-root', + articraftRoot, + recordId, + outputPath, + '--thumbnail-path', + thumbnailPath, + '--floor-plan-path', + floorPlanPath, + ]) + const { stdout, stderr } = await execFileAsync(invocation.command, invocation.args, { + cwd: articraftRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 8, + shell: process.platform === 'win32', + windowsHide: true, + }) + const lastLine = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1) + if (!lastLine) { + throw new Error(stderr.trim() || 'Articraft export produced no output') + } + return JSON.parse(lastLine) as Record +} + +export async function GET() { + const repoRoot = await findRepoRoot() + const manifestPath = path.join( + repoRoot, + 'apps', + 'editor', + 'public', + 'items', + 'articraft-assets.json', + ) + const assets = await readManifest(manifestPath) + return NextResponse.json({ assets }) +} + +export async function DELETE(req: NextRequest) { + const assetId = req.nextUrl.searchParams.get('id')?.trim() ?? '' + if (!assetId || !isSafeAssetId(assetId)) { + return NextResponse.json({ error: 'Valid asset id is required' }, { status: 400 }) + } + + const repoRoot = await findRepoRoot() + const itemRoot = path.join(repoRoot, 'apps', 'editor', 'public', 'items') + const manifestPath = path.join(itemRoot, 'articraft-assets.json') + const manifest = await readManifest(manifestPath) + const nextManifest = manifest.filter((item) => item.id !== assetId) + if (nextManifest.length === manifest.length) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }) + } + await writeManifest(manifestPath, nextManifest) + + const assetDir = path.resolve(itemRoot, assetId) + const resolvedRoot = path.resolve(itemRoot) + if (!(assetDir === resolvedRoot || assetDir.startsWith(`${resolvedRoot}${path.sep}`))) { + return NextResponse.json({ error: 'Invalid asset path' }, { status: 400 }) + } + await fs.rm(assetDir, { recursive: true, force: true }) + + return NextResponse.json({ ok: true, id: assetId }) +} + +export async function POST(req: NextRequest) { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + if (!isRecord(body)) { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const recordId = typeof body.recordId === 'string' ? body.recordId.trim() : '' + if (!/^rec_[A-Za-z0-9._-]+$/.test(recordId)) { + return NextResponse.json({ error: 'recordId is required' }, { status: 400 }) + } + + const repoRoot = await findRepoRoot() + const assetId = sanitizeSegment(`articraft-${recordId}`, `articraft-${Date.now()}`) + const itemRoot = path.join(repoRoot, 'apps', 'editor', 'public', 'items') + const assetDir = path.join(itemRoot, assetId) + const manifestPath = path.join(itemRoot, 'articraft-assets.json') + const modelPath = path.join(assetDir, 'model.glb') + const thumbnailPath = path.join(assetDir, 'thumbnail.png') + const floorPlanPath = path.join(assetDir, 'floor-plan.png') + await fs.mkdir(assetDir, { recursive: true }) + + let exportInfo: Record + try { + exportInfo = await exportModelGlb(repoRoot, recordId, modelPath, thumbnailPath, floorPlanPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json( + { error: `Failed to export Articraft model: ${message}` }, + { status: 500 }, + ) + } + + const createdAt = new Date().toISOString() + const name = + typeof body.name === 'string' && body.name.trim() + ? body.name.trim() + : sanitizeSegment(recordId.replace(/^rec_/, ''), 'Articraft asset') + const prompt = typeof body.prompt === 'string' ? body.prompt : '' + const recordPath = typeof body.recordPath === 'string' ? body.recordPath : '' + const joints = Array.isArray(body.joints) ? body.joints : [] + const modelData = isRecord(body.data) ? body.data : null + const saveToLibrary = body.save !== false + const articraftMetadata = { + recordId, + recordPath, + prompt, + joints, + ...(modelData ? { modelData } : {}), + } + + await fs.writeFile( + path.join(assetDir, 'articraft.json'), + `${JSON.stringify({ ...articraftMetadata, createdAt }, null, 2)}\n`, + 'utf8', + ) + + const asset: SavedAsset = { + id: assetId, + category: 'equipment', + name, + thumbnail: `/items/${assetId}/thumbnail.png`, + floorPlanUrl: `/items/${assetId}/floor-plan.png`, + source: 'mine', + src: `/items/${assetId}/model.glb`, + dimensions: positiveDimensions(exportInfo.dimensions), + tags: ['floor', 'articraft', 'generated'], + articraft: articraftMetadata, + } + + if (saveToLibrary) { + const manifest = await readManifest(manifestPath) + const nextManifest = [asset, ...manifest.filter((item) => item.id !== assetId)] + await writeManifest(manifestPath, nextManifest) + } + + return NextResponse.json({ + asset, + assetDir, + ...(saveToLibrary ? { savedAt: createdAt } : {}), + }) +} diff --git a/apps/editor/app/api/articraft/generate/route.ts b/apps/editor/app/api/articraft/generate/route.ts new file mode 100644 index 000000000..ac85bb521 --- /dev/null +++ b/apps/editor/app/api/articraft/generate/route.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { type NextRequest, NextResponse } from 'next/server' +import { resolveArticraftMaxTurns } from '@/lib/ai-harness-runs/articraft-turn-budget' +import { generateModel } from '../../../../../../packages/articraft-bridge/src/cli' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function compactLogMessage(message: string) { + return message.replace(/\s+/g, ' ').trim().slice(0, 700) +} + +function parseReferenceImage(value: unknown): { buffer: Buffer; ext: string } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + const dataUrl = typeof record.dataUrl === 'string' ? record.dataUrl : '' + const match = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/i.exec(dataUrl) + if (!match) return null + const mime = match[1]?.toLowerCase() + const payload = match[2] + if (!mime || !payload) return null + const buffer = Buffer.from(payload, 'base64') + if (buffer.byteLength === 0 || buffer.byteLength > 8 * 1024 * 1024) return null + return { buffer, ext: mime === 'image/png' ? 'png' : mime === 'image/webp' ? 'webp' : 'jpg' } +} + +async function writeReferenceImage(value: unknown) { + const parsed = parseReferenceImage(value) + if (!parsed) return undefined + const filePath = path.join(os.tmpdir(), `pascal-articraft-ref-${randomUUID()}.${parsed.ext}`) + await fs.writeFile(filePath, parsed.buffer) + return filePath +} + +export async function POST(req: NextRequest) { + let body: { prompt?: string; mode?: 'articulated' | 'static'; image?: unknown; maxTurns?: unknown } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const prompt = body.prompt?.trim() + if (!prompt) { + return NextResponse.json({ error: 'prompt is required' }, { status: 400 }) + } + + const mode = body.mode === 'static' ? 'static' : 'articulated' + let imagePath: string | undefined + try { + imagePath = await writeReferenceImage(body.image) + } catch { + return NextResponse.json({ error: 'Invalid reference image' }, { status: 400 }) + } + const maxTurns = resolveArticraftMaxTurns(prompt, body.maxTurns) + const startedAt = Date.now() + console.log( + `[articraft/generate] start mode=${mode} image=${imagePath ? 'yes' : 'no'} max_turns=${maxTurns ?? 'default'} prompt="${compactLogMessage(prompt).slice(0, 120)}"`, + ) + + // SSE streaming response + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (data: unknown) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + } + + try { + const result = await generateModel({ + prompt, + mode, + maxTurns, + imagePath, + onProgress: (message: string) => { + console.log(`[articraft/generate] ${compactLogMessage(message)}`) + enqueue({ type: 'progress', message }) + }, + }) + console.log( + `[articraft/generate] complete record=${result.recordId} elapsed=${Date.now() - startedAt}ms`, + ) + enqueue({ type: 'result', data: result }) + controller.close() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error( + `[articraft/generate] failed elapsed=${Date.now() - startedAt}ms ${compactLogMessage(message)}`, + ) + enqueue({ type: 'error', message }) + controller.close() + } finally { + if (imagePath) { + await fs.rm(imagePath, { force: true }).catch(() => {}) + } + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/apps/editor/app/api/image-to-3d/assets/route.ts b/apps/editor/app/api/image-to-3d/assets/route.ts new file mode 100644 index 000000000..895428ca2 --- /dev/null +++ b/apps/editor/app/api/image-to-3d/assets/route.ts @@ -0,0 +1,51 @@ +import type { AssetInput } from '@pascal-app/core' +import { type NextRequest, NextResponse } from 'next/server' +import { + findRepoRoot, + generatedManifestPath, + isRecord, + readGeneratedAssets, + upsertGeneratedAsset, +} from '@/lib/generated-assets/manifest' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const repoRoot = await findRepoRoot() + const assets = await readGeneratedAssets(generatedManifestPath(repoRoot)) + return NextResponse.json({ assets }) +} + +function isAssetInput(value: unknown): value is AssetInput & { id: string; source: 'mine' } { + return ( + isRecord(value) && + typeof value.id === 'string' && + typeof value.name === 'string' && + typeof value.src === 'string' && + typeof value.thumbnail === 'string' + ) +} + +export async function POST(req: NextRequest) { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const asset = isRecord(body) ? body.asset : null + if (!isAssetInput(asset)) { + return NextResponse.json({ error: 'asset is required' }, { status: 400 }) + } + + const repoRoot = await findRepoRoot() + const savedAsset: AssetInput & { id: string; source: 'mine' } = { + ...asset, + source: 'mine', + } + await upsertGeneratedAsset(generatedManifestPath(repoRoot), savedAsset) + + return NextResponse.json({ asset: savedAsset, savedAt: new Date().toISOString() }) +} diff --git a/apps/editor/app/api/image-to-3d/generate/route.test.ts b/apps/editor/app/api/image-to-3d/generate/route.test.ts new file mode 100644 index 000000000..6a8e060c6 --- /dev/null +++ b/apps/editor/app/api/image-to-3d/generate/route.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { POST, replaceAssetDir } from './route' + +const originalFalKey = process.env.FAL_KEY +const originalMaxImageMb = process.env.IMAGE_TO_3D_MAX_IMAGE_MB +const originalProvider = process.env.IMAGE_TO_3D_PROVIDER +const originalHunyuan3DApiKey = process.env.HUNYUAN3D_API_KEY +const originalTripo3DKey = process.env.TRIPO3D_API_KEY +const originalApiKey = process.env.APIKEY +const originalTencentSecretId = process.env.TENCENTCLOUD_SECRET_ID +const originalTencentSecretKey = process.env.TENCENTCLOUD_SECRET_KEY + +afterEach(() => { + process.env.FAL_KEY = originalFalKey + process.env.IMAGE_TO_3D_MAX_IMAGE_MB = originalMaxImageMb + process.env.IMAGE_TO_3D_PROVIDER = originalProvider + process.env.HUNYUAN3D_API_KEY = originalHunyuan3DApiKey + process.env.TRIPO3D_API_KEY = originalTripo3DKey + process.env.APIKEY = originalApiKey + process.env.TENCENTCLOUD_SECRET_ID = originalTencentSecretId + process.env.TENCENTCLOUD_SECRET_KEY = originalTencentSecretKey +}) + +function requestWithForm(form: FormData) { + return new Request('http://localhost/api/image-to-3d/generate', { + method: 'POST', + body: form, + }) as Parameters[0] +} + +describe('POST /api/image-to-3d/generate', () => { + test('rejects missing server FAL_KEY before generation', async () => { + delete process.env.FAL_KEY + const res = await POST(requestWithForm(new FormData())) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'image file is required' }) + }) + + test('rejects missing FAL_KEY only for fal requests after upload validation', async () => { + delete process.env.FAL_KEY + const form = new FormData() + form.set('provider', 'fal') + form.set('image', new File(['png'], 'item.png', { type: 'image/png' })) + const res = await POST(requestWithForm(form)) + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'FAL_KEY is not configured on the server' }) + }) + + test('rejects missing Tencent credentials for Hunyuan3D requests', async () => { + delete process.env.HUNYUAN3D_API_KEY + delete process.env.TENCENTCLOUD_SECRET_ID + delete process.env.TENCENTCLOUD_SECRET_KEY + const form = new FormData() + form.set('provider', 'hunyuan3d') + form.set('image', new File(['png'], 'item.png', { type: 'image/png' })) + const res = await POST(requestWithForm(form)) + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ + error: + 'HUNYUAN3D_API_KEY or TENCENTCLOUD_SECRET_ID/TENCENTCLOUD_SECRET_KEY is not configured on the server', + }) + }) + + test('rejects missing Tripo API key for Tripo requests', async () => { + delete process.env.TRIPO3D_API_KEY + delete process.env.APIKEY + const form = new FormData() + form.set('provider', 'tripo') + form.set('image', new File(['png'], 'item.png', { type: 'image/png' })) + const res = await POST(requestWithForm(form)) + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ + error: 'TRIPO3D_API_KEY is not configured on the server', + }) + }) + + test('rejects Tripo client id before calling the provider', async () => { + process.env.TRIPO3D_API_KEY = 'tcli_not_a_secret' + const form = new FormData() + form.set('provider', 'tripo') + form.set('image', new File(['png'], 'item.png', { type: 'image/png' })) + const res = await POST(requestWithForm(form)) + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ + error: + 'TRIPO3D_API_KEY must be the Tripo secret key from API Keys, usually starting with tsk_. Do not use the tcli_ client id.', + }) + }) + + test('rejects non-image uploads', async () => { + process.env.FAL_KEY = 'test' + const form = new FormData() + form.set('image', new File(['hello'], 'hello.txt', { type: 'text/plain' })) + const res = await POST(requestWithForm(form)) + expect(res.status).toBe(400) + }) + + test('rejects oversized images', async () => { + process.env.FAL_KEY = 'test' + process.env.IMAGE_TO_3D_MAX_IMAGE_MB = '1' + const form = new FormData() + form.set('image', new File([new Uint8Array(1024 * 1024 + 1)], 'big.png', { type: 'image/png' })) + const res = await POST(requestWithForm(form)) + expect(res.status).toBe(413) + }) + + test('rejects Hunyuan3D images over provider limit', async () => { + process.env.FAL_KEY = 'test' + process.env.HUNYUAN3D_API_KEY = 'sk-test' + process.env.TENCENTCLOUD_SECRET_ID = 'test' + process.env.TENCENTCLOUD_SECRET_KEY = 'test' + process.env.IMAGE_TO_3D_MAX_IMAGE_MB = '10' + const form = new FormData() + form.set('provider', 'hunyuan3d') + form.set( + 'image', + new File([new Uint8Array(8 * 1024 * 1024 + 1)], 'big.png', { type: 'image/png' }), + ) + const res = await POST(requestWithForm(form)) + expect(res.status).toBe(413) + expect(await res.json()).toEqual({ error: 'Hunyuan3D images must be 8MB or smaller' }) + }) +}) + +describe('replaceAssetDir', () => { + test('replaces a generated asset directory atomically when possible', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pascal-image-to-3d-')) + const tmpDir = path.join(root, 'asset.tmp') + const assetDir = path.join(root, 'asset') + try { + await fs.mkdir(tmpDir, { recursive: true }) + await fs.mkdir(assetDir, { recursive: true }) + await fs.writeFile(path.join(tmpDir, 'model.glb'), 'new') + await fs.writeFile(path.join(assetDir, 'model.glb'), 'old') + + await replaceAssetDir(tmpDir, assetDir) + + await expect(fs.readFile(path.join(assetDir, 'model.glb'), 'utf8')).resolves.toBe('new') + await expect(fs.stat(tmpDir)).rejects.toThrow() + } finally { + await fs.rm(root, { force: true, recursive: true }) + } + }) +}) diff --git a/apps/editor/app/api/image-to-3d/generate/route.ts b/apps/editor/app/api/image-to-3d/generate/route.ts new file mode 100644 index 000000000..08f65251c --- /dev/null +++ b/apps/editor/app/api/image-to-3d/generate/route.ts @@ -0,0 +1,60 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + generateImageTo3DAsset, + ImageTo3DGenerateError, + maxImageTo3DImageBytes, + replaceAssetDir, +} from '@/lib/image-to-3d/generate-service' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export { replaceAssetDir } + +function readText(value: FormDataEntryValue | null, fallback = '') { + return typeof value === 'string' && value.trim() ? value.trim() : fallback +} + +function readBool(value: FormDataEntryValue | null, fallback: boolean) { + if (typeof value !== 'string') return fallback + const normalized = value.trim().toLowerCase() + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true + return fallback +} + +export async function POST(req: NextRequest) { + let form: FormData + try { + form = await req.formData() + } catch { + return NextResponse.json({ error: 'Invalid multipart form data' }, { status: 400 }) + } + + const image = form.get('image') + if (!(image instanceof File)) { + return NextResponse.json({ error: 'image file is required' }, { status: 400 }) + } + if (image.size > maxImageTo3DImageBytes()) { + return NextResponse.json({ error: 'Image is too large' }, { status: 413 }) + } + + try { + const result = await generateImageTo3DAsset({ + image: { + name: image.name, + type: image.type, + buffer: Buffer.from(await image.arrayBuffer()), + }, + prompt: readText(form.get('prompt'), 'object'), + displayName: readText(form.get('name'), 'Image to 3D asset'), + category: readText(form.get('category'), 'equipment'), + provider: readText(form.get('provider'), process.env.IMAGE_TO_3D_PROVIDER ?? 'fal'), + save: readBool(form.get('save'), true), + }) + return NextResponse.json(result) + } catch (error) { + const status = error instanceof ImageTo3DGenerateError ? error.status : 500 + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json({ error: message }, { status }) + } +} diff --git a/apps/editor/app/api/imported-glb/assets/route.ts b/apps/editor/app/api/imported-glb/assets/route.ts new file mode 100644 index 000000000..48cb97ee6 --- /dev/null +++ b/apps/editor/app/api/imported-glb/assets/route.ts @@ -0,0 +1,374 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import type { AssetInput } from '@pascal-app/core' +import { type NextRequest, NextResponse } from 'next/server' +import { + createGeneratedAssetId, + findRepoRoot, + generatedManifestPath, + isSafeGeneratedAssetId, + itemRoot, + readGeneratedAssets, + removeGeneratedAsset, + removeGeneratedAssetDirectory, + upsertGeneratedAsset, +} from '@/lib/generated-assets/manifest' +import { optimizeImportedGlb } from '@/lib/imported-glb/optimizer' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const MAX_GLB_BYTES = 50 * 1024 * 1024 +const MAX_TRIANGLES = 500_000 +const MAX_MESHES = 120 +const MAX_MATERIALS = 80 +const MAX_TEXTURE_SIZE = 4096 +const CATALOG_CATEGORIES = new Set(['electronics', 'equipment', 'structural', 'outdoor']) +const CATALOG_CATEGORY_ALIASES = new Map([ + ['safety', 'electronics'], + ['lighting', 'electronics'], + ['electrical', 'electronics'], + ['hvac', 'electronics'], + ['opening', 'structural'], + ['infrastructure', 'outdoor'], + ['nature', 'outdoor'], + ['vehicle', 'outdoor'], +]) + +function readText(value: FormDataEntryValue | null, fallback = '') { + return typeof value === 'string' && value.trim() ? value.trim() : fallback +} + +function normalizeCatalogCategory(value: string) { + const normalized = CATALOG_CATEGORY_ALIASES.get(value) ?? value + return CATALOG_CATEGORIES.has(normalized) ? normalized : 'equipment' +} + +function assetNameFromFile(file: File) { + return file.name.replace(/\.glb$/i, '').trim() || 'Imported GLB' +} + +type GlbInspection = { + triangles: number + meshes: number + primitives: number + materials: number + images: number + maxTextureSize: number + dimensions: [number, number, number] +} + +function inspectPngDimensions(buffer: Buffer): [number, number] | null { + if ( + buffer.length < 24 || + buffer.readUInt32BE(0) !== 0x8950_4e47 || + buffer.toString('ascii', 12, 16) !== 'IHDR' + ) { + return null + } + return [buffer.readUInt32BE(16), buffer.readUInt32BE(20)] +} + +function inspectJpegDimensions(buffer: Buffer): [number, number] | null { + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null + let offset = 2 + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1 + continue + } + const marker = buffer[offset + 1] + offset += 2 + if (marker === 0xd9 || marker === 0xda) break + if (offset + 2 > buffer.length) break + const length = buffer.readUInt16BE(offset) + if (length < 2 || offset + length > buffer.length) break + if ( + marker === 0xc0 || + marker === 0xc1 || + marker === 0xc2 || + marker === 0xc3 || + marker === 0xc5 || + marker === 0xc6 || + marker === 0xc7 || + marker === 0xc9 || + marker === 0xca || + marker === 0xcb || + marker === 0xcd || + marker === 0xce || + marker === 0xcf + ) { + return [buffer.readUInt16BE(offset + 5), buffer.readUInt16BE(offset + 3)] + } + offset += length + } + return null +} + +function inspectImageDimensions(buffer: Buffer, mimeType?: string): [number, number] | null { + if (mimeType === 'image/png') return inspectPngDimensions(buffer) + if (mimeType === 'image/jpeg') return inspectJpegDimensions(buffer) + return inspectPngDimensions(buffer) ?? inspectJpegDimensions(buffer) +} + +function inspectGlb(buffer: Buffer): GlbInspection { + if (buffer.length < 20 || buffer.readUInt32LE(0) !== 0x4654_6c67) { + throw new Error('Invalid GLB header') + } + const declaredLength = buffer.readUInt32LE(8) + if (declaredLength > buffer.length) throw new Error('Incomplete GLB file') + + let jsonText: string | null = null + let binaryChunk: Buffer | null = null + let offset = 12 + while (offset + 8 <= buffer.length) { + const chunkLength = buffer.readUInt32LE(offset) + const chunkType = buffer.readUInt32LE(offset + 4) + const chunkStart = offset + 8 + const chunkEnd = chunkStart + chunkLength + if (chunkEnd > buffer.length) throw new Error('Invalid GLB chunk length') + const chunk = buffer.subarray(chunkStart, chunkEnd) + if (chunkType === 0x4e4f_534a) jsonText = chunk.toString('utf8').trim() + if (chunkType === 0x004e_4942) binaryChunk = chunk + offset = chunkEnd + } + if (!jsonText) throw new Error('GLB JSON chunk is missing') + + const gltf = JSON.parse(jsonText) as { + accessors?: Array<{ count?: number; min?: number[]; max?: number[] }> + meshes?: Array<{ + primitives?: Array<{ + mode?: number + indices?: number + attributes?: { POSITION?: number } + material?: number + }> + }> + materials?: unknown[] + images?: Array<{ bufferView?: number; mimeType?: string }> + bufferViews?: Array<{ buffer?: number; byteOffset?: number; byteLength?: number }> + } + + const accessors = gltf.accessors ?? [] + let triangles = 0 + let primitives = 0 + const min = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + const max = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY] + for (const mesh of gltf.meshes ?? []) { + for (const primitive of mesh.primitives ?? []) { + primitives += 1 + const positionAccessor = + primitive.attributes?.POSITION !== undefined + ? accessors[primitive.attributes.POSITION] + : undefined + if (positionAccessor?.min && positionAccessor.max) { + for (let i = 0; i < 3; i += 1) { + const minValue = positionAccessor.min[i] + const maxValue = positionAccessor.max[i] + if (typeof minValue === 'number' && Number.isFinite(minValue)) { + min[i] = Math.min(min[i]!, minValue) + } + if (typeof maxValue === 'number' && Number.isFinite(maxValue)) { + max[i] = Math.max(max[i]!, maxValue) + } + } + } + const mode = primitive.mode ?? 4 + const count = + primitive.indices !== undefined + ? (accessors[primitive.indices]?.count ?? 0) + : (positionAccessor?.count ?? 0) + if (mode === 4) triangles += Math.floor(count / 3) + else if (mode === 5 || mode === 6) triangles += Math.max(0, count - 2) + } + } + + let maxTextureSize = 0 + if (binaryChunk) { + for (const image of gltf.images ?? []) { + const view = image.bufferView !== undefined ? gltf.bufferViews?.[image.bufferView] : undefined + if (!view?.byteLength) continue + const imageBuffer = binaryChunk.subarray( + view.byteOffset ?? 0, + (view.byteOffset ?? 0) + view.byteLength, + ) + const dimensions = inspectImageDimensions(imageBuffer, image.mimeType) + if (dimensions) maxTextureSize = Math.max(maxTextureSize, dimensions[0], dimensions[1]) + } + } + + return { + triangles, + meshes: gltf.meshes?.length ?? 0, + primitives, + materials: gltf.materials?.length ?? 0, + images: gltf.images?.length ?? 0, + maxTextureSize, + dimensions: [ + Number.isFinite(min[0]!) && Number.isFinite(max[0]!) ? Math.max(0.05, max[0]! - min[0]!) : 1, + Number.isFinite(min[1]!) && Number.isFinite(max[1]!) ? Math.max(0.05, max[1]! - min[1]!) : 1, + Number.isFinite(min[2]!) && Number.isFinite(max[2]!) ? Math.max(0.05, max[2]! - min[2]!) : 1, + ], + } +} + +function validateGlbInspection(inspection: GlbInspection) { + const reasons: string[] = [] + if (inspection.triangles > MAX_TRIANGLES) { + reasons.push( + `三角面过多:${inspection.triangles.toLocaleString()} > ${MAX_TRIANGLES.toLocaleString()}`, + ) + } + if (inspection.meshes > MAX_MESHES) { + reasons.push(`mesh 过多:${inspection.meshes} > ${MAX_MESHES}`) + } + if (inspection.materials > MAX_MATERIALS) { + reasons.push(`材质过多:${inspection.materials} > ${MAX_MATERIALS}`) + } + if (inspection.maxTextureSize > MAX_TEXTURE_SIZE) { + reasons.push(`贴图过大:${inspection.maxTextureSize}px > ${MAX_TEXTURE_SIZE}px`) + } + return reasons +} + +function sanitizeOptimizationForResponse(optimization: T) { + const { buffer: _buffer, ...rest } = optimization + return rest +} + +export async function GET() { + const repoRoot = await findRepoRoot() + const assets = await readGeneratedAssets(generatedManifestPath(repoRoot)) + return NextResponse.json({ assets }) +} + +export async function DELETE(req: NextRequest) { + const assetId = req.nextUrl.searchParams.get('id')?.trim() ?? '' + if (!assetId || !isSafeGeneratedAssetId(assetId)) { + return NextResponse.json({ error: 'Valid asset id is required' }, { status: 400 }) + } + + const repoRoot = await findRepoRoot() + const manifestPath = generatedManifestPath(repoRoot) + const removed = await removeGeneratedAsset(manifestPath, assetId) + if (!removed) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }) + } + + await removeGeneratedAssetDirectory(repoRoot, assetId) + return NextResponse.json({ ok: true, id: assetId }) +} + +export async function POST(req: NextRequest) { + let form: FormData + try { + form = await req.formData() + } catch { + return NextResponse.json({ error: 'Invalid multipart form data' }, { status: 400 }) + } + + const model = form.get('model') + if (!(model instanceof File)) { + return NextResponse.json({ error: 'model file is required' }, { status: 400 }) + } + if (!model.name.toLowerCase().endsWith('.glb')) { + return NextResponse.json({ error: 'Only .glb files are supported' }, { status: 400 }) + } + if (model.size <= 0) { + return NextResponse.json({ error: 'GLB file is empty' }, { status: 400 }) + } + if (model.size > MAX_GLB_BYTES) { + return NextResponse.json({ error: 'GLB 文件不能超过 50MB' }, { status: 413 }) + } + + const modelBuffer = Buffer.from(await model.arrayBuffer()) + let originalInspection: GlbInspection + try { + originalInspection = inspectGlb(modelBuffer) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json({ error: `GLB file could not be parsed: ${message}` }, { status: 400 }) + } + + const optimization = await optimizeImportedGlb(modelBuffer, { + triangles: originalInspection.triangles, + }) + const finalModelBuffer = optimization.buffer + + let inspection: GlbInspection + try { + inspection = inspectGlb(finalModelBuffer) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json( + { error: `Optimized GLB could not be parsed: ${message}` }, + { status: 500 }, + ) + } + + const complexityErrors = validateGlbInspection(inspection) + if (complexityErrors.length > 0) { + return NextResponse.json( + { + error: `Optimized GLB is still too complex. Please reduce polygons/textures before importing. ${complexityErrors.join('; ')}`, + originalInspection, + inspection, + optimization: sanitizeOptimizationForResponse(optimization), + }, + { status: 413 }, + ) + } + + const displayName = readText(form.get('name'), assetNameFromFile(model)) + const category = normalizeCatalogCategory(readText(form.get('category'), 'equipment')) + const repoRoot = await findRepoRoot() + const assetId = createGeneratedAssetId('imported-glb', displayName) + const assetDir = path.join(itemRoot(repoRoot), assetId) + await fs.mkdir(assetDir, { recursive: true }) + await fs.writeFile(path.join(assetDir, 'model.glb'), finalModelBuffer) + await fs.writeFile( + path.join(assetDir, 'imported-glb.json'), + `${JSON.stringify( + { + sourceFileName: model.name, + sourceFileType: model.type || 'model/gltf-binary', + sourceFileSize: model.size, + originalInspection, + inspection, + optimization: sanitizeOptimizationForResponse(optimization), + importedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + 'utf8', + ) + + const asset: AssetInput & { id: string; source: 'mine' } = { + id: assetId, + category, + name: displayName, + thumbnail: '/icons/cube.webp', + floorPlanUrl: '/icons/cube.webp', + source: 'mine', + src: `/items/${assetId}/model.glb`, + dimensions: inspection.dimensions, + tags: [ + 'floor', + 'imported', + 'glb', + optimization.status === 'optimized' ? 'optimized-glb' : 'original-glb', + `${inspection.triangles}-triangles`, + ], + } + + await upsertGeneratedAsset(generatedManifestPath(repoRoot), asset) + + return NextResponse.json({ + asset, + originalInspection, + inspection, + optimization: sanitizeOptimizationForResponse(optimization), + savedAt: new Date().toISOString(), + }) +} diff --git a/apps/editor/app/api/profile-packs/[path]/route.ts b/apps/editor/app/api/profile-packs/[path]/route.ts new file mode 100644 index 000000000..8618d5328 --- /dev/null +++ b/apps/editor/app/api/profile-packs/[path]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server' +import { + listInstalledProfilePacks, + removeProfilePack, + setProfilePackEnabled, +} from '@/lib/profile-packs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ path: string }> } + +export async function PATCH(request: Request, { params }: RouteParams) { + const { path } = await params + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + const enabled = + typeof body === 'object' && + body !== null && + 'enabled' in body && + typeof body.enabled === 'boolean' + ? body.enabled + : undefined + if (enabled == null) { + return NextResponse.json({ error: 'enabled_boolean_required' }, { status: 400 }) + } + + try { + const pack = await setProfilePackEnabled(decodeURIComponent(path), enabled) + const packs = await listInstalledProfilePacks() + return NextResponse.json({ ok: true, pack, packs }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json({ error: 'profile_pack_update_failed', message }, { status: 400 }) + } +} + +export async function DELETE(_request: Request, { params }: RouteParams) { + const { path } = await params + try { + await removeProfilePack(decodeURIComponent(path)) + const packs = await listInstalledProfilePacks() + return NextResponse.json({ ok: true, packs }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json({ error: 'profile_pack_delete_failed', message }, { status: 400 }) + } +} diff --git a/apps/editor/app/api/profile-packs/cloud/route.ts b/apps/editor/app/api/profile-packs/cloud/route.ts new file mode 100644 index 000000000..aecfc2219 --- /dev/null +++ b/apps/editor/app/api/profile-packs/cloud/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from 'next/server' +import { + installCloudProfilePack, + listCloudProfilePackCatalog, + listInstalledProfilePacks, +} from '@/lib/profile-packs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const CLOUD_PROFILE_PACK_CACHE_TTL_MS = 60_000 + +type CloudCatalogResponsePayload = Awaited> + +let cachedCloudCatalogResponse: + | { + expiresAt: number + promise: Promise + } + | undefined + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export async function GET() { + const now = Date.now() + if (!cachedCloudCatalogResponse || cachedCloudCatalogResponse.expiresAt <= now) { + cachedCloudCatalogResponse = { + expiresAt: now + CLOUD_PROFILE_PACK_CACHE_TTL_MS, + promise: buildCloudCatalogResponse(), + } + } + return NextResponse.json(await cachedCloudCatalogResponse.promise) +} + +async function buildCloudCatalogResponse() { + const catalog = await listCloudProfilePackCatalog() + return { packs: catalog.packs, catalog } +} + +export async function POST(request: Request) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + if (!isRecord(body) || typeof body.id !== 'string') { + return NextResponse.json({ error: 'id_required' }, { status: 400 }) + } + + try { + const result = await installCloudProfilePack( + body.id, + typeof body.version === 'string' ? body.version : undefined, + ) + cachedCloudCatalogResponse = undefined + const packs = await listInstalledProfilePacks() + const catalog = await listCloudProfilePackCatalog() + return NextResponse.json({ ok: true, ...result, packs, cloudPacks: catalog.packs, catalog }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json( + { error: 'cloud_profile_pack_install_failed', message }, + { status: 400 }, + ) + } +} diff --git a/apps/editor/app/api/profile-packs/route.ts b/apps/editor/app/api/profile-packs/route.ts new file mode 100644 index 000000000..5874e55c0 --- /dev/null +++ b/apps/editor/app/api/profile-packs/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server' +import { loadDeviceProfiles } from '@/lib/device-profiles' +import { installProfilePackZip, listInstalledProfilePacks } from '@/lib/profile-packs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const MAX_PROFILE_PACK_BYTES = 8 * 1024 * 1024 +const PROFILE_PACK_CACHE_TTL_MS = 60_000 + +type ProfilePackResponsePayload = Awaited> + +let cachedProfilePackResponse: + | { + expiresAt: number + promise: Promise + } + | undefined + +export async function GET() { + const now = Date.now() + if (!cachedProfilePackResponse || cachedProfilePackResponse.expiresAt <= now) { + cachedProfilePackResponse = { + expiresAt: now + PROFILE_PACK_CACHE_TTL_MS, + promise: buildProfilePackResponse(), + } + } + return NextResponse.json(await cachedProfilePackResponse.promise) +} + +async function buildProfilePackResponse() { + const [packs, loadedProfiles] = await Promise.all([ + listInstalledProfilePacks(), + loadDeviceProfiles(), + ]) + const profileDebug = loadedProfiles.profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + source: profile.source, + sourcePack: profile.sourcePack, + family: profile.family, + layoutFamily: profile.layoutFamily, + primarySemanticRole: profile.primarySemanticRole, + partCount: profile.parts.length, + overrides: profile.overrides ?? [], + })) + const conflicts = profileDebug + .filter((profile) => profile.overrides.length > 0) + .map((profile) => ({ + id: profile.id, + winner: { + source: profile.source, + sourcePack: profile.sourcePack, + }, + overridden: profile.overrides, + })) + return { + packs, + profileDebug, + conflicts, + warnings: loadedProfiles.warnings, + summary: { + enabledCount: packs.filter((pack) => pack.enabled).length, + profileCount: packs + .filter((pack) => pack.enabled) + .reduce((sum, pack) => sum + pack.profileCount, 0), + loadedProfileCount: profileDebug.length, + conflictCount: conflicts.length, + }, + } +} + +export async function POST(request: Request) { + let form: FormData + try { + form = await request.formData() + } catch { + return NextResponse.json({ error: 'invalid_form_data' }, { status: 400 }) + } + + const file = form.get('file') + if (!(file instanceof File)) { + return NextResponse.json({ error: 'file_required' }, { status: 400 }) + } + if (!/\.zip$/i.test(file.name)) { + return NextResponse.json({ error: 'zip_required' }, { status: 400 }) + } + if (file.size > MAX_PROFILE_PACK_BYTES) { + return NextResponse.json({ error: 'file_too_large' }, { status: 413 }) + } + + try { + const result = await installProfilePackZip(Buffer.from(await file.arrayBuffer())) + cachedProfilePackResponse = undefined + const packs = await listInstalledProfilePacks() + return NextResponse.json({ ok: true, ...result, packs }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return NextResponse.json({ error: 'invalid_profile_pack', message }, { status: 400 }) + } +} diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index 1712ad4ad..5162491f0 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { apiGraphSchema } from '@/lib/graph-schema' +import { apiGraphSchema, diagnoseApiGraph } from '@/lib/graph-schema' import { guardSceneApiRequest, sceneApiJson, @@ -8,6 +8,7 @@ import { withSceneApiHeaders, } from '@/lib/scene-api-security' import { getSceneOperations } from '@/lib/scene-store-server' +import { sceneThumbnailUrlSchema } from '@/lib/scene-thumbnail-url' export const dynamic = 'force-dynamic' @@ -16,7 +17,7 @@ type RouteParams = { params: Promise<{ id: string }> } const putSceneSchema = z.object({ name: z.string().min(1).max(200).optional(), graph: apiGraphSchema, - thumbnailUrl: z.string().url().nullable().optional(), + thumbnailUrl: sceneThumbnailUrlSchema, expectedVersion: z.number().int().nonnegative().optional(), }) @@ -67,9 +68,17 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { const parsed = putSceneSchema.safeParse(body) if (!parsed.success) { + const graph = + typeof body === 'object' && body !== null && !Array.isArray(body) + ? (body as { graph?: unknown }).graph + : undefined return sceneApiJson( request, - { error: 'invalid_request', details: parsed.error.issues }, + { + error: 'invalid_request', + details: parsed.error.issues, + diagnostics: diagnoseApiGraph(graph), + }, { status: 400 }, ) } diff --git a/apps/editor/app/api/scenes/[id]/thumbnail/route.ts b/apps/editor/app/api/scenes/[id]/thumbnail/route.ts new file mode 100644 index 000000000..10b31d684 --- /dev/null +++ b/apps/editor/app/api/scenes/[id]/thumbnail/route.ts @@ -0,0 +1,80 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import type { NextRequest } from 'next/server' +import { + guardSceneApiRequest, + sceneApiJson, + sceneApiPreflight, +} from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +const MAX_THUMBNAIL_BYTES = 8 * 1024 * 1024 + +export function OPTIONS(request: NextRequest) { + return sceneApiPreflight(request) +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + + const { id } = await params + const contentType = request.headers.get('content-type')?.toLowerCase() ?? '' + const extension = contentType.includes('image/webp') + ? 'webp' + : contentType.includes('image/jpeg') || contentType.includes('image/jpg') + ? 'jpg' + : contentType.includes('image/png') + ? 'png' + : null + + if (!extension) { + return sceneApiJson(request, { error: 'unsupported_thumbnail_type' }, { status: 415 }) + } + + const bytes = Buffer.from(await request.arrayBuffer()) + if (bytes.length === 0) { + return sceneApiJson(request, { error: 'empty_thumbnail' }, { status: 400 }) + } + if (bytes.length > MAX_THUMBNAIL_BYTES) { + return sceneApiJson(request, { error: 'thumbnail_too_large' }, { status: 413 }) + } + + const operations = await getSceneOperations() + const existing = await operations.loadStoredScene(id) + if (!existing) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + + const safeId = id.replace(/[^a-zA-Z0-9_-]/g, '_') + const publicDir = resolveEditorPublicDir() + const thumbnailDir = path.join(publicDir, 'scene-thumbnails') + await mkdir(thumbnailDir, { recursive: true }) + await writeFile(path.join(thumbnailDir, `${safeId}.${extension}`), bytes) + + const thumbnailUrl = `/scene-thumbnails/${safeId}.${extension}?v=${Date.now()}` + const meta = await operations.saveScene({ + id, + name: existing.name, + projectId: existing.projectId, + ownerId: existing.ownerId, + graph: existing.graph as never, + thumbnailUrl, + expectedVersion: existing.version, + }) + + return sceneApiJson(request, { thumbnailUrl: meta.thumbnailUrl, version: meta.version }) +} + +function resolveEditorPublicDir(): string { + const cwd = process.cwd() + if (cwd.endsWith(`${path.sep}apps${path.sep}editor`)) { + return path.join(cwd, 'public') + } + return path.join(cwd, 'apps', 'editor', 'public') +} diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts index 01ffc3ba8..b5728b502 100644 --- a/apps/editor/app/api/scenes/route.ts +++ b/apps/editor/app/api/scenes/route.ts @@ -1,8 +1,10 @@ import type { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { apiGraphSchema } from '@/lib/graph-schema' +import { apiGraphSchema, diagnoseApiGraph } from '@/lib/graph-schema' import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security' import { getSceneOperations } from '@/lib/scene-store-server' +import { resolveExistingSceneThumbnailUrls } from '@/lib/scene-thumbnail-file' +import { sceneThumbnailUrlSchema } from '@/lib/scene-thumbnail-url' export const dynamic = 'force-dynamic' @@ -11,7 +13,7 @@ const createSceneSchema = z.object({ name: z.string().min(1).max(200), projectId: z.string().min(1).max(200).nullable().optional(), graph: apiGraphSchema, - thumbnailUrl: z.string().url().nullable().optional(), + thumbnailUrl: sceneThumbnailUrlSchema, }) const listQuerySchema = z.object({ @@ -45,7 +47,7 @@ export async function GET(request: NextRequest) { projectId: parsed.data.projectId, limit: parsed.data.limit, }) - return sceneApiJson(request, { scenes }) + return sceneApiJson(request, { scenes: await resolveExistingSceneThumbnailUrls(scenes) }) } export async function POST(request: NextRequest) { @@ -65,9 +67,17 @@ export async function POST(request: NextRequest) { const parsed = createSceneSchema.safeParse(body) if (!parsed.success) { + const graph = + typeof body === 'object' && body !== null && !Array.isArray(body) + ? (body as { graph?: unknown }).graph + : undefined return sceneApiJson( request, - { error: 'invalid_request', details: parsed.error.issues }, + { + error: 'invalid_request', + details: parsed.error.issues, + diagnostics: diagnoseApiGraph(graph), + }, { status: 400 }, ) } diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index cc0bdda3b..c735f9cfe 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -1,16 +1,9 @@ 'use client' -// Loads `@pascal-app/nodes`' built-in plugin into the node registry on the -// client. Mounted from `layout.tsx` so every page in the standalone -// editor gets the registry populated before its first `` / -// `` mounts — without this the registry is empty on the client -// (the server registers in its own module instance, which is unreachable -// from hydrated pages) and every `NodeRenderer` resolves to `null`. The -// `loaded` guard inside `../lib/bootstrap` keeps the side effect -// idempotent under HMR. -import '../lib/bootstrap' +import '@/i18n/init' import type { ReactNode } from 'react' +import { I18nProvider } from '@/i18n/provider' export function ClientBootstrap({ children }: { children: ReactNode }) { - return children + return {children} } diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 390aa6e4a..b184177c6 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -1,33 +1,35 @@ -@import "tailwindcss"; +@import "tailwindcss" source(none); @import "tw-animate-css"; @import "../../../styles/elevation.css"; +@source "."; +@source "../components"; +@source "../lib"; @source "../../../packages/editor/src"; +@source "../../../packages/nodes/src"; +@source "../../../packages/viewer/src"; @custom-variant dark (&:is(.dark *)); @theme { - --font-sans: - var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, - sans-serif; + --font-ui: "PingFang SC", "Microsoft YaHei", SimSun, sans-serif; + --font-sans: var(--font-ui); --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-pixel: var(--font-geist-pixel-square), var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - --font-barlow: - var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, - sans-serif; + --font-barlow: var(--font-ui); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-barlow), sans-serif; + --font-sans: var(--font-ui); --font-mono: var(--font-geist-mono), monospace; --font-pixel: var(--font-geist-pixel-square), var(--font-geist-mono), monospace; - --font-barlow: var(--font-barlow), sans-serif; + --font-barlow: var(--font-ui); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 175ba1564..659d79cae 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -1,49 +1,26 @@ import { Agentation } from 'agentation' import { GeistPixelSquare } from 'geist/font/pixel' -import { Barlow } from 'next/font/google' import localFont from 'next/font/local' -import Script from 'next/script' +import '@/i18n/init' import { ClientBootstrap } from './client-bootstrap' import './globals.css' -const geistSans = localFont({ - src: './fonts/GeistVF.woff', - variable: '--font-geist-sans', -}) const geistMono = localFont({ src: './fonts/GeistMonoVF.woff', variable: '--font-geist-mono', }) -const barlow = Barlow({ - subsets: ['latin'], - weight: ['400', '500', '600', '700'], - variable: '--font-barlow', - display: 'swap', -}) - export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - - - {process.env.NODE_ENV === 'development' && ( - + +` +} + +async function runNodeRenderer(htmlPath: string, screenshotPath: string) { + const rendererScript = path.join(repoRoot, 'apps/editor/scripts/render-primitive-visual-qa.mjs') + await new Promise((resolve, reject) => { + const child = spawn('node', [rendererScript, repoRoot, htmlPath, screenshotPath], { + cwd: repoRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error('renderer timed out')) + }, 60_000) + let stderr = '' + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + child.on('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve() + else reject(new Error(stderr.trim() || `renderer exited with code ${code}`)) + }) + }) +} + +async function renderArtifact(sample: VisualQaSample, artifact: unknown): Promise { + const sampleDir = path.join(outputRoot, sample.id) + await fs.mkdir(sampleDir, { recursive: true }) + const htmlPath = path.join(sampleDir, 'render.html') + const screenshotPath = path.join(sampleDir, 'screenshot.png') + const artifactPath = path.join(sampleDir, 'artifact.json') + await fs.writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8') + await fs.writeFile(htmlPath, renderHtml(artifact), 'utf8') + try { + await runNodeRenderer(htmlPath, screenshotPath) + return { screenshotPath, artifactPath } + } catch (error) { + return { + artifactPath, + renderWarning: error instanceof Error ? error.message : 'render failed', + } + } +} + +async function runSample(sample: VisualQaSample): Promise { + const warnings: string[] = [] + await installPacks(sample.installPacks) + const result = await generatePrimitiveGeometryDraft({ + prompt: sample.prompt, + conversationId: `primitive-visual-qa:${sample.id}`, + source: 'primitive-visual-qa', + }) + const artifact = result.artifact + const shapeCount = artifact?.shapes?.length ?? 0 + const qualityScore = artifact?.profileQuality?.overallScore ?? 0 + const roles = rolesFromArtifact(artifact) + const missingRoles = (sample.requiredRoles ?? []).filter((role) => !roles.has(role)) + const missingRoleGroups = (sample.requiredRoleGroups ?? []).filter( + (group) => !group.some((role) => roles.has(role)), + ) + const forbiddenRolesPresent = (sample.forbiddenRoles ?? []).filter((role) => roles.has(role)) + if (qualityScore < (sample.minQualityScore ?? 0)) + warnings.push(`quality ${qualityScore.toFixed(2)} below ${sample.minQualityScore}`) + if (shapeCount > (sample.maxShapeCount ?? Number.POSITIVE_INFINITY)) + warnings.push(`shapeCount ${shapeCount} above ${sample.maxShapeCount}`) + if (missingRoles.length) warnings.push(`missing roles: ${missingRoles.join(', ')}`) + if (missingRoleGroups.length) + warnings.push( + `missing role groups: ${missingRoleGroups.map((group) => group.join('|')).join(', ')}`, + ) + if (forbiddenRolesPresent.length) + warnings.push(`forbidden roles: ${forbiddenRolesPresent.join(', ')}`) + const rendered: RenderResult = artifact ? await renderArtifact(sample, artifact) : {} + if ('renderWarning' in rendered && rendered.renderWarning) { + warnings.push(`render: ${rendered.renderWarning}`) + } + const { renderWarning: _renderWarning, ...renderedResult } = rendered + return { + id: sample.id, + label: sample.label, + prompt: sample.prompt, + runId: result.runId, + status: warnings.length === 0 && result.status === 'succeeded' ? 'passed' : 'failed', + qualityScore, + shapeCount, + ...renderedResult, + missingRoles, + missingRoleGroups, + forbiddenRolesPresent, + warnings, + } +} + +async function main() { + await fs.mkdir(outputRoot, { recursive: true }) + const samples = await readSamples() + const only = new Set(process.argv.slice(2).filter(Boolean)) + const selected = only.size ? samples.filter((sample) => only.has(sample.id)) : samples + const results: VisualQaResult[] = [] + for (const sample of selected) { + console.log(`primitive visual QA: ${sample.id}`) + results.push(await runSample(sample)) + } + const report = { + createdAt: new Date().toISOString(), + outputRoot, + passed: results.filter((result) => result.status === 'passed').length, + failed: results.filter((result) => result.status === 'failed').length, + results, + } + const reportPath = path.join(outputRoot, 'visual-qa-report.json') + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.log(JSON.stringify({ reportPath, passed: report.passed, failed: report.failed }, null, 2)) + if (report.failed > 0) process.exitCode = 1 +} + +await main() diff --git a/apps/editor/scripts/profile-pack-qa.ts b/apps/editor/scripts/profile-pack-qa.ts new file mode 100644 index 000000000..51928d9ae --- /dev/null +++ b/apps/editor/scripts/profile-pack-qa.ts @@ -0,0 +1,439 @@ +import { spawn } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadPlugin, semanticRecipeRegistry } from '@pascal-app/core' +import { + type DeviceProfileDefinition, + evaluateDeviceProfileQuality, +} from '@pascal-app/core/lib/device-profile-registry' +import { factoryEquipmentPlugin } from '@pascal-app/plugin-factory-equipment' +import { generatePrimitiveGeometryDraft } from '../lib/ai-harness-runs/primitive-generation-service' +import { findRepoRoot, sanitizeSegment } from '../lib/generated-assets/manifest' +import { + auditProfilePackValidation, + installCloudProfilePack, + type ProfilePackValidationResult, + simulatedProfilePackCloudRoot, + validateProfilePackDir, + validateProfilePackZip, +} from '../lib/profile-packs' + +type CliOptions = { + packRef: string + validateOnly: boolean + profileIds: Set + limit?: number +} + +type ProfilePackQaResult = { + profileId: string + prompt: string + runId?: string + status: 'passed' | 'failed' + qualityScore: number + shapeCount: number + detailBudgetMaxShapes?: number + detailBudgetApplied?: boolean + editableSchemaRef?: string + requiredRoles: string[] + missingRoles: string[] + warnings: string[] + screenshotPath?: string + artifactPath?: string +} + +const repoRoot = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) + +function parseArgs(argv: string[]): CliOptions { + const positional: string[] = [] + const profileIds = new Set() + let validateOnly = false + let limit: number | undefined + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (!arg) continue + if (arg === '--validate-only') { + validateOnly = true + continue + } + if (arg === '--profile') { + const value = argv[index + 1] + if (value) profileIds.add(value) + index += 1 + continue + } + if (arg === '--limit') { + const value = Number.parseInt(argv[index + 1] ?? '', 10) + if (Number.isFinite(value) && value > 0) limit = value + index += 1 + continue + } + positional.push(arg) + } + const packRef = positional[0] + if (!packRef) { + throw new Error( + 'Usage: bun apps/editor/scripts/profile-pack-qa.ts [--validate-only] [--profile ] [--limit ]', + ) + } + return { packRef, validateOnly, profileIds, ...(limit ? { limit } : {}) } +} + +function parsePackRef(ref: string) { + const atIndex = ref.lastIndexOf('@') + if (atIndex <= 0) return { id: ref, version: undefined } + return { id: ref.slice(0, atIndex), version: ref.slice(atIndex + 1) } +} + +async function loadValidation(packRef: string): Promise { + const maybePath = path.resolve(packRef) + try { + const stat = await fs.stat(maybePath) + if (stat.isDirectory()) return validateProfilePackDir(maybePath) + if (stat.isFile()) return validateProfilePackZip(await fs.readFile(maybePath)) + } catch { + // Treat non-path refs as simulated cloud package refs. + } + + const { id, version } = parsePackRef(packRef) + const cloudRoot = simulatedProfilePackCloudRoot(await findRepoRoot()) + const zipName = `${id}-${version ?? '0.1.0'}.zip` + return validateProfilePackZip(await fs.readFile(path.join(cloudRoot, zipName))) +} + +function resourceId(value: Record) { + return typeof value.id === 'string' && value.id.trim() ? value.id.trim() : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function qualityRuleForProfile( + validation: ProfilePackValidationResult, + profile: DeviceProfileDefinition, +) { + const ref = + typeof profile.qualityRules === 'string' + ? profile.qualityRules + : isRecord(profile.qualityRules) && typeof profile.qualityRules.id === 'string' + ? profile.qualityRules.id + : undefined + return validation.resources.qualityRules.find((rule) => resourceId(rule) === ref) +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : [] +} + +function asciiAlias(profile: DeviceProfileDefinition) { + return ( + profile.aliases.find((alias) => /^[\x20-\x7e]+$/.test(alias) && /[a-z]/i.test(alias)) ?? + (/^[\x20-\x7e]+$/.test(profile.name) ? profile.name : undefined) ?? + profile.id + ) +} + +function promptForProfile(profile: DeviceProfileDefinition) { + const label = asciiAlias(profile) + return `Create a ${label} industrial equipment using the ${profile.id} profile. Keep the geometry clean, recognizable, and not over-decorated.` +} + +function rolesFromArtifact(artifact: { shapes?: Array<{ semanticRole?: string }> } | undefined) { + return new Set((artifact?.shapes ?? []).map((shape) => shape.semanticRole).filter(Boolean)) +} + +function profileDetailBudgetMaxShapes(profile: DeviceProfileDefinition) { + const budget = profile.detailBudget + if (!isRecord(budget)) return undefined + const maxShapes = budget.maxShapes + return typeof maxShapes === 'number' && Number.isFinite(maxShapes) && maxShapes > 0 + ? Math.floor(maxShapes) + : undefined +} + +function artifactSourceArgs(artifact: unknown) { + if (!isRecord(artifact)) return {} + return isRecord(artifact.sourceArgs) ? artifact.sourceArgs : {} +} + +function profileWithResolvedQualityRule( + profile: DeviceProfileDefinition, + qualityRule: Record | undefined, +) { + return qualityRule ? { ...profile, qualityRules: qualityRule } : profile +} + +function renderHtml(artifact: unknown, label: string) { + const payload = JSON.stringify(artifact).replace(/ + + + + + + +
${label}
+ + +` +} + +async function renderArtifact( + outputDir: string, + profile: DeviceProfileDefinition, + artifact: unknown, +) { + const safeProfile = sanitizeSegment(profile.id, 'profile') + const htmlPath = path.join(outputDir, `${safeProfile}.html`) + const screenshotPath = path.join(outputDir, `${safeProfile}.png`) + const artifactPath = path.join(outputDir, `${safeProfile}.artifact.json`) + await fs.writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8') + await fs.writeFile(htmlPath, renderHtml(artifact, profile.id), 'utf8') + await new Promise((resolve, reject) => { + const child = spawn( + 'node', + ['apps/editor/scripts/render-primitive-visual-qa.mjs', repoRoot, htmlPath, screenshotPath], + { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }, + ) + let stderr = '' + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + child.on('error', reject) + child.on('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(stderr.trim() || `renderer exited with code ${code}`)) + }) + }) + return { screenshotPath, artifactPath } +} + +async function runProfileQa( + validation: ProfilePackValidationResult, + profile: DeviceProfileDefinition, + outputDir: string, +): Promise { + const qualityRule = qualityRuleForProfile(validation, profile) + const requiredRoles = stringArray(qualityRule?.requiredRoles) + const prompt = promptForProfile(profile) + const warnings: string[] = [] + const result = await generatePrimitiveGeometryDraft({ + prompt, + conversationId: `profile-pack-qa:${validation.manifest.id}:${profile.id}`, + context: { + industrySourcePack: { + id: validation.manifest.id, + version: validation.manifest.version, + industry: validation.manifest.industry, + }, + }, + source: 'profile-pack-qa', + }) + const artifact = result.artifact + const roles = rolesFromArtifact(artifact) + const missingRoles = requiredRoles.filter((role) => !roles.has(role)) + if (missingRoles.length) warnings.push(`missing roles: ${missingRoles.join(', ')}`) + const shapeCount = artifact?.shapes?.length ?? 0 + const profileQuality = + artifact?.profileQuality ?? + (artifact?.shapes + ? evaluateDeviceProfileQuality( + profileWithResolvedQualityRule(profile, qualityRule), + artifact.shapes, + { + maxShapes: profileDetailBudgetMaxShapes(profile), + }, + ) + : undefined) + const qualityScore = profileQuality?.overallScore ?? 0 + const maxShapes = profileDetailBudgetMaxShapes(profile) + const sourceArgs = artifactSourceArgs(artifact) + const detailBudgetApplied = sourceArgs.detailBudgetApplied === true + if (maxShapes != null && shapeCount > maxShapes) { + warnings.push(`shape count ${shapeCount} exceeds detailBudget.maxShapes ${maxShapes}`) + } + const minQualityScore = 0.72 + if (qualityScore < minQualityScore) { + warnings.push(`quality ${qualityScore.toFixed(2)} below ${minQualityScore}`) + } + let rendered: { screenshotPath?: string; artifactPath?: string } = {} + if (artifact) { + try { + rendered = await renderArtifact(outputDir, profile, artifact) + } catch (error) { + warnings.push(`render: ${error instanceof Error ? error.message : 'failed'}`) + } + } else { + warnings.push('missing artifact') + } + return { + profileId: profile.id, + prompt, + runId: result.runId, + status: result.status === 'succeeded' && warnings.length === 0 ? 'passed' : 'failed', + qualityScore, + shapeCount, + ...(maxShapes != null ? { detailBudgetMaxShapes: maxShapes } : {}), + ...(detailBudgetApplied ? { detailBudgetApplied } : {}), + ...(profile.editableSchemaRef ? { editableSchemaRef: profile.editableSchemaRef } : {}), + requiredRoles, + missingRoles, + warnings, + ...rendered, + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const validation = await loadValidation(options.packRef) + if ( + validation.manifest.schemaVersion === '2.0' && + !semanticRecipeRegistry.has('factory:centrifugal-pump') + ) { + await loadPlugin(factoryEquipmentPlugin) + } + const audit = auditProfilePackValidation(validation) + const outputDir = path.join( + repoRoot, + 'apps/editor/.generated/profile-pack-qa', + `${sanitizeSegment(validation.manifest.id, 'pack')}@${sanitizeSegment( + validation.manifest.version, + '0.0.0', + )}`, + ) + await fs.mkdir(outputDir, { recursive: true }) + + if (!audit.ok || options.validateOnly) { + const report = { + createdAt: new Date().toISOString(), + pack: validation.manifest, + audit, + results: [], + } + const reportPath = path.join(outputDir, 'profile-pack-qa-report.json') + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.log( + JSON.stringify( + { reportPath, auditOk: audit.ok, packKind: audit.summary.packKind, validateOnly: true }, + null, + 2, + ), + ) + if (!audit.ok) process.exitCode = 1 + return + } + + const { id, version } = parsePackRef(`${validation.manifest.id}@${validation.manifest.version}`) + await installCloudProfilePack(id, version) + const selectedProfiles = validation.profiles + .filter((profile) => options.profileIds.size === 0 || options.profileIds.has(profile.id)) + .slice(0, options.limit) + const results: ProfilePackQaResult[] = [] + for (const profile of selectedProfiles) { + console.log(`profile pack QA: ${profile.id}`) + results.push(await runProfileQa(validation, profile, outputDir)) + } + const report = { + createdAt: new Date().toISOString(), + pack: validation.manifest, + audit, + passed: results.filter((result) => result.status === 'passed').length, + failed: results.filter((result) => result.status === 'failed').length, + results, + } + const reportPath = path.join(outputDir, 'profile-pack-qa-report.json') + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.log( + JSON.stringify( + { + reportPath, + packKind: audit.summary.packKind, + passed: report.passed, + failed: report.failed, + }, + null, + 2, + ), + ) + if (report.failed > 0) process.exitCode = 1 +} + +await main() diff --git a/apps/editor/scripts/render-factory-full-run-qa.mjs b/apps/editor/scripts/render-factory-full-run-qa.mjs new file mode 100644 index 000000000..b410c7b7c --- /dev/null +++ b/apps/editor/scripts/render-factory-full-run-qa.mjs @@ -0,0 +1,134 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import playwright from '../node_modules/@playwright/test/index.js' + +const { chromium } = playwright + +const [baseUrlArg, sceneIdArg, outputDirArg, viewsArg] = process.argv.slice(2) +if (!baseUrlArg || !sceneIdArg || !outputDirArg || !viewsArg) { + console.error( + 'Usage: node render-factory-full-run-qa.mjs ', + ) + process.exit(2) +} + +const baseUrl = baseUrlArg.replace(/\/$/, '') +const sceneId = sceneIdArg +const outputDir = path.resolve(outputDirArg) +const views = JSON.parse(viewsArg) +const resultPath = path.join(outputDir, 'screenshot-result.json') +const screenshotTimeoutMs = 120_000 + +async function launchBrowser() { + let lastError + for (const launchOptions of [ + { headless: true, timeout: 30_000 }, + { headless: true, channel: 'chrome', timeout: 30_000 }, + { headless: true, channel: 'msedge', timeout: 30_000 }, + ]) { + try { + return await chromium.launch(launchOptions) + } catch (error) { + lastError = error + } + } + throw lastError +} + +async function waitForFactoryBridge(page) { + await page.waitForFunction( + () => { + const bridge = window.__pascalFactoryE2e + return typeof bridge?.cameraView === 'function' + }, + null, + { timeout: 45_000 }, + ) +} + +async function setCameraView(page, view) { + await page.evaluate((nextView) => { + window.__pascalFactoryE2e?.cameraView?.(nextView) + }, view) + await page.waitForTimeout(view === 'isometric' ? 1_500 : 1_000) +} + +async function largestCanvasIndex(page) { + return page.locator('canvas').evaluateAll((canvases) => { + const ranked = canvases + .map((canvas, index) => ({ + index, + area: canvas.clientWidth * canvas.clientHeight, + })) + .sort((a, b) => b.area - a.area) + return ranked[0]?.index ?? -1 + }) +} + +const consoleErrors = [] +const pageErrors = [] +const requestFailures = [] +let browser + +try { + await fs.mkdir(outputDir, { recursive: true }) + browser = await launchBrowser() + const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 }) + page.setDefaultTimeout(screenshotTimeoutMs) + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => pageErrors.push(error.message)) + page.on('requestfailed', (request) => { + requestFailures.push(`${request.url()} ${request.failure()?.errorText ?? ''}`.trim()) + }) + + await page.goto(`${baseUrl}/scene/${encodeURIComponent(sceneId)}?factoryE2e=1`, { + waitUntil: 'domcontentloaded', + timeout: 90_000, + }) + await waitForFactoryBridge(page) + await page.waitForTimeout(8_000) + + const canvasCount = await page.locator('canvas').count() + const canvasIndex = await largestCanvasIndex(page) + const shots = [] + if (canvasIndex >= 0) { + const canvas = page.locator('canvas').nth(canvasIndex) + for (const view of views) { + await setCameraView(page, view) + const screenshotPath = path.join(outputDir, `${view}.png`) + const box = await canvas.boundingBox() + if (!box) throw new Error('Unable to locate canvas bounds') + const buffer = await page.screenshot({ + path: screenshotPath, + timeout: screenshotTimeoutMs, + clip: { + x: Math.max(0, box.x), + y: Math.max(0, box.y), + width: Math.max(1, box.width), + height: Math.max(1, box.height), + }, + }) + shots.push({ + view, + path: screenshotPath, + sha256: crypto.createHash('sha256').update(buffer).digest('hex'), + }) + } + } + + await page.screenshot({ + path: path.join(outputDir, 'page.png'), + fullPage: true, + timeout: screenshotTimeoutMs, + }) + await fs.writeFile( + resultPath, + `${JSON.stringify({ canvasCount, shots, consoleErrors, pageErrors, requestFailures }, null, 2)}\n`, + 'utf8', + ) +} finally { + if (browser) await browser.close() +} diff --git a/apps/editor/scripts/render-primitive-visual-qa.mjs b/apps/editor/scripts/render-primitive-visual-qa.mjs new file mode 100644 index 000000000..4a7b1194f --- /dev/null +++ b/apps/editor/scripts/render-primitive-visual-qa.mjs @@ -0,0 +1,62 @@ +import { createServer } from 'node:http' +import fs from 'node:fs/promises' +import path from 'node:path' +import playwright from '../node_modules/@playwright/test/index.js' + +const { chromium } = playwright + +const [repoRootArg, htmlPathArg, screenshotPathArg] = process.argv.slice(2) +if (!repoRootArg || !htmlPathArg || !screenshotPathArg) { + console.error('Usage: node render-primitive-visual-qa.mjs ') + process.exit(2) +} + +const repoRoot = path.resolve(repoRootArg) +const htmlPath = path.resolve(htmlPathArg) +const screenshotPath = path.resolve(screenshotPathArg) + +function contentType(filePath) { + if (filePath.endsWith('.js')) return 'text/javascript' + if (filePath.endsWith('.html')) return 'text/html' + if (filePath.endsWith('.json')) return 'application/json' + if (filePath.endsWith('.png')) return 'image/png' + return 'application/octet-stream' +} + +async function startStaticServer() { + const server = createServer(async (request, response) => { + const requestPath = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname) + const filePath = path.resolve(repoRoot, requestPath.replace(/^\/+/, '')) + if (!(filePath === repoRoot || filePath.startsWith(`${repoRoot}${path.sep}`))) { + response.writeHead(403) + response.end('Forbidden') + return + } + try { + const bytes = await fs.readFile(filePath) + response.writeHead(200, { 'content-type': contentType(filePath) }) + response.end(bytes) + } catch { + response.writeHead(404) + response.end('Not found') + } + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Failed to start static server') + return { server, baseUrl: `http://127.0.0.1:${address.port}` } +} + +const { server, baseUrl } = await startStaticServer() +let browser +try { + browser = await chromium.launch({ headless: true, timeout: 30_000 }) + const page = await browser.newPage({ viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 }) + const rel = path.relative(repoRoot, htmlPath).replace(/\\/g, '/') + await page.goto(`${baseUrl}/${rel}`, { waitUntil: 'networkidle' }) + await page.waitForFunction(() => window.__renderReady === true, null, { timeout: 15_000 }) + await page.screenshot({ path: screenshotPath, fullPage: true }) +} finally { + if (browser) await browser.close() + await new Promise((resolve) => server.close(() => resolve())) +} diff --git a/apps/editor/scripts/scaffold-industry-profile-pack.test.ts b/apps/editor/scripts/scaffold-industry-profile-pack.test.ts new file mode 100644 index 000000000..10f7070a6 --- /dev/null +++ b/apps/editor/scripts/scaffold-industry-profile-pack.test.ts @@ -0,0 +1,497 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { loadPlugin, semanticRecipeRegistry } from '@pascal-app/core' +import { factoryEquipmentPlugin } from '@pascal-app/plugin-factory-equipment' +import { compileProcessStationEquipment } from '../lib/equipment-spec-compiler' +import { normalizeIndustryPackV2Manifest } from '../lib/industry-pack-v2' +import { + auditProfilePackValidation, + validateProfilePackDir, + validateProfilePackZip, +} from '../lib/profile-packs' +import { + normalizeIndustryPackSpec, + scaffoldIndustryProfilePack, +} from './scaffold-industry-profile-pack' + +const tempRoots: string[] = [] + +async function tempDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pascal-profile-pack-scaffold-')) + tempRoots.push(dir) + return dir +} + +describe('scaffold-industry-profile-pack', () => { + afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ) + }) + + test('generates a valid minimal industry profile pack from a spec', async () => { + const root = await tempDir() + const specPath = path.join(root, 'spec.json') + await fs.writeFile( + specPath, + JSON.stringify( + { + industry: 'test-industry', + id: 'industry.test-industry.basic', + name: 'Test Industry Basic Pack', + description: 'Generated test pack.', + capabilities: ['factory_creation'], + factoryArchitectures: [ + { + id: 'test_industry.factory.modular', + label: 'Test industry factory', + industry: 'test-industry', + processId: 'test_industry_full', + layoutStyle: 'linear', + defaultDimensions: { length: 12, width: 6 }, + modules: [ + { + id: 'main_line', + displayLabel: 'Main line', + order: 10, + stationIds: ['feed_pump', 'buffer_tank'], + }, + ], + }, + ], + processTemplates: [ + { + processId: 'test_industry_full', + processLabel: 'Test industry full line', + processDisplayLabel: 'Test industry line', + domain: 'industrial', + aliases: ['test industry line', 'test factory line'], + requiredRoles: ['test_machine'], + defaultLayoutStyle: 'linear', + defaultDimensions: { length: 12, width: 6 }, + stations: [ + { + id: 'feed_pump', + label: 'Feed pump', + displayLabel: 'Feed pump', + role: 'feed_pump', + equipmentHint: 'test_industry.feed_pump centrifugal pump', + footprintHint: 'medium', + }, + { + id: 'buffer_tank', + label: 'Buffer tank', + displayLabel: 'Buffer tank', + role: 'buffer_tank', + equipmentHint: 'test_industry.buffer_tank vertical storage tank', + footprintHint: 'medium', + }, + ], + connections: [ + { + fromStationId: 'buffer_tank', + toStationId: 'feed_pump', + medium: 'liquid', + visualKind: 'pipe', + }, + ], + }, + ], + devices: [ + { + id: 'feed_pump', + name: 'Feed pump', + aliases: ['feed pump', 'centrifugal pump'], + recipeId: 'factory:centrifugal-pump', + layoutFamily: 'pump_skid_layout', + family: 'pump', + defaultDimensions: { length: 2.4, width: 1, height: 1.3 }, + equipmentDefaults: { pumpType: 'centrifugal', flowRate: 160, motorPower: 22 }, + primarySemanticRole: 'pump_casing', + parts: [ + { + kind: 'volute_casing', + semanticRole: 'pump_casing', + required: true, + }, + { + kind: 'ribbed_motor_body', + semanticRole: 'pump_motor', + required: true, + }, + ], + forbiddenRoles: ['vehicle_cabin'], + shapeCount: { min: 2, max: 24 }, + }, + { + id: 'buffer_tank', + name: 'Buffer tank', + aliases: ['buffer tank', 'storage tank'], + recipeId: 'factory:storage-tank', + layoutFamily: 'vertical_tank_layout', + family: 'tank', + defaultDimensions: { length: 2.2, width: 2.2, height: 3.4 }, + equipmentDefaults: { orientation: 'vertical', capacity: 12, liquidLevel: 0.55 }, + primarySemanticRole: 'tank_shell', + parts: [ + { + kind: 'cylindrical_tank', + semanticRole: 'tank_shell', + required: true, + }, + { + kind: 'outlet_port', + semanticRole: 'tank_nozzle', + required: true, + }, + ], + shapeCount: { min: 2, max: 24 }, + }, + ], + }, + null, + 2, + ), + 'utf8', + ) + + const outputRoot = path.join(root, 'cloud') + if (!semanticRecipeRegistry.has('factory:centrifugal-pump')) { + await loadPlugin(factoryEquipmentPlugin) + } + const result = await scaffoldIndustryProfilePack({ + specPath, + outputRoot, + force: true, + }) + const validation = await validateProfilePackDir(result.packDir) + const zipValidation = validateProfilePackZip(await fs.readFile(result.zipPath!)) + const audit = auditProfilePackValidation(validation) + + expect(result.zipPath).toBe(path.join(outputRoot, 'industry.test-industry.basic-0.1.0.zip')) + expect(result.manifest).toMatchObject({ + id: 'industry.test-industry.basic', + version: '0.1.0', + schemaVersion: '2.0', + capabilities: ['factory_creation'], + dependsOnPlugins: ['pascal:factory-equipment'], + profiles: ['profiles/generated.json'], + equipmentBindings: expect.arrayContaining([ + expect.objectContaining({ + profileId: 'test_industry.feed_pump', + recipeId: 'factory:centrifugal-pump', + }), + expect.objectContaining({ + profileId: 'test_industry.buffer_tank', + recipeId: 'factory:storage-tank', + }), + ]), + factoryArchitectures: ['factory-architectures/generated.json'], + processTemplates: ['process-templates/generated.json'], + qualityRules: ['quality-rules/generated-quality.json'], + }) + expect(validation.profiles).toHaveLength(2) + expect(zipValidation.profiles).toHaveLength(2) + expect(validation.resources.factoryArchitectures).toHaveLength(1) + expect(validation.resources.processTemplates).toHaveLength(1) + expect(validation.profiles.map((profile) => profile.id)).toEqual( + expect.arrayContaining(['test_industry.feed_pump', 'test_industry.buffer_tank']), + ) + expect(audit).toMatchObject({ ok: true }) + expect(audit.summary).toMatchObject({ packKind: 'factory-capable' }) + expect(await fs.readFile(path.join(result.packDir, 'README.md'), 'utf8')).toContain( + 'Factory-capable pack', + ) + + expect( + JSON.parse( + await fs.readFile( + path.join(result.packDir, 'factory-architectures', 'generated.json'), + 'utf8', + ), + ), + ).toHaveLength(1) + const processTemplates = JSON.parse( + await fs.readFile(path.join(result.packDir, 'process-templates', 'generated.json'), 'utf8'), + ) + expect(processTemplates).toHaveLength(1) + expect(processTemplates[0].stations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'feed_pump', profileId: 'test_industry.feed_pump' }), + expect.objectContaining({ id: 'buffer_tank', profileId: 'test_industry.buffer_tank' }), + ]), + ) + const v2Manifest = normalizeIndustryPackV2Manifest(validation.manifest) + expect( + compileProcessStationEquipment({ + manifest: v2Manifest, + profiles: validation.resources.rawProfiles, + station: { id: 'feed_pump', profileId: 'test_industry.feed_pump' }, + }), + ).toMatchObject({ kind: 'semantic-assembly', spec: { recipeId: 'factory:centrifugal-pump' } }) + expect( + compileProcessStationEquipment({ + manifest: v2Manifest, + profiles: validation.resources.rawProfiles, + station: { id: 'buffer_tank', profileId: 'test_industry.buffer_tank' }, + }), + ).toMatchObject({ kind: 'semantic-assembly', spec: { recipeId: 'factory:storage-tank' } }) + }) + + test('accepts registered refinery semantic recipes in scaffold specs', async () => { + const root = await tempDir() + const specPath = path.join(root, 'spec.json') + await fs.writeFile( + specPath, + JSON.stringify( + { + industry: 'test-refinery', + id: 'industry.test-refinery.basic', + capabilities: ['factory_creation'], + processTemplates: [ + { + processId: 'test_refinery_full', + processLabel: 'Test refinery', + aliases: ['test refinery'], + stations: [ + { + id: 'atmospheric_distillation_unit', + label: 'Atmospheric distillation unit', + role: 'atmospheric_distillation_unit', + equipmentHint: 'test_refinery.atmospheric_distillation_unit', + profileId: 'test_refinery.atmospheric_distillation_unit', + }, + { + id: 'flare_system', + label: 'Flare system', + role: 'flare_system', + equipmentHint: 'test_refinery.flare_system', + profileId: 'test_refinery.flare_system', + }, + ], + }, + ], + factoryArchitectures: [ + { + id: 'test_refinery.factory', + label: 'Test refinery factory', + processId: 'test_refinery_full', + modules: [ + { + id: 'main', + order: 10, + stationIds: ['atmospheric_distillation_unit', 'flare_system'], + }, + ], + }, + ], + devices: [ + { + id: 'atmospheric_distillation_unit', + name: 'Atmospheric distillation unit', + aliases: ['atmospheric distillation unit', 'CDU'], + recipeId: 'factory:distillation-unit', + family: 'distillation_column', + defaultDimensions: { length: 10.5, width: 6, height: 13.5 }, + equipmentDefaults: { columnType: 'atmospheric' }, + processPorts: [ + { id: 'crude_feed_inlet', medium: 'material', side: 'left', diameter: 0.24 }, + { + id: 'overhead_product_outlet', + medium: 'material', + side: 'right', + diameter: 0.18, + }, + ], + primarySemanticRole: 'distillation_column_shell', + parts: [ + { + kind: 'cylindrical_tank', + semanticRole: 'distillation_column_shell', + required: true, + }, + { + kind: 'heat_exchanger', + semanticRole: 'preheat_exchanger', + required: true, + }, + { + kind: 'generic_body', + semanticRole: 'fired_heater', + required: true, + }, + ], + }, + { + id: 'flare_system', + name: 'Flare system', + aliases: ['flare system'], + recipeId: 'factory:refinery-auxiliary-unit', + family: 'generic', + defaultDimensions: { length: 4.2, width: 2.6, height: 14 }, + equipmentDefaults: { variant: 'flare' }, + processPorts: [ + { id: 'relief_gas_in', medium: 'gas', side: 'left', diameter: 0.18 }, + { id: 'flare_tip', medium: 'gas', side: 'top', diameter: 0.12 }, + ], + primarySemanticRole: 'flare_stack', + parts: [ + { + kind: 'chimney_stack', + semanticRole: 'flare_stack', + required: true, + }, + { + kind: 'cylindrical_tank', + semanticRole: 'knockout_drum', + required: true, + }, + { + kind: 'pipe_run', + semanticRole: 'relief_gas_inlet', + required: true, + }, + ], + }, + ], + }, + null, + 2, + ), + 'utf8', + ) + + const result = await scaffoldIndustryProfilePack({ + specPath, + outputRoot: path.join(root, 'cloud'), + force: true, + }) + const validation = await validateProfilePackDir(result.packDir) + const v2Manifest = normalizeIndustryPackV2Manifest(validation.manifest) + + expect(result.manifest.equipmentBindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + profileId: 'test_refinery.atmospheric_distillation_unit', + recipeId: 'factory:distillation-unit', + }), + expect.objectContaining({ + profileId: 'test_refinery.flare_system', + recipeId: 'factory:refinery-auxiliary-unit', + }), + ]), + ) + expect( + compileProcessStationEquipment({ + manifest: v2Manifest, + profiles: validation.resources.rawProfiles, + station: { + id: 'atmospheric_distillation_unit', + profileId: 'test_refinery.atmospheric_distillation_unit', + }, + }), + ).toMatchObject({ kind: 'semantic-assembly', spec: { recipeId: 'factory:distillation-unit' } }) + expect(validateProfilePackZip(await fs.readFile(result.zipPath!)).manifest).toMatchObject({ + id: 'industry.test-refinery.basic', + }) + }) + + test('rejects factory architecture quantity expansion fields', () => { + expect(() => + normalizeIndustryPackSpec({ + industry: 'test-industry', + capabilities: ['factory_creation'], + factoryArchitectures: [ + { + id: 'test.factory', + label: 'Test factory', + industry: 'test-industry', + processId: 'test_full', + layoutStyle: 'linear', + defaultDimensions: { length: 12, width: 6 }, + modules: [ + { + id: 'main_line', + order: 10, + stationIds: ['test_machine'], + countParam: 'lineCount', + }, + ], + }, + ], + processTemplates: [], + devices: [ + { + id: 'test_machine', + name: 'Test machine', + aliases: ['test machine'], + primarySemanticRole: 'machine_body', + parts: [{ kind: 'generic_body', semanticRole: 'machine_body' }], + }, + ], + }), + ).toThrow(/countParam is not supported/) + }) + + test('reports authoring warnings for building and boiler profiles that are too generic', async () => { + const root = await tempDir() + const specPath = path.join(root, 'spec.json') + await fs.writeFile( + specPath, + JSON.stringify( + { + industry: 'test-refinery', + devices: [ + { + id: 'control_room', + name: 'Control room and MCC', + aliases: ['control room', 'MCC'], + preferredResolver: 'catalog-item', + primarySemanticRole: 'control_room_body', + parts: [ + { kind: 'generic_body', semanticRole: 'control_room_body' }, + { kind: 'control_box', semanticRole: 'mcc_panel' }, + ], + }, + { + id: 'utility_boiler', + name: 'Utility boiler', + aliases: ['steam boiler'], + preferredResolver: 'profile-parts', + primarySemanticRole: 'boiler_body', + parts: [ + { kind: 'generic_body', semanticRole: 'boiler_body' }, + { kind: 'chimney_stack', semanticRole: 'boiler_stack' }, + { kind: 'pipe_manifold', semanticRole: 'steam_header' }, + { kind: 'control_box', semanticRole: 'boiler_control_box' }, + ], + }, + ], + }, + null, + 2, + ), + 'utf8', + ) + + const result = await scaffoldIndustryProfilePack({ + specPath, + outputRoot: path.join(root, 'cloud'), + force: true, + validate: false, + }) + + expect(result.authoringWarnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining([ + 'control_building_catalog_resolver', + 'control_building_missing_shell_details', + 'boiler_missing_process_features', + ]), + ) + expect(await fs.readFile(path.join(result.packDir, 'README.md'), 'utf8')).toContain( + '## Authoring Review', + ) + }) +}) diff --git a/apps/editor/scripts/scaffold-industry-profile-pack.ts b/apps/editor/scripts/scaffold-industry-profile-pack.ts new file mode 100644 index 000000000..893c9a83e --- /dev/null +++ b/apps/editor/scripts/scaffold-industry-profile-pack.ts @@ -0,0 +1,805 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { loadPlugin, semanticRecipeRegistry } from '@pascal-app/core' +import { factoryEquipmentPlugin } from '@pascal-app/plugin-factory-equipment' +import { findRepoRoot } from '../lib/generated-assets/manifest' +import { + annotateProcessTemplatesForV2, + FACTORY_EQUIPMENT_PLUGIN_ID, + inferEquipmentBindingsForProfiles, + withDefaultV2ProfileFields, +} from '../lib/industry-pack-v2-migration' +import { + auditProfilePackValidation, + type ProfilePackDependency, + simulatedProfilePackCloudRoot, + validateProfilePackDir, +} from '../lib/profile-packs' + +type JsonRecord = Record + +export type IndustryPackDeviceSpec = { + id: string + name: string + aliases: string[] + description?: string + family?: string + layoutFamily?: string + archetypeFamily?: string + recipeId?: string + preferredResolver?: 'catalog-item' | 'native-box' | 'native-tank' | 'primitive' | 'profile-parts' + defaultDimensions?: Record + processPorts?: JsonRecord[] + equipmentDefaults?: JsonRecord + recipeParams?: JsonRecord + parts: Array + primarySemanticRole: string + visualCues?: string[] + forbiddenRoles?: string[] + shapeCount?: { min?: number; max?: number } + qualityRuleId?: string + qualityRequiredRoles?: string[] +} + +export type IndustryPackSpec = { + id?: string + name?: string + industry: string + version?: string + schemaVersion?: string + knowledgeSchemaVersion?: string + appCompatibility?: string + locale?: string[] + capabilities?: Array<'factory_creation'> + description?: string + dependsOn?: ProfilePackDependency[] + dependsOnPlugins?: string[] + devices: IndustryPackDeviceSpec[] + factoryArchitectures?: JsonRecord[] + processTemplates?: JsonRecord[] +} + +export type IndustryPackAuthoringWarning = { + deviceId: string + code: string + message: string +} + +export type ScaffoldIndustryPackOptions = { + specPath: string + outputRoot?: string + force?: boolean + validate?: boolean + writeZip?: boolean +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : [] +} + +function slug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function profileId(value: string, industry: string) { + const normalized = slug(value).replace(/-/g, '_') + return normalized.includes('.') + ? normalized + : `${slug(industry).replace(/-/g, '_')}.${normalized}` +} + +function packId(industry: string, explicit?: string) { + return explicit?.trim() || `industry.${slug(industry)}.basic` +} + +function qualityRuleId(profileIdValue: string, explicit?: string) { + return explicit?.trim() || `quality.${profileIdValue}` +} + +function preferredResolver( + value: unknown, +): IndustryPackDeviceSpec['preferredResolver'] | undefined { + return value === 'catalog-item' || + value === 'native-box' || + value === 'native-tank' || + value === 'primitive' || + value === 'profile-parts' + ? value + : undefined +} + +const CRC32_TABLE = (() => { + const table = new Uint32Array(256) + for (let index = 0; index < table.length; index += 1) { + let current = index + for (let bit = 0; bit < 8; bit += 1) { + current = current & 1 ? 0xedb88320 ^ (current >>> 1) : current >>> 1 + } + table[index] = current >>> 0 + } + return table +})() + +function crc32(bytes: Buffer) { + let crc = 0xffffffff + for (const byte of bytes) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()) + const dosTime = + (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2) + const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate() + return { dosDate, dosTime } +} + +function uint16(value: number) { + const buffer = Buffer.allocUnsafe(2) + buffer.writeUInt16LE(value, 0) + return buffer +} + +function uint32(value: number) { + const buffer = Buffer.allocUnsafe(4) + buffer.writeUInt32LE(value >>> 0, 0) + return buffer +} + +async function collectZipFiles(root: string, dir = root): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }) + const files = await Promise.all( + entries.map(async (entry) => { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) return collectZipFiles(root, file) + if (!entry.isFile()) return [] + return [path.relative(root, file).replace(/\\/g, '/')] + }), + ) + return files.flat().sort((left, right) => left.localeCompare(right)) +} + +async function writeProfilePackZipFromDir(packDir: string, zipPath: string) { + const fileNames = await collectZipFiles(packDir) + const localParts: Buffer[] = [] + const centralParts: Buffer[] = [] + let offset = 0 + const { dosDate, dosTime } = dosDateTime() + + for (const name of fileNames) { + const data = await fs.readFile(path.join(packDir, name)) + const nameBytes = Buffer.from(name, 'utf8') + const checksum = crc32(data) + const localHeader = Buffer.concat([ + uint32(0x04034b50), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(dosTime), + uint16(dosDate), + uint32(checksum), + uint32(data.length), + uint32(data.length), + uint16(nameBytes.length), + uint16(0), + nameBytes, + ]) + localParts.push(localHeader, data) + centralParts.push( + Buffer.concat([ + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(dosTime), + uint16(dosDate), + uint32(checksum), + uint32(data.length), + uint32(data.length), + uint16(nameBytes.length), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(offset), + nameBytes, + ]), + ) + offset += localHeader.length + data.length + } + + const localData = Buffer.concat(localParts) + const centralData = Buffer.concat(centralParts) + const endOfCentralDirectory = Buffer.concat([ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(fileNames.length), + uint16(fileNames.length), + uint32(centralData.length), + uint32(localData.length), + uint16(0), + ]) + + await fs.writeFile(zipPath, Buffer.concat([localData, centralData, endOfCentralDirectory])) +} + +function normalizeDevice(raw: unknown, industry: string): IndustryPackDeviceSpec { + if (!isRecord(raw)) throw new Error('Each devices[] item must be an object.') + const id = stringValue(raw.id) + const name = stringValue(raw.name) + const primarySemanticRole = stringValue(raw.primarySemanticRole) + if (!id) throw new Error('Device id is required.') + if (!name) throw new Error(`Device ${id} name is required.`) + if (!primarySemanticRole) throw new Error(`Device ${id} primarySemanticRole is required.`) + if (!Array.isArray(raw.parts) || raw.parts.length === 0) { + throw new Error(`Device ${id} parts must be a non-empty array.`) + } + const parts = raw.parts.map((part, index) => { + if (!isRecord(part)) throw new Error(`Device ${id} parts[${index}] must be an object.`) + const kind = stringValue(part.kind) + const semanticRole = stringValue(part.semanticRole) + if (!kind) throw new Error(`Device ${id} parts[${index}].kind is required.`) + if (!semanticRole) { + throw new Error(`Device ${id} parts[${index}].semanticRole is required.`) + } + return { ...part, kind, semanticRole } + }) + const defaultDimensions = isRecord(raw.defaultDimensions) + ? Object.fromEntries( + Object.entries(raw.defaultDimensions).filter( + (entry): entry is [string, number] => typeof entry[1] === 'number', + ), + ) + : undefined + const equipmentDefaults = isRecord(raw.equipmentDefaults) + ? raw.equipmentDefaults + : isRecord(raw.recipeParams) + ? raw.recipeParams + : undefined + return { + id: profileId(id, industry), + name, + aliases: stringArray(raw.aliases), + ...(stringValue(raw.description) ? { description: stringValue(raw.description) } : {}), + family: stringValue(raw.family) ?? 'generic', + layoutFamily: stringValue(raw.layoutFamily) ?? 'generic_industrial_layout', + ...(stringValue(raw.recipeId) ? { recipeId: stringValue(raw.recipeId) } : {}), + ...(stringValue(raw.archetypeFamily) + ? { archetypeFamily: stringValue(raw.archetypeFamily) } + : {}), + ...(preferredResolver(raw.preferredResolver) + ? { preferredResolver: preferredResolver(raw.preferredResolver) } + : {}), + ...(defaultDimensions ? { defaultDimensions } : {}), + ...(recordArray(raw.processPorts, `Device ${id} processPorts`)?.length + ? { processPorts: recordArray(raw.processPorts, `Device ${id} processPorts`) } + : {}), + ...(equipmentDefaults ? { equipmentDefaults, recipeParams: equipmentDefaults } : {}), + parts, + primarySemanticRole, + ...(stringArray(raw.visualCues).length ? { visualCues: stringArray(raw.visualCues) } : {}), + ...(stringArray(raw.forbiddenRoles).length + ? { forbiddenRoles: stringArray(raw.forbiddenRoles) } + : {}), + ...(isRecord(raw.shapeCount) + ? { + shapeCount: { + ...(typeof raw.shapeCount.min === 'number' ? { min: raw.shapeCount.min } : {}), + ...(typeof raw.shapeCount.max === 'number' ? { max: raw.shapeCount.max } : {}), + }, + } + : {}), + ...(stringValue(raw.qualityRuleId) ? { qualityRuleId: stringValue(raw.qualityRuleId) } : {}), + ...(stringArray(raw.qualityRequiredRoles).length + ? { qualityRequiredRoles: stringArray(raw.qualityRequiredRoles) } + : {}), + } +} + +function recordArray(value: unknown, fieldName: string): JsonRecord[] | undefined { + if (value == null) return undefined + if (!Array.isArray(value)) throw new Error(`Spec ${fieldName} must be an array.`) + return value.map((item, index) => { + if (!isRecord(item)) throw new Error(`Spec ${fieldName}[${index}] must be an object.`) + return item + }) +} + +const FORBIDDEN_FACTORY_ARCHITECTURE_FIELDS = ['parameters', 'flows'] +const FORBIDDEN_FACTORY_MODULE_FIELDS = [ + 'countParam', + 'defaultCount', + 'minCount', + 'maxCount', + 'replicatedStationIds', +] + +function assertSingleProcessFactoryArchitectures(factoryArchitectures: JsonRecord[] | undefined) { + for (const [architectureIndex, architecture] of factoryArchitectures?.entries() ?? []) { + for (const field of FORBIDDEN_FACTORY_ARCHITECTURE_FIELDS) { + if (field in architecture) { + throw new Error( + `Spec factoryArchitectures[${architectureIndex}].${field} is not supported; factory creation uses one process template per request.`, + ) + } + } + const modules = Array.isArray(architecture.modules) ? architecture.modules : [] + for (const [moduleIndex, module] of modules.entries()) { + if (!isRecord(module)) continue + for (const field of FORBIDDEN_FACTORY_MODULE_FIELDS) { + if (field in module) { + throw new Error( + `Spec factoryArchitectures[${architectureIndex}].modules[${moduleIndex}].${field} is not supported; model each factory request as one default process line.`, + ) + } + } + } + } +} + +export function normalizeIndustryPackSpec(raw: unknown): IndustryPackSpec { + if (!isRecord(raw)) throw new Error('Industry pack spec must be an object.') + const industry = stringValue(raw.industry) + if (!industry) throw new Error('Spec industry is required.') + if (!Array.isArray(raw.devices) || raw.devices.length === 0) { + throw new Error('Spec devices must be a non-empty array.') + } + const dependsOn = Array.isArray(raw.dependsOn) + ? raw.dependsOn + .map((dependency) => { + if (!isRecord(dependency)) return undefined + const id = stringValue(dependency.id) + if (!id) return undefined + return { + id, + ...(stringValue(dependency.version) + ? { version: stringValue(dependency.version) } + : {}), + } + }) + .filter((dependency): dependency is ProfilePackDependency => Boolean(dependency)) + : undefined + const dependsOnPlugins = stringArray(raw.dependsOnPlugins) + const factoryArchitectures = recordArray(raw.factoryArchitectures, 'factoryArchitectures') + const processTemplates = recordArray(raw.processTemplates, 'processTemplates') + assertSingleProcessFactoryArchitectures(factoryArchitectures) + const capabilities = stringArray(raw.capabilities).filter( + (capability): capability is 'factory_creation' => capability === 'factory_creation', + ) + return { + ...(stringValue(raw.id) ? { id: stringValue(raw.id) } : {}), + ...(stringValue(raw.name) ? { name: stringValue(raw.name) } : {}), + industry, + version: stringValue(raw.version) ?? '0.1.0', + schemaVersion: stringValue(raw.schemaVersion) ?? '2.0', + knowledgeSchemaVersion: stringValue(raw.knowledgeSchemaVersion) ?? '1.0', + appCompatibility: stringValue(raw.appCompatibility) ?? '>=0.8.0', + locale: stringArray(raw.locale).length ? stringArray(raw.locale) : ['zh-CN', 'en-US'], + ...(capabilities.length ? { capabilities } : {}), + ...(stringValue(raw.description) ? { description: stringValue(raw.description) } : {}), + ...(dependsOn?.length ? { dependsOn } : {}), + ...(dependsOnPlugins.length ? { dependsOnPlugins } : {}), + devices: raw.devices.map((device) => normalizeDevice(device, industry)), + ...(factoryArchitectures?.length ? { factoryArchitectures } : {}), + ...(processTemplates?.length ? { processTemplates } : {}), + } +} + +function uniqueRoles(device: IndustryPackDeviceSpec) { + return [ + ...new Set([ + device.primarySemanticRole, + ...device.parts + .filter((part) => part.required !== false) + .map((part) => part.semanticRole) + .filter(Boolean), + ...(device.qualityRequiredRoles ?? []), + ]), + ] +} + +function deviceAuthoringText(device: IndustryPackDeviceSpec) { + return [ + device.id, + device.name, + device.description, + device.primarySemanticRole, + device.layoutFamily, + device.family, + device.preferredResolver, + ...device.aliases, + ...device.parts.flatMap((part) => [part.kind, part.semanticRole]), + ...(device.visualCues ?? []), + ...(device.qualityRequiredRoles ?? []), + ] + .filter((value): value is string => typeof value === 'string') + .join(' ') + .toLowerCase() +} + +function hasPartKind(device: IndustryPackDeviceSpec, kind: string) { + return device.parts.some((part) => part.kind === kind) +} + +function hasRoleMatch(device: IndustryPackDeviceSpec, pattern: RegExp) { + return [ + device.primarySemanticRole, + ...device.parts.map((part) => part.semanticRole), + ...(device.qualityRequiredRoles ?? []), + ].some((role) => pattern.test(role.toLowerCase().replace(/[_-]/g, ' '))) +} + +function collectAuthoringWarningsForDevice( + device: IndustryPackDeviceSpec, +): IndustryPackAuthoringWarning[] { + const warnings: IndustryPackAuthoringWarning[] = [] + const text = deviceAuthoringText(device) + const isControlBuilding = + /\bcontrol[_\s-]?room\b/.test(text) || + /\bcontrol[_\s-]?building\b/.test(text) || + /\boccupied[_\s-]?building\b/.test(text) || + /\bmcc\b/.test(text) || + text.includes('中控') || + text.includes('控制室') + const isBoiler = /\bboiler\b/.test(text) || text.includes('锅炉') + + if (isControlBuilding && device.preferredResolver === 'catalog-item') { + warnings.push({ + deviceId: device.id, + code: 'control_building_catalog_resolver', + message: + 'Control rooms and occupied buildings should use profile-parts with body, roof, door, window, and panel roles instead of catalog-item fallback.', + }) + } + + if (isControlBuilding) { + const hasBuildingOpenings = + hasPartKind(device, 'generic_opening') || + hasPartKind(device, 'generic_detail_accent') || + hasRoleMatch(device, /\b(door|window|opening|roof|parapet|wall|building)\b/) + if (!hasBuildingOpenings) { + warnings.push({ + deviceId: device.id, + code: 'control_building_missing_shell_details', + message: + 'Control-room-like profiles should include visible building details such as a roof cap/parapet, door, and blast-resistant windows.', + }) + } + } + + if (isBoiler) { + const hasStack = + hasPartKind(device, 'chimney_stack') || hasRoleMatch(device, /\b(stack|chimney)\b/) + const hasVisibleSteamBody = + hasPartKind(device, 'cylindrical_tank') || hasRoleMatch(device, /\b(drum|tube|tube_bank)\b/) + const hasSteamHeader = + hasPartKind(device, 'pipe_manifold') || hasRoleMatch(device, /\b(header|manifold|steam)\b/) + if (!hasStack || !hasVisibleSteamBody || !hasSteamHeader) { + warnings.push({ + deviceId: device.id, + code: 'boiler_missing_process_features', + message: + 'Boiler profiles should show more than a plain box: include a stack, visible steam drum or tube bank, steam header, and control/service details.', + }) + } + } + + return warnings +} + +function collectAuthoringWarnings(spec: IndustryPackSpec) { + return spec.devices.flatMap((device) => collectAuthoringWarningsForDevice(device)) +} + +function profileFromDevice(device: IndustryPackDeviceSpec, industry: string) { + const ruleId = qualityRuleId(device.id, device.qualityRuleId) + return withDefaultV2ProfileFields({ + id: device.id, + name: device.name, + aliases: device.aliases, + industry, + layoutFamily: device.layoutFamily ?? 'generic_industrial_layout', + ...(device.archetypeFamily ? { archetypeFamily: device.archetypeFamily } : {}), + ...(device.preferredResolver ? { preferredResolver: device.preferredResolver } : {}), + ...(device.recipeId ? { recipeId: device.recipeId } : {}), + family: device.family ?? 'generic', + ...(device.defaultDimensions ? { defaultDimensions: device.defaultDimensions } : {}), + ...(device.processPorts?.length ? { processPorts: device.processPorts } : {}), + ...(device.equipmentDefaults ? { equipmentDefaults: device.equipmentDefaults } : {}), + ...(device.recipeParams ? { recipeParams: device.recipeParams } : {}), + parts: device.parts, + primarySemanticRole: device.primarySemanticRole, + qualityRules: ruleId, + ...(device.visualCues ? { visualCues: device.visualCues } : {}), + status: 'stable', + source: 'imported_pack', + ...(device.description ? { description: device.description } : {}), + }) +} + +function qualityRuleFromDevice(device: IndustryPackDeviceSpec) { + const partCount = device.parts.length + return { + id: qualityRuleId(device.id, device.qualityRuleId), + requiredRoles: uniqueRoles(device), + ...(device.forbiddenRoles?.length ? { forbiddenRoles: device.forbiddenRoles } : {}), + shapeCount: { + min: device.shapeCount?.min ?? Math.max(1, Math.floor(partCount * 0.75)), + max: device.shapeCount?.max ?? Math.max(24, partCount * 8), + }, + } +} + +async function writeJson(file: string, value: unknown) { + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +async function writeReadme( + file: string, + spec: IndustryPackSpec, + authoringWarnings: IndustryPackAuthoringWarning[], +) { + const title = spec.name ?? `${spec.industry} Profile Pack` + const factoryCapable = spec.capabilities?.includes('factory_creation') === true + const lines = [ + `# ${title}`, + '', + spec.description ?? `Generated ${spec.industry} industry profile pack.`, + '', + '## Devices', + '', + ...spec.devices.map((device) => `- ${device.name} (${device.id})`), + '', + '## Pack Type', + '', + factoryCapable + ? 'Factory-capable pack: supports factory/process creation through process templates and factory architectures.' + : 'Device-only pack: provides equipment profiles but does not claim factory/process creation support.', + ...(factoryCapable + ? [ + '', + '## Factory Creation', + '', + 'Supported whole-factory/process templates:', + '', + ...(spec.processTemplates?.length + ? spec.processTemplates.map( + (template) => + `- ${stringValue(template.processLabel) ?? stringValue(template.processId) ?? 'Process template'} (${stringValue(template.processId) ?? 'unknown'})`, + ) + : ['- None declared']), + '', + 'Supported factory scopes/modules:', + '', + ...(spec.factoryArchitectures?.length + ? spec.factoryArchitectures.flatMap((architecture) => + Array.isArray(architecture.scopes) && architecture.scopes.length + ? architecture.scopes + .filter(isRecord) + .map( + (scope) => + `- ${stringValue(scope.label) ?? stringValue(scope.id) ?? 'Factory scope'} (${stringValue(scope.id) ?? 'unknown'})`, + ) + : [ + `- ${stringValue(architecture.label) ?? stringValue(architecture.id) ?? 'Factory architecture'} (${stringValue(architecture.id) ?? 'unknown'})`, + ], + ) + : ['- None declared']), + ] + : []), + ...(spec.factoryArchitectures?.length + ? [ + '', + '## Factory Architectures', + '', + ...spec.factoryArchitectures.map( + (architecture) => + `- ${stringValue(architecture.label) ?? stringValue(architecture.id) ?? 'Factory architecture'}`, + ), + ] + : []), + ...(spec.processTemplates?.length + ? [ + '', + '## Process Templates', + '', + ...spec.processTemplates.map( + (template) => + `- ${stringValue(template.processLabel) ?? stringValue(template.processId) ?? 'Process template'}`, + ), + ] + : []), + '', + '## Authoring Review', + '', + ...(authoringWarnings.length + ? authoringWarnings.map( + (warning) => `- ${warning.deviceId}: ${warning.code} - ${warning.message}`, + ) + : ['- No scaffold authoring warnings.']), + '', + '## Validation', + '', + 'Run:', + '', + '```bash', + `bun apps/editor/scripts/profile-pack-qa.ts ${packId(spec.industry, spec.id)}@${spec.version ?? '0.1.0'} --validate-only`, + '```', + '', + ] + await fs.writeFile(file, `${lines.join('\n')}\n`, 'utf8') +} + +export async function scaffoldIndustryProfilePack(options: ScaffoldIndustryPackOptions) { + const raw = JSON.parse((await fs.readFile(options.specPath, 'utf8')).replace(/^\uFEFF/, '')) + const spec = normalizeIndustryPackSpec(raw) + const authoringWarnings = collectAuthoringWarnings(spec) + const repoRoot = await findRepoRoot() + const id = packId(spec.industry, spec.id) + const version = spec.version ?? '0.1.0' + const outputRoot = options.outputRoot ?? simulatedProfilePackCloudRoot(repoRoot) + const packDir = path.join(outputRoot, `${id}-${version}`) + const zipPath = path.join(outputRoot, `${id}-${version}.zip`) + if (!options.force) { + try { + await fs.access(packDir) + throw new Error(`Output directory already exists: ${packDir}. Use --force to replace it.`) + } catch (error) { + if (error instanceof Error && !('code' in error)) throw error + } + try { + await fs.access(zipPath) + throw new Error(`Output zip already exists: ${zipPath}. Use --force to replace it.`) + } catch (error) { + if (error instanceof Error && !('code' in error)) throw error + } + } + await fs.rm(packDir, { recursive: true, force: true }) + if (options.writeZip !== false) await fs.rm(zipPath, { force: true }) + await fs.mkdir(packDir, { recursive: true }) + + const profileFile = 'profiles/generated.json' + const qualityFile = 'quality-rules/generated-quality.json' + const factoryArchitectureFile = spec.factoryArchitectures?.length + ? 'factory-architectures/generated.json' + : undefined + const processTemplateFile = spec.processTemplates?.length + ? 'process-templates/generated.json' + : undefined + if (!semanticRecipeRegistry.has('factory:centrifugal-pump')) { + await loadPlugin(factoryEquipmentPlugin) + } + const profiles = spec.devices.map((device) => profileFromDevice(device, spec.industry)) + const equipmentBindings = inferEquipmentBindingsForProfiles(profiles) + const processTemplates = spec.processTemplates?.length + ? annotateProcessTemplatesForV2({ + processTemplates: spec.processTemplates, + profiles, + bindings: equipmentBindings, + }) + : undefined + const manifest = { + id, + name: spec.name ?? `${spec.industry} Basic Equipment Pack`, + industry: spec.industry, + version, + schemaVersion: spec.schemaVersion ?? '2.0', + knowledgeSchemaVersion: spec.knowledgeSchemaVersion ?? '1.0', + appCompatibility: spec.appCompatibility ?? '>=0.8.0', + locale: spec.locale ?? ['zh-CN', 'en-US'], + ...(spec.capabilities?.length ? { capabilities: spec.capabilities } : {}), + description: spec.description ?? `Generated ${spec.industry} industry profile pack.`, + ...(spec.dependsOn?.length ? { dependsOn: spec.dependsOn } : {}), + dependsOnPlugins: spec.dependsOnPlugins?.length + ? spec.dependsOnPlugins + : [FACTORY_EQUIPMENT_PLUGIN_ID], + profiles: [profileFile], + equipmentBindings, + ...(factoryArchitectureFile ? { factoryArchitectures: [factoryArchitectureFile] } : {}), + ...(processTemplateFile ? { processTemplates: [processTemplateFile] } : {}), + qualityRules: [qualityFile], + } + + await writeJson(path.join(packDir, 'pack.json'), manifest) + await writeJson(path.join(packDir, profileFile), profiles) + await writeJson( + path.join(packDir, qualityFile), + spec.devices.map((device) => qualityRuleFromDevice(device)), + ) + if (factoryArchitectureFile) { + await writeJson(path.join(packDir, factoryArchitectureFile), spec.factoryArchitectures) + } + if (processTemplateFile) { + await writeJson(path.join(packDir, processTemplateFile), processTemplates) + } + await writeReadme(path.join(packDir, 'README.md'), spec, authoringWarnings) + + const validation = options.validate === false ? undefined : await validateProfilePackDir(packDir) + const audit = validation ? auditProfilePackValidation(validation) : undefined + if (audit && !audit.ok) { + throw new Error(`Generated pack failed audit: ${audit.issues.join('; ')}`) + } + if (options.writeZip !== false) { + await writeProfilePackZipFromDir(packDir, zipPath) + } + return { + packDir, + zipPath: options.writeZip === false ? undefined : zipPath, + manifest, + audit, + authoringWarnings, + } +} + +function readArgValue(args: string[], name: string) { + const index = args.indexOf(name) + return index >= 0 ? args[index + 1] : undefined +} + +async function main() { + const args = process.argv.slice(2) + const specPath = readArgValue(args, '--spec') + if (!specPath) { + throw new Error( + 'Usage: bun apps/editor/scripts/scaffold-industry-profile-pack.ts --spec [--out ] [--force] [--skip-validate] [--skip-zip]', + ) + } + const result = await scaffoldIndustryProfilePack({ + specPath: path.resolve(specPath), + outputRoot: readArgValue(args, '--out') + ? path.resolve(readArgValue(args, '--out') ?? '') + : undefined, + force: args.includes('--force'), + validate: !args.includes('--skip-validate'), + writeZip: !args.includes('--skip-zip'), + }) + console.log( + JSON.stringify( + { + packDir: result.packDir, + zipPath: result.zipPath, + id: result.manifest.id, + version: result.manifest.version, + audit: result.audit + ? { + ok: result.audit.ok, + score: result.audit.score, + issues: result.audit.issues, + warnings: result.audit.warnings, + summary: result.audit.summary, + } + : undefined, + authoringWarnings: result.authoringWarnings, + }, + null, + 2, + ), + ) +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) +} diff --git a/apps/ifc-converter/components/IfcConverter.tsx b/apps/ifc-converter/components/IfcConverter.tsx index 6e9c2f302..a038efecc 100644 --- a/apps/ifc-converter/components/IfcConverter.tsx +++ b/apps/ifc-converter/components/IfcConverter.tsx @@ -115,18 +115,7 @@ export default function IfcConverter() { return results }, [pascalData, searchQuery]) - useEffect(() => { - const params = new URLSearchParams(window.location.search) - const requested = params.get('file') - const matched = testFiles.some((f) => f.name === requested) - const initial = matched ? requested! : '01-duplex.ifc' - loadExampleFile(initial) - if (matched) { - document.getElementById('try')?.scrollIntoView({ block: 'start' }) - } - }, []) - - const loadAndConvert = async (data: Uint8Array, name: string) => { + const loadAndConvert = useCallback(async (data: Uint8Array, name: string) => { setFileName(name) setStatus('converting') setSearchQuery('') @@ -148,9 +137,9 @@ export default function IfcConverter() { setStatus('error') setConversionProgress(0) } - } + }, []) - const loadExampleFile = async (filename: string) => { + const loadExampleFile = useCallback(async (filename: string) => { setStatus('loading') setSelectedFile(filename) setError(null) @@ -175,9 +164,20 @@ export default function IfcConverter() { setError(err instanceof Error ? err.message : 'Failed to load file') setStatus('error') } - } + }, [loadAndConvert]) - const handleFile = async (file: File) => { + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const requested = params.get('file') + const matched = testFiles.some((f) => f.name === requested) + const initial = matched ? requested! : '01-duplex.ifc' + loadExampleFile(initial) + if (matched) { + document.getElementById('try')?.scrollIntoView({ block: 'start' }) + } + }, [loadExampleFile]) + + const handleFile = useCallback(async (file: File) => { setStatus('loading') setError(null) setSelectedFile('') @@ -199,7 +199,7 @@ export default function IfcConverter() { setError(err instanceof Error ? err.message : 'Failed to load file') setStatus('error') } - } + }, [loadAndConvert]) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() @@ -210,7 +210,7 @@ export default function IfcConverter() { } else { setError('Please drop a valid IFC file') } - }, []) + }, [handleFile]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() @@ -234,7 +234,7 @@ export default function IfcConverter() { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = fileName.replace('.ifc', '') + '_pascal.json' + a.download = `${fileName.replace('.ifc', '')}_pascal.json` a.click() URL.revokeObjectURL(url) } diff --git a/biome.jsonc b/biome.jsonc index a0b121088..3aa69a640 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -81,9 +81,6 @@ "noNoninteractiveElementInteractions": "off", "useButtonType": "off" }, - "nursery": { - "noShadow": "off" - }, "security": { "noDangerouslySetInnerHtml": "info" } diff --git a/bun.lock b/bun.lock index a3989b630..56704d710 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "dotenv-cli": "^11.0.0", "turbo": "^2.8.15", "typescript": "6.0.2", - "ultracite": "^7.2.5", }, "optionalDependencies": { "@tailwindcss/oxide-darwin-arm64": "4.3.0", @@ -26,20 +25,28 @@ "name": "editor", "version": "0.1.0", "dependencies": { + "@gltf-transform/core": "^4.4.0", + "@gltf-transform/extensions": "^4.4.0", + "@gltf-transform/functions": "^4.4.0", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", + "@pascal-app/articraft-bridge": "*", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-factory-equipment": "*", + "@pascal-app/plugin-trees": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", + "draco3d": "^1.5.7", "geist": "^1.7.0", "lucide-react": "^1.7.0", + "meshoptimizer": "^1.1.1", "next": "16.2.1", "postcss": "^8.5.6", "react": "^19.2.4", @@ -51,13 +58,13 @@ }, "devDependencies": { "@pascal/typescript-config": "*", + "@playwright/test": "^1.61.0", + "@types/draco3d": "^1.4.10", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^2.3.2", - "react-grab": "^0.1.29", - "react-scan": "^0.5.3", "tw-animate-css": "^1.4.0", "typescript": "6.0.2", }, @@ -94,6 +101,19 @@ "typescript": "6.0.2", }, }, + "packages/articraft-bridge": { + "name": "@pascal-app/articraft-bridge", + "version": "0.1.0", + "devDependencies": { + "@pascal-app/core": "*", + "@pascal/typescript-config": "*", + "@types/node": "^25.5.0", + "typescript": "5.9.3", + }, + "peerDependencies": { + "@pascal-app/core": "*", + }, + }, "packages/core": { "name": "@pascal-app/core", "version": "0.8.0", @@ -110,14 +130,10 @@ "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/react": "^19.2.2", - "@types/three": "^0.184.0", "typescript": "6.0.2", }, "peerDependencies": { - "@react-three/drei": "^10", - "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.184", }, }, "packages/editor": { @@ -129,6 +145,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", + "@pascal-app/articraft-bridge": "*", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -255,6 +272,58 @@ "zustand": "^5", }, }, + "packages/plugin-factory-equipment": { + "name": "@pascal-app/plugin-factory-equipment", + "version": "0.1.0", + "devDependencies": { + "@pascal-app/core": "*", + "@pascal/typescript-config": "*", + "@types/bun": "^1.3.0", + "@types/node": "^22.19.12", + "@types/react": "^19.2.2", + "@types/three": "^0.184.0", + "three": "^0.184", + "typescript": "6.0.3", + "zod": "^4", + }, + "peerDependencies": { + "@pascal-app/core": "*", + "three": "^0.184", + "zod": "^4", + }, + }, + "packages/plugin-trees": { + "name": "@pascal-app/plugin-trees", + "version": "0.1.0", + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", + }, + "devDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@pascal/typescript-config": "*", + "@react-three/fiber": "^9", + "@types/node": "^22.19.12", + "@types/react": "^19.2.2", + "@types/three": "^0.184.0", + "react": "^19", + "three": "^0.184", + "typescript": "6.0.3", + "zod": "^4", + "zustand": "^5", + }, + "peerDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@react-three/fiber": "^9", + "react": "^18 || ^19", + "three": "^0.184", + "zod": "^4", + "zustand": "^5", + }, + }, "packages/typescript-config": { "name": "@repo/typescript-config", "version": "0.0.0", @@ -312,42 +381,8 @@ "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@antfu/ni": ["@antfu/ni@30.1.0", "", { "dependencies": { "fzf": "^0.5.2", "package-manager-detector": "^1.6.0", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15" }, "bin": { "ni": "bin/ni.mjs", "nci": "bin/nci.mjs", "nr": "bin/nr.mjs", "nup": "bin/nup.mjs", "nd": "bin/nd.mjs", "nlx": "bin/nlx.mjs", "na": "bin/na.mjs", "nun": "bin/nun.mjs" } }, "sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw=="], - - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], @@ -366,9 +401,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], - "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], - - "@clack/prompts": ["@clack/prompts@1.3.0", "", { "dependencies": { "@clack/core": "1.3.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw=="], + "@dgreenheck/ez-tree": ["@dgreenheck/ez-tree@1.1.0", "", { "peerDependencies": { "three": ">=0.167" } }, "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg=="], "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], @@ -408,6 +441,12 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@gltf-transform/core": ["@gltf-transform/core@4.4.0", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-cOPxOhHFFz5hwmix+li1+Nnq5qMV/QD3fTCsVlApxxFACtFdjkt2R/juseD4gvZ7D2c/yl6OilKH0pvI735YyQ=="], + + "@gltf-transform/extensions": ["@gltf-transform/extensions@4.4.0", "", { "dependencies": { "@gltf-transform/core": "^4.4.0", "ktx-parse": "^1.1.0" } }, "sha512-ZwEgFkkqnUR7d4m6roK9BycxxdoqJNtVyo7w5ShJ9syKBoQiXw2QrTSLwXaUAImSrEIl9Jh/wZTtvSVyviQuXg=="], + + "@gltf-transform/functions": ["@gltf-transform/functions@4.4.0", "", { "dependencies": { "@gltf-transform/core": "^4.4.0", "@gltf-transform/extensions": "^4.4.0", "ktx-parse": "^1.1.0", "ndarray": "^1.0.19", "ndarray-lanczos": "^0.3.0", "ndarray-pixels": "^5.0.1" } }, "sha512-CaSTAVAd2NXNWsxdgvq090rKHqy7AQlcNWV4ec7xtQyS8WEv3S3gVN27ikWmdB8nWEsXUbOIDhtPMLbXI6xDJg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -518,6 +557,8 @@ "@number-flow/react": ["@number-flow/react@0.5.14", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.5.12" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-FGUqjh/P5/ukr0U0ySwb987M0SbRkrnZq70f0wQFncDbXa3SIib4L+FTr5ngvWwGAW8S6b391eTXcfhErZsw4w=="], + "@pascal-app/articraft-bridge": ["@pascal-app/articraft-bridge@workspace:packages/articraft-bridge"], + "@pascal-app/core": ["@pascal-app/core@workspace:packages/core"], "@pascal-app/editor": ["@pascal-app/editor@workspace:packages/editor"], @@ -528,10 +569,16 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], + "@pascal-app/plugin-factory-equipment": ["@pascal-app/plugin-factory-equipment@workspace:packages/plugin-factory-equipment"], + + "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@workspace:packages/plugin-trees"], + "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], + "@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="], + "@pmndrs/msdfonts": ["@pmndrs/msdfonts@1.0.67", "", {}, "sha512-tBEK+suALkhZ5i9rJBR0PYx4uvDD0nVQGoEENBMLTmeL1kNzhbJ7+mfAv1ATW1w42TOuREI9FfAa5ca1N8pcxQ=="], "@pmndrs/pointer-events": ["@pmndrs/pointer-events@6.6.29", "", {}, "sha512-o4YD6VfJgDYjFgde/YyAw2X5KY454tdmOXrHGOvKTWJBHzkL90B5vH4rqmexwRVvaDfT3YLvVh/Dm5cBbgZXMg=="], @@ -542,8 +589,6 @@ "@pmndrs/uikit-pub-sub": ["@pmndrs/uikit-pub-sub@1.0.67", "", { "dependencies": { "@preact/signals-core": "^1.8.0" } }, "sha512-WC14YjujQ+gIliaEN06/7zygIwxKzweasNicwqVjTQ1PRJLe/HK9pyyS7DBAtfUy9y/9i5dfVFJLhGI8R61dWA=="], - "@preact/signals": ["@preact/signals@1.3.4", "", { "dependencies": { "@preact/signals-core": "^1.7.0" }, "peerDependencies": { "preact": "10.x" } }, "sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g=="], - "@preact/signals-core": ["@preact/signals-core@1.14.1", "", {}, "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -624,8 +669,6 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@react-grab/cli": ["@react-grab/cli@0.1.33", "", { "dependencies": { "@antfu/ni": "^30.1.0", "commander": "^14.0.3", "ignore": "^7.0.5", "jsonc-parser": "^3.3.1", "ora": "^9.4.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "smol-toml": "^1.6.1" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-UOc3PwN11Osw0NzaxRLK8trP4X+5iW1Dst3gvHRCafe3wXHyadzHYH8H1hdkcXdlIx3gsoD9ASJ+G/JH+A/jqA=="], - "@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="], "@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], @@ -640,8 +683,6 @@ "@repo/ui": ["@repo/ui@workspace:packages/ui"], - "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], @@ -698,6 +739,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/ndarray": ["@types/ndarray@1.0.14", "", {}, "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg=="], + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], @@ -756,8 +799,6 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -790,16 +831,12 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "bippy": ["bippy@0.5.39", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], @@ -820,14 +857,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - - "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], - "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -840,16 +871,12 @@ "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -862,6 +889,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -874,8 +903,6 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], @@ -904,8 +931,6 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "enhanced-resolve": ["enhanced-resolve@5.21.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ=="], @@ -926,8 +951,6 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -958,8 +981,6 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -980,14 +1001,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], - - "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1014,22 +1029,18 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], - "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], - "geist": ["geist@1.7.0", "", { "peerDependencies": { "next": ">=13.2.0" } }, "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ=="], "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -1038,8 +1049,6 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -1094,6 +1103,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1106,6 +1117,8 @@ "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], @@ -1122,8 +1135,6 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], - "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], @@ -1146,8 +1157,6 @@ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], - "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -1170,8 +1179,6 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1180,15 +1187,11 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "ktx-parse": ["ktx-parse@1.1.0", "", {}, "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -1222,12 +1225,8 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], - "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], @@ -1252,14 +1251,10 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], @@ -1274,18 +1269,22 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="], + + "ndarray-lanczos": ["ndarray-lanczos@0.3.0", "", { "dependencies": { "@types/ndarray": "^1.0.11", "ndarray": "^1.0.19" } }, "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg=="], + + "ndarray-ops": ["ndarray-ops@1.2.2", "", { "dependencies": { "cwise-compiler": "^1.0.0" } }, "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw=="], + + "ndarray-pixels": ["ndarray-pixels@5.0.1", "", { "dependencies": { "@types/ndarray": "^1.0.14", "ndarray": "^1.0.19", "ndarray-ops": "^1.2.2", "sharp": "^0.34.0" } }, "sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], - "number-flow": ["number-flow@0.5.12", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-CIs21h2JkfYG4rfgERaUNAk0Cz+Ef14fNJfSCbGGhgRgconQc9b7rcCQfi9SZ36kNjVXmsl2BrzDbjGtEgumAA=="], - "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1304,20 +1303,14 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -1328,34 +1321,32 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.61.0", "", { "dependencies": { "playwright-core": "1.61.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ=="], + + "playwright-core": ["playwright-core@1.61.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], - "preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], - "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1372,16 +1363,12 @@ "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], - "react-grab": ["react-grab@0.1.33", "", { "dependencies": { "@react-grab/cli": "0.1.33", "bippy": "^0.5.39" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-ER919JMsE4TTrb2CpEivqsIjNMSycD4HtS8v7mS3pq67U7WL1K3+C8m9AYOwW4dpuYh+EanC2eJBmfuczHJZ0A=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-scan": ["react-scan@0.5.6", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/types": "^7.26.0", "@preact/signals": "^1.3.1", "@rollup/pluginutils": "^5.1.3", "bippy": "^0.5.39", "commander": "^14.0.0", "picocolors": "^1.1.1", "preact": "^10.25.1", "prompts": "^2.4.2", "react-grab": "latest" }, "optionalDependencies": { "unplugin": "2.1.0" }, "peerDependencies": { "esbuild": ">=0.18.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["esbuild"], "bin": { "react-scan": "bin/cli.js" } }, "sha512-FrZ75yWjabNQmBbN9wxP+FgfgcPYoOFFWXYBhi6OnUbF/DyJDwOEfV2RRs9IWVfPVjhsku2CPK3oohYC5GEHpg=="], - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], @@ -1396,8 +1383,6 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1442,12 +1427,6 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - - "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], @@ -1456,12 +1435,8 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], - "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], @@ -1472,8 +1447,6 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -1498,8 +1471,6 @@ "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], - "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1538,17 +1509,13 @@ "typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="], - "ultracite": ["ultracite@7.6.5", "", { "dependencies": { "@clack/prompts": "^1.3.0", "commander": "^14.0.3", "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "glob": "^13.0.6", "jsonc-parser": "^3.3.1", "nypm": "^0.6.6", "yaml": "^2.8.4", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.0.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-q68Ht5cTvjzmyDvhwZUPfxWLpcwioG5e5ODrVxBAbc0qWEn5hD3+CT1Tgq8FEFEypjKVote01inkG1nDOE9hmw=="], - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uniq": ["uniq@1.0.1", "", {}, "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="], - "unplugin": ["unplugin@2.1.0", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1568,8 +1535,6 @@ "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1584,14 +1549,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -1602,18 +1561,26 @@ "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@pascal-app/articraft-bridge/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@pascal-app/mcp/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@pascal-app/nodes/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + "@pascal-app/plugin-factory-equipment/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + + "@pascal-app/plugin-factory-equipment/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "@pascal-app/plugin-trees/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + + "@pascal-app/plugin-trees/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "@pascal-app/viewer/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1634,8 +1601,6 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@react-three/drei/three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], "@repo/eslint-config/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1674,16 +1639,12 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "fdir/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "ifc-converter-app/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -1694,12 +1655,18 @@ "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@pascal-app/nodes/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@pascal-app/plugin-factory-equipment/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@pascal-app/plugin-trees/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@pascal-app/viewer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@repo/ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1710,14 +1677,10 @@ "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - "ifc-converter-app/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/cloud/industry.appliance-assembly.basic-0.1.0.zip b/cloud/industry.appliance-assembly.basic-0.1.0.zip new file mode 100644 index 000000000..9cccb2e8a Binary files /dev/null and b/cloud/industry.appliance-assembly.basic-0.1.0.zip differ diff --git a/cloud/industry.appliance-assembly.basic-0.1.0/README.md b/cloud/industry.appliance-assembly.basic-0.1.0/README.md new file mode 100644 index 000000000..1c4fd95b1 --- /dev/null +++ b/cloud/industry.appliance-assembly.basic-0.1.0/README.md @@ -0,0 +1,62 @@ +# Appliance Assembly Basic Equipment Pack + +Factory-capable home appliance final assembly pack for a strict two-floor workshop building with roof, mezzanine production areas, conveyor lines, kitting, foaming, testing, aging, packaging, palletizing, controls, and utility equipment. + +## Devices + +- Two-floor workshop shell (appliance_assembly.two_floor_workshop_shell) +- Parts kitting station (appliance_assembly.parts_kitting_station) +- Mezzanine storage rack (appliance_assembly.mezzanine_storage_rack) +- Door liner assembly station (appliance_assembly.door_liner_assembly_station) +- Vertical lift conveyor (appliance_assembly.vertical_lift_conveyor) +- Sheet metal buffer rack (appliance_assembly.sheet_metal_buffer) +- Cabinet foaming press (appliance_assembly.cabinet_foaming_press) +- Curing buffer conveyor (appliance_assembly.curing_buffer_conveyor) +- Final assembly conveyor (appliance_assembly.final_assembly_conveyor) +- Screw fastening station (appliance_assembly.screw_fastening_station) +- Leak test station (appliance_assembly.leak_test_station) +- Functional test bench (appliance_assembly.functional_test_bench) +- Aging burn-in rack (appliance_assembly.aging_burn_in_rack) +- Vision inspection gate (appliance_assembly.vision_inspection_gate) +- Carton packaging station (appliance_assembly.carton_packaging_station) +- Palletizing robot cell (appliance_assembly.palletizing_robot_cell) +- Material cart (appliance_assembly.material_cart) +- Mezzanine control room (appliance_assembly.mezzanine_control_room) +- Line control cabinet (appliance_assembly.line_control_cabinet) +- Utility air skid (appliance_assembly.utility_air_skid) +- Overhead utility rack (appliance_assembly.overhead_utility_rack) + +## Pack Type + +Factory-capable pack: supports factory/process creation through process templates and factory architectures. + +## Factory Creation + +Supported whole-factory/process templates: + +- Appliance assembly final assembly factory (appliance_assembly_final_assembly_factory) + +Supported factory scopes/modules: + +- Main final assembly line (main_final_assembly_line) +- Full two-floor appliance assembly factory (full_two_floor_factory) + +## Factory Architectures + +- Appliance assembly two-floor final assembly architecture + +## Process Templates + +- Appliance assembly final assembly factory + +## Authoring Review + +- No scaffold authoring warnings. + +## Validation + +Run: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.appliance-assembly.basic@0.1.0 --validate-only +``` diff --git a/cloud/industry.appliance-assembly.basic-0.1.0/factory-architectures/generated.json b/cloud/industry.appliance-assembly.basic-0.1.0/factory-architectures/generated.json new file mode 100644 index 000000000..3c00c05ab --- /dev/null +++ b/cloud/industry.appliance-assembly.basic-0.1.0/factory-architectures/generated.json @@ -0,0 +1,176 @@ +[ + { + "id": "appliance_assembly.factory.two_floor_final_assembly", + "label": "Appliance assembly two-floor final assembly architecture", + "industry": "appliance-assembly", + "processId": "appliance_assembly_final_assembly_factory", + "layoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 72, + "width": 34 + }, + "scopes": [ + { + "id": "main_final_assembly_line", + "label": "Main final assembly line", + "includeModules": [ + "building_shell", + "level_2_material_kitting", + "level_1_cabinet_preparation", + "level_1_final_assembly", + "level_1_quality_test", + "level_1_packaging", + "line_control" + ] + }, + { + "id": "full_two_floor_factory", + "label": "Full two-floor appliance assembly factory", + "includeModules": [ + "building_shell", + "level_2_material_kitting", + "level_2_subassembly", + "level_1_cabinet_preparation", + "level_1_final_assembly", + "level_1_quality_test", + "level_2_aging_storage", + "level_1_packaging", + "intralogistics", + "line_control", + "utilities" + ] + } + ], + "modules": [ + { + "id": "building_shell", + "displayLabel": "Two-floor workshop shell and roof", + "order": 5, + "stationIds": [ + "two_floor_workshop_shell" + ] + }, + { + "id": "level_2_material_kitting", + "displayLabel": "Level 2 material kitting", + "order": 10, + "stationIds": [ + "parts_kitting_station", + "mezzanine_storage_rack" + ] + }, + { + "id": "level_2_subassembly", + "displayLabel": "Level 2 door and liner subassembly", + "order": 20, + "stationIds": [ + "door_liner_assembly_station", + "vertical_lift_conveyor" + ] + }, + { + "id": "level_1_cabinet_preparation", + "displayLabel": "Level 1 cabinet preparation", + "order": 30, + "stationIds": [ + "sheet_metal_buffer", + "cabinet_foaming_press", + "curing_buffer_conveyor" + ] + }, + { + "id": "level_1_final_assembly", + "displayLabel": "Level 1 final assembly", + "order": 40, + "stationIds": [ + "final_assembly_conveyor", + "screw_fastening_station" + ] + }, + { + "id": "level_1_quality_test", + "displayLabel": "Level 1 quality and functional test", + "order": 50, + "stationIds": [ + "leak_test_station", + "functional_test_bench", + "vision_inspection_gate" + ] + }, + { + "id": "level_2_aging_storage", + "displayLabel": "Level 2 aging and buffer storage", + "order": 60, + "stationIds": [ + "aging_burn_in_rack" + ] + }, + { + "id": "level_1_packaging", + "displayLabel": "Level 1 packaging and dispatch", + "order": 70, + "stationIds": [ + "carton_packaging_station", + "palletizing_robot_cell" + ] + }, + { + "id": "intralogistics", + "displayLabel": "Intralogistics", + "order": 80, + "stationIds": [ + "vertical_lift_conveyor", + "material_cart" + ] + }, + { + "id": "line_control", + "displayLabel": "Line control and mezzanine MCC", + "order": 90, + "stationIds": [ + "mezzanine_control_room", + "line_control_cabinet" + ] + }, + { + "id": "utilities", + "displayLabel": "Compressed air and utility spine", + "order": 100, + "stationIds": [ + "utility_air_skid", + "overhead_utility_rack" + ] + } + ], + "layoutHints": { + "primaryAxis": "x", + "secondaryAxis": "z", + "indoorWorkshop": true, + "floorCount": 2, + "mainProductionFloor": 1, + "secondFloorStationIds": [ + "parts_kitting_station", + "door_liner_assembly_station", + "mezzanine_storage_rack", + "mezzanine_control_room", + "aging_burn_in_rack" + ], + "buildingShellStationId": "two_floor_workshop_shell", + "longAxisStationId": "final_assembly_conveyor", + "highestStationId": "two_floor_workshop_shell", + "sideBranchStationIds": [ + "line_control_cabinet", + "utility_air_skid", + "overhead_utility_rack", + "material_cart" + ], + "scale": "conceptual", + "notes": [ + "Generate a visible two-floor workshop shell first, including walls, second-floor slab intent, roof, doors, and window bands.", + "Keep the final assembly conveyor on level 1 as the main long axis.", + "Place kitting, door liner subassembly, light storage, and aging racks on level 2, feeding the line through the vertical lift conveyor.", + "Keep controls and utilities along the side wall or mezzanine edge." + ] + } + } +] diff --git a/cloud/industry.appliance-assembly.basic-0.1.0/pack.json b/cloud/industry.appliance-assembly.basic-0.1.0/pack.json new file mode 100644 index 000000000..bb645375a --- /dev/null +++ b/cloud/industry.appliance-assembly.basic-0.1.0/pack.json @@ -0,0 +1,52 @@ +{ + "id": "industry.appliance-assembly.basic", + "name": "Appliance Assembly Basic Equipment Pack", + "industry": "appliance-assembly", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Factory-capable home appliance final assembly pack for a strict two-floor workshop building with roof, mezzanine production areas, conveyor lines, kitting, foaming, testing, aging, packaging, palletizing, controls, and utility equipment.", + "profiles": [ + "profiles/generated.json" + ], + "qualityRules": [ + "quality-rules/generated-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/generated.json" + ], + "processTemplates": [ + "process-templates/generated.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "appliance_assembly.mezzanine_storage_rack", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.appliance-assembly.basic-0.1.0/process-templates/generated.json b/cloud/industry.appliance-assembly.basic-0.1.0/process-templates/generated.json new file mode 100644 index 000000000..8c731955e --- /dev/null +++ b/cloud/industry.appliance-assembly.basic-0.1.0/process-templates/generated.json @@ -0,0 +1,483 @@ +[ + { + "processId": "appliance_assembly_final_assembly_factory", + "processLabel": "Appliance assembly final assembly factory", + "processDisplayLabel": "家电总装工厂", + "domain": "manufacturing", + "aliases": [ + "家电总装厂", + "家电装配厂", + "冰箱装配厂", + "洗衣机装配厂", + "两层家电工厂", + "两层楼家电厂房", + "appliance assembly factory", + "two-floor appliance factory", + "home appliance final assembly", + "refrigerator assembly line", + "washing machine assembly line" + ], + "requiredRoles": [ + "two_floor_workshop_shell", + "parts_kitting_station", + "sheet_metal_buffer", + "cabinet_foaming_press", + "final_assembly_conveyor", + "screw_fastening_station", + "functional_test_bench", + "aging_burn_in_rack", + "carton_packaging_station", + "palletizing_robot_cell", + "mezzanine_control_room" + ], + "defaultLayoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 72, + "width": 34 + }, + "safetyTags": [ + "indoor_workshop", + "conveyor_line", + "two_floor_workshop", + "manual_assembly", + "electrical_test" + ], + "stations": [ + { + "id": "two_floor_workshop_shell", + "label": "Two-floor workshop shell", + "displayLabel": "两层厂房主体", + "role": "two_floor_workshop_shell", + "equipmentHint": "appliance_assembly.two_floor_workshop_shell two-floor appliance assembly workshop building with main walls, second-floor slab, roof, loading doors, window bands, roof vents, and parapet", + "footprintHint": "building", + "safetyTags": [ + "building_shell", + "roof", + "two_floor_workshop" + ], + "profileId": "appliance_assembly.two_floor_workshop_shell" + }, + { + "id": "parts_kitting_station", + "label": "Parts kitting station", + "displayLabel": "零部件齐套工位", + "role": "parts_kitting_station", + "equipmentHint": "appliance_assembly.parts_kitting_station mezzanine parts kitting station with workbench, bins, tool rail, and pick light display", + "footprintHint": "medium", + "safetyTags": [ + "material", + "manual_work", + "mezzanine" + ], + "profileId": "appliance_assembly.parts_kitting_station" + }, + { + "id": "mezzanine_storage_rack", + "label": "Mezzanine storage rack", + "displayLabel": "夹层轻型料架", + "role": "mezzanine_storage_rack", + "equipmentHint": "appliance_assembly.mezzanine_storage_rack tall light storage rack on upper floor with multiple shelf levels, bins, and aisle label", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "mezzanine" + ], + "profileId": "appliance_assembly.mezzanine_storage_rack" + }, + { + "id": "door_liner_assembly_station", + "label": "Door liner assembly station", + "displayLabel": "门体内胆预装工位", + "role": "door_liner_assembly_station", + "equipmentHint": "appliance_assembly.door_liner_assembly_station appliance door and liner subassembly bench with fixture plate, clamp blocks, tool rail, and operator panel", + "footprintHint": "medium", + "safetyTags": [ + "subassembly", + "manual_work", + "mezzanine" + ], + "profileId": "appliance_assembly.door_liner_assembly_station" + }, + { + "id": "vertical_lift_conveyor", + "label": "Vertical lift conveyor", + "displayLabel": "垂直提升输送机", + "role": "vertical_lift_conveyor", + "equipmentHint": "appliance_assembly.vertical_lift_conveyor vertical lift conveyor connecting mezzanine and level 1 with tower frame, belt platform, guard fence, and drive motor", + "footprintHint": "tall", + "safetyTags": [ + "lift", + "conveyor", + "two_floor" + ], + "profileId": "appliance_assembly.vertical_lift_conveyor" + }, + { + "id": "sheet_metal_buffer", + "label": "Sheet metal buffer", + "displayLabel": "箱体钣金缓存架", + "role": "sheet_metal_buffer", + "equipmentHint": "appliance_assembly.sheet_metal_buffer cabinet sheet metal buffer rack with tower frame, vertical panel slots, and warning label", + "footprintHint": "large", + "safetyTags": [ + "material", + "sharp_edges" + ], + "profileId": "appliance_assembly.sheet_metal_buffer" + }, + { + "id": "cabinet_foaming_press", + "label": "Cabinet foaming press", + "displayLabel": "箱体发泡压机", + "role": "cabinet_foaming_press", + "equipmentHint": "appliance_assembly.cabinet_foaming_press refrigerator cabinet foaming press with large press body, platens, service door, hydraulic panel, and stack light", + "footprintHint": "large", + "safetyTags": [ + "press", + "guarding" + ], + "profileId": "appliance_assembly.cabinet_foaming_press" + }, + { + "id": "curing_buffer_conveyor", + "label": "Curing buffer conveyor", + "displayLabel": "固化缓存输送线", + "role": "curing_buffer_conveyor", + "equipmentHint": "appliance_assembly.curing_buffer_conveyor long curing buffer conveyor with frame, belt surface, side guards, and repeated pallet stops", + "footprintHint": "long", + "safetyTags": [ + "conveyor", + "buffer" + ], + "profileId": "appliance_assembly.curing_buffer_conveyor" + }, + { + "id": "final_assembly_conveyor", + "label": "Final assembly conveyor", + "displayLabel": "总装流水线", + "role": "final_assembly_conveyor", + "equipmentHint": "appliance_assembly.final_assembly_conveyor long appliance final assembly conveyor with frame, roller bed, belt surface, side guards, drive motor, and pallet fixtures", + "footprintHint": "long", + "safetyTags": [ + "conveyor", + "assembly" + ], + "profileId": "appliance_assembly.final_assembly_conveyor" + }, + { + "id": "screw_fastening_station", + "label": "Screw fastening station", + "displayLabel": "自动拧紧工位", + "role": "screw_fastening_station", + "equipmentHint": "appliance_assembly.screw_fastening_station screw fastening workstation with bench, suspended tool rail, operator HMI, torque display, and andon light", + "footprintHint": "medium", + "safetyTags": [ + "manual_work", + "assembly" + ], + "profileId": "appliance_assembly.screw_fastening_station" + }, + { + "id": "leak_test_station", + "label": "Leak test station", + "displayLabel": "气密检测站", + "role": "leak_test_station", + "equipmentHint": "appliance_assembly.leak_test_station appliance leak test station with enclosed frame, clamp plate, gauge display, pneumatic manifold, and reject chute", + "footprintHint": "medium", + "safetyTags": [ + "test", + "pneumatic" + ], + "profileId": "appliance_assembly.leak_test_station" + }, + { + "id": "functional_test_bench", + "label": "Functional test bench", + "displayLabel": "功能测试台", + "role": "functional_test_bench", + "equipmentHint": "appliance_assembly.functional_test_bench functional test bench with instrument display, cable tray, power sockets, control panel, and status tower", + "footprintHint": "medium", + "safetyTags": [ + "electrical_test", + "quality" + ], + "profileId": "appliance_assembly.functional_test_bench" + }, + { + "id": "aging_burn_in_rack", + "label": "Aging burn-in rack", + "displayLabel": "老化测试架", + "role": "aging_burn_in_rack", + "equipmentHint": "appliance_assembly.aging_burn_in_rack high-bay aging and burn-in rack with multi-level frame, electrical cabinet, cable tray, warning labels, and pallet loads", + "footprintHint": "tall", + "safetyTags": [ + "aging", + "electrical_test", + "upper_floor" + ], + "profileId": "appliance_assembly.aging_burn_in_rack" + }, + { + "id": "vision_inspection_gate", + "label": "Vision inspection gate", + "displayLabel": "外观视觉检测门架", + "role": "vision_inspection_gate", + "equipmentHint": "appliance_assembly.vision_inspection_gate gantry vision inspection gate with tower frame, camera light panels, display, and side reject chute", + "footprintHint": "medium", + "safetyTags": [ + "inspection", + "quality" + ], + "profileId": "appliance_assembly.vision_inspection_gate" + }, + { + "id": "carton_packaging_station", + "label": "Carton packaging station", + "displayLabel": "纸箱包装工位", + "role": "carton_packaging_station", + "equipmentHint": "appliance_assembly.carton_packaging_station appliance carton packaging station with conveyor, carton sealer body, tape head, strapping arch, and control box", + "footprintHint": "large", + "safetyTags": [ + "packaging", + "conveyor" + ], + "profileId": "appliance_assembly.carton_packaging_station" + }, + { + "id": "palletizing_robot_cell", + "label": "Palletizing robot cell", + "displayLabel": "机器人码垛单元", + "role": "palletizing_robot_cell", + "equipmentHint": "appliance_assembly.palletizing_robot_cell robot palletizing cell with orange robot arm, pallet table, infeed conveyor, guard fence, and warning label", + "footprintHint": "large", + "safetyTags": [ + "robot", + "guarding", + "dispatch" + ], + "profileId": "appliance_assembly.palletizing_robot_cell" + }, + { + "id": "material_cart", + "label": "Material cart", + "displayLabel": "物料周转车", + "role": "material_cart", + "equipmentHint": "appliance_assembly.material_cart wheeled material cart with low platform, bins, push handle, four wheels, and status label", + "footprintHint": "medium", + "safetyTags": [ + "logistics", + "material" + ], + "profileId": "appliance_assembly.material_cart" + }, + { + "id": "mezzanine_control_room", + "label": "Mezzanine control room", + "displayLabel": "夹层控制室", + "role": "mezzanine_control_room", + "equipmentHint": "appliance_assembly.mezzanine_control_room small occupied mezzanine control room with building shell, roof parapet, door, windows, HMI panel, and cable entry", + "footprintHint": "medium", + "safetyTags": [ + "control_room", + "occupied_building", + "mezzanine" + ], + "profileId": "appliance_assembly.mezzanine_control_room" + }, + { + "id": "line_control_cabinet", + "label": "Line control cabinet", + "displayLabel": "产线控制柜", + "role": "line_control_cabinet", + "equipmentHint": "appliance_assembly.line_control_cabinet line PLC and drive cabinet row with electrical cabinet body, HMI display, cable tray, warning labels, and status light", + "footprintHint": "medium", + "safetyTags": [ + "electrical", + "control" + ], + "profileId": "appliance_assembly.line_control_cabinet" + }, + { + "id": "utility_air_skid", + "label": "Utility air skid", + "displayLabel": "压缩空气撬装", + "role": "utility_air_skid", + "equipmentHint": "appliance_assembly.utility_air_skid compressed air utility skid with compressor body, motor, inlet outlet ports, manifold, and control box", + "footprintHint": "medium", + "safetyTags": [ + "utility", + "compressed_air" + ], + "profileId": "appliance_assembly.utility_air_skid" + }, + { + "id": "overhead_utility_rack", + "label": "Overhead utility rack", + "displayLabel": "架空公用工程管廊", + "role": "overhead_utility_rack", + "equipmentHint": "appliance_assembly.overhead_utility_rack overhead utility rack with parallel compressed air pipe, cable tray, valve body, and support frame", + "footprintHint": "long", + "safetyTags": [ + "utility", + "overhead", + "pipe" + ], + "profileId": "appliance_assembly.overhead_utility_rack" + } + ], + "connections": [ + { + "fromStationId": "mezzanine_storage_rack", + "toStationId": "parts_kitting_station", + "medium": "material", + "visualKind": "material_transfer", + "fromPortId": "rack_out", + "toPortId": "kit_in" + }, + { + "fromStationId": "parts_kitting_station", + "toStationId": "vertical_lift_conveyor", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "kit_out", + "toPortId": "lift_in" + }, + { + "fromStationId": "door_liner_assembly_station", + "toStationId": "vertical_lift_conveyor", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "subassembly_out", + "toPortId": "lift_in" + }, + { + "fromStationId": "vertical_lift_conveyor", + "toStationId": "final_assembly_conveyor", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "lift_out", + "toPortId": "line_in" + }, + { + "fromStationId": "sheet_metal_buffer", + "toStationId": "cabinet_foaming_press", + "medium": "material", + "visualKind": "material_transfer", + "fromPortId": "cabinet_blank_out", + "toPortId": "press_in" + }, + { + "fromStationId": "cabinet_foaming_press", + "toStationId": "curing_buffer_conveyor", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "foamed_cabinet_out", + "toPortId": "curing_in" + }, + { + "fromStationId": "curing_buffer_conveyor", + "toStationId": "final_assembly_conveyor", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "cured_cabinet_out", + "toPortId": "line_in" + }, + { + "fromStationId": "final_assembly_conveyor", + "toStationId": "screw_fastening_station", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "line_station", + "toPortId": "tool_station" + }, + { + "fromStationId": "screw_fastening_station", + "toStationId": "leak_test_station", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "assembled_out", + "toPortId": "leak_test_in" + }, + { + "fromStationId": "leak_test_station", + "toStationId": "functional_test_bench", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "leak_ok_out", + "toPortId": "functional_test_in" + }, + { + "fromStationId": "functional_test_bench", + "toStationId": "aging_burn_in_rack", + "medium": "material", + "visualKind": "agv_route", + "fromPortId": "tested_out", + "toPortId": "aging_in" + }, + { + "fromStationId": "aging_burn_in_rack", + "toStationId": "vision_inspection_gate", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "aging_out", + "toPortId": "inspection_in" + }, + { + "fromStationId": "vision_inspection_gate", + "toStationId": "carton_packaging_station", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "inspection_ok_out", + "toPortId": "packaging_in" + }, + { + "fromStationId": "carton_packaging_station", + "toStationId": "palletizing_robot_cell", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "carton_out", + "toPortId": "palletizing_in" + }, + { + "fromStationId": "palletizing_robot_cell", + "toStationId": "material_cart", + "medium": "material", + "visualKind": "agv_route", + "fromPortId": "pallet_out", + "toPortId": "cart_deck" + }, + { + "fromStationId": "mezzanine_control_room", + "toStationId": "line_control_cabinet", + "medium": "signal", + "visualKind": "cable_tray", + "fromPortId": "control_panel", + "toPortId": "plc_in" + }, + { + "fromStationId": "line_control_cabinet", + "toStationId": "final_assembly_conveyor", + "medium": "signal", + "visualKind": "cable_tray", + "fromPortId": "drive_control", + "toPortId": "conveyor_drive" + }, + { + "fromStationId": "utility_air_skid", + "toStationId": "overhead_utility_rack", + "medium": "air", + "visualKind": "pipe", + "fromPortId": "compressed_air_out", + "toPortId": "air_header" + }, + { + "fromStationId": "overhead_utility_rack", + "toStationId": "leak_test_station", + "medium": "air", + "visualKind": "pipe", + "fromPortId": "air_header", + "toPortId": "test_air_in" + } + ] + } +] diff --git a/cloud/industry.appliance-assembly.basic-0.1.0/profiles/generated.json b/cloud/industry.appliance-assembly.basic-0.1.0/profiles/generated.json new file mode 100644 index 000000000..46f5a1f17 --- /dev/null +++ b/cloud/industry.appliance-assembly.basic-0.1.0/profiles/generated.json @@ -0,0 +1,1626 @@ +[ + { + "id": "appliance_assembly.two_floor_workshop_shell", + "name": "Two-floor workshop shell", + "aliases": [ + "两层厂房主体", + "两层家电厂房", + "家电装配厂房", + "带屋顶厂房", + "two-floor workshop shell", + "two-story appliance workshop", + "roofed assembly hall" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 8, + "width": 4.8, + "height": 5 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "two_floor_workshop_shell", + "required": true, + "length": 8, + "width": 4.8, + "height": 4.6, + "primaryColor": "#e5e7eb" + }, + { + "kind": "generic_base", + "semanticRole": "main_roof", + "required": true, + "attachToRole": "two_floor_workshop_shell", + "anchor": "top", + "length": 8, + "width": 5, + "thickness": 0.18, + "darkColor": "#64748b" + }, + { + "kind": "generic_panel", + "semanticRole": "second_floor_slab", + "required": true, + "attachToRole": "two_floor_workshop_shell", + "anchor": "shell_center", + "arrayAlong": "length", + "count": 2, + "length": 3.8, + "height": 0.12, + "color": "#94a3b8" + }, + { + "kind": "generic_opening", + "semanticRole": "loading_door", + "required": true, + "attachToRole": "two_floor_workshop_shell", + "anchor": "front", + "length": 1.2, + "height": 1.8 + }, + { + "kind": "generic_opening", + "semanticRole": "level_2_window_band", + "required": true, + "attachToRole": "two_floor_workshop_shell", + "anchor": "front", + "arrayAlong": "length", + "count": 4, + "length": 0.7, + "height": 0.42, + "color": "#93c5fd" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "roof_ventilators", + "required": false, + "attachToRole": "main_roof", + "anchor": "top", + "arrayAlong": "length", + "count": 3, + "accentColor": "#38bdf8" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "roof_parapet", + "required": false, + "attachToRole": "two_floor_workshop_shell", + "anchor": "top", + "arrayAlong": "length", + "count": 2, + "accentColor": "#334155" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "floor_level_marker", + "required": false, + "attachToRole": "two_floor_workshop_shell", + "anchor": "front", + "arrayAlong": "length", + "count": 2, + "accentColor": "#f59e0b" + } + ], + "primarySemanticRole": "two_floor_workshop_shell", + "qualityRules": "quality.appliance_assembly.two_floor_workshop_shell", + "visualCues": [ + "two-floor workshop building shell", + "visible main roof", + "second-floor slab or mezzanine line", + "large loading door", + "level 2 window band", + "roof vents or parapet" + ], + "status": "stable", + "source": "imported_pack" + }, + { + "id": "appliance_assembly.parts_kitting_station", + "name": "Parts kitting station", + "aliases": [ + "零部件齐套工位", + "拣配工位", + "备料工位", + "parts kitting station", + "pick-to-light kitting bench" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.6, + "width": 1.2, + "height": 2.1 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "kitting_workbench", + "required": true, + "length": 2.4, + "width": 1, + "height": 0.85 + }, + { + "kind": "generic_detail_accent", + "semanticRole": "parts_bins", + "required": true, + "attachToRole": "kitting_workbench", + "anchor": "top", + "arrayAlong": "length", + "count": 5, + "accentColor": "#f59e0b" + }, + { + "kind": "cable_tray", + "semanticRole": "pick_light_bar", + "required": true, + "attachToRole": "kitting_workbench", + "anchor": "back" + }, + { + "kind": "generic_display", + "semanticRole": "kit_display", + "attachToRole": "pick_light_bar", + "anchor": "service_side" + }, + { + "kind": "operator_panel", + "semanticRole": "operator_hmi", + "attachToRole": "kitting_workbench", + "anchor": "service_side" + } + ], + "primarySemanticRole": "kitting_workbench", + "qualityRules": "quality.appliance_assembly.parts_kitting_station", + "visualCues": [ + "workbench", + "parts bins", + "pick light bar", + "operator display" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.mezzanine_storage_rack", + "name": "Mezzanine storage rack", + "aliases": [ + "夹层轻型料架", + "二层物料架", + "家电零件料架", + "mezzanine storage rack", + "light parts rack" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 4.2, + "width": 1.2, + "height": 3.2 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "mezzanine_rack_frame", + "required": true, + "length": 4.1, + "width": 1.1, + "height": 3, + "levelCount": 4 + }, + { + "kind": "pallet_table", + "semanticRole": "parts_totes", + "required": true, + "attachToRole": "mezzanine_rack_frame", + "anchor": "front", + "arrayAlong": "height", + "count": 3 + }, + { + "kind": "warning_label", + "semanticRole": "rack_load_label", + "attachToRole": "mezzanine_rack_frame", + "anchor": "service_side" + } + ], + "primarySemanticRole": "mezzanine_rack_frame", + "qualityRules": "quality.appliance_assembly.mezzanine_storage_rack", + "visualCues": [ + "tall rack", + "multiple shelf levels", + "parts totes" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.door_liner_assembly_station", + "name": "Door liner assembly station", + "aliases": [ + "门体内胆预装工位", + "门胆装配工位", + "内胆预装台", + "door liner assembly station", + "liner subassembly bench" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.8, + "width": 1.3, + "height": 2 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "liner_fixture_table", + "required": true, + "length": 2.6, + "width": 1.1, + "height": 0.85 + }, + { + "kind": "generic_panel", + "semanticRole": "liner_fixture_plate", + "required": true, + "attachToRole": "liner_fixture_table", + "anchor": "top", + "color": "#cbd5e1" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "clamp_blocks", + "required": true, + "attachToRole": "liner_fixture_plate", + "anchor": "top", + "arrayAlong": "length", + "count": 4 + }, + { + "kind": "cable_tray", + "semanticRole": "suspended_tool_rail", + "attachToRole": "liner_fixture_table", + "anchor": "back" + }, + { + "kind": "operator_panel", + "semanticRole": "operator_hmi", + "attachToRole": "liner_fixture_table", + "anchor": "service_side" + } + ], + "primarySemanticRole": "liner_fixture_table", + "qualityRules": "quality.appliance_assembly.door_liner_assembly_station", + "visualCues": [ + "fixture table", + "liner plate", + "clamp blocks", + "tool rail" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.vertical_lift_conveyor", + "name": "Vertical lift conveyor", + "aliases": [ + "垂直提升输送机", + "升降输送机", + "夹层提升机", + "vertical lift conveyor", + "mezzanine lift conveyor" + ], + "industry": "appliance-assembly", + "layoutFamily": "linear_transport_layout", + "family": "conveyor", + "defaultDimensions": { + "length": 2.4, + "width": 1.8, + "height": 5.6 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "lift_tower_frame", + "required": true, + "length": 2.2, + "width": 1.6, + "height": 5.4, + "levelCount": 3 + }, + { + "kind": "conveyor_frame", + "semanticRole": "lift_carriage", + "required": true, + "attachToRole": "lift_tower_frame", + "anchor": "front", + "length": 1.8, + "width": 1.2, + "height": 0.35 + }, + { + "kind": "belt_surface", + "semanticRole": "lift_transfer_belt", + "required": true, + "attachToRole": "lift_carriage", + "anchor": "top" + }, + { + "kind": "guard_fence", + "semanticRole": "lift_safety_guard", + "required": true, + "attachToRole": "lift_tower_frame", + "anchor": "front" + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "lift_drive_motor", + "attachToRole": "lift_tower_frame", + "anchor": "drive_side" + } + ], + "primarySemanticRole": "lift_tower_frame", + "qualityRules": "quality.appliance_assembly.vertical_lift_conveyor", + "visualCues": [ + "tall tower frame", + "lift carriage", + "upper and lower mezzanine landing intent", + "belt transfer", + "safety guarding", + "drive motor" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.sheet_metal_buffer", + "name": "Sheet metal buffer rack", + "aliases": [ + "箱体钣金缓存架", + "钣金缓存架", + "箱壳缓存架", + "sheet metal buffer rack", + "cabinet body buffer rack" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 5.2, + "width": 1.6, + "height": 2.6 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "sheet_buffer_frame", + "required": true, + "length": 5, + "width": 1.4, + "height": 2.4, + "levelCount": 2 + }, + { + "kind": "generic_panel", + "semanticRole": "vertical_sheet_panels", + "required": true, + "attachToRole": "sheet_buffer_frame", + "anchor": "front", + "arrayAlong": "length", + "count": 6, + "color": "#e5e7eb" + }, + { + "kind": "warning_label", + "semanticRole": "sharp_edge_warning", + "attachToRole": "sheet_buffer_frame", + "anchor": "service_side" + } + ], + "primarySemanticRole": "sheet_buffer_frame", + "qualityRules": "quality.appliance_assembly.sheet_metal_buffer", + "visualCues": [ + "long rack", + "vertical sheet panels", + "warning label" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.cabinet_foaming_press", + "name": "Cabinet foaming press", + "aliases": [ + "箱体发泡压机", + "冰箱发泡压机", + "发泡模架", + "cabinet foaming press", + "refrigerator foaming press" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "machine_tool", + "defaultDimensions": { + "length": 7.2, + "width": 3.2, + "height": 3.6 + }, + "parts": [ + { + "kind": "rounded_machine_body", + "semanticRole": "foaming_press_body", + "required": true, + "length": 5.8, + "width": 2.5, + "height": 2.2, + "color": "#d8dde5" + }, + { + "kind": "structural_tower_frame", + "semanticRole": "press_line_frame", + "required": true, + "length": 6.8, + "width": 3, + "height": 3.2, + "levelCount": 2 + }, + { + "kind": "generic_panel", + "semanticRole": "mold_clamp_panels", + "required": true, + "attachToRole": "foaming_press_body", + "anchor": "front", + "arrayAlong": "length", + "count": 4, + "color": "#facc15" + }, + { + "kind": "generic_opening", + "semanticRole": "service_door_window", + "required": true, + "attachToRole": "foaming_press_body", + "anchor": "front", + "color": "#7dd3fc" + }, + { + "kind": "service_platform", + "semanticRole": "top_maintenance_platform", + "required": true, + "attachToRole": "press_line_frame", + "anchor": "top" + }, + { + "kind": "pipe_manifold", + "semanticRole": "foam_injection_manifold", + "required": true, + "attachToRole": "press_line_frame", + "anchor": "top" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "hydraulic_clamp_heads", + "required": true, + "attachToRole": "foaming_press_body", + "anchor": "front", + "arrayAlong": "length", + "count": 4, + "accentColor": "#111827" + }, + { + "kind": "control_box", + "semanticRole": "hydraulic_control_box", + "required": true, + "attachToRole": "foaming_press_body", + "anchor": "service_side" + }, + { + "kind": "status_light_strip", + "semanticRole": "press_stack_light", + "attachToRole": "foaming_press_body", + "anchor": "top" + } + ], + "primarySemanticRole": "foaming_press_body", + "qualityRules": "quality.appliance_assembly.cabinet_foaming_press", + "visualCues": [ + "long multi-station foaming line", + "white structural frame", + "yellow mold clamp panels", + "top maintenance platform", + "foam injection manifold", + "service window", + "hydraulic control box" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.curing_buffer_conveyor", + "name": "Curing buffer conveyor", + "aliases": [ + "固化缓存输送线", + "发泡固化线", + "缓存输送线", + "curing buffer conveyor", + "cabinet curing conveyor" + ], + "industry": "appliance-assembly", + "layoutFamily": "linear_transport_layout", + "family": "conveyor", + "defaultDimensions": { + "length": 10, + "width": 1.4, + "height": 1 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "curing_conveyor_frame", + "required": true, + "length": 10, + "width": 1.2, + "height": 0.5 + }, + { + "kind": "belt_surface", + "semanticRole": "curing_transfer_surface", + "required": true, + "attachToRole": "curing_conveyor_frame", + "anchor": "top", + "length": 9.6 + }, + { + "kind": "roller_array", + "semanticRole": "buffer_rollers", + "required": true, + "attachToRole": "curing_conveyor_frame", + "anchor": "top", + "count": 14 + }, + { + "kind": "generic_detail_accent", + "semanticRole": "pallet_stops", + "attachToRole": "curing_conveyor_frame", + "anchor": "top", + "arrayAlong": "length", + "count": 6 + }, + { + "kind": "guard_fence", + "semanticRole": "side_guard", + "attachToRole": "curing_conveyor_frame", + "anchor": "left" + } + ], + "primarySemanticRole": "curing_conveyor_frame", + "qualityRules": "quality.appliance_assembly.curing_buffer_conveyor", + "visualCues": [ + "long conveyor", + "roller bed", + "pallet stops", + "side guards" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.final_assembly_conveyor", + "name": "Final assembly conveyor", + "aliases": [ + "总装流水线", + "家电总装线", + "装配输送线", + "final assembly conveyor", + "appliance assembly line" + ], + "industry": "appliance-assembly", + "layoutFamily": "linear_transport_layout", + "family": "conveyor", + "defaultDimensions": { + "length": 16, + "width": 1.6, + "height": 1 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "assembly_conveyor_frame", + "required": true, + "length": 16, + "width": 1.35, + "height": 0.55 + }, + { + "kind": "roller_array", + "semanticRole": "assembly_roller_bed", + "required": true, + "attachToRole": "assembly_conveyor_frame", + "anchor": "top", + "count": 18 + }, + { + "kind": "belt_surface", + "semanticRole": "assembly_transfer_surface", + "required": true, + "attachToRole": "assembly_conveyor_frame", + "anchor": "top", + "length": 15.4 + }, + { + "kind": "generic_detail_accent", + "semanticRole": "appliance_pallet_fixtures", + "required": true, + "attachToRole": "assembly_conveyor_frame", + "anchor": "top", + "arrayAlong": "length", + "count": 5 + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "conveyor_drive_motor", + "attachToRole": "assembly_conveyor_frame", + "anchor": "drive_side" + }, + { + "kind": "guard_fence", + "semanticRole": "line_side_guard", + "attachToRole": "assembly_conveyor_frame", + "anchor": "left" + } + ], + "primarySemanticRole": "assembly_conveyor_frame", + "qualityRules": "quality.appliance_assembly.final_assembly_conveyor", + "visualCues": [ + "long assembly conveyor", + "roller bed", + "appliance pallet fixtures", + "drive motor" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.screw_fastening_station", + "name": "Screw fastening station", + "aliases": [ + "自动拧紧工位", + "螺钉拧紧工位", + "扭矩工位", + "screw fastening station", + "torque tool station" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.4, + "width": 1.2, + "height": 2.2 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "fastening_workbench", + "required": true, + "length": 2.2, + "width": 1, + "height": 0.85 + }, + { + "kind": "cable_tray", + "semanticRole": "torque_tool_rail", + "required": true, + "attachToRole": "fastening_workbench", + "anchor": "back" + }, + { + "kind": "generic_display", + "semanticRole": "torque_display", + "required": true, + "attachToRole": "torque_tool_rail", + "anchor": "service_side" + }, + { + "kind": "operator_panel", + "semanticRole": "operator_hmi", + "attachToRole": "fastening_workbench", + "anchor": "service_side" + }, + { + "kind": "status_light_strip", + "semanticRole": "andon_light", + "attachToRole": "torque_tool_rail", + "anchor": "top" + } + ], + "primarySemanticRole": "fastening_workbench", + "qualityRules": "quality.appliance_assembly.screw_fastening_station", + "visualCues": [ + "operator bench", + "suspended tool rail", + "torque display", + "andon light" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.leak_test_station", + "name": "Leak test station", + "aliases": [ + "气密检测站", + "密封性检测台", + "泄漏测试站", + "leak test station", + "airtightness test station" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.8, + "width": 1.5, + "height": 2.2 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "leak_test_frame", + "required": true, + "length": 2.5, + "width": 1.3, + "height": 2, + "levelCount": 2 + }, + { + "kind": "generic_panel", + "semanticRole": "clamp_plate", + "required": true, + "attachToRole": "leak_test_frame", + "anchor": "front", + "color": "#cbd5e1" + }, + { + "kind": "generic_display", + "semanticRole": "pressure_gauge_display", + "required": true, + "attachToRole": "leak_test_frame", + "anchor": "service_side" + }, + { + "kind": "pipe_manifold", + "semanticRole": "pneumatic_manifold", + "required": true, + "attachToRole": "leak_test_frame", + "anchor": "right" + }, + { + "kind": "generic_spout", + "semanticRole": "reject_chute", + "attachToRole": "leak_test_frame", + "anchor": "back" + } + ], + "primarySemanticRole": "leak_test_frame", + "qualityRules": "quality.appliance_assembly.leak_test_station", + "visualCues": [ + "test frame", + "clamp plate", + "pressure display", + "pneumatic manifold" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.functional_test_bench", + "name": "Functional test bench", + "aliases": [ + "功能测试台", + "电性能测试台", + "家电测试台", + "functional test bench", + "electrical function test bench" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.8, + "width": 1.3, + "height": 1.9 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "functional_test_bench", + "required": true, + "length": 2.6, + "width": 1.1, + "height": 0.85 + }, + { + "kind": "generic_display", + "semanticRole": "instrument_display", + "required": true, + "attachToRole": "functional_test_bench", + "anchor": "back" + }, + { + "kind": "generic_control_panel", + "semanticRole": "test_control_panel", + "required": true, + "attachToRole": "functional_test_bench", + "anchor": "service_side" + }, + { + "kind": "cable_tray", + "semanticRole": "test_cable_tray", + "attachToRole": "functional_test_bench", + "anchor": "top" + }, + { + "kind": "status_light_strip", + "semanticRole": "test_status_light", + "attachToRole": "instrument_display", + "anchor": "top" + } + ], + "primarySemanticRole": "functional_test_bench", + "qualityRules": "quality.appliance_assembly.functional_test_bench", + "visualCues": [ + "test bench", + "instrument display", + "control panel", + "cable tray" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.aging_burn_in_rack", + "name": "Aging burn-in rack", + "aliases": [ + "老化测试架", + "通电老化架", + "高架老化库", + "aging burn-in rack", + "appliance aging rack" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "electrical", + "defaultDimensions": { + "length": 6.2, + "width": 1.6, + "height": 3.2 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "aging_rack_frame", + "required": true, + "length": 6, + "width": 1.35, + "height": 3, + "levelCount": 4 + }, + { + "kind": "electrical_cabinet", + "semanticRole": "aging_power_cabinet", + "required": true, + "attachToRole": "aging_rack_frame", + "anchor": "service_side" + }, + { + "kind": "cable_tray", + "semanticRole": "burn_in_cable_bus", + "required": true, + "attachToRole": "aging_rack_frame", + "anchor": "top" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "test_socket_grid", + "required": false, + "attachToRole": "aging_rack_frame", + "anchor": "front", + "arrayAlong": "length", + "count": 10, + "accentColor": "#ef4444" + }, + { + "kind": "pallet_table", + "semanticRole": "appliance_load_slots", + "required": true, + "attachToRole": "aging_rack_frame", + "anchor": "front", + "arrayAlong": "height", + "count": 3 + }, + { + "kind": "generic_detail_accent", + "semanticRole": "cooling_vent_panel", + "required": false, + "attachToRole": "aging_power_cabinet", + "anchor": "front", + "arrayAlong": "height", + "count": 4, + "accentColor": "#475569" + }, + { + "kind": "status_light_strip", + "semanticRole": "rack_status_lights", + "required": false, + "attachToRole": "aging_rack_frame", + "anchor": "top" + }, + { + "kind": "warning_label", + "semanticRole": "electrical_warning_label", + "attachToRole": "aging_power_cabinet", + "anchor": "front" + } + ], + "primarySemanticRole": "aging_rack_frame", + "qualityRules": "quality.appliance_assembly.aging_burn_in_rack", + "visualCues": [ + "long burn-in rack row", + "power cabinet", + "dense test socket grid", + "overhead cable bus", + "cooling vent panel", + "multi-level appliance slots", + "rack status lights" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.vision_inspection_gate", + "name": "Vision inspection gate", + "aliases": [ + "外观视觉检测门架", + "视觉检测门架", + "外观检测站", + "vision inspection gate", + "appearance inspection gantry" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.6, + "width": 1.8, + "height": 2.6 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "inspection_gantry_frame", + "required": true, + "length": 2.4, + "width": 1.5, + "height": 2.4, + "levelCount": 2 + }, + { + "kind": "generic_panel", + "semanticRole": "camera_light_panel", + "required": true, + "attachToRole": "inspection_gantry_frame", + "anchor": "top", + "color": "#e0f2fe" + }, + { + "kind": "generic_display", + "semanticRole": "inspection_display", + "required": true, + "attachToRole": "inspection_gantry_frame", + "anchor": "service_side" + }, + { + "kind": "generic_spout", + "semanticRole": "reject_chute", + "attachToRole": "inspection_gantry_frame", + "anchor": "right" + }, + { + "kind": "status_light_strip", + "semanticRole": "inspection_status_light", + "attachToRole": "inspection_gantry_frame", + "anchor": "top" + } + ], + "primarySemanticRole": "inspection_gantry_frame", + "qualityRules": "quality.appliance_assembly.vision_inspection_gate", + "visualCues": [ + "gantry frame", + "camera light panel", + "inspection display", + "reject chute" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.carton_packaging_station", + "name": "Carton packaging station", + "aliases": [ + "纸箱包装工位", + "家电装箱机", + "封箱打包工位", + "carton packaging station", + "appliance carton sealer" + ], + "industry": "appliance-assembly", + "layoutFamily": "linear_transport_layout", + "family": "generic", + "defaultDimensions": { + "length": 4.6, + "width": 1.5, + "height": 2 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "packaging_conveyor", + "required": true, + "length": 4.4, + "width": 1.1, + "height": 0.55 + }, + { + "kind": "rounded_machine_body", + "semanticRole": "packaging_machine_body", + "required": true, + "attachToRole": "packaging_conveyor", + "anchor": "top", + "length": 1.4, + "width": 1.15, + "height": 1.1, + "color": "#e5e7eb" + }, + { + "kind": "generic_panel", + "semanticRole": "tape_head", + "required": true, + "attachToRole": "packaging_machine_body", + "anchor": "front", + "color": "#facc15" + }, + { + "kind": "structural_tower_frame", + "semanticRole": "strapping_arch", + "required": true, + "attachToRole": "packaging_conveyor", + "anchor": "top", + "height": 1.4 + }, + { + "kind": "control_box", + "semanticRole": "packaging_control_box", + "attachToRole": "packaging_machine_body", + "anchor": "service_side" + } + ], + "primarySemanticRole": "packaging_machine_body", + "qualityRules": "quality.appliance_assembly.carton_packaging_station", + "visualCues": [ + "carton sealer body", + "conveyor bed", + "tape head", + "strapping arch" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.palletizing_robot_cell", + "name": "Palletizing robot cell", + "aliases": [ + "机器人码垛单元", + "码垛工作站", + "家电码垛机器人", + "palletizing robot cell", + "robot palletizing cell" + ], + "industry": "appliance-assembly", + "layoutFamily": "robot_workcell_layout", + "family": "robot_arm", + "defaultDimensions": { + "length": 5.2, + "width": 4, + "height": 2.8 + }, + "parts": [ + { + "kind": "circular_base", + "semanticRole": "robot_base", + "required": true, + "radius": 0.45, + "height": 0.18, + "color": "#f59e0b" + }, + { + "kind": "vertical_pole", + "semanticRole": "shoulder_joint", + "required": true, + "attachToRole": "robot_base", + "anchor": "top", + "height": 0.95, + "radius": 0.16, + "color": "#f59e0b" + }, + { + "kind": "support_bracket", + "semanticRole": "upper_arm", + "required": true, + "attachToRole": "shoulder_joint", + "anchor": "top", + "length": 1.05, + "color": "#f59e0b" + }, + { + "kind": "support_bracket", + "semanticRole": "forearm", + "required": true, + "attachToRole": "upper_arm", + "anchor": "front", + "length": 0.95, + "color": "#f59e0b" + }, + { + "kind": "pallet_table", + "semanticRole": "pallet_stack_zone", + "required": true, + "position": [ + 1.2, + 0.35, + 0.8 + ] + }, + { + "kind": "conveyor_frame", + "semanticRole": "infeed_conveyor", + "position": [ + -1.1, + 0.35, + 0 + ], + "length": 2.8, + "width": 0.9, + "height": 0.5 + }, + { + "kind": "guard_fence", + "semanticRole": "safety_barrier", + "required": true, + "position": [ + 0, + 0.65, + 0 + ], + "length": 4.8, + "width": 3.7 + } + ], + "primarySemanticRole": "robot_base", + "qualityRules": "quality.appliance_assembly.palletizing_robot_cell", + "visualCues": [ + "orange robot arm", + "pallet stack zone", + "infeed conveyor", + "safety fence" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.material_cart", + "name": "Material cart", + "aliases": [ + "物料周转车", + "线边物料车", + "转运车", + "material cart", + "line-side material trolley" + ], + "industry": "appliance-assembly", + "layoutFamily": "vehicle_layout", + "family": "vehicle", + "defaultDimensions": { + "length": 1.8, + "width": 0.9, + "height": 1 + }, + "parts": [ + { + "kind": "mobile_platform_chassis", + "semanticRole": "cart_platform", + "required": true, + "length": 1.7, + "width": 0.85, + "height": 0.22, + "color": "#64748b" + }, + { + "kind": "wheel_set", + "semanticRole": "cart_wheels", + "required": true, + "attachToRole": "cart_platform", + "anchor": "bottom" + }, + { + "kind": "generic_handle", + "semanticRole": "push_handle", + "required": true, + "attachToRole": "cart_platform", + "anchor": "back" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "material_bins", + "required": true, + "attachToRole": "cart_platform", + "anchor": "top", + "arrayAlong": "length", + "count": 3 + } + ], + "primarySemanticRole": "cart_platform", + "qualityRules": "quality.appliance_assembly.material_cart", + "visualCues": [ + "wheeled platform cart", + "push handle", + "material bins" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.mezzanine_control_room", + "name": "Mezzanine control room", + "aliases": [ + "夹层控制室", + "产线控制室", + "MCC控制室", + "mezzanine control room", + "line control room" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 4.6, + "width": 3.2, + "height": 2.8 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "control_room_shell", + "required": true, + "length": 4.4, + "width": 3, + "height": 2.5, + "primaryColor": "#e5e7eb" + }, + { + "kind": "generic_panel", + "semanticRole": "roof_parapet", + "required": true, + "attachToRole": "control_room_shell", + "anchor": "top", + "color": "#64748b" + }, + { + "kind": "generic_opening", + "semanticRole": "access_door", + "required": true, + "attachToRole": "control_room_shell", + "anchor": "front" + }, + { + "kind": "generic_opening", + "semanticRole": "control_room_windows", + "required": true, + "attachToRole": "control_room_shell", + "anchor": "front", + "arrayAlong": "length", + "count": 3, + "color": "#93c5fd" + }, + { + "kind": "generic_display", + "semanticRole": "line_overview_hmi", + "attachToRole": "control_room_shell", + "anchor": "service_side" + }, + { + "kind": "cable_tray", + "semanticRole": "service_cable_entry", + "attachToRole": "control_room_shell", + "anchor": "back" + } + ], + "primarySemanticRole": "control_room_shell", + "qualityRules": "quality.appliance_assembly.mezzanine_control_room", + "visualCues": [ + "small occupied control room", + "roof parapet", + "door", + "windows", + "cable entry" + ], + "status": "stable", + "source": "imported_pack" + }, + { + "id": "appliance_assembly.line_control_cabinet", + "name": "Line control cabinet", + "aliases": [ + "产线控制柜", + "PLC控制柜", + "驱动柜", + "line control cabinet", + "PLC cabinet row" + ], + "industry": "appliance-assembly", + "layoutFamily": "box_enclosure_layout", + "family": "electrical", + "defaultDimensions": { + "length": 2.8, + "width": 0.6, + "height": 2.1 + }, + "parts": [ + { + "kind": "electrical_cabinet", + "semanticRole": "control_cabinet", + "required": true, + "length": 2.6, + "width": 0.55, + "height": 2 + }, + { + "kind": "generic_display", + "semanticRole": "hmi_panel", + "required": true, + "attachToRole": "control_cabinet", + "anchor": "front" + }, + { + "kind": "cable_tray", + "semanticRole": "cabinet_cable_tray", + "attachToRole": "control_cabinet", + "anchor": "top" + }, + { + "kind": "warning_label", + "semanticRole": "electrical_warning_label", + "attachToRole": "control_cabinet", + "anchor": "front" + }, + { + "kind": "status_light_strip", + "semanticRole": "cabinet_status_light", + "attachToRole": "control_cabinet", + "anchor": "top" + } + ], + "primarySemanticRole": "control_cabinet", + "qualityRules": "quality.appliance_assembly.line_control_cabinet", + "visualCues": [ + "cabinet row", + "HMI panel", + "cable tray", + "warning labels" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.utility_air_skid", + "name": "Utility air skid", + "aliases": [ + "压缩空气撬装", + "空压机撬", + "公用工程空压机", + "utility air skid", + "compressed air skid" + ], + "industry": "appliance-assembly", + "layoutFamily": "rotating_machine_layout", + "family": "compressor", + "defaultDimensions": { + "length": 2.8, + "width": 1.1, + "height": 1.3 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "utility_skid_base", + "required": true, + "length": 2.7, + "width": 1, + "height": 0.18 + }, + { + "kind": "rounded_machine_body", + "semanticRole": "air_compressor_body", + "required": true, + "attachToRole": "utility_skid_base", + "anchor": "top", + "length": 1.2, + "width": 0.6, + "height": 0.7 + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "drive_motor", + "required": true, + "attachToRole": "air_compressor_body", + "anchor": "drive_side" + }, + { + "kind": "inlet_port", + "semanticRole": "air_inlet", + "attachToRole": "air_compressor_body", + "anchor": "left" + }, + { + "kind": "outlet_port", + "semanticRole": "compressed_air_outlet", + "attachToRole": "air_compressor_body", + "anchor": "right" + }, + { + "kind": "pipe_manifold", + "semanticRole": "compressed_air_manifold", + "attachToRole": "utility_skid_base", + "anchor": "back" + }, + { + "kind": "control_box", + "semanticRole": "compressor_control_box", + "attachToRole": "utility_skid_base", + "anchor": "service_side" + } + ], + "primarySemanticRole": "air_compressor_body", + "qualityRules": "quality.appliance_assembly.utility_air_skid", + "visualCues": [ + "skid base", + "compressor body", + "drive motor", + "air manifold" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "appliance_assembly.overhead_utility_rack", + "name": "Overhead utility rack", + "aliases": [ + "架空公用工程管廊", + "车间管线桥架", + "压缩空气管廊", + "overhead utility rack", + "utility pipe and cable rack" + ], + "industry": "appliance-assembly", + "layoutFamily": "pipe_valve_layout", + "family": "pipe_system", + "defaultDimensions": { + "length": 12, + "width": 1.2, + "height": 2.8 + }, + "parts": [ + { + "kind": "pipe_run", + "semanticRole": "compressed_air_header", + "required": true, + "length": 12, + "radius": 0.06, + "axis": "x" + }, + { + "kind": "pipe_run", + "semanticRole": "vacuum_utility_pipe", + "required": true, + "position": [ + 0, + 0.18, + 0.16 + ], + "length": 11.6, + "radius": 0.045, + "axis": "x" + }, + { + "kind": "cable_tray", + "semanticRole": "overhead_cable_tray", + "required": true, + "position": [ + 0, + 0.35, + -0.18 + ], + "length": 11.8 + }, + { + "kind": "valve_body", + "semanticRole": "air_isolation_valve", + "attachToRole": "compressed_air_header", + "anchor": "service_side" + }, + { + "kind": "structural_tower_frame", + "semanticRole": "utility_rack_support", + "attachToRole": "compressed_air_header", + "anchor": "bottom", + "levelCount": 2 + } + ], + "primarySemanticRole": "compressed_air_header", + "qualityRules": "quality.appliance_assembly.overhead_utility_rack", + "visualCues": [ + "overhead parallel pipes", + "cable tray", + "isolation valve", + "rack supports" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.appliance-assembly.basic-0.1.0/quality-rules/generated-quality.json b/cloud/industry.appliance-assembly.basic-0.1.0/quality-rules/generated-quality.json new file mode 100644 index 000000000..3626b36ef --- /dev/null +++ b/cloud/industry.appliance-assembly.basic-0.1.0/quality-rules/generated-quality.json @@ -0,0 +1,369 @@ +[ + { + "id": "quality.appliance_assembly.two_floor_workshop_shell", + "requiredRoles": [ + "two_floor_workshop_shell", + "main_roof", + "second_floor_slab", + "loading_door", + "level_2_window_band" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "chimney_stack" + ], + "shapeCount": { + "min": 6, + "max": 82 + } + }, + { + "id": "quality.appliance_assembly.parts_kitting_station", + "requiredRoles": [ + "kitting_workbench", + "parts_bins", + "pick_light_bar", + "kit_display", + "operator_hmi" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "chimney_stack" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.appliance_assembly.mezzanine_storage_rack", + "requiredRoles": [ + "mezzanine_rack_frame", + "parts_totes", + "rack_load_label" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 4, + "max": 52 + } + }, + { + "id": "quality.appliance_assembly.door_liner_assembly_station", + "requiredRoles": [ + "liner_fixture_table", + "liner_fixture_plate", + "clamp_blocks", + "suspended_tool_rail", + "operator_hmi" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 5, + "max": 58 + } + }, + { + "id": "quality.appliance_assembly.vertical_lift_conveyor", + "requiredRoles": [ + "lift_tower_frame", + "lift_carriage", + "lift_transfer_belt", + "lift_safety_guard", + "lift_drive_motor" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 6, + "max": 70 + } + }, + { + "id": "quality.appliance_assembly.sheet_metal_buffer", + "requiredRoles": [ + "sheet_buffer_frame", + "vertical_sheet_panels", + "sharp_edge_warning" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 4, + "max": 60 + } + }, + { + "id": "quality.appliance_assembly.cabinet_foaming_press", + "requiredRoles": [ + "foaming_press_body", + "press_line_frame", + "mold_clamp_panels", + "service_door_window", + "top_maintenance_platform", + "foam_injection_manifold", + "hydraulic_clamp_heads", + "hydraulic_control_box", + "press_stack_light" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "chimney_stack" + ], + "shapeCount": { + "min": 9, + "max": 88 + } + }, + { + "id": "quality.appliance_assembly.curing_buffer_conveyor", + "requiredRoles": [ + "curing_conveyor_frame", + "curing_transfer_surface", + "buffer_rollers", + "pallet_stops", + "side_guard" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 8, + "max": 80 + } + }, + { + "id": "quality.appliance_assembly.final_assembly_conveyor", + "requiredRoles": [ + "assembly_conveyor_frame", + "assembly_roller_bed", + "assembly_transfer_surface", + "appliance_pallet_fixtures", + "conveyor_drive_motor", + "line_side_guard" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 10, + "max": 90 + } + }, + { + "id": "quality.appliance_assembly.screw_fastening_station", + "requiredRoles": [ + "fastening_workbench", + "torque_tool_rail", + "torque_display", + "operator_hmi", + "andon_light" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.appliance_assembly.leak_test_station", + "requiredRoles": [ + "leak_test_frame", + "clamp_plate", + "pressure_gauge_display", + "pneumatic_manifold", + "reject_chute" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "chimney_stack" + ], + "shapeCount": { + "min": 6, + "max": 65 + } + }, + { + "id": "quality.appliance_assembly.functional_test_bench", + "requiredRoles": [ + "functional_test_bench", + "instrument_display", + "test_control_panel", + "test_cable_tray", + "test_status_light" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 5, + "max": 58 + } + }, + { + "id": "quality.appliance_assembly.aging_burn_in_rack", + "requiredRoles": [ + "aging_rack_frame", + "aging_power_cabinet", + "burn_in_cable_bus", + "appliance_load_slots", + "electrical_warning_label" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 9, + "max": 96 + } + }, + { + "id": "quality.appliance_assembly.vision_inspection_gate", + "requiredRoles": [ + "inspection_gantry_frame", + "camera_light_panel", + "inspection_display", + "reject_chute", + "inspection_status_light" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 6, + "max": 65 + } + }, + { + "id": "quality.appliance_assembly.carton_packaging_station", + "requiredRoles": [ + "packaging_machine_body", + "packaging_conveyor", + "tape_head", + "strapping_arch", + "packaging_control_box" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 6, + "max": 72 + } + }, + { + "id": "quality.appliance_assembly.palletizing_robot_cell", + "requiredRoles": [ + "robot_base", + "shoulder_joint", + "upper_arm", + "forearm", + "pallet_stack_zone", + "infeed_conveyor", + "safety_barrier" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 8, + "max": 85 + } + }, + { + "id": "quality.appliance_assembly.material_cart", + "requiredRoles": [ + "cart_platform", + "cart_wheels", + "push_handle", + "material_bins" + ], + "forbiddenRoles": [ + "chimney_stack" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.appliance_assembly.mezzanine_control_room", + "requiredRoles": [ + "control_room_shell", + "roof_parapet", + "access_door", + "control_room_windows", + "line_overview_hmi", + "service_cable_entry" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "chimney_stack" + ], + "shapeCount": { + "min": 6, + "max": 64 + } + }, + { + "id": "quality.appliance_assembly.line_control_cabinet", + "requiredRoles": [ + "control_cabinet", + "hmi_panel", + "cabinet_cable_tray", + "electrical_warning_label", + "cabinet_status_light" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 5, + "max": 58 + } + }, + { + "id": "quality.appliance_assembly.utility_air_skid", + "requiredRoles": [ + "air_compressor_body", + "utility_skid_base", + "drive_motor", + "air_inlet", + "compressed_air_outlet", + "compressed_air_manifold", + "compressor_control_box" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 7, + "max": 78 + } + }, + { + "id": "quality.appliance_assembly.overhead_utility_rack", + "requiredRoles": [ + "compressed_air_header", + "vacuum_utility_pipe", + "overhead_cable_tray", + "air_isolation_valve", + "utility_rack_support" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 6, + "max": 76 + } + } +] diff --git a/cloud/industry.cement.basic-0.1.0.zip b/cloud/industry.cement.basic-0.1.0.zip new file mode 100644 index 000000000..446b05197 Binary files /dev/null and b/cloud/industry.cement.basic-0.1.0.zip differ diff --git a/cloud/industry.cement.basic-0.1.0/README.md b/cloud/industry.cement.basic-0.1.0/README.md new file mode 100644 index 000000000..fa3540f5d --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/README.md @@ -0,0 +1,50 @@ +# Cement Basic Equipment Pack + +水泥行业基础资源包,覆盖水泥厂整厂、熟料烧成线,以及常见水泥生产设备。 + +## Pack Type + +Factory-capable pack: supports whole-factory/process creation through process templates and factory architectures. + +## Factory Creation + +支持整厂 / 工序: + +- Full cement plant (`cement_plant_full`) +- Clinker production line (`cement_clinker_line`) + +支持范围: + +- Clinker burning line (`pyro_line`) +- Complete clinker production system (`clinker_system`) +- Full cement plant (`cement_plant`) +- Clinker production line (`clinker_line`) + +## Devices + +- Limestone crusher (`cement.limestone_crusher`) +- Stacker reclaimer (`cement.stack_reclaimer`) +- Vertical raw mill (`cement.vertical_raw_mill`) +- Raw meal homogenization silo (`cement.raw_meal_homogenization_silo`) +- Coal mill (`cement.coal_mill`) +- Preheater tower (`cement.preheater_tower`) +- Rotary kiln (`cement.rotary_kiln`) +- Kiln burner (`cement.kiln_burner`) +- Kiln hood (`cement.kiln_hood`) +- Grate cooler (`cement.grate_cooler`) +- Clinker crusher (`cement.clinker_crusher`) +- Belt conveyor (`cement.belt_conveyor`) +- Clinker silo (`cement.clinker_silo`) +- ESP dust collector (`cement.esp_dust_collector`) +- Process stack (`cement.process_stack`) +- Cement mill (`cement.cement_mill`) +- Cement silo (`cement.cement_silo`) +- Cement packer (`cement.cement_packer`) +- WHR boiler (`cement.whr_boiler`) +- Central control room (`cement.control_room`) + +## Validation + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.cement.basic@0.1.0 --validate-only +``` diff --git a/cloud/industry.cement.basic-0.1.0/factory-architectures/cement-plant.json b/cloud/industry.cement.basic-0.1.0/factory-architectures/cement-plant.json new file mode 100644 index 000000000..a0d3927b1 --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/factory-architectures/cement-plant.json @@ -0,0 +1,180 @@ +{ + "id": "cement.plant.modular_outdoor", + "label": "Cement plant modular architecture", + "industry": "cement", + "processId": "cement_plant_full", + "layoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 80, + "width": 32 + }, + "scopes": [ + { + "id": "pyro_line", + "label": "Clinker burning line", + "includeModules": [ + "clinker_production", + "gas_and_dedusting" + ], + "aliases": [ + "pyro line", + "clinker burning line", + "烧成线", + "熟料烧成线" + ] + }, + { + "id": "clinker_system", + "label": "Complete clinker production system", + "includeModules": [ + "raw_material_preparation", + "raw_meal_preparation", + "fuel_preparation", + "clinker_production", + "gas_and_dedusting", + "clinker_storage" + ], + "aliases": [ + "clinker system", + "complete clinker system", + "clinker production system", + "熟料系统", + "熟料生产系统", + "熟料烧成系统", + "完整熟料系统" + ] + }, + { + "id": "cement_plant", + "label": "Full cement plant", + "includeModules": [ + "raw_material_preparation", + "raw_meal_preparation", + "fuel_preparation", + "clinker_production", + "gas_and_dedusting", + "clinker_storage", + "cement_grinding", + "packing_and_dispatch", + "utilities" + ], + "aliases": [] + } + ], + "modules": [ + { + "id": "raw_material_preparation", + "displayLabel": "石灰石破碎与预均化", + "order": 10, + "stationIds": [ + "limestone_crusher", + "pre_homogenization" + ] + }, + { + "id": "raw_meal_preparation", + "displayLabel": "生料制备", + "order": 20, + "stationIds": [ + "raw_mill", + "raw_meal_silo", + "raw_meal_feed" + ] + }, + { + "id": "fuel_preparation", + "displayLabel": "燃料制备", + "order": 30, + "stationIds": [ + "raw_coal_silo", + "coal_mill", + "coal_powder_bin", + "kiln_burner" + ] + }, + { + "id": "clinker_production", + "displayLabel": "熟料烧成", + "order": 40, + "stationIds": [ + "preheater_tower", + "rotary_kiln", + "kiln_hood", + "grate_cooler" + ] + }, + { + "id": "gas_and_dedusting", + "displayLabel": "烟气与收尘", + "order": 50, + "stationIds": [ + "tertiary_air_duct", + "kiln_tail_esp", + "sp_boiler", + "aqc_boiler", + "process_stack" + ] + }, + { + "id": "clinker_storage", + "displayLabel": "熟料储运", + "order": 60, + "stationIds": [ + "clinker_crusher", + "clinker_conveying", + "clinker_silo" + ] + }, + { + "id": "cement_grinding", + "displayLabel": "水泥粉磨", + "order": 70, + "stationIds": [ + "gypsum_storage", + "additive_silo", + "cement_mill", + "cement_separator", + "cement_silo" + ] + }, + { + "id": "packing_and_dispatch", + "displayLabel": "包装发运", + "order": 80, + "stationIds": [ + "cement_packer" + ] + }, + { + "id": "utilities", + "displayLabel": "辅助系统", + "order": 90, + "stationIds": [ + "control_room" + ] + } + ], + "layoutHints": { + "primaryAxis": "x", + "secondaryAxis": "z", + "outdoorHeavyIndustry": true, + "highestStationId": "preheater_tower", + "longAxisStationId": "rotary_kiln", + "sideBranchStationIds": [ + "coal_mill", + "kiln_tail_esp", + "process_stack", + "sp_boiler", + "aqc_boiler", + "control_room" + ], + "omitPerimeterWalls": true, + "scale": "conceptual", + "notes": [ + "Treat scopes as selections from one factory architecture tree instead of separate hardcoded plants.", + "Keep raw material and raw meal preparation upstream of the pyro line.", + "Place fuel preparation and gas/dedusting as side modules around the preheater and kiln.", + "Place grinding and packing downstream of clinker storage for full cement plant requests." + ] + } +} diff --git a/cloud/industry.cement.basic-0.1.0/factory-architectures/clinker-line.json b/cloud/industry.cement.basic-0.1.0/factory-architectures/clinker-line.json new file mode 100644 index 000000000..193500bc6 --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/factory-architectures/clinker-line.json @@ -0,0 +1,64 @@ +{ + "id": "cement.clinker_line.linear_outdoor", + "label": "Cement clinker production line architecture", + "industry": "cement", + "processId": "cement_clinker_production_line", + "layoutStyle": "linear", + "defaultDimensions": { + "length": 34, + "width": 12 + }, + "zones": [ + { + "id": "raw_meal_feed_zone", + "label": "Raw meal feed", + "displayLabel": "\u751f\u6599\u5582\u6599", + "order": 1 + }, + { + "id": "preheater_zone", + "label": "Preheater and calciner", + "displayLabel": "\u9884\u70ed\u5206\u89e3", + "order": 2 + }, + { + "id": "kiln_zone", + "label": "Rotary kiln burning", + "displayLabel": "\u56de\u8f6c\u7a91\u7145\u70e7", + "order": 3 + }, + { + "id": "cooling_zone", + "label": "Clinker cooling", + "displayLabel": "\u719f\u6599\u51b7\u5374", + "order": 4 + }, + { + "id": "storage_zone", + "label": "Clinker conveying and storage", + "displayLabel": "\u719f\u6599\u8f93\u9001\u50a8\u5b58", + "order": 5 + }, + { + "id": "dedusting_zone", + "label": "Dedusting", + "displayLabel": "\u7a91\u5c3e\u9664\u5c18", + "order": 6, + "side": "right" + } + ], + "layoutHints": { + "primaryAxis": "x", + "outdoorHeavyIndustry": true, + "highestStationId": "preheater_tower", + "longAxisStationId": "rotary_kiln", + "sideBranchStationIds": ["bag_filter"], + "omitPerimeterWalls": true, + "scale": "conceptual", + "notes": [ + "Keep the preheater tower upstream of the rotary kiln tail.", + "Place the grate cooler at the kiln head downstream side.", + "Place the bag filter as an offset side branch for kiln/preheater exhaust visualization." + ] + } +} diff --git a/cloud/industry.cement.basic-0.1.0/pack.json b/cloud/industry.cement.basic-0.1.0/pack.json new file mode 100644 index 000000000..8763de754 --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/pack.json @@ -0,0 +1,261 @@ +{ + "id": "industry.cement.basic", + "name": "Cement Basic Equipment Pack", + "industry": "cement", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Cement plant profile pack for common production-line equipment, covering pyroprocess, grinding, conveying, storage, dust collection, and packaging scenarios.", + "profiles": [ + "profiles/pyroprocess.json", + "profiles/grinding.json", + "profiles/conveying.json", + "profiles/storage.json", + "profiles/dust-collection-packaging.json", + "profiles/plant-special-equipment.json" + ], + "qualityRules": [ + "quality-rules/cement-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/clinker-line.json", + "factory-architectures/cement-plant.json" + ], + "processTemplates": [ + "process-templates/clinker-production-line.json", + "process-templates/cement-plant.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "cement.rotary_kiln", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "hot_meal_in": "inlet", + "clinker_out": "outlet", + "kiln_exhaust_out": "outlet", + "power_in": "inlet" + } + }, + { + "profileId": "cement.preheater_tower", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "raw_meal_in": "inlet", + "hot_meal_out": "outlet", + "tertiary_air_in": "inlet", + "exhaust_gas_out": "outlet" + } + }, + { + "profileId": "cement.cyclone_separator", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "cement.cement_mill", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "cement.cement_separator", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "coarse_feed_in": "inlet", + "fine_product_out": "outlet", + "coarse_reject_out": "outlet" + } + }, + { + "profileId": "cement.clinker_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "top_feed_inlet": "inlet", + "bottom_discharge_outlet": "outlet" + } + }, + { + "profileId": "cement.cement_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "top_feed_inlet": "inlet", + "bulk_discharge_outlet": "outlet" + } + }, + { + "profileId": "cement.raw_coal_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "top_coal_inlet": "inlet", + "bottom_coal_outlet": "outlet" + } + }, + { + "profileId": "cement.coal_powder_bin", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "powder_feed_inlet": "inlet", + "powder_to_kiln_burner": "inlet", + "powder_to_calciner": "inlet" + } + }, + { + "profileId": "cement.gypsum_hopper", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "gypsum_feed_in": "inlet", + "gypsum_out": "outlet" + } + }, + { + "profileId": "cement.additive_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "additive_feed_in": "inlet", + "additive_out": "outlet" + } + }, + { + "profileId": "cement.raw_meal_homogenization_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "top_feed_inlet": "inlet", + "raw_meal_out": "outlet" + } + }, + { + "profileId": "cement.process_stack", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.cement.basic-0.1.0/process-templates/cement-plant.json b/cloud/industry.cement.basic-0.1.0/process-templates/cement-plant.json new file mode 100644 index 000000000..045f57d9d --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/process-templates/cement-plant.json @@ -0,0 +1,709 @@ +[ + { + "processId": "cement_plant_full", + "processLabel": "Full cement plant", + "processDisplayLabel": "水泥工厂", + "domain": "chemical", + "aliases": [ + "水泥工厂", + "水泥厂", + "完整水泥厂", + "生成一个水泥工厂", + "创建一个水泥工厂", + "cement plant", + "full cement plant", + "cement factory", + "clinker system", + "complete clinker system", + "熟料系统", + "熟料生产系统", + "完整熟料系统" + ], + "requiredRoles": [ + "limestone_crusher", + "pre_homogenization", + "raw_mill", + "raw_meal_silo", + "raw_meal_feed", + "raw_coal_silo", + "coal_mill", + "coal_powder_bin", + "preheater_tower", + "rotary_kiln", + "kiln_burner", + "kiln_hood", + "grate_cooler", + "clinker_crusher", + "clinker_conveying", + "clinker_silo", + "kiln_tail_esp", + "whr_boiler", + "aqc_boiler", + "process_stack", + "gypsum_storage", + "additive_silo", + "cement_mill", + "cement_separator", + "cement_silo", + "cement_packer", + "control_room" + ], + "defaultLayoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 80, + "width": 32 + }, + "safetyTags": [ + "hot_material", + "dust", + "rotating_equipment", + "heavy_industry", + "explosion_risk" + ], + "stations": [ + { + "id": "limestone_crusher", + "label": "Limestone crusher", + "displayLabel": "石灰石破碎机", + "role": "limestone_crusher", + "equipmentHint": "cement.limestone_crusher heavy-duty single-shaft impact limestone crusher: large rectangular crusher housing (4.2m long, 2.4m wide, 2.6m tall), oversized limestone feed hopper at top-left with wide flared opening, crusher housing body with access doors, drive motor and gearbox on side, crushed material discharge chute at bottom-right", + "footprintHint": "large", + "safetyTags": [ + "material", + "dust", + "rotating_equipment" + ], + "profileId": "cement.limestone_crusher" + }, + { + "id": "pre_homogenization", + "label": "Stacker reclaimer", + "displayLabel": "堆取料机", + "role": "pre_homogenization", + "equipmentHint": "cement.stack_reclaimer limestone pre-homogenization stacker reclaimer: long bridge boom spanning 10m, bucket wheel at one end (0.48m radius) for scraping stockpile, stockpile conveyor belt running along the boom length, travel car riding on longitudinal rail track, operator walkway along boom, used to blend and reclaim limestone from circular or longitudinal stockpile", + "footprintHint": "long", + "safetyTags": [ + "material", + "conveyor" + ], + "profileId": "cement.stack_reclaimer" + }, + { + "id": "raw_mill", + "label": "Raw mill", + "displayLabel": "原料磨", + "role": "raw_mill", + "equipmentHint": "cement.vertical_raw_mill vertical roller mill for raw meal grinding: tall enclosed mill body (3.4m wide, 2.6m deep, 4.2m tall), top-mounted dynamic separator classifier, raw material feed chute entering from the side at mid-height, hot gas inlet duct at base for drying, large central gearbox drive unit at base, local instrument panel on side", + "footprintHint": "large", + "safetyTags": [ + "dust", + "rotating_equipment" + ], + "profileId": "cement.vertical_raw_mill" + }, + { + "id": "raw_meal_silo", + "label": "Raw meal homogenization silo", + "displayLabel": "生料均化库", + "role": "raw_meal_silo", + "equipmentHint": "cement.raw_meal_homogenization_silo tall cylindrical raw meal homogenization silo: cylindrical shell (3.8m diameter, 7.2m tall cylinder) on a concrete support base with aeration floor manifold at base for air blending, top pneumatic fill inlet, side-mounted access ladder, bottom discharge outlet hopper with aeration cone", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "dust" + ], + "profileId": "cement.raw_meal_homogenization_silo" + }, + { + "id": "raw_meal_feed", + "label": "Raw meal bucket elevator", + "displayLabel": "生料喂料提升机", + "role": "raw_meal_feed", + "equipmentHint": "cement.bucket_elevator tall enclosed bucket elevator for raw meal: narrow vertical casing (1.2m wide, 0.9m deep) rising 6m, boot section at base with inlet hopper receiving raw meal, head casing at top with discharge spout, leg casing enclosing the bucket chain, head drive motor and gearbox on top, maintenance access platform at head level", + "footprintHint": "tall", + "safetyTags": [ + "dust", + "height", + "conveyor" + ], + "profileId": "cement.bucket_elevator" + }, + { + "id": "raw_coal_silo", + "label": "Raw coal silo", + "displayLabel": "原煤仓", + "role": "raw_coal_silo", + "equipmentHint": "cement.raw_coal_silo cylindrical raw coal storage silo: cylindrical shell (3.2m diameter, 4.2m tall cylinder) with dark exterior finish due to coal dust, conical bottom discharge hopper for gravity feeding to coal feeder, top inlet port for conveyor or truck delivery of raw coal, silo sits on concrete support base", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "dust", + "explosion_risk" + ], + "profileId": "cement.raw_coal_silo" + }, + { + "id": "coal_mill", + "label": "Coal mill", + "displayLabel": "煤磨", + "role": "coal_mill", + "equipmentHint": "cement.coal_mill vertical roller coal mill for pulverized fuel preparation: enclosed mill body (3.4m long, 2.4m wide, 3.6m tall) with dynamic separator classifier on top, raw coal feed hopper at side, inert gas inlet duct for explosion prevention, large drive motor and gearbox at base, pressurized enclosure with explosion vents", + "footprintHint": "large", + "safetyTags": [ + "fuel", + "dust", + "explosion_risk" + ], + "profileId": "cement.vertical_raw_mill" + }, + { + "id": "coal_powder_bin", + "label": "Coal powder bin", + "displayLabel": "煤粉仓", + "role": "coal_powder_bin", + "equipmentHint": "cement.coal_powder_bin pressurized cylindrical coal powder storage bin: dark cylindrical shell (2.2m diameter, 2.8m tall) with steep cone-bottom discharge, two separate discharge pipes at bottom — one feeding kiln burner and one feeding calciner in preheater tower, top pneumatic fill inlet from coal mill, smaller and darker than raw coal silo", + "footprintHint": "tall", + "safetyTags": [ + "fuel", + "dust", + "explosion_risk" + ], + "profileId": "cement.preheater_tower" + }, + { + "id": "preheater_tower", + "label": "Preheater tower", + "displayLabel": "预热器塔", + "role": "preheater_tower", + "equipmentHint": "cement.preheater_tower five-stage cyclone preheater tower with inline calciner: very tall multi-level steel frame tower (2.78m wide, 1.98m deep, 8.1m tall) with 6 cyclone separator vessels arranged in mirrored left and right columns across 3 stages, calciner riser vessel at base of tower, central meal collection hopper, interstage gas ducts and meal drop pipes between cyclone stages, zig-zag internal staircase inside the right bay of the tower frame, diagonal bracing throughout", + "footprintHint": "tall", + "safetyTags": [ + "hot_gas", + "dust", + "height" + ], + "profileId": "cement.preheater_tower" + }, + { + "id": "rotary_kiln", + "label": "Rotary kiln", + "displayLabel": "回转窑", + "role": "rotary_kiln", + "equipmentHint": "cement.rotary_kiln long inclined rotary cement kiln: long cylindrical shell (6.4m long at conceptual scale, diameter 0.4m, represents real 4m diameter × 60m length kiln) inclined 3-4% toward kiln head, three riding rings (tyre rings) encircling the shell at tail, center, and head positions, three pairs of support roller stations under each riding ring, large girth gear ring near center-right with side drive pinion and motor gearbox unit, yellow coupling guard near drive, feed hopper at elevated kiln tail end, discharge outlet hood at lower kiln head end, service platform along one side", + "footprintHint": "long", + "safetyTags": [ + "hot_material", + "rotating_equipment" + ], + "profileId": "cement.rotary_kiln" + }, + { + "id": "kiln_burner", + "label": "Kiln burner", + "displayLabel": "窑头燃烧器", + "role": "kiln_burner", + "equipmentHint": "cement.kiln_burner multi-channel cement kiln burner lance: long lance assembly (3.8m long, 0.9m wide, 1.2m tall) consisting of concentric pipes — central solid fuel pipe, primary air annulus, swirl air channel, and outer coal/gas pipe — mounted on a wheeled burner carriage for retraction, flame outlet nozzle at front end, fuel and air inlet connections at rear", + "footprintHint": "long", + "safetyTags": [ + "fuel", + "hot_material" + ], + "profileId": "cement.rotary_kiln" + }, + { + "id": "kiln_hood", + "label": "Kiln hood", + "displayLabel": "窑头罩", + "role": "kiln_hood", + "equipmentHint": "cement.kiln_hood kiln head hood enclosure: box-like hood structure (3.2m long, 2.4m wide, 2.4m tall) enclosing the kiln head discharge area, large circular opening on left face for kiln shell seal, central burner lance opening for burner insertion, hot clinker discharge opening at bottom-right connecting to grate cooler, refractory-lined interior, inspection doors on sides", + "footprintHint": "large", + "safetyTags": [ + "hot_material" + ], + "profileId": "cement.rotary_kiln" + }, + { + "id": "grate_cooler", + "label": "Grate cooler", + "displayLabel": "篦冷机", + "role": "grate_cooler", + "equipmentHint": "cement.grate_cooler horizontal push-type grate clinker cooler: large rectangular housing casing (7m long, 2m wide, 2.4m tall) — this is a BOX-SHAPED ENCLOSURE not a conveyor — with hot clinker inlet transition hopper at left end elevated to accept clinker dropping from kiln hood, internal multi-section grate plate bed that pushes clinker from inlet to outlet, under-grate cooling air plenum bank along the base receiving cooling air fans, top maintenance walkway with guard rail running along the housing roof, hydraulic push-drive unit on the side, right-end discharge chute outlet to clinker crusher", + "footprintHint": "long", + "safetyTags": [ + "hot_material", + "cooling_air" + ], + "profileId": "cement.grate_cooler" + }, + { + "id": "tertiary_air_duct", + "label": "Tertiary air duct", + "displayLabel": "三次风管", + "role": "tertiary_air_duct", + "equipmentHint": "cement.tertiary_air_duct large hot tertiary air duct: long rectangular duct (6.2m long, 0.9m wide, 1.4m tall) elevated above ground level on supports, transports hot secondary air taken from clinker cooler top back to the calciner inlet in the preheater tower, expansion joint visible near cooler connection, thermally insulated shell", + "footprintHint": "long", + "safetyTags": [ + "hot_gas" + ], + "profileId": "cement.grate_cooler" + }, + { + "id": "clinker_crusher", + "label": "Clinker crusher", + "displayLabel": "熟料破碎机", + "role": "clinker_crusher", + "equipmentHint": "cement.clinker_crusher hammer clinker crusher at cooler discharge: compact crusher unit (3m long, 1.8m wide, 1.8m tall), crusher housing with crusher rolls or hammer rotors inside, hot clinker inlet chute at top-left receiving from grate cooler discharge, crushed clinker outlet chute at bottom-right connecting to conveyor, drive motor on side", + "footprintHint": "large", + "safetyTags": [ + "hot_material", + "rotating_equipment" + ], + "profileId": "cement.grate_cooler" + }, + { + "id": "clinker_conveying", + "label": "Clinker conveyor", + "displayLabel": "熟料输送", + "role": "clinker_conveying", + "equipmentHint": "cement.belt_conveyor clinker belt conveyor transferring cooled crushed clinker to storage: flat belt conveyor frame (8m long, 0.9m wide) with support idler roller sets every 1-1.5m, head drive pulley and motor at one end, tail pulley at other end, enclosed skirt boards on sides, transfer chute at each end", + "footprintHint": "long", + "safetyTags": [ + "material", + "conveyor" + ], + "profileId": "cement.belt_conveyor" + }, + { + "id": "clinker_silo", + "label": "Clinker silo", + "displayLabel": "熟料库", + "role": "clinker_silo", + "equipmentHint": "cement.clinker_silo tall cylindrical clinker storage silo: large cylindrical shell (3.6m diameter, 5.4m tall cylinder) on reinforced concrete support base, bottom cone discharge hopper, top feed inlet from conveyor, side access platform and ladder, holds clinker buffer stock between kiln and cement mill", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "dust" + ], + "profileId": "cement.cement_mill" + }, + { + "id": "kiln_tail_esp", + "label": "Kiln tail ESP", + "displayLabel": "窑尾电收尘", + "role": "kiln_tail_esp", + "equipmentHint": "cement.esp_dust_collector kiln tail electrostatic precipitator: long rectangular ESP chamber (5.2m long, 2.2m wide, 3.2m tall) with multiple electrode field sections inside, large gas inlet plenum at left side, clean gas outlet at right, row of pyramidal dust hoppers along the bottom for collected dust discharge, high-voltage transformer and rectifier mounted on top or side, insulator housings visible at top", + "footprintHint": "large", + "safetyTags": [ + "dust", + "exhaust" + ], + "profileId": "cement.bag_filter" + }, + { + "id": "sp_boiler", + "label": "SP waste heat boiler (kiln tail)", + "displayLabel": "窑尾SP余热锅炉", + "role": "whr_boiler", + "equipmentHint": "cement.whr_boiler kiln-tail SP (Suspension Preheater) waste heat recovery boiler: tall vertical boiler casing (3.8m wide, 2.4m deep, 5.2m tall) with horizontal tube bank rows inside, hot exhaust gas inlet duct at lower-left side receiving ~346°C gas from preheater tower, cooled gas outlet duct at upper-right, steam drum on top, external steam piping and safety valves, service platform around upper section. Located beside the preheater tower.", + "footprintHint": "tall", + "safetyTags": [ + "hot_gas", + "utility" + ], + "profileId": "cement.preheater_tower" + }, + { + "id": "aqc_boiler", + "label": "AQC waste heat boiler (kiln head)", + "displayLabel": "窑头AQC余热锅炉", + "role": "aqc_boiler", + "equipmentHint": "cement.whr_boiler kiln-head AQC (Air Quenching Cooler) waste heat recovery boiler: tall vertical boiler casing (3.8m wide, 2.4m deep, 5.2m tall) with horizontal tube bank rows inside, hot exhaust gas inlet duct at lower-left side receiving ~360°C cooling air exhaust from grate cooler middle section, large settling chamber at base for dust collection before tube bank, cooled gas outlet duct at upper-right connecting to cooler bag filter, steam drum on top, external steam piping. Located beside the grate cooler on the kiln head side.", + "footprintHint": "tall", + "safetyTags": [ + "hot_gas", + "utility" + ], + "profileId": "cement.grate_cooler" + }, + { + "id": "process_stack", + "label": "Process stack", + "displayLabel": "烟囱", + "role": "process_stack", + "equipmentHint": "cement.process_stack tall industrial cement kiln exhaust stack: very tall cylindrical stack (2.4m diameter at base, tapering slightly, 10m tall in conceptual scale representing 80-120m real height), reinforced concrete or steel construction, wide square base plinth, alternating red and white warning bands near top, gas inlet duct at base side, open top discharge, lightning rod at apex", + "footprintHint": "tall", + "safetyTags": [ + "exhaust", + "height" + ], + "profileId": "cement.rotary_kiln" + }, + { + "id": "gypsum_storage", + "label": "Gypsum hopper", + "displayLabel": "石膏仓", + "role": "gypsum_storage", + "equipmentHint": "cement.gypsum_hopper rectangular gypsum storage hopper bin for cement mill additive: box-like hopper (3m long, 2.4m wide, 3.5m tall) with light grey or white color reflecting gypsum color, sloped discharge bottom hopper feeding to weighing belt conveyor, top loading by truck or conveyor, open or covered top, smaller and lower than silo-type storage", + "footprintHint": "large", + "safetyTags": [ + "storage", + "dust" + ], + "profileId": "cement.cement_mill" + }, + { + "id": "additive_silo", + "label": "Additive silo", + "displayLabel": "混合材库", + "role": "additive_silo", + "equipmentHint": "cement.additive_silo cylindrical silo for supplementary cementitious material such as fly ash or slag: cylindrical shell (2.8m diameter, 3.8m tall cylinder) with neutral grey color, bottom cone discharge hopper, pneumatic fill pipe on upper shell for truck tanker delivery, similar in shape to cement silo but smaller, discharge feeds to weighing conveyor leading to cement mill", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "dust" + ], + "profileId": "cement.cement_mill" + }, + { + "id": "cement_mill", + "label": "Cement mill", + "displayLabel": "水泥磨", + "role": "cement_mill", + "equipmentHint": "cement.cement_mill horizontal ball cement grinding mill: long cylindrical shell (7m long, 1.8m diameter) lying horizontally on trunnion bearing pedestals at each end, multiple riding ring flanges along the shell (6 rings), central girth gear ring with drive pinion and motor-gearbox unit on one side, yellow coupling guard, feed inlet trunnion at left end, product outlet trunnion at right end", + "footprintHint": "long", + "safetyTags": [ + "dust", + "rotating_equipment" + ], + "profileId": "cement.cement_mill" + }, + { + "id": "cement_separator", + "label": "Cement separator", + "displayLabel": "水泥磨选粉机", + "role": "cement_separator", + "equipmentHint": "cement.cement_separator dynamic air classifier separator in closed-circuit with cement mill: tall cylindrical body (2.4m diameter, 4.8m tall) mounted on a support base, variable speed rotor drive motor on top, coarse cement feed inlet on side at mid-height receiving product from cement mill, fine cement product outlet at top discharging to cement silo via air slide, coarse reject cone at bottom returning oversized material back to cement mill feed", + "footprintHint": "tall", + "safetyTags": [ + "dust", + "rotating_equipment" + ], + "profileId": "cement.cement_mill" + }, + { + "id": "cement_silo", + "label": "Cement silo", + "displayLabel": "水泥库", + "role": "cement_silo", + "equipmentHint": "cement.cement_silo finished cement storage silo: cylindrical shell (3.2m diameter, 5.1m tall cylinder) on support base, pneumatic fill manifold pipe on upper shell for receiving cement from separator, bulk discharge outlet at bottom with aeration system, top access platform, serves as buffer between grinding and dispatch", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "dust" + ], + "profileId": "cement.cement_silo" + }, + { + "id": "cement_packer", + "label": "Cement packer", + "displayLabel": "水泥包装机", + "role": "cement_packer", + "equipmentHint": "cement.cement_packer rotary bag cement packer: circular rotating packer body (2.6m wide, 1.8m deep, 2.2m tall) with central cement feed hopper at top, 8-12 filling spouts arranged radially around the circumference, operator control panel on side, weighing display on each spout, bag discharge chute leading to bag conveyor at bottom", + "footprintHint": "large", + "safetyTags": [ + "packaging", + "dust" + ], + "profileId": "cement.cement_packer" + }, + { + "id": "control_room", + "label": "Central control room", + "displayLabel": "水泥厂中控室", + "role": "control_room", + "equipmentHint": "cement.control_room cement plant central control room building: single-storey enclosed control building with concrete wall body, flat roof cap, entrance door, observation windows facing the kiln/grinding line, MCC cabinet row, cable-tray entry, and SCADA/process-control panels; place as occupied building near utilities, not as a loose cabinet row", + "footprintHint": "large", + "safetyTags": [ + "power", + "control", + "occupied_building" + ], + "profileId": "cement.control_room" + } + ], + "connections": [ + { + "fromStationId": "limestone_crusher", + "toStationId": "pre_homogenization", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "crushed_material_out", + "toPortId": "raw_material_in" + }, + { + "fromStationId": "pre_homogenization", + "toStationId": "raw_mill", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "blended_material_out", + "toPortId": "raw_feed_in" + }, + { + "fromStationId": "raw_mill", + "toStationId": "raw_meal_silo", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "raw_meal_out", + "toPortId": "top_feed_inlet" + }, + { + "fromStationId": "raw_meal_silo", + "toStationId": "raw_meal_feed", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "raw_meal_out", + "toPortId": "raw_meal_in" + }, + { + "fromStationId": "raw_meal_feed", + "toStationId": "preheater_tower", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "raw_meal_out", + "toPortId": "raw_meal_in" + }, + { + "fromStationId": "preheater_tower", + "toStationId": "rotary_kiln", + "medium": "material", + "visualKind": "hot_material_chute", + "fromPortId": "hot_meal_out", + "toPortId": "hot_meal_in" + }, + { + "fromStationId": "raw_coal_silo", + "toStationId": "coal_mill", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "bottom_coal_outlet", + "toPortId": "coal_feed_in" + }, + { + "fromStationId": "coal_mill", + "toStationId": "coal_powder_bin", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "pulverized_fuel_out", + "toPortId": "powder_feed_inlet" + }, + { + "fromStationId": "coal_powder_bin", + "toStationId": "kiln_burner", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "powder_to_kiln_burner", + "toPortId": "fuel_in" + }, + { + "fromStationId": "coal_powder_bin", + "toStationId": "preheater_tower", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "powder_to_calciner", + "toPortId": "tertiary_air_in" + }, + { + "fromStationId": "kiln_burner", + "toStationId": "kiln_hood", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "flame_out", + "toPortId": "burner_opening" + }, + { + "fromStationId": "rotary_kiln", + "toStationId": "kiln_hood", + "medium": "material", + "visualKind": "hot_material_chute", + "fromPortId": "clinker_out", + "toPortId": "kiln_head_in" + }, + { + "fromStationId": "kiln_hood", + "toStationId": "grate_cooler", + "medium": "material", + "visualKind": "hot_material_chute", + "fromPortId": "hot_clinker_out", + "toPortId": "hot_clinker_in" + }, + { + "fromStationId": "grate_cooler", + "toStationId": "clinker_crusher", + "medium": "material", + "visualKind": "hot_material_chute", + "fromPortId": "cooled_clinker_out", + "toPortId": "hot_clinker_in" + }, + { + "fromStationId": "clinker_crusher", + "toStationId": "clinker_conveying", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "crushed_clinker_out", + "toPortId": "material_in" + }, + { + "fromStationId": "clinker_conveying", + "toStationId": "clinker_silo", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "material_out", + "toPortId": "top_feed_inlet" + }, + { + "fromStationId": "clinker_silo", + "toStationId": "cement_mill", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "bottom_discharge_outlet", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "gypsum_storage", + "toStationId": "cement_mill", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "gypsum_out", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "additive_silo", + "toStationId": "cement_mill", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "additive_out", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "cement_mill", + "toStationId": "cement_separator", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "product_outlet", + "toPortId": "coarse_feed_in" + }, + { + "fromStationId": "cement_separator", + "toStationId": "cement_silo", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "fine_product_out", + "toPortId": "top_feed_inlet" + }, + { + "fromStationId": "cement_separator", + "toStationId": "cement_mill", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "coarse_reject_out", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "cement_silo", + "toStationId": "cement_packer", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "bulk_discharge_outlet", + "toPortId": "cement_feed_inlet" + }, + { + "fromStationId": "preheater_tower", + "toStationId": "kiln_tail_esp", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "exhaust_gas_out", + "toPortId": "dust_gas_in" + }, + { + "fromStationId": "kiln_tail_esp", + "toStationId": "process_stack", + "medium": "gas", + "visualKind": "air_duct", + "fromPortId": "clean_air_out", + "toPortId": "stack_gas_in" + }, + { + "fromStationId": "grate_cooler", + "toStationId": "tertiary_air_duct", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "cooler_exhaust_out", + "toPortId": "cooler_air_in" + }, + { + "fromStationId": "tertiary_air_duct", + "toStationId": "preheater_tower", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "tertiary_air_out", + "toPortId": "tertiary_air_in" + }, + { + "fromStationId": "preheater_tower", + "toStationId": "sp_boiler", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "exhaust_gas_out", + "toPortId": "hot_gas_in" + }, + { + "fromStationId": "grate_cooler", + "toStationId": "aqc_boiler", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "cooler_exhaust_out", + "toPortId": "hot_gas_in" + }, + { + "fromStationId": "grate_cooler", + "toStationId": "raw_mill", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "cooler_exhaust_out", + "toPortId": "hot_gas_in" + }, + { + "fromStationId": "grate_cooler", + "toStationId": "coal_mill", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "cooler_exhaust_out", + "toPortId": "hot_gas_in" + }, + { + "fromStationId": "control_room", + "toStationId": "raw_mill", + "medium": "power", + "visualKind": "cable_tray", + "fromPortId": "power_out", + "toPortId": "power_in" + }, + { + "fromStationId": "control_room", + "toStationId": "rotary_kiln", + "medium": "power", + "visualKind": "cable_tray", + "fromPortId": "power_out", + "toPortId": "power_in" + }, + { + "fromStationId": "control_room", + "toStationId": "cement_mill", + "medium": "power", + "visualKind": "cable_tray", + "fromPortId": "power_out", + "toPortId": "power_in" + } + ] + } +] diff --git a/cloud/industry.cement.basic-0.1.0/process-templates/clinker-production-line.json b/cloud/industry.cement.basic-0.1.0/process-templates/clinker-production-line.json new file mode 100644 index 000000000..6079445f7 --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/process-templates/clinker-production-line.json @@ -0,0 +1,193 @@ +[ + { + "processId": "cement_clinker_production_line", + "processLabel": "Cement clinker production line", + "processDisplayLabel": "水泥熟料产线", + "domain": "chemical", + "aliases": [ + "水泥熟料产线", + "熟料工序", + "熟料烧成工序", + "熟料生产工序", + "熟料产线", + "水泥熟料", + "熟料煅烧", + "水泥窑", + "cement clinker line", + "clinker production line", + "clinker kiln line" + ], + "requiredRoles": [ + "raw_meal_feed", + "preheater_tower", + "rotary_kiln", + "grate_cooler", + "clinker_conveying", + "clinker_silo", + "kiln_dedusting" + ], + "defaultLayoutStyle": "linear", + "defaultDimensions": { + "length": 34, + "width": 12 + }, + "safetyTags": [ + "hot_material", + "dust", + "rotating_equipment", + "heavy_industry" + ], + "stations": [ + { + "id": "raw_meal_feed", + "label": "Raw meal feed elevator", + "displayLabel": "生料喂料", + "role": "raw_meal_feed", + "equipmentHint": "cement bucket elevator and raw meal feed chute feeding the preheater tower", + "footprintHint": "tall", + "safetyTags": [ + "material", + "elevator" + ], + "profileId": "cement.preheater_tower" + }, + { + "id": "preheater_tower", + "label": "Preheater tower", + "displayLabel": "预热器塔", + "role": "preheater_tower", + "equipmentHint": "cement.preheater_tower cyclone preheater tower with calciner riser, platforms, meal drop pipes, and gas ducts", + "footprintHint": "tall", + "safetyTags": [ + "hot_gas", + "dust", + "height" + ], + "profileId": "cement.preheater_tower" + }, + { + "id": "rotary_kiln", + "label": "Rotary kiln", + "displayLabel": "回转窑", + "role": "rotary_kiln", + "equipmentHint": "cement.rotary_kiln long inclined rotary kiln with riding rings, support rollers, girth gear, drive unit, kiln tail feed hopper, and kiln head outlet", + "footprintHint": "long", + "safetyTags": [ + "hot_material", + "rotating_equipment" + ], + "profileId": "cement.rotary_kiln" + }, + { + "id": "grate_cooler", + "label": "Grate cooler", + "displayLabel": "篦冷机", + "role": "grate_cooler", + "equipmentHint": "cement.grate_cooler clinker grate cooler with hot clinker inlet, grate bed, drive unit, and service platform", + "footprintHint": "long", + "safetyTags": [ + "hot_material", + "cooling_air" + ], + "profileId": "cement.grate_cooler" + }, + { + "id": "clinker_conveying", + "label": "Clinker conveyor", + "displayLabel": "熟料输送", + "role": "clinker_conveying", + "equipmentHint": "cement.belt_conveyor clinker conveyor transferring cooled clinker to storage", + "footprintHint": "long", + "safetyTags": [ + "material", + "conveyor" + ], + "profileId": "cement.belt_conveyor" + }, + { + "id": "clinker_silo", + "label": "Clinker silo", + "displayLabel": "熟料库", + "role": "clinker_silo", + "equipmentHint": "cement.clinker_silo tall clinker storage silo with top feed inlet and bottom discharge hopper", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "dust" + ], + "profileId": "cement.clinker_silo" + }, + { + "id": "bag_filter", + "label": "Kiln bag filter", + "displayLabel": "窑尾袋收尘器", + "role": "kiln_dedusting", + "equipmentHint": "cement.bag_filter baghouse dust collector for kiln, preheater, and cooler exhaust dust", + "footprintHint": "tall", + "safetyTags": [ + "dust", + "exhaust" + ], + "profileId": "cement.bag_filter" + } + ], + "connections": [ + { + "fromStationId": "raw_meal_feed", + "toStationId": "preheater_tower", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "raw_meal_out", + "toPortId": "raw_meal_in" + }, + { + "fromStationId": "preheater_tower", + "toStationId": "rotary_kiln", + "medium": "material", + "visualKind": "hot_material_chute", + "fromPortId": "hot_meal_out", + "toPortId": "hot_meal_in" + }, + { + "fromStationId": "rotary_kiln", + "toStationId": "grate_cooler", + "medium": "material", + "visualKind": "hot_material_chute", + "fromPortId": "clinker_out", + "toPortId": "hot_clinker_in" + }, + { + "fromStationId": "grate_cooler", + "toStationId": "clinker_conveying", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "cooled_clinker_out", + "toPortId": "material_in" + }, + { + "fromStationId": "clinker_conveying", + "toStationId": "clinker_silo", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "material_out", + "toPortId": "top_feed_inlet" + }, + { + "fromStationId": "preheater_tower", + "toStationId": "bag_filter", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "exhaust_gas_out", + "toPortId": "dust_gas_in" + }, + { + "fromStationId": "grate_cooler", + "toStationId": "bag_filter", + "medium": "gas", + "visualKind": "hot_gas_duct", + "fromPortId": "cooler_exhaust_out", + "toPortId": "dust_gas_in" + } + ] + } +] diff --git a/cloud/industry.cement.basic-0.1.0/profiles/conveying.json b/cloud/industry.cement.basic-0.1.0/profiles/conveying.json new file mode 100644 index 000000000..693f8b5c5 --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/profiles/conveying.json @@ -0,0 +1,324 @@ +[ + { + "id": "cement.belt_conveyor", + "name": "皮带输送机", + "aliases": [ + "皮带输送机", + "胶带输送机", + "belt conveyor", + "cement belt conveyor" + ], + "industry": "cement", + "family": "conveyor", + "layoutFamily": "linear_transport_layout", + "archetypeFamily": "material_handling", + "defaultDimensions": { + "length": 8, + "width": 0.9, + "height": 0.8 + }, + "primarySemanticRole": "belt_surface", + "qualityRules": "quality.cement.belt_conveyor", + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "conveyor_frame", + "required": true + }, + { + "kind": "roller_array", + "semanticRole": "support_rollers", + "required": true + }, + { + "kind": "belt_surface", + "semanticRole": "belt_surface", + "required": true + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "head_drive_unit" + }, + { + "kind": "bearing_block", + "semanticRole": "tail_pulley_bearing" + }, + { + "kind": "hopper_body", + "semanticRole": "transfer_chute" + }, + { + "kind": "warning_label", + "semanticRole": "safety_warning" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥厂通用皮带输送机 profile,包含机架、托辊、皮带、头部驱动和转运溜槽。", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "support_rollers": { + "detailLevel": "low", + "count": 5 + }, + "safety_warning": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.screw_conveyor", + "name": "螺旋输送机", + "aliases": [ + "螺旋输送机", + "绞龙", + "screw conveyor", + "auger conveyor" + ], + "industry": "cement", + "family": "conveyor", + "layoutFamily": "linear_transport_layout", + "archetypeFamily": "material_handling", + "defaultDimensions": { + "length": 4.5, + "width": 0.65, + "height": 0.8 + }, + "primarySemanticRole": "screw_flight", + "qualityRules": "quality.cement.screw_conveyor", + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "trough_frame", + "required": true + }, + { + "kind": "roller_array", + "semanticRole": "screw_flight", + "required": true + }, + { + "kind": "belt_surface", + "semanticRole": "trough_cover" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "screw_drive_unit" + }, + { + "kind": "bearing_block", + "semanticRole": "end_bearing" + }, + { + "kind": "hopper_body", + "semanticRole": "powder_inlet_hopper" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "粉料或颗粒物输送用螺旋输送机 profile,表达槽体、螺旋、端部轴承和驱动。", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "screw_flight": { + "detailLevel": "low", + "count": 5 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.bucket_elevator", + "name": "斗式提升机", + "aliases": [ + "斗式提升机", + "提升机", + "bucket elevator", + "raw meal feed elevator", + "raw meal feed", + "raw_meal_feed" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "material_handling", + "defaultDimensions": { + "length": 1.2, + "width": 0.9, + "height": 6 + }, + "primarySemanticRole": "elevator_leg_casing", + "qualityRules": "quality.cement.bucket_elevator", + "preferredResolver": "profile-parts", + "processPorts": [ + { + "id": "raw_meal_in", + "medium": "material", + "side": "left", + "height": 0.72, + "offset": 0 + }, + { + "id": "raw_meal_out", + "medium": "material", + "side": "right", + "height": 5.45, + "offset": 0 + } + ], + "visualCues": [ + "tall sealed rectangular elevator leg casing", + "wide boot casing and bottom inlet hopper", + "larger head casing with external motor gearbox", + "side discharge spout connected to the preheater feed", + "service platform with guard rails and access ladder" + ], + "parts": [ + { + "kind": "generic_body", + "semanticRole": "elevator_leg_casing", + "required": true, + "length": 0.62, + "width": 0.48, + "height": 5, + "position": [ + 0, + 2.58, + 0 + ], + "cornerRadius": 0.025 + }, + { + "kind": "generic_base", + "semanticRole": "boot_section", + "required": true, + "length": 0.95, + "width": 0.7, + "thickness": 0.42, + "position": [ + 0, + 0.21, + 0 + ], + "cornerRadius": 0.035 + }, + { + "kind": "generic_body", + "semanticRole": "head_casing", + "required": true, + "length": 0.9, + "width": 0.66, + "height": 0.82, + "position": [ + 0, + 5.48, + 0 + ], + "cornerRadius": 0.03 + }, + { + "kind": "generic_panel", + "semanticRole": "inspection_door", + "length": 0.26, + "height": 0.62, + "thickness": 0.018, + "position": [ + 0, + 2.1, + 0.255 + ], + "color": "#94a3b8" + }, + { + "kind": "generic_panel", + "semanticRole": "head_access_door", + "length": 0.28, + "height": 0.36, + "thickness": 0.018, + "position": [ + 0.12, + 5.48, + 0.345 + ], + "color": "#94a3b8" + }, + { + "kind": "hopper_body", + "semanticRole": "inlet_boot_hopper", + "length": 0.55, + "width": 0.42, + "height": 0.42, + "position": [ + -0.36, + 0.72, + 0.16 + ] + }, + { + "kind": "generic_spout", + "semanticRole": "discharge_spout", + "radius": 0.08, + "length": 0.72, + "axis": "x", + "position": [ + 0.72, + 5.45, + 0 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "head_drive_unit", + "length": 0.54, + "width": 0.32, + "height": 0.34, + "position": [ + -0.64, + 5.5, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "head_service_platform", + "length": 1.35, + "width": 0.68, + "height": 0.72, + "position": [ + 0, + 5.1, + 0.58 + ] + } + ], + "status": "stable", + "source": "imported_pack", + "description": "垂直输送熟料、粉料或原料的斗式提升机 profile。", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "head_service_platform": { + "detailLevel": "low", + "rungCount": 4 + }, + "head_drive_unit": { + "detailLevel": "low", + "count": 1 + }, + "inspection_door": { + "detailLevel": "low" + } + } + } + } +] diff --git a/cloud/industry.cement.basic-0.1.0/profiles/dust-collection-packaging.json b/cloud/industry.cement.basic-0.1.0/profiles/dust-collection-packaging.json new file mode 100644 index 000000000..885f74e3e --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/profiles/dust-collection-packaging.json @@ -0,0 +1,144 @@ +[ + { + "id": "cement.bag_filter", + "name": "袋收尘器", + "aliases": [ + "袋收尘器", + "袋式除尘器", + "bag filter", + "baghouse filter", + "dust collector" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "generic_industrial", + "defaultDimensions": { + "length": 2.8, + "width": 1.8, + "height": 3 + }, + "primarySemanticRole": "filter_housing", + "qualityRules": "quality.cement.bag_filter", + "parts": [ + { + "kind": "generic_body", + "semanticRole": "filter_housing", + "required": true + }, + { + "kind": "hopper_body", + "semanticRole": "hopper_base", + "required": true + }, + { + "kind": "generic_panel", + "semanticRole": "access_doors" + }, + { + "kind": "pipe_manifold", + "semanticRole": "clean_air_manifold" + }, + { + "kind": "generic_spout", + "semanticRole": "dust_discharge" + }, + { + "kind": "service_platform", + "semanticRole": "maintenance_platform" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "filter_bag_rows" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥厂除尘系统袋收尘器 profile。", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "access_doors": { + "detailLevel": "low" + }, + "filter_bag_rows": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.cement_packer", + "name": "水泥包装机", + "aliases": [ + "水泥包装机", + "包装机", + "cement packer", + "rotary packer", + "packing machine" + ], + "industry": "cement", + "family": "machine_tool", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "enclosed_machine", + "defaultDimensions": { + "length": 2.6, + "width": 1.8, + "height": 2.2 + }, + "primarySemanticRole": "packer_body", + "qualityRules": "quality.cement.cement_packer", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "packer_base", + "required": true + }, + { + "kind": "generic_body", + "semanticRole": "packer_body", + "required": true + }, + { + "kind": "hopper_body", + "semanticRole": "cement_feed_hopper", + "required": true + }, + { + "kind": "discharge_chute", + "semanticRole": "bag_discharge_chute" + }, + { + "kind": "operator_panel", + "semanticRole": "operator_panel" + }, + { + "kind": "display_screen", + "semanticRole": "weighing_display" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "rotary_drive_unit" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "袋装水泥包装机 profile,包含机体、进料斗、出袋端、控制面板和传动。", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "operator_panel": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.cement.basic-0.1.0/profiles/grinding.json b/cloud/industry.cement.basic-0.1.0/profiles/grinding.json new file mode 100644 index 000000000..4ad1f874c --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/profiles/grinding.json @@ -0,0 +1,657 @@ +[ + { + "id": "cement.vertical_raw_mill", + "name": "立磨", + "aliases": [ + "立磨", + "生料立磨", + "煤粉立磨", + "vertical raw mill", + "vertical roller mill", + "VRM", + "水泥立磨" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 3.4, + "width": 2.6, + "height": 4.2 + }, + "primarySemanticRole": "mill_body", + "qualityRules": "quality.cement.vertical_raw_mill", + "parts": [ + { + "kind": "skid_base", + "semanticRole": "mill_base", + "required": true, + "length": 2.8, + "width": 2.2, + "height": 0.28, + "position": [ + 0, + 0.14, + 0 + ], + "metalColor": "#4b5563" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "mill_body", + "required": true, + "axis": "y", + "height": 2.05, + "radius": 0.82, + "position": [ + 0, + 1.35, + 0 + ], + "primaryColor": "#8b949e", + "metalColor": "#64748b" + }, + { + "kind": "hopper_body", + "semanticRole": "grinding_table_housing", + "required": true, + "length": 1.45, + "width": 1.45, + "height": 0.72, + "topLengthScale": 1.35, + "topWidthScale": 1.35, + "position": [ + 0, + 0.72, + 0 + ], + "primaryColor": "#737b84", + "darkColor": "#374151" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "dynamic_separator", + "required": true, + "axis": "y", + "height": 1.1, + "radius": 0.52, + "position": [ + 0, + 2.92, + 0 + ], + "primaryColor": "#a3aab3", + "metalColor": "#64748b" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "mill_drive_unit", + "required": true, + "length": 0.92, + "height": 0.34, + "radius": 0.13, + "position": [ + 0.98, + 0.42, + -0.72 + ], + "primaryColor": "#475569", + "secondaryColor": "#334155" + }, + { + "kind": "hopper_body", + "semanticRole": "raw_feed_chute", + "required": true, + "length": 0.75, + "width": 0.46, + "height": 0.58, + "topLengthScale": 1.45, + "topWidthScale": 1.25, + "position": [ + -1.15, + 1.95, + 0 + ], + "primaryColor": "#94a3b8", + "darkColor": "#475569" + }, + { + "kind": "pipe_run", + "semanticRole": "hot_gas_inlet", + "required": true, + "axis": "x", + "length": 0.95, + "radius": 0.12, + "position": [ + -1.12, + 0.78, + -0.35 + ], + "metalColor": "#64748b" + }, + { + "kind": "outlet_port", + "semanticRole": "raw_meal_out", + "required": true, + "axis": "y", + "radius": 0.16, + "length": 0.34, + "position": [ + 0, + 3.62, + 0 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "operator_panel", + "semanticRole": "local_control_panel", + "length": 0.34, + "width": 0.12, + "height": 0.58, + "position": [ + 1.18, + 0.82, + 0.64 + ], + "primaryColor": "#334155", + "accentColor": "#facc15" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥生料或煤粉立磨 profile,包含磨体、进料斗、热风管、主传动和本地控制。", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72, + "parts": { + "dynamic_separator": { + "detailLevel": "low" + }, + "grinding_table_housing": { + "detailLevel": "low" + }, + "local_control_panel": { + "detailLevel": "low" + } + } + }, + "processPorts": [ + { + "id": "raw_feed_in", + "medium": "material", + "side": "left", + "height": 1.95, + "offset": 0 + }, + { + "id": "raw_meal_out", + "medium": "material", + "side": "top", + "height": 3.62, + "offset": 0 + }, + { + "id": "hot_gas_in", + "medium": "gas", + "side": "back", + "height": 0.78, + "offset": -0.35 + }, + { + "id": "power_in", + "medium": "power", + "side": "right", + "height": 0.42, + "offset": -0.72 + } + ], + "visualCues": [ + "vertical roller raw mill is a tall cylindrical mill body on a heavy base, not a generic box", + "conical grinding table housing at lower body with central gearbox drive at base", + "dynamic separator/classifier cylinder mounted on top of the mill body", + "raw material feed chute enters from one side at mid height", + "hot gas inlet duct enters near the base for drying, product outlet leaves from the top" + ], + "roleAliases": { + "mill_base": [ + "skid_base", + "support_base", + "foundation_base" + ], + "mill_body": [ + "vertical_mill_shell", + "cylindrical_tank", + "roller_mill_body" + ], + "grinding_table_housing": [ + "grinding_bowl", + "grinding_table", + "hopper_body" + ], + "dynamic_separator": [ + "classifier", + "separator", + "top_separator", + "cylindrical_tank" + ], + "mill_drive_unit": [ + "gearbox", + "main_drive", + "motor_gearbox_unit" + ], + "raw_feed_chute": [ + "feed_chute", + "raw_material_feed", + "hopper_body" + ], + "hot_gas_inlet": [ + "hot_air_inlet", + "drying_gas_inlet", + "pipe_run" + ], + "raw_meal_out": [ + "product_outlet", + "top_outlet", + "outlet_port" + ] + }, + "forbiddenRoles": [ + "belt_surface", + "conveyor_frame", + "vehicle_tire", + "vehicle_cabin" + ], + "preferredResolver": "profile-parts" + }, + { + "id": "cement.cement_mill", + "name": "水泥磨", + "aliases": [ + "水泥磨", + "球磨机", + "cement mill", + "ball mill" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "enclosed_machine", + "defaultDimensions": { + "length": 7, + "width": 2.2, + "height": 2.2, + "diameter": 1.8 + }, + "primarySemanticRole": "mill_shell", + "qualityRules": "quality.cement.cement_mill", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "mill_shell", + "required": true, + "axis": "x" + }, + { + "kind": "skid_base", + "semanticRole": "mill_support_base", + "required": true + }, + { + "kind": "flange_ring", + "semanticRole": "mill_riding_ring", + "required": true + }, + { + "kind": "bearing_block", + "semanticRole": "mill_trunnion_bearing", + "required": true + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "mill_drive_unit" + }, + { + "kind": "coupling_guard", + "semanticRole": "mill_coupling_guard" + }, + { + "kind": "inlet_port", + "semanticRole": "feed_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "product_outlet" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "卧式水泥磨或球磨机 profile,包含筒体、轴承座、传动和进出料端。", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72, + "parts": { + "mill_riding_ring": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "mill_coupling_guard": { + "detailLevel": "low", + "ringCount": 3, + "spokeCount": 12 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "horizontal", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.cement_separator", + "name": "选粉机", + "aliases": [ + "选粉机", + "水泥选粉机", + "cement separator", + "air classifier", + "dynamic separator", + "O-Sepa separator" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 2.4, + "width": 2.4, + "height": 4.8 + }, + "primarySemanticRole": "separator_body", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "separator_body", + "required": true, + "axis": "y", + "height": 3.2, + "radius": 0.76, + "position": [ + 0, + 2.6, + 0 + ], + "primaryColor": "#94a3b8", + "metalColor": "#64748b" + }, + { + "kind": "skid_base", + "semanticRole": "separator_support_base", + "required": true, + "length": 2.1, + "width": 2.1, + "height": 0.22, + "position": [ + 0, + 0.11, + 0 + ], + "metalColor": "#6b7280" + }, + { + "kind": "hopper_body", + "semanticRole": "coarse_reject_cone", + "required": true, + "length": 1.2, + "width": 1.2, + "height": 0.9, + "topLengthScale": 1.55, + "topWidthScale": 1.55, + "position": [ + 0, + 0.72, + 0 + ], + "primaryColor": "#6b7280", + "darkColor": "#374151" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "separator_drive_unit", + "length": 0.55, + "height": 0.42, + "radius": 0.12, + "position": [ + 0, + 4.55, + 0 + ], + "primaryColor": "#475569", + "secondaryColor": "#334155" + }, + { + "kind": "inlet_port", + "semanticRole": "coarse_feed_in", + "radius": 0.14, + "length": 0.32, + "axis": "x", + "position": [ + -0.82, + 2.5, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "fine_product_out", + "radius": 0.18, + "length": 0.35, + "axis": "y", + "position": [ + 0, + 4.52, + 0 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "outlet_port", + "semanticRole": "coarse_reject_out", + "radius": 0.12, + "length": 0.3, + "axis": "x", + "position": [ + 0.78, + 0.42, + 0 + ], + "metalColor": "#6b7280" + } + ], + "visualCues": [ + "tall cylindrical air classifier body on a support base", + "feed inlet at mid-height on the side for ground cement from mill", + "variable speed rotor drive motor mounted on top", + "fine product exit at top flowing to cement silo via air slide", + "coarse reject cone at bottom returning oversize material back to cement mill", + "smaller diameter than preheater cyclones; taller than wide" + ], + "roleAliases": { + "separator_body": [ + "cylindrical_tank", + "classifier_body", + "separator_shell" + ], + "separator_support_base": [ + "support_base", + "skid_base" + ], + "coarse_reject_cone": [ + "reject_hopper", + "coarse_cone", + "hopper_body" + ], + "separator_drive_unit": [ + "rotor_drive", + "classifier_motor", + "motor_gearbox_unit" + ], + "coarse_feed_in": [ + "mill_product_inlet", + "feed_port", + "inlet_port" + ], + "fine_product_out": [ + "fine_outlet", + "product_outlet", + "top_outlet", + "outlet_port" + ], + "coarse_reject_out": [ + "coarse_outlet", + "reject_outlet", + "return_outlet", + "outlet_port" + ] + }, + "status": "stable", + "source": "imported_pack", + "description": "水泥磨圈流系统选粉机 profile,竖式圆筒体,顶部传动,侧面进料,顶部出细粉,底部锥斗排粗料返回磨机。", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 48 + }, + "processPorts": [ + { + "id": "coarse_feed_in", + "medium": "material", + "side": "left", + "height": 2.5, + "offset": 0 + }, + { + "id": "fine_product_out", + "medium": "material", + "side": "top", + "height": 4.52, + "offset": 0 + }, + { + "id": "coarse_reject_out", + "medium": "material", + "side": "right", + "height": 0.65, + "offset": 0 + } + ], + "qualityRules": "quality.cement.cement_separator", + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.roller_press", + "name": "辊压机", + "aliases": [ + "辊压机", + "roller press", + "high pressure grinding roll", + "HPGR" + ], + "industry": "cement", + "family": "machine_tool", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "enclosed_machine", + "defaultDimensions": { + "length": 2.8, + "width": 2, + "height": 2.2 + }, + "primarySemanticRole": "roller_press_body", + "qualityRules": "quality.cement.roller_press", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "machine_base", + "required": true + }, + { + "kind": "generic_body", + "semanticRole": "roller_press_body", + "required": true + }, + { + "kind": "generic_panel", + "semanticRole": "press_roll_pair", + "required": true + }, + { + "kind": "hopper_body", + "semanticRole": "material_feed_hopper" + }, + { + "kind": "discharge_chute", + "semanticRole": "pressed_material_discharge" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "roller_press_drive_unit" + }, + { + "kind": "guard_fence", + "semanticRole": "safety_guard" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥粉磨系统辊压机 profile,表达机架、辊对、进料斗、出料端和传动防护。", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "roller_press_body": { + "detailLevel": "low", + "count": 5 + }, + "press_roll_pair": { + "detailLevel": "low" + }, + "roller_press_drive_unit": { + "detailLevel": "low", + "count": 5 + }, + "safety_guard": { + "detailLevel": "low", + "ringCount": 3, + "spokeCount": 12 + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.cement.basic-0.1.0/profiles/plant-special-equipment.json b/cloud/industry.cement.basic-0.1.0/profiles/plant-special-equipment.json new file mode 100644 index 000000000..4237ba058 --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/profiles/plant-special-equipment.json @@ -0,0 +1,1700 @@ +[ + { + "id": "cement.limestone_crusher", + "name": "Limestone crusher", + "aliases": [ + "limestone crusher", + "cement crusher", + "stone crusher", + "石灰石破碎机", + "破碎机" + ], + "industry": "cement", + "family": "machine_tool", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "material_handling", + "defaultDimensions": { + "length": 4.2, + "width": 2.4, + "height": 2.6 + }, + "primarySemanticRole": "crusher_housing", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "crusher_base", + "required": true, + "length": 4.1, + "width": 2.3, + "height": 0.28 + }, + { + "kind": "generic_body", + "semanticRole": "crusher_housing", + "required": true, + "length": 3.2, + "width": 1.8, + "height": 1.45, + "position": [ + 0, + 0.9, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "limestone_feed_hopper", + "required": true, + "length": 1.7, + "width": 1.5, + "height": 1.15, + "position": [ + -1.2, + 1.85, + 0 + ] + }, + { + "kind": "discharge_chute", + "semanticRole": "crushed_material_discharge", + "required": true, + "position": [ + 1.7, + 0.65, + 0 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "crusher_drive_unit", + "required": true, + "position": [ + 0.9, + 0.52, + -1.35 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "maintenance_doors", + "position": [ + 0, + 1, + -0.94 + ] + } + ], + "visualCues": [ + "feed hopper over a heavy crusher housing", + "side drive unit", + "discharge chute toward downstream conveyor" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual cement limestone crusher profile for factory generation.", + "qualityRules": "quality.cement.limestone_crusher", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "maintenance_doors": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.stack_reclaimer", + "name": "Stacker reclaimer", + "aliases": [ + "stacker reclaimer", + "reclaimer", + "pre-homogenization", + "堆取料机", + "预均化" + ], + "industry": "cement", + "family": "conveyor", + "layoutFamily": "linear_transport_layout", + "archetypeFamily": "material_handling", + "defaultDimensions": { + "length": 10, + "width": 2.6, + "height": 2.8 + }, + "primarySemanticRole": "reclaimer_bridge", + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "reclaimer_bridge", + "required": true, + "length": 9.6, + "width": 0.55, + "height": 0.42, + "position": [ + 0, + 1.35, + 0 + ] + }, + { + "kind": "belt_surface", + "semanticRole": "stockpile_conveyor", + "required": true, + "length": 9.2, + "width": 0.42, + "position": [ + 0, + 1.55, + 0 + ] + }, + { + "kind": "generic_body", + "semanticRole": "reclaimer_travel_car", + "required": true, + "length": 1.2, + "width": 1.05, + "height": 1.1, + "position": [ + -2.2, + 0.85, + 0 + ] + }, + { + "kind": "flange_ring", + "semanticRole": "bucket_wheel", + "required": true, + "radius": 0.48, + "position": [ + -3.15, + 1.05, + 0 + ] + }, + { + "kind": "skid_base", + "semanticRole": "rail_track", + "required": true, + "length": 10, + "width": 2.2, + "height": 0.08, + "position": [ + 0, + 0.05, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "operator_walkway", + "position": [ + 1.4, + 1.75, + 0.44 + ] + } + ], + "visualCues": [ + "long bridge over material stockpile", + "bucket wheel on one side", + "belt conveyor along the bridge" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual stacker/reclaimer for limestone pre-homogenization.", + "qualityRules": "quality.cement.stack_reclaimer", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "bucket_wheel": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.raw_meal_homogenization_silo", + "name": "Raw meal homogenization silo", + "aliases": [ + "raw meal silo", + "homogenization silo", + "生料均化库", + "生料库" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 4.2, + "width": 4.2, + "height": 8.5, + "diameter": 3.8 + }, + "primarySemanticRole": "raw_meal_silo_shell", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "raw_meal_silo_shell", + "required": true, + "height": 7.2, + "radius": 1.75, + "position": [ + 0, + 4, + 0 + ], + "axis": "y" + }, + { + "kind": "skid_base", + "semanticRole": "silo_support_base", + "required": true, + "length": 3.9, + "width": 3.9, + "height": 0.25, + "position": [ + 0, + 0.12, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "bottom_discharge_hopper", + "required": true, + "length": 2.5, + "width": 2.5, + "height": 1, + "position": [ + 0, + 0.9, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "top_feed_inlet", + "required": true, + "position": [ + 0, + 8, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "raw_meal_out", + "required": true, + "position": [ + 1.9, + 0.85, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "aeration_floor_manifold", + "position": [ + 0, + 0.55, + 0 + ] + } + ], + "visualCues": [ + "tall cylindrical homogenization silo", + "bottom discharge hopper", + "top feed inlet", + "aeration floor manifold" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual raw meal homogenization silo for cement plant layout.", + "qualityRules": "quality.cement.raw_meal_homogenization_silo", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "processPorts": [ + { + "id": "top_feed_inlet", + "medium": "material", + "side": "top", + "height": 8, + "offset": 0 + }, + { + "id": "raw_meal_out", + "medium": "material", + "side": "right", + "height": 0.85, + "offset": 0 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.coal_mill", + "name": "Coal mill", + "aliases": [ + "coal mill", + "fuel mill", + "煤磨", + "燃料制备" + ], + "industry": "cement", + "family": "machine_tool", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "enclosed_machine", + "defaultDimensions": { + "length": 3.4, + "width": 2.4, + "height": 3.6 + }, + "primarySemanticRole": "coal_mill_body", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "mill_base", + "required": true, + "length": 3.2, + "width": 2.2, + "height": 0.25 + }, + { + "kind": "generic_body", + "semanticRole": "coal_mill_body", + "required": true, + "length": 2.4, + "width": 1.7, + "height": 1.7, + "position": [ + 0, + 1, + 0 + ] + }, + { + "kind": "cylindrical_tank", + "semanticRole": "dynamic_separator", + "required": true, + "axis": "y", + "height": 1.2, + "radius": 0.62, + "position": [ + 0, + 2.35, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "coal_feed_hopper", + "position": [ + -1.15, + 1.75, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "inert_gas_duct", + "position": [ + 1.15, + 2.1, + 0 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "mill_drive_unit", + "required": true, + "position": [ + 0.8, + 0.52, + -1.25 + ] + } + ], + "visualCues": [ + "compact vertical mill body", + "separator above mill", + "coal feed hopper", + "inert gas duct" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual coal mill profile for cement kiln fuel preparation.", + "qualityRules": "quality.cement.coal_mill", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.kiln_burner", + "name": "Kiln burner", + "aliases": [ + "kiln burner", + "cement burner", + "窑头燃烧器", + "燃烧器" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "linear_transport_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 4.2, + "width": 1, + "height": 1.25 + }, + "primarySemanticRole": "burner_lance", + "parts": [ + { + "kind": "pipe_run", + "semanticRole": "burner_lance", + "required": true, + "axis": "x", + "length": 3.9, + "radius": 0.105, + "position": [ + 0, + 0.86, + 0 + ], + "metalColor": "#475569" + }, + { + "kind": "pipe_run", + "semanticRole": "primary_air_channel", + "required": true, + "axis": "x", + "length": 3.55, + "radius": 0.072, + "position": [ + 0, + 0.99, + 0.16 + ], + "metalColor": "#64748b" + }, + { + "kind": "pipe_run", + "semanticRole": "coal_fuel_pipe", + "required": true, + "axis": "x", + "length": 3.55, + "radius": 0.046, + "position": [ + 0, + 0.99, + -0.16 + ], + "metalColor": "#334155" + }, + { + "kind": "pipe_run", + "semanticRole": "gas_oil_lance", + "axis": "x", + "length": 3.25, + "radius": 0.032, + "position": [ + 0.08, + 1.1, + 0 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "flange_ring", + "semanticRole": "burner_mounting_flange", + "required": true, + "axis": "x", + "radius": 0.18, + "tubeRadius": 0.018, + "includeBolts": false, + "position": [ + 1.78, + 0.86, + 0 + ], + "metalColor": "#6b7280" + }, + { + "kind": "outlet_port", + "semanticRole": "multi_channel_nozzle_tip", + "required": true, + "axis": "x", + "radius": 0.13, + "length": 0.28, + "position": [ + 2.08, + 0.86, + 0 + ], + "metalColor": "#f97316" + }, + { + "kind": "inlet_port", + "semanticRole": "fuel_in", + "required": true, + "axis": "x", + "radius": 0.08, + "length": 0.3, + "position": [ + -2.08, + 0.96, + -0.16 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "inlet_port", + "semanticRole": "primary_air_in", + "required": true, + "axis": "x", + "radius": 0.1, + "length": 0.3, + "position": [ + -2.08, + 0.99, + 0.16 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "skid_base", + "semanticRole": "burner_carriage", + "required": true, + "length": 1.85, + "width": 0.78, + "height": 0.16, + "position": [ + -0.75, + 0.18, + 0 + ], + "metalColor": "#111827" + }, + { + "kind": "generic_panel", + "semanticRole": "carriage_rail_pair", + "length": 2.2, + "height": 0.08, + "thickness": 0.05, + "position": [ + -0.75, + 0.08, + 0.34 + ], + "color": "#111827" + }, + { + "kind": "generic_panel", + "semanticRole": "carriage_rail_pair", + "length": 2.2, + "height": 0.08, + "thickness": 0.05, + "position": [ + -0.75, + 0.08, + -0.34 + ], + "color": "#111827" + } + ], + "visualCues": [ + "long multi-channel burner lance aligned with rotary kiln axis and inserted through kiln hood burner port", + "coaxial/parallel primary air, coal/fuel, and gas-oil lance pipes, not a conveyor or simple duct", + "orange or refractory-colored nozzle tip at the kiln-facing end", + "mounting flange at hood wall and retractable carriage/rails under the lance", + "fuel and primary air inlets at the rear service end" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual multi-channel kiln burner for cement pyroprocess visualization.", + "qualityRules": "quality.cement.kiln_burner", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 82, + "parts": { + "burner_mounting_flange": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "carriage_rail_pair": { + "detailLevel": "low" + } + } + }, + "processPorts": [ + { + "id": "fuel_in", + "medium": "material", + "side": "left", + "height": 0.96, + "offset": -0.16 + }, + { + "id": "primary_air_in", + "medium": "gas", + "side": "left", + "height": 0.99, + "offset": 0.16 + }, + { + "id": "flame_out", + "medium": "gas", + "side": "right", + "height": 0.86, + "offset": 0 + } + ], + "roleAliases": { + "burner_lance": [ + "main_burner_pipe", + "burner_pipe", + "pipe_run" + ], + "primary_air_channel": [ + "primary_air_pipe", + "axial_air_channel", + "pipe_run" + ], + "coal_fuel_pipe": [ + "fuel_pipe", + "pulverized_coal_pipe", + "pipe_run" + ], + "gas_oil_lance": [ + "central_lance", + "oil_gas_lance", + "pipe_run" + ], + "multi_channel_nozzle_tip": [ + "flame_out", + "burner_tip", + "outlet_port" + ], + "burner_mounting_flange": [ + "hood_mounting_flange", + "flange_ring" + ], + "burner_carriage": [ + "retraction_carriage", + "skid_base" + ], + "carriage_rail_pair": [ + "burner_rails", + "guide_rails", + "generic_panel" + ] + }, + "forbiddenRoles": [ + "belt_surface", + "conveyor_frame", + "vehicle_tire", + "vehicle_cabin" + ], + "preferredResolver": "profile-parts" + }, + { + "id": "cement.kiln_hood", + "name": "Kiln hood", + "aliases": [ + "kiln hood", + "kiln head hood", + "窑头罩" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 3.4, + "width": 2.55, + "height": 2.65 + }, + "primarySemanticRole": "kiln_hood_shell", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "hood_base", + "required": true, + "length": 3.25, + "width": 2.45, + "height": 0.24, + "position": [ + 0, + 0.12, + 0 + ], + "metalColor": "#4b5563" + }, + { + "kind": "generic_body", + "semanticRole": "kiln_hood_shell", + "required": true, + "length": 2.85, + "width": 2.15, + "height": 1.85, + "position": [ + 0, + 1.18, + 0 + ], + "primaryColor": "#475569", + "metalColor": "#374151" + }, + { + "kind": "generic_panel", + "semanticRole": "refractory_front_wall", + "required": true, + "length": 2.2, + "height": 1.55, + "thickness": 0.08, + "position": [ + -1.48, + 1.22, + 0 + ], + "rotation": [ + 0, + 1.5707963267948966, + 0 + ], + "color": "#334155" + }, + { + "kind": "flange_ring", + "semanticRole": "kiln_head_in", + "required": true, + "axis": "x", + "radius": 0.43, + "tubeRadius": 0.035, + "includeBolts": false, + "position": [ + -1.58, + 1.2, + 0 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "flange_ring", + "semanticRole": "burner_opening", + "required": true, + "axis": "x", + "radius": 0.19, + "tubeRadius": 0.02, + "includeBolts": false, + "position": [ + -1.62, + 1.22, + -0.42 + ], + "metalColor": "#facc15" + }, + { + "kind": "hopper_body", + "semanticRole": "hot_clinker_drop_chute", + "required": true, + "length": 0.9, + "width": 0.78, + "height": 0.68, + "topLengthScale": 1.45, + "topWidthScale": 1.25, + "position": [ + 1.18, + 0.58, + 0 + ], + "primaryColor": "#78716c", + "darkColor": "#44403c" + }, + { + "kind": "outlet_port", + "semanticRole": "hot_clinker_out", + "required": true, + "axis": "x", + "radius": 0.18, + "length": 0.34, + "position": [ + 1.72, + 0.52, + 0 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "pipe_run", + "semanticRole": "secondary_air_return_duct", + "required": true, + "axis": "z", + "length": 1.15, + "radius": 0.16, + "position": [ + 0.55, + 2.18, + 0.86 + ], + "metalColor": "#b45309" + }, + { + "kind": "pipe_run", + "semanticRole": "tertiary_air_takeoff", + "axis": "x", + "length": 1.25, + "radius": 0.14, + "position": [ + 0.8, + 2, + -0.96 + ], + "metalColor": "#b45309" + }, + { + "kind": "generic_panel", + "semanticRole": "inspection_door", + "length": 0.42, + "height": 0.58, + "thickness": 0.045, + "position": [ + 0.12, + 1.02, + 1.1 + ], + "color": "#94a3b8" + }, + { + "kind": "service_platform", + "semanticRole": "hood_service_platform", + "length": 2.35, + "width": 0.34, + "height": 0.28, + "overallHeight": 0.18, + "position": [ + 0.15, + 2.18, + 1.18 + ], + "metalColor": "#64748b", + "color": "#facc15" + } + ], + "visualCues": [ + "refractory-lined kiln head hood enclosing the rotary kiln discharge end, not a simple black box", + "large circular kiln shell opening on the kiln side with seal/flange ring", + "smaller burner opening beside or below kiln centerline for the burner lance to enter the hood", + "bottom or low-side hot clinker drop chute leading directly to the grate cooler inlet", + "secondary air return / tertiary air takeoff ducting on upper hood area", + "inspection door and maintenance platform around the hood" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual kiln head hood connecting kiln, burner, and clinker cooler.", + "qualityRules": "quality.cement.kiln_hood", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 86, + "parts": { + "kiln_head_in": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "burner_opening": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "hood_service_platform": { + "detailLevel": "low" + } + } + }, + "processPorts": [ + { + "id": "kiln_head_in", + "medium": "material", + "side": "left", + "height": 1.2, + "offset": 0 + }, + { + "id": "burner_opening", + "medium": "gas", + "side": "left", + "height": 1.22, + "offset": -0.42 + }, + { + "id": "hot_clinker_out", + "medium": "material", + "side": "right", + "height": 0.52, + "offset": 0 + }, + { + "id": "secondary_air_in", + "medium": "gas", + "side": "back", + "height": 2.18, + "offset": 0.86 + }, + { + "id": "tertiary_air_out", + "medium": "gas", + "side": "front", + "height": 2, + "offset": -0.96 + } + ], + "roleAliases": { + "kiln_hood_shell": [ + "hood_body", + "refractory_hood", + "generic_body" + ], + "kiln_head_in": [ + "kiln_outlet_opening", + "kiln_seal_ring", + "flange_ring" + ], + "burner_opening": [ + "burner_port", + "burner_lance_opening", + "flange_ring" + ], + "hot_clinker_drop_chute": [ + "clinker_drop_chute", + "cooler_inlet_chute", + "hopper_body" + ], + "hot_clinker_out": [ + "clinker_outlet", + "cooler_feed_outlet", + "outlet_port" + ], + "secondary_air_return_duct": [ + "cooler_air_return", + "secondary_air_duct", + "pipe_run" + ], + "tertiary_air_takeoff": [ + "tertiary_air_duct_connection", + "hot_air_takeoff", + "pipe_run" + ], + "hood_service_platform": [ + "maintenance_platform", + "service_platform" + ] + }, + "forbiddenRoles": [ + "belt_surface", + "conveyor_frame", + "vehicle_tire", + "vehicle_cabin" + ], + "preferredResolver": "profile-parts" + }, + { + "id": "cement.tertiary_air_duct", + "name": "Tertiary air duct", + "aliases": [ + "tertiary air duct", + "三次风管", + "三次风" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "linear_transport_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 6.2, + "width": 0.9, + "height": 1.4 + }, + "primarySemanticRole": "tertiary_air_duct_shell", + "parts": [ + { + "kind": "pipe_run", + "semanticRole": "tertiary_air_duct_shell", + "required": true, + "axis": "x", + "length": 5.8, + "radius": 0.26, + "position": [ + 0, + 1.05, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "cooler_air_in", + "required": true, + "position": [ + -3.05, + 1.05, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "tertiary_air_out", + "required": true, + "position": [ + 3.05, + 1.05, + 0 + ] + }, + { + "kind": "skid_base", + "semanticRole": "duct_supports", + "required": true, + "length": 5.6, + "width": 0.65, + "height": 0.18, + "position": [ + 0, + 0.18, + 0 + ] + }, + { + "kind": "flange_ring", + "semanticRole": "duct_expansion_joint", + "position": [ + 0, + 1.05, + 0 + ] + } + ], + "visualCues": [ + "large horizontal hot air duct", + "flanged expansion joint", + "support base" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual tertiary air duct between cooler and calciner/preheater.", + "qualityRules": "quality.cement.tertiary_air_duct", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "duct_expansion_joint": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.clinker_crusher", + "name": "Clinker crusher", + "aliases": [ + "clinker crusher", + "熟料破碎机", + "熟料破碎" + ], + "industry": "cement", + "family": "machine_tool", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "material_handling", + "defaultDimensions": { + "length": 3, + "width": 1.8, + "height": 1.8 + }, + "primarySemanticRole": "clinker_crusher_housing", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "crusher_base", + "required": true, + "length": 2.9, + "width": 1.7, + "height": 0.22 + }, + { + "kind": "generic_body", + "semanticRole": "clinker_crusher_housing", + "required": true, + "length": 2.4, + "width": 1.35, + "height": 1.1, + "position": [ + 0, + 0.78, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "hot_clinker_in", + "required": true, + "position": [ + -1.45, + 1, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "crushed_clinker_out", + "required": true, + "position": [ + 1.45, + 0.58, + 0 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "crusher_drive_unit", + "position": [ + 0.75, + 0.45, + -1 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "roll_access_cover", + "position": [ + 0, + 1.18, + 0 + ] + } + ], + "visualCues": [ + "compact crusher below cooler outlet", + "inlet and discharge ports", + "side drive" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual clinker crusher for cooler discharge.", + "qualityRules": "quality.cement.clinker_crusher", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "roll_access_cover": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.esp_dust_collector", + "name": "Electrostatic precipitator", + "aliases": [ + "ESP", + "electrostatic precipitator", + "kiln tail ESP", + "电收尘", + "静电除尘" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "generic_industrial", + "defaultDimensions": { + "length": 5.2, + "width": 2.2, + "height": 3.2 + }, + "primarySemanticRole": "esp_collector_chambers", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "esp_support_base", + "required": true, + "length": 5.1, + "width": 2, + "height": 0.22 + }, + { + "kind": "generic_body", + "semanticRole": "esp_collector_chambers", + "required": true, + "length": 4.5, + "width": 1.8, + "height": 2, + "position": [ + 0, + 1.55, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "dust_hopper_bank", + "required": true, + "length": 4, + "width": 1.45, + "height": 0.9, + "position": [ + 0, + 0.72, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "dust_gas_in", + "required": true, + "position": [ + -2.7, + 1.65, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "clean_air_out", + "required": true, + "position": [ + 2.7, + 1.75, + 0 + ] + }, + { + "kind": "generic_detail_accent", + "semanticRole": "electrode_field_rows", + "position": [ + 0, + 2.25, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "top_service_platform", + "position": [ + 0, + 2.8, + 0.95 + ] + } + ], + "visualCues": [ + "long rectangular ESP chamber", + "multiple dust hoppers below", + "large inlet and outlet plenums" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual electrostatic precipitator profile for cement kiln exhaust.", + "qualityRules": "quality.cement.esp_dust_collector", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 90 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.process_stack", + "name": "Process stack", + "aliases": [ + "process stack", + "cement stack", + "chimney", + "smokestack", + "烟囱" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "vessel_layout", + "archetypeFamily": "generic_industrial", + "defaultDimensions": { + "length": 2.4, + "width": 2.4, + "height": 10, + "diameter": 1.2 + }, + "primarySemanticRole": "stack_shell", + "parts": [ + { + "kind": "skid_base", + "semanticRole": "stack_base_plinth", + "required": true, + "length": 2.2, + "width": 2.2, + "height": 0.35 + }, + { + "kind": "chimney_stack", + "semanticRole": "stack_shell", + "required": true, + "height": 9.2, + "radius": 0.42, + "position": [ + 0, + 4.9, + 0 + ], + "warningStripes": true + }, + { + "kind": "inlet_port", + "semanticRole": "stack_gas_in", + "required": true, + "position": [ + -0.75, + 1.15, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "stack_outlet", + "required": true, + "position": [ + 0, + 9.75, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "stack_access_platform", + "position": [ + 0, + 6, + 0.55 + ] + } + ], + "visualCues": [ + "tall chimney stack", + "base inlet duct", + "warning bands", + "access platform" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual process exhaust stack for cement plant visualization.", + "qualityRules": "quality.cement.process_stack", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.whr_boiler", + "name": "Waste heat recovery boiler", + "aliases": [ + "whr boiler", + "waste heat recovery boiler", + "AQC boiler", + "SP boiler", + "余热锅炉", + "余热发电" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 3.8, + "width": 2.4, + "height": 5.2 + }, + "primarySemanticRole": "boiler_casing", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "boiler_base", + "required": true, + "length": 3.6, + "width": 2.2, + "height": 0.25 + }, + { + "kind": "generic_body", + "semanticRole": "boiler_casing", + "required": true, + "length": 2.8, + "width": 1.8, + "height": 4.1, + "position": [ + 0, + 2.35, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "tube_bank", + "required": true, + "position": [ + 0, + 2.55, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "hot_gas_in", + "required": true, + "position": [ + -1.85, + 3.2, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "cooled_gas_out", + "required": true, + "position": [ + 1.85, + 3.2, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "boiler_service_platform", + "position": [ + 0.15, + 4.4, + 1.05 + ] + } + ], + "visualCues": [ + "tall boiler casing", + "visible tube bank/manifold", + "hot gas inlet and outlet", + "service platform" + ], + "status": "stable", + "source": "imported_pack", + "description": "Conceptual cement kiln waste heat recovery boiler profile.", + "qualityRules": "quality.cement.whr_boiler", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.control_room", + "name": "Central control room", + "aliases": [ + "central control room", + "cement control room", + "cement plant control room", + "process control room", + "MCC room", + "MCC control", + "mcc_control", + "control cabinet room", + "水泥厂中控室", + "中控室", + "控制室", + "MCC控制室", + "MCC控制柜" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "occupied_building", + "preferredResolver": "profile-parts", + "defaultDimensions": { + "length": 5.2, + "width": 2.8, + "height": 2.6 + }, + "primarySemanticRole": "control_room_body", + "parts": [ + { + "kind": "generic_body", + "semanticRole": "control_room_body", + "required": true, + "length": 5.2, + "width": 2.8, + "height": 2.25, + "primaryColor": "#d1d5db", + "cornerRadius": 0.04 + }, + { + "kind": "generic_body", + "semanticRole": "control_room_roof_cap", + "required": true, + "length": 5.45, + "width": 3.05, + "height": 0.18, + "alignAbove": "control_room_body", + "primaryColor": "#94a3b8" + }, + { + "kind": "generic_opening", + "semanticRole": "control_room_door", + "required": true, + "length": 0.82, + "height": 1.6, + "attachToRole": "control_room_body", + "anchor": "front", + "offset": [ + -1.7, + -0.12, + 0 + ] + }, + { + "kind": "generic_detail_accent", + "semanticRole": "kiln_line_observation_window", + "required": true, + "length": 1.1, + "height": 0.42, + "attachToRole": "control_room_body", + "anchor": "front", + "offset": [ + 0.7, + 0.28, + 0 + ], + "accentColor": "#88c0d0", + "opacity": 0.42 + }, + { + "kind": "generic_detail_accent", + "semanticRole": "grinding_line_observation_window", + "length": 0.9, + "height": 0.38, + "attachToRole": "control_room_body", + "anchor": "right", + "offset": [ + 0, + 0.24, + 0.25 + ], + "accentColor": "#88c0d0", + "opacity": 0.38 + }, + { + "kind": "control_box", + "semanticRole": "mcc_panel_row", + "required": true, + "attachToRole": "control_room_body", + "anchor": "service_side" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "scada_display_panel", + "length": 1.2, + "height": 0.38, + "attachToRole": "control_room_body", + "anchor": "front", + "offset": [ + 1.65, + 0.2, + 0 + ], + "accentColor": "#0f172a" + }, + { + "kind": "pipe_run", + "semanticRole": "cable_tray_entry", + "required": true, + "length": 2, + "radius": 0.035, + "axis": "x", + "attachToRole": "control_room_body", + "anchor": "back" + } + ], + "visualCues": [ + "single-storey cement plant central control building", + "flat roof cap and concrete wall body", + "entrance door and blue-tinted observation windows toward kiln and grinding lines", + "MCC cabinet row and SCADA display panel", + "cable-tray entry for power and control connections" + ], + "status": "stable", + "source": "imported_pack", + "description": "Cement plant occupied central control room and MCC room profile, modeled as a small building instead of a cabinet row.", + "qualityRules": "quality.cement.control_room", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 64 + }, + "processPorts": [ + { + "id": "power_out", + "medium": "power", + "side": "back", + "height": 0.45, + "offset": 0 + }, + { + "id": "control_network_out", + "medium": "signal", + "side": "back", + "height": 0.55, + "offset": 0.2 + } + ] + } +] diff --git a/cloud/industry.cement.basic-0.1.0/profiles/pyroprocess.json b/cloud/industry.cement.basic-0.1.0/profiles/pyroprocess.json new file mode 100644 index 000000000..f17b60d68 --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/profiles/pyroprocess.json @@ -0,0 +1,1165 @@ +[ + { + "id": "cement.rotary_kiln", + "name": "回转窑", + "aliases": [ + "回转窑", + "水泥回转窑", + "水泥窑", + "rotary kiln", + "cement kiln", + "kiln shell" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 6.4, + "width": 0.8, + "height": 0.9, + "diameter": 0.4 + }, + "primarySemanticRole": "vessel_shell", + "processPorts": [ + { + "id": "hot_meal_in", + "medium": "material", + "side": "left", + "height": 0.9, + "offset": 0 + }, + { + "id": "clinker_out", + "medium": "material", + "side": "right", + "height": 0.65, + "offset": 0 + }, + { + "id": "kiln_exhaust_out", + "medium": "gas", + "side": "left", + "height": 1.1, + "offset": -0.25 + }, + { + "id": "power_in", + "medium": "power", + "side": "back", + "height": 0.35, + "offset": 0.9 + } + ], + "qualityRules": "quality.cement.rotary_kiln", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "vessel_shell", + "required": true, + "axis": "x", + "length": 6, + "radius": 0.2, + "position": [ + 0, + 0.34, + 0 + ], + "primaryColor": "#6f747a", + "metalColor": "#9ca3af" + }, + { + "kind": "skid_base", + "semanticRole": "kiln_support_base", + "required": true, + "length": 6.2, + "width": 0.7, + "height": 0.12, + "position": [ + 0, + 0.05, + 0 + ], + "metalColor": "#6b7280" + }, + { + "id": "riding-ring-tail", + "kind": "flange_ring", + "semanticRole": "riding_ring", + "required": true, + "axis": "x", + "radius": 0.23, + "tubeRadius": 0.018, + "includeBolts": false, + "position": [ + -2, + 0.36, + 0 + ], + "metalColor": "#4b5563" + }, + { + "id": "riding-ring-center", + "kind": "flange_ring", + "semanticRole": "riding_ring", + "required": true, + "axis": "x", + "radius": 0.23, + "tubeRadius": 0.018, + "includeBolts": false, + "position": [ + 0, + 0.34, + 0 + ], + "metalColor": "#4b5563" + }, + { + "id": "riding-ring-head", + "kind": "flange_ring", + "semanticRole": "riding_ring", + "required": true, + "axis": "x", + "radius": 0.23, + "tubeRadius": 0.018, + "includeBolts": false, + "position": [ + 2, + 0.32, + 0 + ], + "metalColor": "#4b5563" + }, + { + "id": "support-roller-tail", + "kind": "support_roller_pair", + "semanticRole": "support_roller", + "required": true, + "length": 0.42, + "width": 0.56, + "height": 0.18, + "radius": 0.05, + "rollerLength": 0.16, + "position": [ + -2, + 0.12, + 0 + ], + "metalColor": "#64748b" + }, + { + "id": "support-roller-center", + "kind": "support_roller_pair", + "semanticRole": "support_roller", + "required": true, + "length": 0.42, + "width": 0.56, + "height": 0.18, + "radius": 0.05, + "rollerLength": 0.16, + "position": [ + 0, + 0.12, + 0 + ], + "metalColor": "#64748b" + }, + { + "id": "support-roller-head", + "kind": "support_roller_pair", + "semanticRole": "support_roller", + "required": true, + "length": 0.42, + "width": 0.56, + "height": 0.18, + "radius": 0.05, + "rollerLength": 0.16, + "position": [ + 2, + 0.12, + 0 + ], + "metalColor": "#64748b" + }, + { + "id": "girth-gear", + "kind": "flange_ring", + "semanticRole": "girth_gear", + "axis": "x", + "radius": 0.26, + "tubeRadius": 0.025, + "includeBolts": false, + "position": [ + 0.9, + 0.33, + 0 + ], + "metalColor": "#374151" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "kiln_drive_unit", + "required": true, + "length": 0.7, + "height": 0.22, + "radius": 0.08, + "position": [ + 0.9, + 0.17, + -0.55 + ], + "primaryColor": "#64748b", + "secondaryColor": "#475569" + }, + { + "kind": "coupling_guard", + "semanticRole": "drive_coupling_guard", + "length": 0.42, + "radius": 0.055, + "position": [ + 0.9, + 0.26, + -0.38 + ], + "color": "#facc15" + }, + { + "kind": "hopper_body", + "semanticRole": "kiln_tail_feed_hopper", + "required": true, + "length": 0.55, + "width": 0.36, + "height": 0.52, + "position": [ + -3.25, + 0.58, + 0 + ], + "primaryColor": "#94a3b8" + }, + { + "kind": "outlet_port", + "semanticRole": "kiln_head_outlet", + "required": true, + "radius": 0.14, + "length": 0.34, + "axis": "x", + "position": [ + 3.25, + 0.3, + 0 + ], + "metalColor": "#9ca3af" + }, + { + "kind": "service_platform", + "semanticRole": "inspection_platform", + "length": 3, + "width": 0.24, + "height": 0.34, + "overallHeight": 0.22, + "position": [ + 0.45, + 0.62, + 0.38 + ], + "metalColor": "#64748b", + "color": "#facc15" + } + ], + "dimensionRules": [ + { + "from": "diameter", + "toPart": "cylindrical_tank", + "toParam": "diameter" + }, + { + "from": "length", + "toPart": "cylindrical_tank", + "toParam": "height", + "scale": 0.92 + } + ], + "visualCues": [ + "long slightly inclined kiln shell", + "normalized from common cement kiln proportions such as 4m x 60m with L/D around 15", + "three riding rings around the shell", + "three support stations, each with paired trunnion rollers under the riding ring", + "girth gear close to the middle drive station with side drive unit", + "yellow coupling guard near the drive", + "feed hopper at the higher kiln tail", + "discharge hood at the lower kiln head", + "service platform and guard rail along one side" + ], + "layoutHints": { + "axis": "x", + "inclinePercent": 4, + "supportStations": 3, + "lengthToDiameterRatio": 15, + "drivePosition": "middle-right", + "platformSide": "right" + }, + "roleAliases": { + "kiln_shell": [ + "vessel_shell", + "cylindrical_tank" + ], + "kiln_support_base": [ + "support_pier", + "support_base", + "concrete_pier", + "skid_base" + ], + "inspection_platform": [ + "maintenance_platform", + "platform_railing", + "access_platform" + ], + "kiln_tail_feed_hopper": [ + "feed_end_hood", + "feed_chute", + "inlet_port", + "hopper_body" + ], + "kiln_head_outlet": [ + "discharge_end_hood", + "discharge_chute", + "outlet_port" + ], + "riding_ring": [ + "tyre_ring", + "kiln_tyre", + "support_ring", + "flange_ring" + ], + "support_roller": [ + "support_rollers", + "roller_array", + "trunnion_roller", + "bearing_block", + "support_roller_pair" + ], + "girth_gear": [ + "gear_ring", + "drive_ring" + ], + "kiln_drive_unit": [ + "drive_pinion", + "drive_gearbox", + "motor_gearbox_unit" + ] + }, + "forbiddenRoles": [ + "vehicle_tire", + "aircraft_wing", + "vehicle_cabin" + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥熟料煅烧用长筒形回转窑 profile,强调窑体、轮带、托轮、传动、窑尾料斗和检修平台。", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 90, + "parts": { + "riding-ring-tail": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "riding-ring-center": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "riding-ring-head": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "support-roller-tail": { + "detailLevel": "low", + "count": 5 + }, + "support-roller-center": { + "detailLevel": "low", + "count": 5 + }, + "support-roller-head": { + "detailLevel": "low", + "count": 5 + }, + "girth-gear": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "drive_coupling_guard": { + "detailLevel": "low", + "ringCount": 3, + "spokeCount": 12 + } + } + }, + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.grate_cooler", + "name": "篦冷机", + "aliases": [ + "篦冷机", + "熟料篦冷机", + "grate cooler", + "clinker cooler", + "push grate cooler" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 7, + "width": 2, + "height": 2.4 + }, + "primarySemanticRole": "cooler_outer_casing", + "qualityRules": "quality.cement.grate_cooler", + "parts": [ + { + "kind": "generic_body", + "semanticRole": "cooler_outer_casing", + "required": true, + "length": 6.6, + "width": 1.8, + "height": 2.1, + "position": [ + 0, + 1.15, + 0 + ], + "primaryColor": "#94a3b8", + "metalColor": "#64748b" + }, + { + "kind": "skid_base", + "semanticRole": "cooler_support_base", + "required": true, + "length": 6.6, + "width": 1.8, + "height": 0.18, + "position": [ + 0, + 0.09, + 0 + ], + "metalColor": "#6b7280" + }, + { + "kind": "generic_panel", + "semanticRole": "cooler_grate_bed", + "required": true, + "length": 5.8, + "height": 1.35, + "thickness": 0.045, + "position": [ + 0, + 0.86, + 0.04 + ], + "rotation": [ + -1.5707963267948966, + 0, + 0 + ], + "color": "#475569" + }, + { + "kind": "generic_panel", + "semanticRole": "grate_plate_rows", + "required": true, + "length": 5.7, + "height": 0.18, + "thickness": 0.035, + "arrayAlong": "x", + "count": 8, + "position": [ + 0, + 0.92, + 0.05 + ], + "rotation": [ + -1.5707963267948966, + 0, + 0 + ], + "color": "#334155" + }, + { + "kind": "hopper_body", + "semanticRole": "hot_clinker_inlet_transition", + "required": true, + "length": 1, + "width": 0.85, + "height": 0.95, + "topLengthScale": 1.3, + "topWidthScale": 1.2, + "position": [ + -3.1, + 1.65, + 0 + ], + "primaryColor": "#78716c", + "darkColor": "#44403c" + }, + { + "kind": "pipe_manifold", + "semanticRole": "cooler_air_inlet_plenum", + "axis": "x", + "length": 5.2, + "radius": 0.055, + "position": [ + 0, + 0.36, + -0.72 + ], + "metalColor": "#64748b" + }, + { + "kind": "service_platform", + "semanticRole": "cooler_top_walkway", + "length": 5, + "width": 0.32, + "height": 0.28, + "overallHeight": 0.18, + "position": [ + 0.5, + 2.28, + 0.76 + ], + "metalColor": "#64748b", + "color": "#facc15" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "cooler_drive_unit", + "length": 0.65, + "height": 0.22, + "radius": 0.08, + "position": [ + 2.2, + 0.38, + -0.72 + ], + "primaryColor": "#64748b", + "secondaryColor": "#475569" + }, + { + "kind": "outlet_port", + "semanticRole": "cooler_discharge_chute", + "radius": 0.16, + "length": 0.3, + "axis": "x", + "position": [ + 3.4, + 0.42, + 0 + ], + "metalColor": "#9ca3af" + } + ], + "visualCues": [ + "large enclosed rectangular box-shaped cooler casing spanning the full length ? not a belt conveyor", + "flat internal grate plate bed made of short transverse grate rows inside the lower casing", + "hot clinker inlet transition hopper at the kiln end, elevated above the grate bed", + "under-grate cooling air plenum and fan connection ducting along the base", + "top maintenance walkway with guard rail along the casing edge", + "side hydraulic or gearbox push-drive unit for the grate lanes", + "right-end discharge chute outlet leading to clinker crusher or conveyor", + "reinforced side access panels and heavy industrial casing proportions" + ], + "layoutHints": { + "axis": "x", + "inletSide": "left", + "outletSide": "right", + "primaryOrientation": "horizontal" + }, + "roleAliases": { + "cooler_outer_casing": [ + "cooler_housing", + "cooler_casing", + "cooler_shell", + "cooler_body", + "generic_body" + ], + "cooler_support_base": [ + "support_base", + "support_legs", + "cooler_frame", + "skid_base" + ], + "cooler_grate_bed": [ + "grate_plates", + "grate_plate_rows", + "grate_sections", + "grate_bed_surface", + "roller_array" + ], + "hot_clinker_inlet_transition": [ + "hot_clinker_inlet", + "inlet_hopper", + "kiln_discharge_transition", + "hopper_body" + ], + "cooler_air_inlet_plenum": [ + "air_plenum", + "under_grate_air_box", + "cooling_air_header", + "pipe_manifold" + ], + "cooler_top_walkway": [ + "top_walkway", + "maintenance_platform", + "service_platform" + ], + "cooler_drive_unit": [ + "hydraulic_drive", + "push_drive", + "grate_drive", + "motor_gearbox_unit" + ], + "cooler_discharge_chute": [ + "discharge_outlet", + "clinker_outlet", + "cooled_clinker_out", + "outlet_port" + ], + "grate_plate_rows": [ + "grate_rows", + "transverse_grate_plates", + "grate_modules", + "generic_panel" + ] + }, + "forbiddenRoles": [ + "aircraft_wing", + "belt_surface", + "conveyor_frame", + "vehicle_cabin", + "vehicle_tire" + ], + "status": "stable", + "source": "imported_pack", + "description": "熟料冷却用篦冷机 profile,大矩形壳体封装篦板床体、冷却风箱和顶部走道,左端高温熟料入口,右端出料。", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 70, + "parts": { + "cooler_grate_bed": { + "detailLevel": "low", + "count": 6 + }, + "cooler_top_walkway": { + "detailLevel": "low" + }, + "grate_plate_rows": { + "detailLevel": "low", + "count": 8 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.preheater_tower", + "name": "水泥预热器塔架", + "aliases": [ + "预热器", + "预热器塔架", + "水泥预热器", + "旋风预热器", + "五级预热器", + "预热器钢架", + "preheater tower", + "cement preheater", + "cyclone preheater" + ], + "industry": "cement", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 2.78, + "width": 1.98, + "height": 8.1 + }, + "primarySemanticRole": "preheater_tower_body", + "processPorts": [ + { + "id": "raw_meal_in", + "medium": "material", + "side": "left", + "height": 7.2, + "offset": -0.32 + }, + { + "id": "hot_meal_out", + "medium": "material", + "side": "right", + "height": 1.35, + "offset": 0.12 + }, + { + "id": "tertiary_air_in", + "medium": "gas", + "side": "left", + "height": 4.6, + "offset": 0.64 + }, + { + "id": "exhaust_gas_out", + "medium": "gas", + "side": "top", + "height": 8.1, + "offset": 0.24 + } + ], + "qualityRules": "quality.cement.preheater_tower", + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "preheater_tower_body", + "required": true, + "length": 2.58, + "width": 1.54, + "height": 7.75, + "levelCount": 7, + "bayCount": 2, + "stairFlights": 7, + "externalStairs": true, + "includeDiagonalBraces": true, + "thickness": 0.045, + "position": [ + 0, + 3.88, + 0 + ], + "darkColor": "#111827", + "metalColor": "#64748b", + "accentColor": "#0f172a", + "stairPlacement": "inside", + "stairSide": "right" + }, + { + "id": "cyclone-top-left", + "kind": "cyclone_separator_unit", + "semanticRole": "preheater_cyclone", + "required": true, + "height": 1.28, + "radius": 0.28, + "bodyHeight": 0.76, + "position": [ + -0.66, + 7.08, + 0.08 + ], + "side": "right", + "primaryColor": "#a7b0b7", + "metalColor": "#64748b" + }, + { + "id": "cyclone-top-right", + "kind": "cyclone_separator_unit", + "semanticRole": "preheater_cyclone", + "required": true, + "height": 1.28, + "radius": 0.28, + "bodyHeight": 0.76, + "position": [ + 0.12, + 7.08, + 0.08 + ], + "side": "left", + "primaryColor": "#a7b0b7", + "metalColor": "#64748b" + }, + { + "id": "cyclone-mid-left", + "kind": "cyclone_separator_unit", + "semanticRole": "preheater_cyclone", + "required": true, + "height": 1.38, + "radius": 0.31, + "bodyHeight": 0.84, + "position": [ + -0.68, + 5.72, + 0.1 + ], + "side": "right", + "primaryColor": "#a3aab3", + "metalColor": "#64748b" + }, + { + "id": "cyclone-mid-right", + "kind": "cyclone_separator_unit", + "semanticRole": "preheater_cyclone", + "required": true, + "height": 1.38, + "radius": 0.31, + "bodyHeight": 0.84, + "position": [ + 0.14, + 5.72, + 0.1 + ], + "side": "left", + "primaryColor": "#a3aab3", + "metalColor": "#64748b" + }, + { + "id": "cyclone-lower-left", + "kind": "cyclone_separator_unit", + "semanticRole": "preheater_cyclone", + "required": true, + "height": 1.46, + "radius": 0.34, + "bodyHeight": 0.9, + "position": [ + -0.62, + 4.3, + 0.08 + ], + "side": "right", + "primaryColor": "#a3aab3", + "metalColor": "#64748b" + }, + { + "id": "cyclone-lower-right", + "kind": "cyclone_separator_unit", + "semanticRole": "preheater_cyclone", + "required": true, + "height": 1.46, + "radius": 0.34, + "bodyHeight": 0.9, + "position": [ + 0.2, + 4.3, + 0.08 + ], + "side": "left", + "primaryColor": "#a3aab3", + "metalColor": "#64748b" + }, + { + "kind": "hopper_body", + "semanticRole": "central_meal_collection_hopper", + "required": true, + "length": 0.86, + "width": 0.62, + "height": 1.02, + "topLengthScale": 2.3, + "topWidthScale": 2.05, + "position": [ + -0.22, + 3.3, + 0.06 + ], + "primaryColor": "#9ca3af", + "darkColor": "#374151" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "calciner_riser_body", + "axis": "y", + "height": 2.5, + "radius": 0.34, + "position": [ + -0.22, + 2.18, + 0.04 + ], + "primaryColor": "#9ca3af", + "metalColor": "#64748b" + }, + { + "kind": "pipe_run", + "semanticRole": "preheater_gas_duct", + "required": true, + "axis": "x", + "length": 1.02, + "radius": 0.07, + "position": [ + -0.12, + 7.66, + 0.08 + ], + "metalColor": "#64748b" + }, + { + "kind": "pipe_run", + "semanticRole": "preheater_gas_duct", + "axis": "y", + "length": 0.62, + "radius": 0.065, + "position": [ + -0.24, + 7.35, + 0.08 + ], + "metalColor": "#64748b" + }, + { + "kind": "pipe_run", + "semanticRole": "preheater_gas_duct", + "axis": "y", + "length": 0.62, + "radius": 0.065, + "position": [ + 0, + 7.35, + 0.08 + ], + "metalColor": "#64748b" + }, + { + "kind": "pipe_run", + "semanticRole": "preheater_gas_duct", + "axis": "x", + "length": 1.08, + "radius": 0.065, + "position": [ + -0.12, + 6.15, + 0.08 + ], + "metalColor": "#64748b" + }, + { + "kind": "pipe_run", + "semanticRole": "meal_drop_pipe", + "axis": "y", + "length": 1.25, + "radius": 0.045, + "position": [ + -0.48, + 3.6, + 0.08 + ], + "metalColor": "#475569" + }, + { + "kind": "pipe_run", + "semanticRole": "meal_drop_pipe", + "axis": "y", + "length": 1.25, + "radius": 0.045, + "position": [ + 0.13999999999999999, + 3.6, + 0.08 + ], + "metalColor": "#475569" + }, + { + "kind": "hopper_body", + "semanticRole": "bottom_meal_discharge_hopper", + "length": 0.5, + "width": 0.38, + "height": 0.74, + "topLengthScale": 1.75, + "topWidthScale": 1.55, + "position": [ + -0.22, + 0.72, + 0.04 + ], + "primaryColor": "#9ca3af", + "darkColor": "#374151" + } + ], + "visualCues": [ + "tall narrow multi-floor steel frame tower with repeated decks and diagonal bracing", + "six cyclone separator vessels arranged as mirrored left/right stages near the upper tower", + "lower cyclone outlets feed a central flared meal collection hopper and vertical calciner riser", + "top and interstage gas ducts connect the cyclone stages while drop pipes remain visible", + "right-side zig-zag stair is inside the steel frame, with vessel columns shifted into the remaining equipment bay" + ], + "layoutHints": { + "levels": 7, + "cycloneColumns": 2, + "cycloneStages": 3, + "towerFrame": "multi_level_steel_frame_with_external_stairs", + "referenceImage": "cement cyclone preheater tower with side stair and central hopper", + "stairPlacement": "inside_right_bay", + "equipmentBayCenterX": -0.24 + }, + "roleAliases": { + "preheater_tower_body": [ + "structural_tower_frame", + "tower_column", + "tower_beam", + "tower_diagonal_brace" + ], + "multi_level_platform": [ + "platform_guard_rail", + "internal_stair_flight", + "internal_stair_landing", + "internal_stair_guard_rail", + "access_ladder", + "service_platform" + ], + "preheater_cyclone": [ + "cyclone_separator_unit", + "cyclone_body", + "cyclone_cone" + ], + "preheater_gas_duct": [ + "gas_duct_header", + "cyclone_connection_duct", + "pipe_run" + ], + "meal_drop_pipe": [ + "drop_pipe", + "dipleg", + "material_drop_pipe" + ], + "central_meal_collection_hopper": [ + "hopper_body", + "meal_collection_hopper", + "bottom_meal_discharge_hopper" + ] + }, + "forbiddenRoles": [ + "vehicle_tire", + "vehicle_cabin", + "aircraft_wing" + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥窑尾旋风预热器塔架 profile,表达多层钢结构、右侧外置折返楼梯、双列旋风筒、级间管道、中央汇料锥斗和下部煅烧/下料段。", + "editableSchemaRef": "tower_frame.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 180, + "parts": { + "preheater_tower_body": { + "detailLevel": "low" + }, + "cyclone-top-left": { + "detailLevel": "low" + }, + "cyclone-top-right": { + "detailLevel": "low" + }, + "cyclone-mid-left": { + "detailLevel": "low" + }, + "cyclone-mid-right": { + "detailLevel": "low" + }, + "cyclone-lower-left": { + "detailLevel": "low" + }, + "cyclone-lower-right": { + "detailLevel": "low" + } + } + }, + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.cyclone_separator", + "name": "旋风筒", + "aliases": [ + "旋风筒", + "旋风分离器", + "cyclone separator", + "preheater cyclone" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 1.8, + "width": 1.8, + "height": 3.2, + "diameter": 1.4 + }, + "primarySemanticRole": "cyclone_body", + "qualityRules": "quality.cement.cyclone_separator", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "cyclone_body", + "required": true + }, + { + "kind": "inlet_port", + "semanticRole": "tangential_gas_inlet", + "required": true + }, + { + "kind": "outlet_port", + "semanticRole": "gas_outlet", + "required": true + }, + { + "kind": "hopper_body", + "semanticRole": "cone_discharge_hopper" + }, + { + "kind": "skid_base", + "semanticRole": "support_frame" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "预热器系统旋风分离器 profile,包含筒体、切向入口、出口和下部料斗。", + "editableSchemaRef": "tower_frame.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 180, + "parts": { + "cyclone_body": { + "detailLevel": "low" + }, + "support_frame": { + "detailLevel": "low" + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.cement.basic-0.1.0/profiles/storage.json b/cloud/industry.cement.basic-0.1.0/profiles/storage.json new file mode 100644 index 000000000..73efa8fcb --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/profiles/storage.json @@ -0,0 +1,811 @@ +[ + { + "id": "cement.clinker_silo", + "name": "熟料库", + "aliases": [ + "熟料库", + "熟料筒仓", + "clinker silo", + "clinker storage silo" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 4, + "width": 4, + "height": 8, + "diameter": 3.6 + }, + "primarySemanticRole": "silo_shell", + "qualityRules": "quality.cement.clinker_silo", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "silo_shell", + "required": true, + "axis": "y", + "height": 5.4, + "radius": 1.25, + "position": [ + 0, + 4, + 0 + ] + }, + { + "kind": "skid_base", + "semanticRole": "silo_support_base", + "required": true, + "length": 3.4, + "width": 3.4, + "height": 0.3, + "position": [ + 0, + 0.15, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "bottom_discharge_hopper", + "required": true, + "length": 2.2, + "width": 2.2, + "height": 1.2, + "topLengthScale": 1.45, + "topWidthScale": 1.45, + "position": [ + 0, + 1, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "top_feed_inlet", + "radius": 0.18, + "length": 0.35, + "axis": "y", + "position": [ + 0, + 6.95, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "bottom_discharge_outlet", + "radius": 0.18, + "length": 0.45, + "axis": "x", + "position": [ + 1.25, + 0.7, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "top_access_platform", + "length": 2.4, + "width": 0.72, + "height": 0.6, + "position": [ + 0, + 6.55, + 1.55 + ] + } + ], + "status": "stable", + "source": "imported_pack", + "description": "熟料储存筒仓 profile,包含筒体、支撑、底部料斗和顶部检修平台。", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "processPorts": [ + { + "id": "top_feed_inlet", + "medium": "material", + "side": "top", + "height": 6.95, + "offset": 0 + }, + { + "id": "bottom_discharge_outlet", + "medium": "material", + "side": "right", + "height": 0.7, + "offset": 0 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.cement_silo", + "name": "水泥库", + "aliases": [ + "水泥库", + "水泥筒仓", + "cement silo", + "cement storage silo" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 3.6, + "width": 3.6, + "height": 7.5, + "diameter": 3.2 + }, + "primarySemanticRole": "cement_silo_shell", + "qualityRules": "quality.cement.cement_silo", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "cement_silo_shell", + "required": true, + "axis": "y", + "height": 5.1, + "radius": 1.15, + "position": [ + 0, + 3.75, + 0 + ] + }, + { + "kind": "skid_base", + "semanticRole": "silo_support_base", + "required": true, + "length": 3.1, + "width": 3.1, + "height": 0.28, + "position": [ + 0, + 0.14, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "bulk_discharge_hopper", + "required": true, + "length": 2, + "width": 2, + "height": 1.05, + "topLengthScale": 1.45, + "topWidthScale": 1.45, + "position": [ + 0, + 0.92, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "pneumatic_fill_manifold", + "length": 2, + "radius": 0.08, + "position": [ + 0, + 6.25, + 0.9 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "bulk_discharge_outlet", + "radius": 0.16, + "length": 0.42, + "axis": "x", + "position": [ + 1.1, + 0.62, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "top_access_platform", + "length": 2.2, + "width": 0.68, + "height": 0.58, + "position": [ + 0, + 6.25, + 1.42 + ] + } + ], + "status": "stable", + "source": "imported_pack", + "description": "成品水泥储存筒仓 profile,包含筒体、气力输送管汇、底部卸料和检修平台。", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "processPorts": [ + { + "id": "top_feed_inlet", + "medium": "material", + "side": "top", + "height": 6.25, + "offset": 0 + }, + { + "id": "bulk_discharge_outlet", + "medium": "material", + "side": "right", + "height": 0.62, + "offset": 0 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.raw_coal_silo", + "name": "原煤仓", + "aliases": [ + "原煤仓", + "煤仓", + "raw coal silo", + "coal bin", + "coal storage silo" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 3.6, + "width": 3.6, + "height": 6.5, + "diameter": 3.2 + }, + "primarySemanticRole": "silo_shell", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "silo_shell", + "required": true, + "axis": "y", + "height": 4.2, + "radius": 1.1, + "position": [ + 0, + 3.6, + 0 + ], + "primaryColor": "#78716c", + "metalColor": "#57534e" + }, + { + "kind": "skid_base", + "semanticRole": "silo_support_base", + "required": true, + "length": 3.2, + "width": 3.2, + "height": 0.28, + "position": [ + 0, + 0.14, + 0 + ], + "metalColor": "#6b7280" + }, + { + "kind": "hopper_body", + "semanticRole": "bottom_discharge_hopper", + "required": true, + "length": 2, + "width": 2, + "height": 1.1, + "topLengthScale": 1.4, + "topWidthScale": 1.4, + "position": [ + 0, + 0.9, + 0 + ], + "primaryColor": "#92400e", + "darkColor": "#78350f" + }, + { + "kind": "inlet_port", + "semanticRole": "top_coal_inlet", + "radius": 0.16, + "length": 0.32, + "axis": "y", + "position": [ + 0, + 6.18, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "bottom_coal_outlet", + "radius": 0.16, + "length": 0.4, + "axis": "x", + "position": [ + 1.1, + 0.62, + 0 + ], + "metalColor": "#9ca3af" + } + ], + "visualCues": [ + "cylindrical raw coal storage silo with dark exterior finish indicating coal dust", + "conical bottom discharge hopper with coal feeder outlet", + "top coal inlet port for truck or conveyor delivery", + "shorter than cement or clinker silos due to smaller coal storage volume" + ], + "status": "stable", + "source": "imported_pack", + "description": "煤磨原料储存仓 profile,原煤入炉前的缓冲储仓,带底部卸料斗和顶部入煤口。", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 48 + }, + "processPorts": [ + { + "id": "top_coal_inlet", + "medium": "material", + "side": "top", + "height": 6.18, + "offset": 0 + }, + { + "id": "bottom_coal_outlet", + "medium": "material", + "side": "right", + "height": 0.62, + "offset": 0 + } + ], + "qualityRules": "quality.cement.raw_coal_silo", + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.coal_powder_bin", + "name": "煤粉仓", + "aliases": [ + "煤粉仓", + "煤粉储仓", + "coal powder bin", + "pulverized fuel bin", + "coal powder silo" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 2.4, + "width": 2.4, + "height": 4.8, + "diameter": 2.2 + }, + "primarySemanticRole": "powder_bin_shell", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "powder_bin_shell", + "required": true, + "axis": "y", + "height": 2.8, + "radius": 0.78, + "position": [ + 0, + 2.8, + 0 + ], + "primaryColor": "#374151", + "metalColor": "#1f2937" + }, + { + "kind": "skid_base", + "semanticRole": "silo_support_base", + "required": true, + "length": 2.1, + "width": 2.1, + "height": 0.22, + "position": [ + 0, + 0.11, + 0 + ], + "metalColor": "#6b7280" + }, + { + "kind": "hopper_body", + "semanticRole": "cone_discharge_hopper", + "required": true, + "length": 1.4, + "width": 1.4, + "height": 0.9, + "topLengthScale": 1.5, + "topWidthScale": 1.5, + "position": [ + 0, + 0.75, + 0 + ], + "primaryColor": "#1f2937", + "darkColor": "#111827" + }, + { + "kind": "inlet_port", + "semanticRole": "powder_feed_inlet", + "radius": 0.12, + "length": 0.28, + "axis": "y", + "position": [ + 0, + 4.55, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "powder_to_kiln_burner", + "radius": 0.1, + "length": 0.28, + "axis": "x", + "position": [ + 0.82, + 0.55, + 0.3 + ], + "metalColor": "#6b7280" + }, + { + "kind": "outlet_port", + "semanticRole": "powder_to_calciner", + "radius": 0.1, + "length": 0.28, + "axis": "x", + "position": [ + -0.82, + 0.55, + -0.3 + ], + "metalColor": "#6b7280" + } + ], + "visualCues": [ + "compact dark cylindrical pressurized bin for pulverized coal powder", + "steep cone-bottom discharge for free-flowing coal powder", + "two separate discharge outlets: one to kiln burner, one to calciner", + "top pneumatic fill inlet from coal mill", + "smaller and darker than raw coal silo" + ], + "status": "stable", + "source": "imported_pack", + "description": "煤磨产出煤粉的缓冲储仓,分配煤粉至窑头燃烧器和分解炉。", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 40 + }, + "processPorts": [ + { + "id": "powder_feed_inlet", + "medium": "material", + "side": "top", + "height": 4.55, + "offset": 0 + }, + { + "id": "powder_to_kiln_burner", + "medium": "material", + "side": "right", + "height": 0.85, + "offset": 0.3 + }, + { + "id": "powder_to_calciner", + "medium": "material", + "side": "left", + "height": 0.85, + "offset": -0.3 + } + ], + "qualityRules": "quality.cement.coal_powder_bin", + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.gypsum_hopper", + "name": "石膏仓", + "aliases": [ + "石膏仓", + "石膏储仓", + "gypsum hopper", + "gypsum silo", + "gypsum storage" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 3, + "width": 2.4, + "height": 3.5 + }, + "primarySemanticRole": "hopper_shell", + "parts": [ + { + "kind": "generic_body", + "semanticRole": "hopper_shell", + "required": true, + "length": 2.8, + "width": 2.2, + "height": 2.4, + "position": [ + 0, + 1.85, + 0 + ], + "primaryColor": "#d6d3d1", + "metalColor": "#a8a29e" + }, + { + "kind": "skid_base", + "semanticRole": "silo_support_base", + "required": true, + "length": 2.8, + "width": 2.2, + "height": 0.2, + "position": [ + 0, + 0.1, + 0 + ], + "metalColor": "#6b7280" + }, + { + "kind": "hopper_body", + "semanticRole": "gypsum_discharge_hopper", + "required": true, + "length": 1.6, + "width": 1.3, + "height": 0.85, + "topLengthScale": 1.6, + "topWidthScale": 1.55, + "position": [ + 0, + 0.62, + 0 + ], + "primaryColor": "#d6d3d1", + "darkColor": "#a8a29e" + }, + { + "kind": "inlet_port", + "semanticRole": "gypsum_feed_in", + "radius": 0.14, + "length": 0.28, + "axis": "y", + "position": [ + 0, + 3.28, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "gypsum_out", + "radius": 0.12, + "length": 0.3, + "axis": "x", + "position": [ + 1.05, + 0.38, + 0 + ], + "metalColor": "#9ca3af" + } + ], + "visualCues": [ + "rectangular hopper bin for crushed gypsum storage", + "white or light grey exterior reflecting gypsum color", + "discharge hopper at bottom feeding to weighing belt conveyor", + "smaller and lower-profile than silo-style storage" + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥磨配料用石膏储仓 profile,矩形仓体带底部卸料斗,向水泥磨供石膏。", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 36 + }, + "processPorts": [ + { + "id": "gypsum_feed_in", + "medium": "material", + "side": "top", + "height": 3.28, + "offset": 0 + }, + { + "id": "gypsum_out", + "medium": "material", + "side": "right", + "height": 0.38, + "offset": 0 + } + ], + "qualityRules": "quality.cement.gypsum_hopper", + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "cement.additive_silo", + "name": "混合材库", + "aliases": [ + "混合材库", + "混合材仓", + "additive silo", + "fly ash silo", + "slag silo", + "supplementary cementitious material silo" + ], + "industry": "cement", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "length": 3.2, + "width": 3.2, + "height": 6, + "diameter": 2.8 + }, + "primarySemanticRole": "silo_shell", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "silo_shell", + "required": true, + "axis": "y", + "height": 3.8, + "radius": 0.98, + "position": [ + 0, + 3.3, + 0 + ], + "primaryColor": "#a3a3a3", + "metalColor": "#737373" + }, + { + "kind": "skid_base", + "semanticRole": "silo_support_base", + "required": true, + "length": 2.8, + "width": 2.8, + "height": 0.25, + "position": [ + 0, + 0.12, + 0 + ], + "metalColor": "#6b7280" + }, + { + "kind": "hopper_body", + "semanticRole": "additive_discharge_hopper", + "required": true, + "length": 1.7, + "width": 1.7, + "height": 1, + "topLengthScale": 1.4, + "topWidthScale": 1.4, + "position": [ + 0, + 0.85, + 0 + ], + "primaryColor": "#9ca3af", + "darkColor": "#6b7280" + }, + { + "kind": "pipe_manifold", + "semanticRole": "pneumatic_fill_pipe", + "length": 1.8, + "radius": 0.07, + "position": [ + 0, + 5.4, + 0.8 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "additive_out", + "radius": 0.14, + "length": 0.38, + "axis": "x", + "position": [ + 0.98, + 0.58, + 0 + ], + "metalColor": "#9ca3af" + } + ], + "visualCues": [ + "cylindrical silo for supplementary cementitious materials such as fly ash or slag", + "similar to cement silo but slightly smaller; grey or neutral color", + "pneumatic fill pipe on upper shell for truck tanker delivery", + "bottom discharge hopper feeding weighing conveyor to cement mill" + ], + "status": "stable", + "source": "imported_pack", + "description": "水泥磨配料用混合材(粉煤灰/矿渣粉)储仓,气力输送上料,底部计量卸料。", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 48 + }, + "processPorts": [ + { + "id": "additive_feed_in", + "medium": "material", + "side": "top", + "height": 5.4, + "offset": 0 + }, + { + "id": "additive_out", + "medium": "material", + "side": "right", + "height": 0.58, + "offset": 0 + } + ], + "qualityRules": "quality.cement.additive_silo", + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.cement.basic-0.1.0/quality-rules/cement-quality.json b/cloud/industry.cement.basic-0.1.0/quality-rules/cement-quality.json new file mode 100644 index 000000000..e456c404a --- /dev/null +++ b/cloud/industry.cement.basic-0.1.0/quality-rules/cement-quality.json @@ -0,0 +1,464 @@ +[ + { + "id": "quality.cement.rotary_kiln", + "requiredRoles": [ + "vessel_shell", + "kiln_support_base", + "riding_ring", + "support_roller", + "girth_gear", + "kiln_drive_unit", + "kiln_tail_feed_hopper", + "kiln_head_outlet" + ], + "forbiddenRoles": [ + "vehicle_tire", + "vehicle_cabin", + "aircraft_wing" + ], + "shapeCount": { + "min": 24, + "max": 90 + }, + "dimensionExpectations": { + "lengthToDiameterRatio": { + "min": 8, + "max": 18 + } + } + }, + { + "id": "quality.cement.grate_cooler", + "requiredRoles": [ + "cooler_outer_casing", + "cooler_support_base", + "cooler_grate_bed", + "grate_plate_rows", + "hot_clinker_inlet_transition", + "cooler_air_inlet_plenum" + ], + "shapeCount": { + "min": 12, + "max": 78 + }, + "forbiddenRoles": [ + "belt_surface", + "conveyor_frame", + "vehicle_tire", + "vehicle_cabin" + ] + }, + { + "id": "quality.cement.preheater_tower", + "requiredRoles": [ + "preheater_tower_body", + "tower_diagonal_brace", + "multi_level_platform", + "internal_stair_flight", + "preheater_cyclone", + "preheater_gas_duct", + "meal_drop_pipe", + "central_meal_collection_hopper" + ], + "shapeCount": { + "min": 70, + "max": 230 + } + }, + { + "id": "quality.cement.cyclone_separator", + "requiredRoles": [ + "cyclone_body", + "tangential_gas_inlet", + "gas_outlet" + ], + "shapeCount": { + "min": 6, + "max": 48 + } + }, + { + "id": "quality.cement.vertical_raw_mill", + "requiredRoles": [ + "mill_base", + "mill_body", + "grinding_table_housing", + "dynamic_separator", + "raw_feed_chute", + "hot_gas_inlet", + "mill_drive_unit" + ], + "shapeCount": { + "min": 10, + "max": 78 + }, + "forbiddenRoles": [ + "belt_surface", + "conveyor_frame", + "vehicle_tire", + "vehicle_cabin" + ] + }, + { + "id": "quality.cement.cement_mill", + "requiredRoles": [ + "mill_shell", + "mill_support_base", + "mill_trunnion_bearing" + ], + "shapeCount": { + "min": 12, + "max": 80 + }, + "dimensionExpectations": { + "lengthToDiameterRatio": { + "min": 2.4, + "max": 6 + } + } + }, + { + "id": "quality.cement.roller_press", + "requiredRoles": [ + "machine_base", + "roller_press_body", + "press_roll_pair" + ], + "shapeCount": { + "min": 8, + "max": 60 + } + }, + { + "id": "quality.cement.belt_conveyor", + "requiredRoles": [ + "conveyor_frame", + "support_rollers", + "belt_surface" + ], + "shapeCount": { + "min": 8, + "max": 60 + } + }, + { + "id": "quality.cement.screw_conveyor", + "requiredRoles": [ + "trough_frame", + "screw_flight" + ], + "shapeCount": { + "min": 7, + "max": 60 + } + }, + { + "id": "quality.cement.bucket_elevator", + "requiredRoles": [ + "elevator_leg_casing", + "boot_section", + "head_casing", + "inlet_boot_hopper", + "discharge_spout", + "head_drive_unit", + "head_service_platform" + ], + "shapeCount": { + "min": 12, + "max": 60 + } + }, + { + "id": "quality.cement.clinker_silo", + "requiredRoles": [ + "silo_shell", + "silo_support_base", + "bottom_discharge_hopper" + ], + "shapeCount": { + "min": 8, + "max": 70 + } + }, + { + "id": "quality.cement.cement_silo", + "requiredRoles": [ + "cement_silo_shell", + "silo_support_base", + "bulk_discharge_hopper" + ], + "shapeCount": { + "min": 8, + "max": 70 + } + }, + { + "id": "quality.cement.bag_filter", + "requiredRoles": [ + "filter_housing", + "hopper_base" + ], + "shapeCount": { + "min": 8, + "max": 70 + } + }, + { + "id": "quality.cement.cement_packer", + "requiredRoles": [ + "packer_base", + "packer_body", + "cement_feed_hopper" + ], + "shapeCount": { + "min": 8, + "max": 70 + } + }, + { + "id": "quality.cement.limestone_crusher", + "requiredRoles": [ + "crusher_base", + "crusher_housing", + "limestone_feed_hopper", + "crushed_material_discharge", + "crusher_drive_unit" + ], + "shapeCount": { + "min": 6, + "max": 80 + } + }, + { + "id": "quality.cement.stack_reclaimer", + "requiredRoles": [ + "reclaimer_bridge", + "stockpile_conveyor", + "reclaimer_travel_car", + "bucket_wheel", + "rail_track" + ], + "shapeCount": { + "min": 6, + "max": 80 + } + }, + { + "id": "quality.cement.raw_meal_homogenization_silo", + "requiredRoles": [ + "raw_meal_silo_shell", + "silo_support_base", + "bottom_discharge_hopper", + "top_feed_inlet", + "raw_meal_out" + ], + "shapeCount": { + "min": 6, + "max": 80 + } + }, + { + "id": "quality.cement.coal_mill", + "requiredRoles": [ + "mill_base", + "coal_mill_body", + "dynamic_separator", + "mill_drive_unit" + ], + "shapeCount": { + "min": 6, + "max": 80 + } + }, + { + "id": "quality.cement.kiln_burner", + "requiredRoles": [ + "burner_lance", + "primary_air_channel", + "coal_fuel_pipe", + "burner_mounting_flange", + "multi_channel_nozzle_tip", + "burner_carriage", + "fuel_in", + "primary_air_in" + ], + "shapeCount": { + "min": 10, + "max": 86 + }, + "forbiddenRoles": [ + "belt_surface", + "conveyor_frame", + "vehicle_tire", + "vehicle_cabin" + ] + }, + { + "id": "quality.cement.kiln_hood", + "requiredRoles": [ + "hood_base", + "kiln_hood_shell", + "kiln_head_in", + "burner_opening", + "hot_clinker_drop_chute", + "hot_clinker_out", + "secondary_air_return_duct" + ], + "shapeCount": { + "min": 10, + "max": 90 + }, + "forbiddenRoles": [ + "belt_surface", + "conveyor_frame", + "vehicle_tire", + "vehicle_cabin" + ] + }, + { + "id": "quality.cement.tertiary_air_duct", + "requiredRoles": [ + "tertiary_air_duct_shell", + "cooler_air_in", + "tertiary_air_out", + "duct_supports" + ], + "shapeCount": { + "min": 5, + "max": 80 + } + }, + { + "id": "quality.cement.clinker_crusher", + "requiredRoles": [ + "crusher_base", + "clinker_crusher_housing", + "hot_clinker_in", + "crushed_clinker_out" + ], + "shapeCount": { + "min": 6, + "max": 80 + } + }, + { + "id": "quality.cement.esp_dust_collector", + "requiredRoles": [ + "esp_support_base", + "esp_collector_chambers", + "dust_hopper_bank", + "dust_gas_in", + "clean_air_out" + ], + "shapeCount": { + "min": 7, + "max": 80 + } + }, + { + "id": "quality.cement.process_stack", + "requiredRoles": [ + "stack_base_plinth", + "stack_shell", + "stack_gas_in", + "stack_outlet" + ], + "shapeCount": { + "min": 5, + "max": 80 + } + }, + { + "id": "quality.cement.whr_boiler", + "requiredRoles": [ + "boiler_base", + "boiler_casing", + "tube_bank", + "hot_gas_in", + "cooled_gas_out" + ], + "shapeCount": { + "min": 6, + "max": 80 + } + }, + { + "id": "quality.cement.raw_coal_silo", + "requiredRoles": [ + "silo_shell", + "silo_support_base", + "bottom_discharge_hopper" + ], + "shapeCount": { + "min": 5, + "max": 48 + } + }, + { + "id": "quality.cement.coal_powder_bin", + "requiredRoles": [ + "powder_bin_shell", + "silo_support_base", + "cone_discharge_hopper" + ], + "shapeCount": { + "min": 5, + "max": 40 + } + }, + { + "id": "quality.cement.gypsum_hopper", + "requiredRoles": [ + "hopper_shell", + "silo_support_base" + ], + "shapeCount": { + "min": 4, + "max": 36 + } + }, + { + "id": "quality.cement.additive_silo", + "requiredRoles": [ + "silo_shell", + "silo_support_base", + "additive_discharge_hopper" + ], + "shapeCount": { + "min": 5, + "max": 48 + } + }, + { + "id": "quality.cement.cement_separator", + "requiredRoles": [ + "separator_body", + "separator_support_base", + "coarse_reject_cone" + ], + "shapeCount": { + "min": 6, + "max": 48 + } + }, + { + "id": "quality.cement.control_room", + "requiredRoles": [ + "control_room_body", + "control_room_roof_cap", + "control_room_door", + "kiln_line_observation_window", + "mcc_panel_row", + "cable_tray_entry" + ], + "shapeCount": { + "min": 7, + "max": 64 + }, + "forbiddenRoles": [ + "vehicle_cabin", + "vehicle_tire", + "belt_surface" + ] + } +] diff --git a/cloud/industry.discrete-manufacturing.basic-0.1.0.zip b/cloud/industry.discrete-manufacturing.basic-0.1.0.zip new file mode 100644 index 000000000..43781a3fe Binary files /dev/null and b/cloud/industry.discrete-manufacturing.basic-0.1.0.zip differ diff --git a/cloud/industry.discrete-manufacturing.basic-0.1.0/README.md b/cloud/industry.discrete-manufacturing.basic-0.1.0/README.md new file mode 100644 index 000000000..dd3fdbc54 --- /dev/null +++ b/cloud/industry.discrete-manufacturing.basic-0.1.0/README.md @@ -0,0 +1,57 @@ +# 离散制造基础包 + +离散制造基础包 :覆盖 CNC 加工中心、装配工位、AGV 牵引车、工装台、测试台、物料车和码垛工作站等典型离散制造设备。 + +## Devices + +- CNC machining center (discrete_manufacturing.cnc_machining_center) +- Robot workcell (discrete_manufacturing.robot_workcell) +- Assembly workstation (discrete_manufacturing.assembly_workstation) +- Roller conveyor (discrete_manufacturing.roller_conveyor) +- Vision inspection station (discrete_manufacturing.vision_inspection_station) +- Packaging station (discrete_manufacturing.packaging_station) +- AGV tugger (discrete_manufacturing.agv_tugger) +- Storage rack (discrete_manufacturing.storage_rack) +- Line control cabinet (discrete_manufacturing.line_control_cabinet) +- Compressor skid (discrete_manufacturing.compressor_skid) +- Pallet kitting table (discrete_manufacturing.pallet_table) +- Chip conveyor (discrete_manufacturing.chip_conveyor) +- Safety fence (discrete_manufacturing.safety_fence) +- Utility pipe corridor (discrete_manufacturing.pipe_corridor) +- Fixture table (discrete_manufacturing.fixture_table) +- Functional test bench (discrete_manufacturing.test_bench) +- Material cart (discrete_manufacturing.material_cart) +- Palletizing workcell (discrete_manufacturing.palletizing_workcell) + +## Pack Type + +Factory-capable pack: supports factory/process creation through process templates and factory architectures. + +## Factory Creation + +Supported whole-factory/process templates: + +- Discrete manufacturing flexible workshop (discrete_manufacturing_flexible_workshop) + +Supported factory scopes/modules: + +- Machining cell (machining_cell) +- Assembly and robot cell (assembly_cell) +- Full discrete manufacturing workshop (full_workshop) + +## Factory Architectures + +- Discrete manufacturing flexible workshop architecture + +## Process Templates + +- Discrete manufacturing flexible workshop + +## Validation + +Run: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.discrete-manufacturing.basic@0.1.0 --validate-only +``` + diff --git a/cloud/industry.discrete-manufacturing.basic-0.1.0/factory-architectures/generated.json b/cloud/industry.discrete-manufacturing.basic-0.1.0/factory-architectures/generated.json new file mode 100644 index 000000000..c96c6f132 --- /dev/null +++ b/cloud/industry.discrete-manufacturing.basic-0.1.0/factory-architectures/generated.json @@ -0,0 +1,190 @@ +[ + { + "id": "discrete_manufacturing.factory.flexible_workshop", + "label": "Discrete manufacturing flexible workshop architecture", + "industry": "discrete-manufacturing", + "processId": "discrete_manufacturing_flexible_workshop", + "layoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 62, + "width": 30 + }, + "scopes": [ + { + "id": "machining_cell", + "label": "Machining cell", + "includeModules": [ + "machining", + "inspection", + "line_control", + "fixture_preparation", + "testing" + ] + }, + { + "id": "assembly_cell", + "label": "Assembly and robot cell", + "includeModules": [ + "robotics", + "assembly", + "line_control", + "fixture_preparation", + "testing", + "palletizing" + ] + }, + { + "id": "full_workshop", + "label": "Full discrete manufacturing workshop", + "includeModules": [ + "raw_material_storage", + "machining", + "robotics", + "assembly", + "inspection", + "packaging", + "intralogistics", + "line_control", + "utilities", + "fixture_preparation", + "testing", + "palletizing", + "material_handling" + ] + } + ], + "modules": [ + { + "id": "raw_material_storage", + "displayLabel": "Material storage and kitting", + "order": 10, + "stationIds": [ + "storage_rack", + "pallet_table" + ] + }, + { + "id": "machining", + "displayLabel": "CNC machining", + "order": 20, + "stationIds": [ + "cnc_machining_center", + "chip_conveyor" + ] + }, + { + "id": "robotics", + "displayLabel": "Robot handling", + "order": 30, + "stationIds": [ + "robot_workcell", + "safety_fence" + ] + }, + { + "id": "assembly", + "displayLabel": "Assembly line", + "order": 40, + "stationIds": [ + "assembly_workstation", + "roller_conveyor" + ] + }, + { + "id": "inspection", + "displayLabel": "Inspection and test", + "order": 50, + "stationIds": [ + "vision_inspection_station" + ] + }, + { + "id": "packaging", + "displayLabel": "Packing and dispatch", + "order": 60, + "stationIds": [ + "packaging_station" + ] + }, + { + "id": "intralogistics", + "displayLabel": "Intralogistics", + "order": 70, + "stationIds": [ + "agv_tugger", + "pipe_corridor" + ] + }, + { + "id": "line_control", + "displayLabel": "Line control", + "order": 80, + "stationIds": [ + "line_control_cabinet" + ] + }, + { + "id": "utilities", + "displayLabel": "Compressed air and utilities", + "order": 90, + "stationIds": [ + "compressor_skid", + "pipe_corridor" + ] + }, + { + "id": "fixture_preparation", + "displayLabel": "Fixture preparation", + "order": 15, + "stationIds": [ + "fixture_table" + ] + }, + { + "id": "testing", + "displayLabel": "Functional test", + "order": 55, + "stationIds": [ + "test_bench" + ] + }, + { + "id": "palletizing", + "displayLabel": "Palletizing and staging", + "order": 65, + "stationIds": [ + "palletizing_workcell" + ] + }, + { + "id": "material_handling", + "displayLabel": "Material handling carts", + "order": 75, + "stationIds": [ + "material_cart", + "agv_tugger" + ] + } + ], + "layoutHints": { + "primaryAxis": "x", + "secondaryAxis": "z", + "indoorWorkshop": true, + "highestStationId": "storage_rack", + "longAxisStationId": "roller_conveyor", + "sideBranchStationIds": [ + "line_control_cabinet", + "compressor_skid", + "storage_rack", + "material_cart" + ], + "scale": "conceptual", + "notes": [ + "Keep machining and robot handling in adjacent bays so robot loading can serve CNC equipment.", + "Place assembly and conveyor downstream of machining.", + "Place inspection before packaging and dispatch.", + "Use AGV and pipe corridor as logistics and utility spines along the workshop side." + ] + } + } +] diff --git a/cloud/industry.discrete-manufacturing.basic-0.1.0/pack.json b/cloud/industry.discrete-manufacturing.basic-0.1.0/pack.json new file mode 100644 index 000000000..90a5fc763 --- /dev/null +++ b/cloud/industry.discrete-manufacturing.basic-0.1.0/pack.json @@ -0,0 +1,52 @@ +{ + "id": "industry.discrete-manufacturing.basic", + "name": "离散制造基础包", + "industry": "discrete-manufacturing", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "离散制造基础包 :覆盖 CNC 加工中心、装配工位、AGV 牵引车、工装台、测试台、物料车和码垛工作站等典型离散制造设备。", + "profiles": [ + "profiles/generated.json" + ], + "qualityRules": [ + "quality-rules/generated-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/generated.json" + ], + "processTemplates": [ + "process-templates/generated.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "discrete_manufacturing.storage_rack", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.discrete-manufacturing.basic-0.1.0/process-templates/generated.json b/cloud/industry.discrete-manufacturing.basic-0.1.0/process-templates/generated.json new file mode 100644 index 000000000..d09d57f08 --- /dev/null +++ b/cloud/industry.discrete-manufacturing.basic-0.1.0/process-templates/generated.json @@ -0,0 +1,428 @@ +[ + { + "processId": "discrete_manufacturing_flexible_workshop", + "processLabel": "Discrete manufacturing flexible workshop", + "processDisplayLabel": "离散制造柔性车间", + "domain": "manufacturing", + "aliases": [ + "离散制造车间", + "离散制造柔性车间", + "离散制造工厂", + "柔性制造车间", + "柔性制造工厂", + "机械加工装配车间", + "做一个离散制造工厂", + "生成一个离散制造车间", + "discrete manufacturing workshop", + "flexible manufacturing cell", + "machining and assembly workshop" + ], + "requiredRoles": [ + "storage_rack", + "cnc_machining_center", + "robot_workcell", + "assembly_workstation", + "roller_conveyor", + "vision_inspection_station", + "packaging_station", + "agv_tugger", + "line_control_cabinet", + "fixture_table", + "test_bench", + "material_cart", + "palletizing_workcell" + ], + "defaultLayoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 62, + "width": 30 + }, + "safetyTags": [ + "machine_guarding", + "robot_cell", + "intralogistics", + "indoor_workshop" + ], + "stations": [ + { + "id": "storage_rack", + "label": "Storage rack", + "displayLabel": "立体料架", + "role": "storage_rack", + "equipmentHint": "discrete_manufacturing.storage_rack tall warehouse storage rack with uprights, shelves, pallet loads, and aisle marker", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "material" + ], + "profileId": "discrete_manufacturing.storage_rack" + }, + { + "id": "pallet_table", + "label": "Pallet kitting table", + "displayLabel": "托盘备料台", + "role": "pallet_table", + "equipmentHint": "discrete_manufacturing.pallet_table low kitting pallet table with pallet top, support legs, and material bins", + "footprintHint": "medium", + "safetyTags": [ + "material", + "manual_work" + ], + "profileId": "discrete_manufacturing.pallet_table" + }, + { + "id": "cnc_machining_center", + "label": "CNC machining center", + "displayLabel": "CNC加工中心", + "role": "cnc_machining_center", + "equipmentHint": "discrete_manufacturing.cnc_machining_center enclosed CNC machining center with sliding door, viewing window, control panel, chip conveyor, and stack light", + "footprintHint": "large", + "safetyTags": [ + "machine_tool", + "guarding" + ], + "profileId": "discrete_manufacturing.cnc_machining_center" + }, + { + "id": "chip_conveyor", + "label": "Chip conveyor", + "displayLabel": "排屑输送机", + "role": "chip_conveyor", + "equipmentHint": "discrete_manufacturing.chip_conveyor small inclined chip conveyor with trough frame, belt, discharge chute, and drive motor", + "footprintHint": "long", + "safetyTags": [ + "conveyor", + "chips" + ], + "profileId": "discrete_manufacturing.chip_conveyor" + }, + { + "id": "robot_workcell", + "label": "Robot workcell", + "displayLabel": "机器人工作站", + "role": "robot_workcell", + "equipmentHint": "discrete_manufacturing.robot_workcell six-axis robot workcell with pedestal, articulated arm, end effector, fixture table, and guarding", + "footprintHint": "large", + "safetyTags": [ + "robot", + "guarding" + ], + "profileId": "discrete_manufacturing.robot_workcell" + }, + { + "id": "safety_fence", + "label": "Safety fence", + "displayLabel": "安全围栏", + "role": "safety_fence", + "equipmentHint": "discrete_manufacturing.safety_fence modular industrial safety fence around robot cell with posts, mesh panels, and gate", + "footprintHint": "large", + "safetyTags": [ + "guarding" + ], + "profileId": "discrete_manufacturing.safety_fence" + }, + { + "id": "assembly_workstation", + "label": "Assembly workstation", + "displayLabel": "装配工位", + "role": "assembly_workstation", + "equipmentHint": "discrete_manufacturing.assembly_workstation ergonomic assembly workstation with bench frame, fixture plate, tool rail, light tower, and operator panel", + "footprintHint": "medium", + "safetyTags": [ + "manual_work", + "assembly" + ], + "profileId": "discrete_manufacturing.assembly_workstation" + }, + { + "id": "roller_conveyor", + "label": "Roller conveyor", + "displayLabel": "滚筒输送线", + "role": "roller_conveyor", + "equipmentHint": "discrete_manufacturing.roller_conveyor roller conveyor line with frame, repeated rollers, belt surface, side guards, and drive unit", + "footprintHint": "long", + "safetyTags": [ + "conveyor" + ], + "profileId": "discrete_manufacturing.roller_conveyor" + }, + { + "id": "vision_inspection_station", + "label": "Vision inspection station", + "displayLabel": "视觉检测工位", + "role": "vision_inspection_station", + "equipmentHint": "discrete_manufacturing.vision_inspection_station inspection station with enclosed frame, camera gantry, light panel, display, and reject chute", + "footprintHint": "medium", + "safetyTags": [ + "inspection", + "quality" + ], + "profileId": "discrete_manufacturing.vision_inspection_station" + }, + { + "id": "packaging_station", + "label": "Packaging station", + "displayLabel": "包装工位", + "role": "packaging_station", + "equipmentHint": "discrete_manufacturing.packaging_station packaging station with carton sealer body, conveyor bed, tape head, control box, and discharge roller table", + "footprintHint": "large", + "safetyTags": [ + "packaging", + "conveyor" + ], + "profileId": "discrete_manufacturing.packaging_station" + }, + { + "id": "agv_tugger", + "label": "AGV tugger", + "displayLabel": "AGV小车", + "role": "agv_tugger", + "equipmentHint": "discrete_manufacturing.agv_tugger low autonomous mobile robot tugger with rounded chassis, lidar sensors, status light strip, emergency stop, and tow hitch", + "footprintHint": "medium", + "safetyTags": [ + "mobile_robot", + "logistics" + ], + "profileId": "discrete_manufacturing.agv_tugger" + }, + { + "id": "pipe_corridor", + "label": "Utility pipe corridor", + "displayLabel": "公用管廊", + "role": "pipe_corridor", + "equipmentHint": "discrete_manufacturing.pipe_corridor utility pipe rack corridor with steel supports, compressed air pipe, cable tray, valves, and walkway", + "footprintHint": "long", + "safetyTags": [ + "utility", + "pipe" + ], + "profileId": "discrete_manufacturing.pipe_corridor" + }, + { + "id": "line_control_cabinet", + "label": "Line control cabinet", + "displayLabel": "产线控制柜", + "role": "line_control_cabinet", + "equipmentHint": "discrete_manufacturing.line_control_cabinet electrical control cabinet row with cabinet doors, HMI panel, warning labels, and cable tray", + "footprintHint": "medium", + "safetyTags": [ + "electrical", + "control" + ], + "profileId": "discrete_manufacturing.line_control_cabinet" + }, + { + "id": "compressor_skid", + "label": "Compressor skid", + "displayLabel": "空压机撬装", + "role": "compressor_skid", + "equipmentHint": "discrete_manufacturing.compressor_skid industrial compressor skid with motor, rounded compressor body, inlet outlet ports, control box, and base frame", + "footprintHint": "medium", + "safetyTags": [ + "utility", + "compressed_air" + ], + "profileId": "discrete_manufacturing.compressor_skid" + }, + { + "id": "fixture_table", + "label": "Fixture table", + "displayLabel": "工装台", + "role": "fixture_table", + "equipmentHint": "discrete_manufacturing.fixture_table robust fixture table with T-slot surface, clamp blocks, locator pins, support legs, and tool rail", + "footprintHint": "medium", + "safetyTags": [ + "fixture", + "manual_work" + ], + "profileId": "discrete_manufacturing.fixture_table" + }, + { + "id": "test_bench", + "label": "Functional test bench", + "displayLabel": "功能测试台", + "role": "test_bench", + "equipmentHint": "discrete_manufacturing.test_bench industrial test bench with work surface, instrument display, control panel, cable tray, and indicator light", + "footprintHint": "medium", + "safetyTags": [ + "test", + "quality" + ], + "profileId": "discrete_manufacturing.test_bench" + }, + { + "id": "material_cart", + "label": "Material cart", + "displayLabel": "物料车", + "role": "material_cart", + "equipmentHint": "discrete_manufacturing.material_cart low wheeled material cart with platform deck, handle, four wheels, and material bins", + "footprintHint": "medium", + "safetyTags": [ + "logistics", + "material" + ], + "profileId": "discrete_manufacturing.material_cart" + }, + { + "id": "palletizing_workcell", + "label": "Palletizing workcell", + "displayLabel": "码垛工作站", + "role": "palletizing_workcell", + "equipmentHint": "discrete_manufacturing.palletizing_workcell palletizing robot workcell with robot arm, pallet table, safety fence, conveyor infeed, and stack zone", + "footprintHint": "large", + "safetyTags": [ + "robot", + "palletizing", + "guarding" + ], + "profileId": "discrete_manufacturing.robot_workcell" + } + ], + "connections": [ + { + "fromStationId": "storage_rack", + "toStationId": "pallet_table", + "medium": "material", + "visualKind": "material_transfer", + "fromPortId": "pallet_out", + "toPortId": "kit_in" + }, + { + "fromStationId": "pallet_table", + "toStationId": "cnc_machining_center", + "medium": "material", + "visualKind": "agv_route", + "fromPortId": "kit_out", + "toPortId": "machine_load_door" + }, + { + "fromStationId": "cnc_machining_center", + "toStationId": "chip_conveyor", + "medium": "material", + "visualKind": "chip_chute", + "fromPortId": "chip_out", + "toPortId": "chip_in" + }, + { + "fromStationId": "cnc_machining_center", + "toStationId": "robot_workcell", + "medium": "material", + "visualKind": "robot_transfer", + "fromPortId": "machine_door", + "toPortId": "fixture_table" + }, + { + "fromStationId": "robot_workcell", + "toStationId": "assembly_workstation", + "medium": "material", + "visualKind": "material_transfer", + "fromPortId": "fixture_out", + "toPortId": "assembly_fixture" + }, + { + "fromStationId": "assembly_workstation", + "toStationId": "roller_conveyor", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "assembled_out", + "toPortId": "conveyor_in" + }, + { + "fromStationId": "roller_conveyor", + "toStationId": "vision_inspection_station", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "conveyor_out", + "toPortId": "inspection_in" + }, + { + "fromStationId": "vision_inspection_station", + "toStationId": "packaging_station", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "accepted_out", + "toPortId": "packaging_in" + }, + { + "fromStationId": "packaging_station", + "toStationId": "agv_tugger", + "medium": "material", + "visualKind": "agv_route", + "fromPortId": "packed_out", + "toPortId": "load_deck" + }, + { + "fromStationId": "compressor_skid", + "toStationId": "pipe_corridor", + "medium": "air", + "visualKind": "pipe", + "fromPortId": "compressed_air_out", + "toPortId": "air_header" + }, + { + "fromStationId": "pipe_corridor", + "toStationId": "cnc_machining_center", + "medium": "air", + "visualKind": "pipe", + "fromPortId": "air_header", + "toPortId": "machine_air_in" + }, + { + "fromStationId": "line_control_cabinet", + "toStationId": "roller_conveyor", + "medium": "signal", + "visualKind": "cable_tray", + "fromPortId": "control_out", + "toPortId": "drive_control" + }, + { + "fromStationId": "storage_rack", + "toStationId": "fixture_table", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "material_out", + "toPortId": "fixture_in" + }, + { + "fromStationId": "fixture_table", + "toStationId": "cnc_machining_center", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "fixture_out", + "toPortId": "machine_load_door" + }, + { + "fromStationId": "vision_inspection_station", + "toStationId": "test_bench", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "inspection_out", + "toPortId": "test_in" + }, + { + "fromStationId": "test_bench", + "toStationId": "packaging_station", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "tested_out", + "toPortId": "packaging_in" + }, + { + "fromStationId": "packaging_station", + "toStationId": "palletizing_workcell", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "packed_out", + "toPortId": "palletizing_in" + }, + { + "fromStationId": "palletizing_workcell", + "toStationId": "material_cart", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "pallet_out", + "toPortId": "cart_deck" + } + ] + } +] diff --git a/cloud/industry.discrete-manufacturing.basic-0.1.0/profiles/generated.json b/cloud/industry.discrete-manufacturing.basic-0.1.0/profiles/generated.json new file mode 100644 index 000000000..797b74700 --- /dev/null +++ b/cloud/industry.discrete-manufacturing.basic-0.1.0/profiles/generated.json @@ -0,0 +1,1250 @@ +[ + { + "id": "discrete_manufacturing.cnc_machining_center", + "name": "CNC machining center", + "aliases": [ + "CNC加工中心", + "加工中心", + "数控加工中心", + "机加工中心", + "cnc machining center", + "vertical machining center" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "machine_tool", + "defaultDimensions": { + "length": 3.4, + "width": 2.2, + "height": 2.4 + }, + "parts": [ + { + "kind": "rounded_machine_body", + "semanticRole": "machine_enclosure", + "required": true, + "length": 3.2, + "width": 1.8, + "height": 2.1, + "color": "#d8dde5" + }, + { + "kind": "generic_opening", + "semanticRole": "sliding_door_window", + "required": true, + "attachToRole": "machine_enclosure", + "anchor": "front", + "color": "#84b7d8" + }, + { + "kind": "generic_control_panel", + "semanticRole": "cnc_control_panel", + "required": true, + "attachToRole": "machine_enclosure", + "anchor": "service_side" + }, + { + "kind": "status_light_strip", + "semanticRole": "machine_stack_light", + "attachToRole": "machine_enclosure", + "anchor": "top" + }, + { + "kind": "generic_spout", + "semanticRole": "chip_discharge_port", + "required": true, + "attachToRole": "machine_enclosure", + "anchor": "back" + } + ], + "primarySemanticRole": "machine_enclosure", + "qualityRules": "quality.discrete_manufacturing.cnc_machining_center", + "visualCues": [ + "box enclosure", + "front sliding door", + "blue window", + "side control panel" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.robot_workcell", + "name": "Robot workcell", + "aliases": [ + "机器人工作站", + "六轴机器人工作站", + "机械臂工作站", + "robot workcell", + "robot handling cell" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "robot_workcell_layout", + "family": "robot_arm", + "defaultDimensions": { + "length": 4.2, + "width": 3.4, + "height": 2.6 + }, + "parts": [ + { + "kind": "circular_base", + "semanticRole": "robot_base", + "required": true, + "radius": 0.42, + "height": 0.18, + "color": "#f59e0b" + }, + { + "kind": "vertical_pole", + "semanticRole": "shoulder_joint", + "required": true, + "attachToRole": "robot_base", + "anchor": "top", + "height": 0.9, + "radius": 0.16, + "color": "#f59e0b" + }, + { + "kind": "support_bracket", + "semanticRole": "upper_arm", + "required": true, + "attachToRole": "robot_shoulder", + "anchor": "top", + "length": 1, + "color": "#f59e0b" + }, + { + "kind": "support_bracket", + "semanticRole": "forearm", + "required": true, + "attachToRole": "upper_arm", + "anchor": "front", + "length": 0.9, + "color": "#f59e0b" + }, + { + "kind": "flange_ring", + "semanticRole": "tool_flange", + "required": true, + "attachToRole": "forearm", + "anchor": "front" + }, + { + "kind": "pallet_table", + "semanticRole": "work_table", + "required": true, + "position": [ + 0.85, + 0.35, + 0.75 + ] + }, + { + "kind": "guard_fence", + "semanticRole": "safety_barrier", + "required": true, + "position": [ + 0, + 0.65, + 0 + ], + "length": 4, + "width": 3.2 + } + ], + "primarySemanticRole": "robot_base", + "qualityRules": "quality.discrete_manufacturing.robot_workcell", + "visualCues": [ + "six-axis robot", + "guard fence", + "fixture table", + "orange articulated arm" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.assembly_workstation", + "name": "Assembly workstation", + "aliases": [ + "装配工位", + "人工装配台", + "装配工作站", + "assembly workstation", + "manual assembly station" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.4, + "width": 1.2, + "height": 2 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "workbench", + "required": true, + "length": 2.2, + "width": 0.9, + "height": 0.85 + }, + { + "kind": "generic_panel", + "semanticRole": "fixture_plate", + "required": true, + "attachToRole": "workbench", + "anchor": "top", + "color": "#a3a3a3" + }, + { + "kind": "cable_tray", + "semanticRole": "tool_rail", + "required": true, + "attachToRole": "workbench", + "anchor": "back" + }, + { + "kind": "operator_panel", + "semanticRole": "operator_hmi", + "attachToRole": "workbench", + "anchor": "service_side" + }, + { + "kind": "status_light_strip", + "semanticRole": "andon_light", + "attachToRole": "tool_rail", + "anchor": "top" + } + ], + "primarySemanticRole": "workbench", + "qualityRules": "quality.discrete_manufacturing.assembly_workstation", + "visualCues": [ + "bench", + "fixture plate", + "tool rail", + "operator panel" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.roller_conveyor", + "name": "Roller conveyor", + "aliases": [ + "滚筒输送线", + "滚筒线", + "输送线", + "roller conveyor", + "assembly conveyor" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "linear_transport_layout", + "family": "conveyor", + "defaultDimensions": { + "length": 8, + "width": 1.1, + "height": 0.9 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "conveyor_frame", + "required": true, + "length": 8, + "width": 1, + "height": 0.45 + }, + { + "kind": "roller_array", + "semanticRole": "roller_bed", + "required": true, + "attachToRole": "conveyor_frame", + "anchor": "top", + "count": 12 + }, + { + "kind": "belt_surface", + "semanticRole": "transfer_surface", + "required": true, + "attachToRole": "conveyor_frame", + "anchor": "top", + "length": 7.8 + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "conveyor_drive_motor", + "attachToRole": "conveyor_frame", + "anchor": "drive_side" + }, + { + "kind": "guard_fence", + "semanticRole": "side_guard", + "attachToRole": "conveyor_frame", + "anchor": "left" + } + ], + "primarySemanticRole": "conveyor_frame", + "qualityRules": "quality.discrete_manufacturing.roller_conveyor", + "visualCues": [ + "long roller bed", + "drive motor", + "side guards" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.vision_inspection_station", + "name": "Vision inspection station", + "aliases": [ + "视觉检测工位", + "视觉检测站", + "检测工位", + "vision inspection station", + "inspection station" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2, + "width": 1.4, + "height": 2.2 + }, + "parts": [ + { + "kind": "generic_base", + "semanticRole": "inspection_base", + "required": true, + "length": 1.8, + "width": 1.2, + "height": 0.25 + }, + { + "kind": "structural_tower_frame", + "semanticRole": "inspection_frame", + "required": true, + "attachToRole": "inspection_base", + "anchor": "top", + "height": 1.8, + "levelCount": 2 + }, + { + "kind": "generic_display", + "semanticRole": "inspection_display", + "required": true, + "attachToRole": "inspection_frame", + "anchor": "service_side" + }, + { + "kind": "generic_panel", + "semanticRole": "camera_light_panel", + "required": true, + "attachToRole": "inspection_frame", + "anchor": "top", + "color": "#e0f2fe" + }, + { + "kind": "generic_spout", + "semanticRole": "reject_chute", + "attachToRole": "inspection_base", + "anchor": "right" + } + ], + "primarySemanticRole": "inspection_frame", + "qualityRules": "quality.discrete_manufacturing.vision_inspection_station", + "visualCues": [ + "inspection frame", + "camera light panel", + "display", + "reject chute" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.packaging_station", + "name": "Packaging station", + "aliases": [ + "包装工位", + "包装站", + "封箱机", + "packaging station", + "carton sealer" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "linear_transport_layout", + "family": "generic", + "defaultDimensions": { + "length": 3.2, + "width": 1.2, + "height": 1.6 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "packaging_conveyor", + "required": true, + "length": 3, + "width": 0.9, + "height": 0.55 + }, + { + "kind": "rounded_machine_body", + "semanticRole": "packaging_body", + "required": true, + "attachToRole": "packaging_conveyor", + "anchor": "top", + "length": 1.1, + "width": 0.95, + "height": 0.9, + "color": "#e5e7eb" + }, + { + "kind": "generic_panel", + "semanticRole": "tape_head", + "required": true, + "attachToRole": "packaging_body", + "anchor": "front", + "color": "#facc15" + }, + { + "kind": "control_box", + "semanticRole": "packaging_control_box", + "attachToRole": "packaging_body", + "anchor": "service_side" + }, + { + "kind": "roller_array", + "semanticRole": "discharge_rollers", + "attachToRole": "packaging_conveyor", + "anchor": "back", + "count": 5 + } + ], + "primarySemanticRole": "packaging_body", + "qualityRules": "quality.discrete_manufacturing.packaging_station", + "visualCues": [ + "carton sealer", + "conveyor bed", + "tape head", + "control box" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.agv_tugger", + "name": "AGV tugger", + "aliases": [ + "AGV小车", + "AMR小车", + "牵引AGV", + "移动机器人", + "agv tugger", + "amr tugger" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "vehicle_layout", + "family": "vehicle", + "defaultDimensions": { + "length": 1.6, + "width": 0.9, + "height": 0.45 + }, + "parts": [ + { + "kind": "mobile_platform_chassis", + "semanticRole": "mobile_platform_chassis", + "required": true, + "length": 1.55, + "width": 0.85, + "height": 0.28, + "color": "#2563eb" + }, + { + "kind": "lidar_sensor", + "semanticRole": "front_lidar", + "required": true, + "attachToRole": "mobile_platform_chassis", + "anchor": "front" + }, + { + "kind": "emergency_stop_button", + "semanticRole": "emergency_stop", + "required": true, + "attachToRole": "mobile_platform_chassis", + "anchor": "top" + }, + { + "kind": "status_light_strip", + "semanticRole": "status_light", + "attachToRole": "mobile_platform_chassis", + "anchor": "front" + }, + { + "kind": "generic_handle", + "semanticRole": "tow_hitch", + "attachToRole": "mobile_platform_chassis", + "anchor": "back" + } + ], + "primarySemanticRole": "mobile_platform_chassis", + "qualityRules": "quality.discrete_manufacturing.agv_tugger", + "visualCues": [ + "low blue mobile base", + "lidar", + "emergency stop", + "tow hitch" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.storage_rack", + "name": "Storage rack", + "aliases": [ + "立体料架", + "仓储货架", + "原料货架", + "storage rack", + "warehouse rack" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 4, + "width": 1.2, + "height": 3 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "rack_frame", + "required": true, + "length": 4, + "width": 1.1, + "height": 3, + "levelCount": 3 + }, + { + "kind": "pallet_table", + "semanticRole": "pallet_load", + "required": true, + "attachToRole": "rack_frame", + "anchor": "front", + "arrayAlong": "height", + "count": 2 + }, + { + "kind": "warning_label", + "semanticRole": "rack_label", + "attachToRole": "rack_frame", + "anchor": "service_side" + } + ], + "primarySemanticRole": "rack_frame", + "qualityRules": "quality.discrete_manufacturing.storage_rack", + "visualCues": [ + "tall rack", + "multiple shelf levels", + "pallet loads" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.line_control_cabinet", + "name": "Line control cabinet", + "aliases": [ + "产线控制柜", + "电控柜", + "PLC控制柜", + "line control cabinet", + "control cabinet row" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "electrical", + "defaultDimensions": { + "length": 2.4, + "width": 0.55, + "height": 2 + }, + "parts": [ + { + "kind": "electrical_cabinet", + "semanticRole": "control_cabinet", + "required": true, + "length": 2.2, + "width": 0.5, + "height": 1.9 + }, + { + "kind": "generic_display", + "semanticRole": "hmi_panel", + "required": true, + "attachToRole": "control_cabinet", + "anchor": "front" + }, + { + "kind": "cable_tray", + "semanticRole": "cable_tray", + "attachToRole": "control_cabinet", + "anchor": "top" + }, + { + "kind": "warning_label", + "semanticRole": "electrical_warning_label", + "attachToRole": "control_cabinet", + "anchor": "front" + } + ], + "primarySemanticRole": "control_cabinet", + "qualityRules": "quality.discrete_manufacturing.line_control_cabinet", + "visualCues": [ + "cabinet row", + "HMI panel", + "cable tray", + "warning labels" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.compressor_skid", + "name": "Compressor skid", + "aliases": [ + "空压机撬装", + "空压机组", + "压缩空气站", + "compressor skid", + "air compressor skid" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "rotating_machine_layout", + "family": "compressor", + "defaultDimensions": { + "length": 2.6, + "width": 1.1, + "height": 1.2 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "compressor_skid_base", + "required": true, + "length": 2.5, + "width": 1, + "height": 0.18 + }, + { + "kind": "rounded_machine_body", + "semanticRole": "compressor_body", + "required": true, + "attachToRole": "compressor_skid_base", + "anchor": "top", + "length": 1.1, + "width": 0.55, + "height": 0.65 + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "drive_motor", + "required": true, + "attachToRole": "compressor_body", + "anchor": "drive_side" + }, + { + "kind": "inlet_port", + "semanticRole": "air_inlet", + "attachToRole": "compressor_body", + "anchor": "left" + }, + { + "kind": "outlet_port", + "semanticRole": "compressed_air_outlet", + "attachToRole": "compressor_body", + "anchor": "right" + }, + { + "kind": "control_box", + "semanticRole": "compressor_control_box", + "attachToRole": "compressor_skid_base", + "anchor": "service_side" + } + ], + "primarySemanticRole": "compressor_body", + "qualityRules": "quality.discrete_manufacturing.compressor_skid", + "visualCues": [ + "skid base", + "rounded compressor body", + "motor", + "ports", + "control box" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.pallet_table", + "name": "Pallet kitting table", + "aliases": [ + "托盘备料台", + "备料台", + "托盘工位", + "pallet table", + "kitting table" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 1.8, + "width": 1.1, + "height": 0.75 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "pallet_table", + "required": true, + "length": 1.8, + "width": 1.1, + "height": 0.75 + }, + { + "kind": "generic_detail_accent", + "semanticRole": "material_bins", + "required": true, + "attachToRole": "pallet_table", + "anchor": "top", + "arrayAlong": "width", + "count": 3 + } + ], + "primarySemanticRole": "pallet_table", + "qualityRules": "quality.discrete_manufacturing.pallet_table", + "visualCues": [ + "low table", + "material bins", + "kitting surface" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.chip_conveyor", + "name": "Chip conveyor", + "aliases": [ + "排屑输送机", + "排屑机", + "chip conveyor", + "scrap conveyor" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "linear_transport_layout", + "family": "conveyor", + "defaultDimensions": { + "length": 2.8, + "width": 0.55, + "height": 0.9 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "chip_conveyor_frame", + "required": true, + "length": 2.8, + "width": 0.5, + "height": 0.35, + "rotation": [ + 0, + 0, + -0.18 + ] + }, + { + "kind": "belt_surface", + "semanticRole": "chip_belt", + "required": true, + "attachToRole": "chip_conveyor_frame", + "anchor": "top" + }, + { + "kind": "generic_spout", + "semanticRole": "chip_discharge_chute", + "attachToRole": "chip_conveyor_frame", + "anchor": "back" + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "chip_conveyor_drive", + "attachToRole": "chip_conveyor_frame", + "anchor": "drive_side" + } + ], + "primarySemanticRole": "chip_conveyor_frame", + "qualityRules": "quality.discrete_manufacturing.chip_conveyor", + "visualCues": [ + "small inclined conveyor", + "trough", + "discharge chute", + "drive motor" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.safety_fence", + "name": "Safety fence", + "aliases": [ + "安全围栏", + "机器人围栏", + "防护围栏", + "safety fence", + "robot guarding fence" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 4, + "width": 3, + "height": 1.8 + }, + "parts": [ + { + "kind": "guard_fence", + "semanticRole": "safety_fence", + "required": true, + "length": 4, + "width": 3, + "height": 1.8 + }, + { + "kind": "warning_label", + "semanticRole": "safety_warning_label", + "attachToRole": "safety_fence", + "anchor": "front" + } + ], + "primarySemanticRole": "safety_fence", + "qualityRules": "quality.discrete_manufacturing.safety_fence", + "visualCues": [ + "mesh fence", + "posts", + "gate", + "warning label" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.pipe_corridor", + "name": "Utility pipe corridor", + "aliases": [ + "公用管廊", + "压缩空气管廊", + "车间管廊", + "utility pipe corridor", + "pipe rack" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "pipe_valve_layout", + "family": "pipe_system", + "defaultDimensions": { + "length": 9, + "width": 1, + "height": 2.2 + }, + "parts": [ + { + "kind": "pipe_run", + "semanticRole": "compressed_air_pipe", + "required": true, + "length": 9, + "radius": 0.06, + "axis": "x" + }, + { + "kind": "pipe_run", + "semanticRole": "coolant_pipe", + "required": true, + "position": [ + 0, + 0.18, + 0.16 + ], + "length": 8.8, + "radius": 0.045, + "axis": "x" + }, + { + "kind": "pipe_elbow", + "semanticRole": "utility_pipe_elbow", + "required": true, + "attachToRole": "compressed_air_pipe", + "anchor": "right" + }, + { + "kind": "valve_body", + "semanticRole": "valve_body", + "attachToRole": "compressed_air_pipe", + "anchor": "service_side" + } + ], + "primarySemanticRole": "compressed_air_pipe", + "qualityRules": "quality.discrete_manufacturing.pipe_corridor", + "visualCues": [ + "long pipe rack", + "parallel utility pipe", + "cable tray", + "valve" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.fixture_table", + "name": "Fixture table", + "aliases": [ + "工装台", + "夹具台", + "治具台", + "fixture table", + "jig table" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.2, + "width": 1.2, + "height": 0.9 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "fixture_table", + "required": true, + "length": 2.1, + "width": 1.1, + "height": 0.8 + }, + { + "kind": "generic_panel", + "semanticRole": "t_slot_surface", + "required": true, + "attachToRole": "fixture_table", + "anchor": "top", + "color": "#9ca3af" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "clamp_blocks", + "required": true, + "attachToRole": "t_slot_surface", + "anchor": "top", + "arrayAlong": "length", + "count": 4 + }, + { + "kind": "vertical_pole", + "semanticRole": "locator_pins", + "attachToRole": "t_slot_surface", + "anchor": "top", + "count": 4, + "height": 0.18, + "radius": 0.025 + }, + { + "kind": "cable_tray", + "semanticRole": "tool_rail", + "attachToRole": "fixture_table", + "anchor": "back" + } + ], + "primarySemanticRole": "fixture_table", + "qualityRules": "quality.discrete_manufacturing.fixture_table", + "visualCues": [ + "fixture table", + "T-slot surface", + "clamp blocks", + "locator pins" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.test_bench", + "name": "Functional test bench", + "aliases": [ + "功能测试台", + "测试台", + "检测台", + "functional test bench", + "test bench" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "box_enclosure_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.4, + "width": 1.2, + "height": 1.8 + }, + "parts": [ + { + "kind": "pallet_table", + "semanticRole": "test_bench", + "required": true, + "length": 2.2, + "width": 1, + "height": 0.85 + }, + { + "kind": "generic_display", + "semanticRole": "instrument_display", + "required": true, + "attachToRole": "test_bench", + "anchor": "back" + }, + { + "kind": "generic_control_panel", + "semanticRole": "test_control_panel", + "required": true, + "attachToRole": "test_bench", + "anchor": "service_side" + }, + { + "kind": "cable_tray", + "semanticRole": "test_cable_tray", + "attachToRole": "test_bench", + "anchor": "top" + }, + { + "kind": "status_light_strip", + "semanticRole": "test_status_light", + "attachToRole": "instrument_display", + "anchor": "top" + } + ], + "primarySemanticRole": "test_bench", + "qualityRules": "quality.discrete_manufacturing.test_bench", + "visualCues": [ + "test bench", + "instrument display", + "control panel", + "cable tray" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.material_cart", + "name": "Material cart", + "aliases": [ + "物料车", + "转运车", + "料车", + "material cart", + "trolley cart" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "vehicle_layout", + "family": "vehicle", + "defaultDimensions": { + "length": 1.8, + "width": 0.9, + "height": 1 + }, + "parts": [ + { + "kind": "mobile_platform_chassis", + "semanticRole": "cart_platform", + "required": true, + "length": 1.7, + "width": 0.85, + "height": 0.22, + "color": "#64748b" + }, + { + "kind": "wheel_set", + "semanticRole": "cart_wheels", + "required": true, + "attachToRole": "cart_platform", + "anchor": "bottom" + }, + { + "kind": "generic_handle", + "semanticRole": "push_handle", + "required": true, + "attachToRole": "cart_platform", + "anchor": "back" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "material_bins", + "required": true, + "attachToRole": "cart_platform", + "anchor": "top", + "arrayAlong": "length", + "count": 3 + } + ], + "primarySemanticRole": "cart_platform", + "qualityRules": "quality.discrete_manufacturing.material_cart", + "visualCues": [ + "wheeled material cart", + "platform deck", + "push handle", + "material bins" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "discrete_manufacturing.palletizing_workcell", + "name": "Palletizing workcell", + "aliases": [ + "码垛工作站", + "码垛单元", + "机器人码垛工作站", + "palletizing workcell", + "robot palletizing cell" + ], + "industry": "discrete-manufacturing", + "layoutFamily": "robot_workcell_layout", + "family": "robot_arm", + "defaultDimensions": { + "length": 4.6, + "width": 3.6, + "height": 2.6 + }, + "parts": [ + { + "kind": "circular_base", + "semanticRole": "robot_base", + "required": true, + "radius": 0.42, + "height": 0.18, + "color": "#f59e0b" + }, + { + "kind": "vertical_pole", + "semanticRole": "shoulder_joint", + "required": true, + "attachToRole": "robot_base", + "anchor": "top", + "height": 0.9, + "radius": 0.16, + "color": "#f59e0b" + }, + { + "kind": "support_bracket", + "semanticRole": "upper_arm", + "required": true, + "attachToRole": "shoulder_joint", + "anchor": "top", + "length": 1, + "color": "#f59e0b" + }, + { + "kind": "support_bracket", + "semanticRole": "forearm", + "required": true, + "attachToRole": "upper_arm", + "anchor": "front", + "length": 0.9, + "color": "#f59e0b" + }, + { + "kind": "pallet_table", + "semanticRole": "work_table", + "required": true, + "position": [ + 1, + 0.35, + 0.7 + ] + }, + { + "kind": "conveyor_frame", + "semanticRole": "infeed_conveyor", + "required": false, + "position": [ + -1, + 0.35, + 0 + ], + "length": 2.4, + "width": 0.8, + "height": 0.5 + }, + { + "kind": "guard_fence", + "semanticRole": "safety_barrier", + "required": true, + "position": [ + 0, + 0.65, + 0 + ], + "length": 4.2, + "width": 3.4 + } + ], + "primarySemanticRole": "robot_base", + "qualityRules": "quality.discrete_manufacturing.palletizing_workcell", + "visualCues": [ + "palletizing robot", + "pallet stack zone", + "infeed conveyor", + "safety fence" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.discrete-manufacturing.basic-0.1.0/quality-rules/generated-quality.json b/cloud/industry.discrete-manufacturing.basic-0.1.0/quality-rules/generated-quality.json new file mode 100644 index 000000000..aefa102b8 --- /dev/null +++ b/cloud/industry.discrete-manufacturing.basic-0.1.0/quality-rules/generated-quality.json @@ -0,0 +1,253 @@ +[ + { + "id": "quality.discrete_manufacturing.cnc_machining_center", + "requiredRoles": [ + "machine_enclosure", + "sliding_door_window", + "cnc_control_panel", + "machine_stack_light", + "chip_discharge_port" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "aircraft_wing" + ], + "shapeCount": { + "min": 5, + "max": 45 + } + }, + { + "id": "quality.discrete_manufacturing.robot_workcell", + "requiredRoles": [ + "robot_base", + "shoulder_joint", + "upper_arm", + "forearm", + "tool_flange", + "work_table", + "safety_barrier" + ], + "forbiddenRoles": [ + "vehicle_body" + ], + "shapeCount": { + "min": 7, + "max": 70 + } + }, + { + "id": "quality.discrete_manufacturing.assembly_workstation", + "requiredRoles": [ + "workbench", + "fixture_plate", + "tool_rail", + "operator_hmi", + "andon_light" + ], + "shapeCount": { + "min": 5, + "max": 42 + } + }, + { + "id": "quality.discrete_manufacturing.roller_conveyor", + "requiredRoles": [ + "conveyor_frame", + "roller_bed", + "transfer_surface", + "conveyor_drive_motor", + "side_guard" + ], + "shapeCount": { + "min": 5, + "max": 60 + } + }, + { + "id": "quality.discrete_manufacturing.vision_inspection_station", + "requiredRoles": [ + "inspection_frame", + "inspection_base", + "inspection_display", + "camera_light_panel", + "reject_chute" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.discrete_manufacturing.packaging_station", + "requiredRoles": [ + "packaging_body", + "packaging_conveyor", + "tape_head", + "packaging_control_box", + "discharge_rollers" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.discrete_manufacturing.agv_tugger", + "requiredRoles": [ + "mobile_platform_chassis", + "front_lidar", + "emergency_stop", + "status_light", + "tow_hitch" + ], + "shapeCount": { + "min": 5, + "max": 40 + } + }, + { + "id": "quality.discrete_manufacturing.storage_rack", + "requiredRoles": [ + "rack_frame", + "pallet_load", + "rack_label" + ], + "shapeCount": { + "min": 4, + "max": 80 + } + }, + { + "id": "quality.discrete_manufacturing.line_control_cabinet", + "requiredRoles": [ + "control_cabinet", + "hmi_panel", + "cable_tray", + "electrical_warning_label" + ], + "shapeCount": { + "min": 4, + "max": 35 + } + }, + { + "id": "quality.discrete_manufacturing.compressor_skid", + "requiredRoles": [ + "compressor_body", + "compressor_skid_base", + "drive_motor", + "air_inlet", + "compressed_air_outlet", + "compressor_control_box" + ], + "shapeCount": { + "min": 6, + "max": 50 + } + }, + { + "id": "quality.discrete_manufacturing.pallet_table", + "requiredRoles": [ + "pallet_table", + "material_bins" + ], + "shapeCount": { + "min": 2, + "max": 25 + } + }, + { + "id": "quality.discrete_manufacturing.chip_conveyor", + "requiredRoles": [ + "chip_conveyor_frame", + "chip_belt", + "chip_discharge_chute", + "chip_conveyor_drive" + ], + "shapeCount": { + "min": 4, + "max": 35 + } + }, + { + "id": "quality.discrete_manufacturing.safety_fence", + "requiredRoles": [ + "safety_fence", + "safety_warning_label" + ], + "shapeCount": { + "min": 2, + "max": 35 + } + }, + { + "id": "quality.discrete_manufacturing.pipe_corridor", + "requiredRoles": [ + "compressed_air_pipe", + "coolant_pipe", + "utility_pipe_elbow", + "valve_body" + ], + "shapeCount": { + "min": 4, + "max": 50 + } + }, + { + "id": "quality.discrete_manufacturing.fixture_table", + "requiredRoles": [ + "fixture_table", + "t_slot_surface", + "clamp_blocks", + "locator_pins", + "tool_rail" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.discrete_manufacturing.test_bench", + "requiredRoles": [ + "test_bench", + "instrument_display", + "test_control_panel", + "test_cable_tray", + "test_status_light" + ], + "shapeCount": { + "min": 5, + "max": 45 + } + }, + { + "id": "quality.discrete_manufacturing.material_cart", + "requiredRoles": [ + "cart_platform", + "cart_wheels", + "push_handle", + "material_bins" + ], + "shapeCount": { + "min": 4, + "max": 45 + } + }, + { + "id": "quality.discrete_manufacturing.palletizing_workcell", + "requiredRoles": [ + "robot_base", + "shoulder_joint", + "upper_arm", + "forearm", + "work_table", + "safety_barrier" + ], + "shapeCount": { + "min": 7, + "max": 75 + } + } +] diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0.zip b/cloud/industry.electrolytic-aluminum.basic-0.1.0.zip new file mode 100644 index 000000000..3e82ff978 Binary files /dev/null and b/cloud/industry.electrolytic-aluminum.basic-0.1.0.zip differ diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/README.md b/cloud/industry.electrolytic-aluminum.basic-0.1.0/README.md new file mode 100644 index 000000000..079d0a6b3 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/README.md @@ -0,0 +1,39 @@ +# Electrolytic Aluminum Basic Equipment Pack + +电解铝行业基础资源包,覆盖电解铝厂整厂、电解车间到铸造线,以及常见电解铝设备。 + +## Pack Type + +Factory-capable pack: supports whole-factory/process creation through process templates and factory architectures. + +## Factory Creation + +支持整厂 / 工序: + +- Electrolytic aluminum smelter (`electrolytic_aluminum_smelter_full`) +- Electrolytic aluminum potroom and casting line (`electrolytic_aluminum_potroom_casting_line`) + +支持范围: + +- Potroom electrolysis core (`potroom_core`) +- Molten metal transfer and casting (`metal_transfer_and_casting`) +- Full electrolytic aluminum smelter (`full_smelter`) + +## Devices + +- Potline module (`electrolytic_aluminum.potline_module`) +- Pot tending overhead crane (`electrolytic_aluminum.pot_tending_crane`) +- Rectifier transformer station (`electrolytic_aluminum.rectifier_transformer_station`) +- Alumina storage silo (`electrolytic_aluminum.alumina_storage_silo`) +- Alumina conveying line (`electrolytic_aluminum.alumina_conveying_line`) +- Dry scrubber baghouse (`electrolytic_aluminum.dry_scrubber_baghouse`) +- Vacuum tapping ladle (`electrolytic_aluminum.vacuum_tapping_ladle`) +- Anode assembly station (`electrolytic_aluminum.anode_assembly_station`) +- Molten aluminum holding furnace (`electrolytic_aluminum.molten_aluminum_holding_furnace`) +- Continuous ingot casting line (`electrolytic_aluminum.continuous_ingot_casting_line`) + +## Validation + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.electrolytic-aluminum.basic@0.1.0 --validate-only +``` diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/factory-architectures/electrolytic-aluminum-smelter.json b/cloud/industry.electrolytic-aluminum.basic-0.1.0/factory-architectures/electrolytic-aluminum-smelter.json new file mode 100644 index 000000000..37e45c7b4 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/factory-architectures/electrolytic-aluminum-smelter.json @@ -0,0 +1,138 @@ +{ + "id": "electrolytic_aluminum.smelter.modular_potline", + "label": "Electrolytic aluminum smelter modular architecture", + "industry": "electrolytic-aluminum", + "processId": "electrolytic_aluminum_smelter_full", + "layoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 72, + "width": 32 + }, + "scopes": [ + { + "id": "potroom_core", + "label": "Potroom electrolysis core", + "includeModules": [ + "rectifier_power", + "alumina_handling", + "potroom_electrolysis", + "fume_treatment" + ] + }, + { + "id": "metal_transfer_and_casting", + "label": "Molten metal transfer and casting", + "includeModules": [ + "potroom_electrolysis", + "metal_transfer", + "holding_and_casting" + ] + }, + { + "id": "full_smelter", + "label": "Full electrolytic aluminum smelter", + "includeModules": [ + "rectifier_power", + "alumina_handling", + "potroom_electrolysis", + "anode_handling", + "fume_treatment", + "metal_transfer", + "holding_and_casting", + "utilities" + ] + } + ], + "modules": [ + { + "id": "rectifier_power", + "displayLabel": "整流供电", + "order": 10, + "stationIds": [ + "rectifier_transformer_station" + ] + }, + { + "id": "alumina_handling", + "displayLabel": "氧化铝储运与加料", + "order": 20, + "stationIds": [ + "alumina_storage_silo", + "alumina_conveying_line" + ] + }, + { + "id": "potroom_electrolysis", + "displayLabel": "电解车间", + "order": 30, + "stationIds": [ + "potline_module", + "pot_tending_crane" + ] + }, + { + "id": "anode_handling", + "displayLabel": "阳极组装与更换", + "order": 40, + "stationIds": [ + "anode_assembly_station" + ] + }, + { + "id": "fume_treatment", + "displayLabel": "电解烟气净化", + "order": 50, + "stationIds": [ + "dry_scrubber_baghouse" + ] + }, + { + "id": "metal_transfer", + "displayLabel": "出铝与铝液转运", + "order": 60, + "stationIds": [ + "vacuum_tapping_ladle" + ] + }, + { + "id": "holding_and_casting", + "displayLabel": "保温与铸锭", + "order": 70, + "stationIds": [ + "molten_aluminum_holding_furnace", + "continuous_ingot_casting_line" + ] + }, + { + "id": "utilities", + "displayLabel": "辅助系统", + "order": 80, + "stationIds": [ + "rectifier_transformer_station", + "dry_scrubber_baghouse" + ] + } + ], + "layoutHints": { + "primaryAxis": "x", + "secondaryAxis": "z", + "indoorPotroom": true, + "outdoorHeavyIndustry": true, + "longAxisStationId": "potline_module", + "highestStationId": "dry_scrubber_baghouse", + "sideBranchStationIds": [ + "rectifier_transformer_station", + "alumina_storage_silo", + "dry_scrubber_baghouse", + "anode_assembly_station" + ], + "scale": "conceptual", + "notes": [ + "Represent the potroom as one or more long parallel electrolytic-cell bays.", + "Keep rectifier power beside the potline and show copper busbar transfer into electrolytic cells.", + "Place alumina storage and conveying upstream of potroom feed points.", + "Place dry scrubber and baghouse as a side exhaust-treatment branch from the potroom.", + "Place vacuum tapping ladles between potroom and holding/casting area." + ] + } +} diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/pack.json b/cloud/industry.electrolytic-aluminum.basic-0.1.0/pack.json new file mode 100644 index 000000000..4286386b6 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/pack.json @@ -0,0 +1,90 @@ +{ + "id": "industry.electrolytic-aluminum.basic", + "name": "Electrolytic Aluminum Basic Equipment Pack", + "industry": "electrolytic-aluminum", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Basic equipment profiles for electrolytic aluminum smelters, covering potroom electrolysis, alumina handling, rectifier power supply, pot tending, fume treatment, molten aluminum transfer, anode assembly, holding furnace, and ingot casting scenarios.", + "profiles": [ + "profiles/generated.json", + "profiles/potline-module.json" + ], + "qualityRules": [ + "quality-rules/generated-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/electrolytic-aluminum-smelter.json" + ], + "processTemplates": [ + "process-templates/electrolytic-aluminum-smelter.json", + "process-templates/potroom-casting-line.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "electrolytic_aluminum.alumina_storage_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "electrolytic_aluminum.vacuum_tapping_ladle", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "electrolytic_aluminum.molten_aluminum_holding_furnace", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/process-templates/electrolytic-aluminum-smelter.json b/cloud/industry.electrolytic-aluminum.basic-0.1.0/process-templates/electrolytic-aluminum-smelter.json new file mode 100644 index 000000000..c01a5ee76 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/process-templates/electrolytic-aluminum-smelter.json @@ -0,0 +1,255 @@ +[ + { + "processId": "electrolytic_aluminum_smelter_full", + "processLabel": "Electrolytic aluminum smelter", + "processDisplayLabel": "电解铝厂", + "domain": "metallurgy", + "aliases": [ + "电解铝厂", + "铝电解厂", + "电解铝车间", + "生成一个电解铝厂", + "创建一个电解铝厂", + "aluminum smelter", + "aluminium smelter", + "electrolytic aluminum plant", + "aluminum electrolysis plant" + ], + "requiredRoles": [ + "rectifier_transformer_station", + "alumina_storage_silo", + "alumina_conveying_line", + "potline_module", + "pot_tending_crane", + "dry_scrubber_baghouse", + "vacuum_tapping_ladle", + "anode_assembly_station", + "molten_aluminum_holding_furnace", + "continuous_ingot_casting_line" + ], + "defaultLayoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 72, + "width": 32 + }, + "safetyTags": [ + "molten_metal", + "high_current", + "fluoride_fume", + "heavy_industry", + "crane" + ], + "stations": [ + { + "id": "rectifier_transformer_station", + "label": "Rectifier transformer station", + "displayLabel": "整流变压器站", + "role": "rectifier_transformer_station", + "equipmentHint": "electrolytic_aluminum.rectifier_transformer_station rectifier transformer station with transformer body, radiator bank, rectifier cabinet, control panel, copper busbar, and safety fence", + "footprintHint": "large", + "safetyTags": [ + "high_current", + "power", + "electrical" + ], + "profileId": "electrolytic_aluminum.rectifier_transformer_station" + }, + { + "id": "alumina_storage_silo", + "label": "Alumina storage silo", + "displayLabel": "氧化铝仓", + "role": "alumina_storage_silo", + "equipmentHint": "electrolytic_aluminum.alumina_storage_silo tall alumina powder silo with cylindrical shell, conical discharge hopper, pneumatic conveying pipe, and access ladder", + "footprintHint": "tall", + "safetyTags": [ + "powder", + "storage", + "height" + ], + "profileId": "electrolytic_aluminum.alumina_storage_silo" + }, + { + "id": "alumina_conveying_line", + "label": "Alumina conveying line", + "displayLabel": "氧化铝输送线", + "role": "alumina_conveying_line", + "equipmentHint": "electrolytic_aluminum.alumina_conveying_line covered alumina conveying line with conveyor frame, roller bed, feed hopper, and drive unit", + "footprintHint": "long", + "safetyTags": [ + "powder", + "conveyor" + ], + "profileId": "electrolytic_aluminum.alumina_conveying_line" + }, + { + "id": "potline_module", + "label": "Aluminum potline module", + "displayLabel": "铝电解槽列", + "role": "potline_module", + "equipmentHint": "electrolytic_aluminum.potline_module two long rows of reduction pots with covered hoods, anode cover rows, copper busbars, alumina feed manifold, fume collection header, and side service platforms", + "footprintHint": "long", + "safetyTags": [ + "molten_metal", + "high_current", + "fluoride_fume" + ], + "profileId": "electrolytic_aluminum.electrolytic_cell" + }, + { + "id": "pot_tending_crane", + "label": "Pot tending overhead crane", + "displayLabel": "电解多功能天车", + "role": "pot_tending_crane", + "equipmentHint": "electrolytic_aluminum.pot_tending_crane pot tending overhead crane with bridge girder, runway rails, tool carriage, hoist drive, operator cabin, and vertical anode-changing tool", + "footprintHint": "long", + "safetyTags": [ + "crane", + "maintenance", + "height" + ], + "profileId": "electrolytic_aluminum.pot_tending_crane" + }, + { + "id": "anode_assembly_station", + "label": "Anode assembly station", + "displayLabel": "阳极组装站", + "role": "anode_assembly_station", + "equipmentHint": "electrolytic_aluminum.anode_assembly_station anode assembly station with frame, carbon block table, anode carbon block, rodding bar, control panel, and safety guard", + "footprintHint": "large", + "safetyTags": [ + "carbon", + "material_handling" + ], + "profileId": "electrolytic_aluminum.anode_assembly_station" + }, + { + "id": "dry_scrubber_baghouse", + "label": "Dry scrubber baghouse", + "displayLabel": "干法净化布袋除尘", + "role": "dry_scrubber_baghouse", + "equipmentHint": "electrolytic_aluminum.dry_scrubber_baghouse dry scrubber and baghouse fume treatment with rectangular filter body, dust hoppers, gas inlet header, induced draft fan, clean gas stack, and maintenance platform", + "footprintHint": "tall", + "safetyTags": [ + "fluoride_fume", + "dust", + "exhaust" + ], + "profileId": "electrolytic_aluminum.dry_scrubber_baghouse" + }, + { + "id": "vacuum_tapping_ladle", + "label": "Vacuum tapping ladle", + "displayLabel": "真空抬包", + "role": "vacuum_tapping_ladle", + "equipmentHint": "electrolytic_aluminum.vacuum_tapping_ladle round molten aluminum vacuum tapping ladle with top suction nozzle, side tapping spout, lifting trunnions, and transport saddle", + "footprintHint": "medium", + "safetyTags": [ + "molten_metal", + "transfer" + ], + "profileId": "electrolytic_aluminum.vacuum_tapping_ladle" + }, + { + "id": "molten_aluminum_holding_furnace", + "label": "Molten aluminum holding furnace", + "displayLabel": "铝液保温炉", + "role": "molten_aluminum_holding_furnace", + "equipmentHint": "electrolytic_aluminum.molten_aluminum_holding_furnace horizontal molten aluminum holding furnace with furnace shell, access door, tap spout, exhaust stack, and operator platform", + "footprintHint": "large", + "safetyTags": [ + "molten_metal", + "thermal" + ], + "profileId": "electrolytic_aluminum.molten_aluminum_holding_furnace" + }, + { + "id": "continuous_ingot_casting_line", + "label": "Continuous ingot casting line", + "displayLabel": "连续铸锭线", + "role": "continuous_ingot_casting_line", + "equipmentHint": "electrolytic_aluminum.continuous_ingot_casting_line continuous aluminum ingot casting line with casting conveyor frame, mold chain, pouring tundish, end drive, operator panel, and safety guard", + "footprintHint": "long", + "safetyTags": [ + "molten_metal", + "casting", + "conveyor" + ], + "profileId": "electrolytic_aluminum.alumina_conveying_line" + } + ], + "connections": [ + { + "fromStationId": "rectifier_transformer_station", + "toStationId": "potline_module", + "medium": "power", + "visualKind": "busbar", + "fromPortId": "dc_busbar_out", + "toPortId": "high_current_busbar" + }, + { + "fromStationId": "alumina_storage_silo", + "toStationId": "alumina_conveying_line", + "medium": "material", + "visualKind": "pneumatic_pipe", + "fromPortId": "silo_discharge", + "toPortId": "feed_hopper" + }, + { + "fromStationId": "alumina_conveying_line", + "toStationId": "potline_module", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "alumina_out", + "toPortId": "alumina_feed_manifold" + }, + { + "fromStationId": "anode_assembly_station", + "toStationId": "pot_tending_crane", + "medium": "material", + "visualKind": "material_transfer", + "fromPortId": "prepared_anode_out", + "toPortId": "anode_changing_tool" + }, + { + "fromStationId": "pot_tending_crane", + "toStationId": "potline_module", + "medium": "material", + "visualKind": "crane_transfer", + "fromPortId": "anode_changing_tool", + "toPortId": "anode_cover_row" + }, + { + "fromStationId": "potline_module", + "toStationId": "dry_scrubber_baghouse", + "medium": "gas", + "visualKind": "fume_duct", + "fromPortId": "pot_hood_panel", + "toPortId": "gas_inlet_header" + }, + { + "fromStationId": "potline_module", + "toStationId": "vacuum_tapping_ladle", + "medium": "molten_metal", + "visualKind": "hot_metal_transfer", + "fromPortId": "metal_tap", + "toPortId": "vacuum_suction_nozzle" + }, + { + "fromStationId": "vacuum_tapping_ladle", + "toStationId": "molten_aluminum_holding_furnace", + "medium": "molten_metal", + "visualKind": "hot_metal_transfer", + "fromPortId": "tapping_spout_pipe", + "toPortId": "tap_spout" + }, + { + "fromStationId": "molten_aluminum_holding_furnace", + "toStationId": "continuous_ingot_casting_line", + "medium": "molten_metal", + "visualKind": "hot_metal_chute", + "fromPortId": "tap_spout", + "toPortId": "pouring_tundish" + } + ] + } +] diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/process-templates/potroom-casting-line.json b/cloud/industry.electrolytic-aluminum.basic-0.1.0/process-templates/potroom-casting-line.json new file mode 100644 index 000000000..ccdbdace3 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/process-templates/potroom-casting-line.json @@ -0,0 +1,160 @@ +[ + { + "processId": "electrolytic_aluminum_potroom_casting_line", + "processLabel": "Electrolytic aluminum potroom and casting line", + "processDisplayLabel": "电解铝电解车间与铸造线", + "domain": "metallurgy", + "aliases": [ + "电解铝电解车间", + "电解车间和铸造线", + "铝电解生产线", + "potroom and casting line", + "aluminum potline", + "aluminum electrolysis line" + ], + "requiredRoles": [ + "potline_module", + "pot_tending_crane", + "dry_scrubber_baghouse", + "vacuum_tapping_ladle", + "molten_aluminum_holding_furnace", + "continuous_ingot_casting_line" + ], + "defaultLayoutStyle": "linear", + "defaultDimensions": { + "length": 46, + "width": 16 + }, + "safetyTags": [ + "molten_metal", + "high_current", + "fluoride_fume", + "crane" + ], + "stations": [ + { + "id": "potline_module", + "label": "Aluminum potline module", + "displayLabel": "铝电解槽列", + "role": "potline_module", + "equipmentHint": "electrolytic_aluminum.potline_module two long rows of reduction pots with covered hoods, anode cover rows, copper busbars, alumina feed manifold, fume collection header, and side service platforms", + "footprintHint": "long", + "safetyTags": [ + "molten_metal", + "high_current", + "fluoride_fume" + ], + "profileId": "electrolytic_aluminum.electrolytic_cell" + }, + { + "id": "pot_tending_crane", + "label": "Pot tending overhead crane", + "displayLabel": "电解多功能天车", + "role": "pot_tending_crane", + "equipmentHint": "electrolytic_aluminum.pot_tending_crane overhead pot tending crane above potline with bridge girder, trolley, hoist, cabin, and vertical tool", + "footprintHint": "long", + "safetyTags": [ + "crane", + "maintenance" + ], + "profileId": "electrolytic_aluminum.pot_tending_crane" + }, + { + "id": "dry_scrubber_baghouse", + "label": "Dry scrubber baghouse", + "displayLabel": "干法净化布袋除尘", + "role": "dry_scrubber_baghouse", + "equipmentHint": "electrolytic_aluminum.dry_scrubber_baghouse side fume treatment branch with filter body, hoppers, inlet header, fan, stack, and maintenance platform", + "footprintHint": "tall", + "safetyTags": [ + "fluoride_fume", + "dust", + "exhaust" + ], + "profileId": "electrolytic_aluminum.dry_scrubber_baghouse" + }, + { + "id": "vacuum_tapping_ladle", + "label": "Vacuum tapping ladle", + "displayLabel": "真空抬包", + "role": "vacuum_tapping_ladle", + "equipmentHint": "electrolytic_aluminum.vacuum_tapping_ladle molten aluminum transfer ladle between potroom and holding furnace", + "footprintHint": "medium", + "safetyTags": [ + "molten_metal", + "transfer" + ], + "profileId": "electrolytic_aluminum.vacuum_tapping_ladle" + }, + { + "id": "molten_aluminum_holding_furnace", + "label": "Molten aluminum holding furnace", + "displayLabel": "铝液保温炉", + "role": "molten_aluminum_holding_furnace", + "equipmentHint": "electrolytic_aluminum.molten_aluminum_holding_furnace horizontal holding furnace with tap spout and operator platform", + "footprintHint": "large", + "safetyTags": [ + "molten_metal", + "thermal" + ], + "profileId": "electrolytic_aluminum.molten_aluminum_holding_furnace" + }, + { + "id": "continuous_ingot_casting_line", + "label": "Continuous ingot casting line", + "displayLabel": "连续铸锭线", + "role": "continuous_ingot_casting_line", + "equipmentHint": "electrolytic_aluminum.continuous_ingot_casting_line long casting conveyor with repeated ingot molds, pouring tundish, drive unit, and guard fence", + "footprintHint": "long", + "safetyTags": [ + "molten_metal", + "casting", + "conveyor" + ], + "profileId": "electrolytic_aluminum.continuous_ingot_casting_line" + } + ], + "connections": [ + { + "fromStationId": "pot_tending_crane", + "toStationId": "potline_module", + "medium": "material", + "visualKind": "crane_transfer", + "fromPortId": "anode_changing_tool", + "toPortId": "anode_cover_row" + }, + { + "fromStationId": "potline_module", + "toStationId": "dry_scrubber_baghouse", + "medium": "gas", + "visualKind": "fume_duct", + "fromPortId": "pot_hood_panel", + "toPortId": "gas_inlet_header" + }, + { + "fromStationId": "potline_module", + "toStationId": "vacuum_tapping_ladle", + "medium": "molten_metal", + "visualKind": "hot_metal_transfer", + "fromPortId": "metal_tap", + "toPortId": "vacuum_suction_nozzle" + }, + { + "fromStationId": "vacuum_tapping_ladle", + "toStationId": "molten_aluminum_holding_furnace", + "medium": "molten_metal", + "visualKind": "hot_metal_transfer", + "fromPortId": "tapping_spout_pipe", + "toPortId": "tap_spout" + }, + { + "fromStationId": "molten_aluminum_holding_furnace", + "toStationId": "continuous_ingot_casting_line", + "medium": "molten_metal", + "visualKind": "hot_metal_chute", + "fromPortId": "tap_spout", + "toPortId": "pouring_tundish" + } + ] + } +] diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/profiles/generated.json b/cloud/industry.electrolytic-aluminum.basic-0.1.0/profiles/generated.json new file mode 100644 index 000000000..5529a5862 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/profiles/generated.json @@ -0,0 +1,1334 @@ +[ + { + "id": "electrolytic_aluminum.electrolytic_cell", + "name": "Aluminum electrolytic cell", + "aliases": [ + "electrolytic cell", + "reduction pot", + "aluminum pot", + "铝电解槽", + "电解槽", + "预焙阳极电解槽" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "electrolysis_equipment", + "family": "generic", + "defaultDimensions": { + "length": 9.6, + "width": 3.2, + "height": 2.1 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 9.6, + "width": 3.2, + "height": 1.05, + "position": [ + 0, + 0.55, + 0 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_panel", + "semanticRole": "pot_hood_panel", + "required": true, + "length": 8.8, + "width": 0.08, + "height": 0.5, + "position": [ + 0, + 1.28, + 1.63 + ], + "color": "#94a3b8" + }, + { + "kind": "generic_panel", + "semanticRole": "anode_cover_row", + "required": true, + "length": 8.2, + "width": 2.4, + "height": 0.18, + "position": [ + 0, + 1.55, + 0 + ], + "color": "#475569" + }, + { + "kind": "bar_pair", + "semanticRole": "high_current_busbar", + "required": true, + "length": 9.2, + "height": 0.12, + "width": 0.32, + "position": [ + 0, + 1.9, + -1.85 + ], + "color": "#b45309" + }, + { + "kind": "pipe_manifold", + "semanticRole": "alumina_feed_manifold", + "required": true, + "length": 7.6, + "radius": 0.06, + "count": 8, + "position": [ + 0, + 1.82, + 0.95 + ], + "metalColor": "#cbd5e1" + }, + { + "kind": "service_platform", + "semanticRole": "potroom_service_platform", + "length": 9.8, + "width": 0.55, + "height": 1.15, + "position": [ + 0, + 1.15, + -2.2 + ], + "metalColor": "#475569", + "color": "#facc15" + } + ], + "primarySemanticRole": "cell_body", + "qualityRules": "quality.electrolytic_aluminum.electrolytic_cell", + "visualCues": [ + "long rectangular reduction pot", + "covered pot hood", + "top anode cover row", + "copper-colored busbars", + "alumina feed manifold", + "side service platform" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "pot_hood_panel": { + "detailLevel": "low" + }, + "anode_cover_row": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.pot_tending_crane", + "name": "Pot tending overhead crane", + "aliases": [ + "pot tending crane", + "pot tending machine", + "PTM", + "multi-purpose pot crane", + "多功能天车", + "电解多功能天车", + "电解槽维护机" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "material_handling", + "family": "generic", + "defaultDimensions": { + "length": 10, + "width": 5, + "height": 4.5 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "crane_bridge_girder", + "required": true, + "length": 10, + "width": 0.55, + "height": 0.55, + "position": [ + 0, + 3.8, + 0 + ], + "primaryColor": "#f59e0b", + "secondaryColor": "#92400e" + }, + { + "kind": "bar_pair", + "semanticRole": "crane_runway_rail", + "required": true, + "length": 10.5, + "width": 4.8, + "height": 0.08, + "position": [ + 0, + 3.45, + 0 + ], + "color": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "tool_carriage", + "required": true, + "length": 1.15, + "width": 1.5, + "height": 0.65, + "position": [ + 0.8, + 3.2, + 0 + ], + "primaryColor": "#475569" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "hoist_drive_unit", + "required": true, + "length": 1.1, + "height": 0.36, + "radius": 0.18, + "position": [ + -0.4, + 3.3, + 0.85 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "operator_cabin", + "required": true, + "length": 0.9, + "width": 0.65, + "height": 0.75, + "position": [ + 3.6, + 2.9, + -1.65 + ], + "color": "#60a5fa" + }, + { + "kind": "generic_panel", + "semanticRole": "anode_changing_tool", + "required": true, + "length": 0.28, + "width": 0.28, + "height": 1.7, + "position": [ + 0.8, + 2.15, + 0 + ], + "color": "#111827" + }, + { + "kind": "service_platform", + "semanticRole": "crane_maintenance_walkway", + "length": 8.8, + "width": 0.45, + "height": 3.95, + "position": [ + 0, + 3.95, + 0.62 + ], + "metalColor": "#475569", + "color": "#facc15" + } + ], + "primarySemanticRole": "crane_bridge_girder", + "qualityRules": "quality.electrolytic_aluminum.pot_tending_crane", + "visualCues": [ + "overhead bridge crane", + "long runway rails", + "tool carriage", + "hoist drive", + "operator cabin", + "vertical anode-changing tool" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "operator_cabin": { + "detailLevel": "low" + }, + "anode_changing_tool": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.rectifier_transformer_station", + "name": "Rectifier transformer station", + "aliases": [ + "rectifier station", + "rectifier transformer", + "aluminum smelter rectifier", + "整流机组", + "整流变压器", + "供电整流机组" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "electrical_power", + "family": "generic", + "defaultDimensions": { + "length": 4.8, + "width": 2.4, + "height": 2.5 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "rectifier_transformer_body", + "required": true, + "length": 3.2, + "width": 1.6, + "height": 1.9, + "position": [ + -0.5, + 1.05, + 0 + ], + "primaryColor": "#2563eb", + "secondaryColor": "#1e3a8a" + }, + { + "kind": "generic_panel", + "semanticRole": "cooling_radiator_bank", + "required": true, + "length": 0.18, + "width": 1.7, + "height": 1.6, + "position": [ + 1.25, + 1.12, + 0 + ], + "color": "#94a3b8" + }, + { + "kind": "generic_body", + "semanticRole": "rectifier_cabinet", + "required": true, + "length": 1.25, + "width": 1.2, + "height": 1.8, + "position": [ + 2, + 0.98, + 0 + ], + "primaryColor": "#e5e7eb", + "secondaryColor": "#64748b" + }, + { + "kind": "operator_panel", + "semanticRole": "power_control_panel", + "required": true, + "position": [ + 2.65, + 1.2, + -0.62 + ] + }, + { + "kind": "bar_pair", + "semanticRole": "dc_busbar_bridge", + "required": true, + "length": 4.4, + "width": 0.42, + "height": 0.12, + "position": [ + 0.4, + 2.35, + -1.35 + ], + "color": "#b45309" + }, + { + "kind": "guard_fence", + "semanticRole": "electrical_safety_fence", + "length": 4.8, + "width": 2.5, + "height": 1.2, + "count": 5, + "position": [ + 0, + 0.62, + 0 + ] + } + ], + "primarySemanticRole": "rectifier_transformer_body", + "qualityRules": "quality.electrolytic_aluminum.rectifier_transformer_station", + "visualCues": [ + "large blue transformer body", + "radiator bank", + "rectifier cabinet", + "copper busbar bridge", + "safety fence" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "cooling_radiator_bank": { + "detailLevel": "low" + }, + "power_control_panel": { + "detailLevel": "low" + }, + "electrical_safety_fence": { + "detailLevel": "low", + "ringCount": 3, + "spokeCount": 12 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.alumina_storage_silo", + "name": "Alumina storage silo", + "aliases": [ + "alumina silo", + "alumina storage bin", + "氧化铝仓", + "氧化铝料仓", + "氧化铝贮仓" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "vessel_layout", + "archetypeFamily": "bulk_storage", + "family": "tank", + "defaultDimensions": { + "length": 2.8, + "width": 2.8, + "height": 6.5 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "silo_shell", + "required": true, + "height": 5.2, + "radius": 0.95, + "axis": "y", + "position": [ + 0, + 3.25, + 0 + ], + "primaryColor": "#cbd5e1", + "metalColor": "#94a3b8" + }, + { + "kind": "conical_hopper", + "semanticRole": "silo_discharge_cone", + "required": true, + "radiusTop": 0.95, + "radiusBottom": 0.18, + "height": 1.35, + "position": [ + 0, + 0.8, + 0 + ], + "primaryColor": "#94a3b8" + }, + { + "kind": "pipe_manifold", + "semanticRole": "pneumatic_conveying_pipe", + "required": true, + "length": 2.4, + "radius": 0.08, + "count": 3, + "position": [ + 0.4, + 5.9, + 0.9 + ] + }, + { + "kind": "platform_with_ladder", + "semanticRole": "silo_access_platform", + "length": 1.8, + "width": 0.8, + "height": 4.8, + "rungCount": 10, + "position": [ + 1.25, + 4.6, + 0 + ] + } + ], + "primarySemanticRole": "silo_shell", + "qualityRules": "quality.electrolytic_aluminum.alumina_storage_silo", + "visualCues": [ + "tall cylindrical powder silo", + "conical discharge hopper", + "top pneumatic conveying pipe", + "access platform and ladder" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "silo_access_platform": { + "detailLevel": "low", + "count": 6, + "rungCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.alumina_conveying_line", + "name": "Alumina conveying line", + "aliases": [ + "alumina conveyor", + "alumina conveying line", + "氧化铝输送线", + "氧化铝输送设备", + "粉料输送线" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "linear_transport_layout", + "archetypeFamily": "bulk_material_handling", + "family": "conveyor", + "defaultDimensions": { + "length": 8, + "width": 1.2, + "height": 1.4 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "conveyor_frame", + "required": true, + "length": 8, + "width": 0.9, + "height": 0.75, + "position": [ + 0, + 0.65, + 0 + ] + }, + { + "kind": "roller_array", + "semanticRole": "roller_bed", + "required": true, + "length": 7.6, + "width": 0.78, + "count": 14, + "position": [ + 0, + 1, + 0 + ] + }, + { + "kind": "belt_surface", + "semanticRole": "covered_conveyor_belt", + "required": true, + "length": 7.8, + "width": 0.82, + "height": 0.08, + "position": [ + 0, + 1.12, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "feed_hopper", + "required": true, + "length": 0.9, + "width": 0.75, + "height": 0.8, + "position": [ + -3.5, + 1.65, + 0 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "conveyor_drive_unit", + "required": true, + "length": 0.9, + "height": 0.32, + "position": [ + 4.05, + 0.85, + 0.58 + ] + } + ], + "primarySemanticRole": "conveyor_frame", + "qualityRules": "quality.electrolytic_aluminum.alumina_conveying_line", + "visualCues": [ + "long conveyor frame", + "repeated rollers", + "belt surface", + "feed hopper", + "end drive unit" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "roller_bed": { + "detailLevel": "low", + "count": 5 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.dry_scrubber_baghouse", + "name": "Dry scrubber baghouse", + "aliases": [ + "dry scrubber", + "baghouse", + "fume treatment system", + "干法净化", + "电解烟气净化", + "布袋除尘器", + "干法吸附净化系统" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "air_pollution_control", + "family": "generic", + "defaultDimensions": { + "length": 5.5, + "width": 2.6, + "height": 4.5 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "baghouse_filter_body", + "required": true, + "length": 3.8, + "width": 2.1, + "height": 3, + "position": [ + 0, + 2.15, + 0 + ], + "primaryColor": "#94a3b8", + "secondaryColor": "#64748b" + }, + { + "kind": "hopper_body", + "semanticRole": "dust_collection_hopper", + "required": true, + "length": 0.85, + "width": 0.7, + "height": 0.95, + "position": [ + -1.2, + 0.72, + 0 + ] + }, + { + "kind": "hopper_body", + "semanticRole": "dust_collection_hopper", + "required": true, + "length": 0.85, + "width": 0.7, + "height": 0.95, + "position": [ + 1.2, + 0.72, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "gas_inlet_header", + "required": true, + "length": 3.6, + "radius": 0.16, + "count": 4, + "position": [ + -0.5, + 3.65, + -1.3 + ] + }, + { + "kind": "chimney_stack", + "semanticRole": "clean_gas_stack", + "required": true, + "height": 4.2, + "radius": 0.22, + "position": [ + 2.6, + 3.1, + 0.75 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "induced_draft_fan", + "required": true, + "length": 1.2, + "height": 0.48, + "position": [ + 2.4, + 1.05, + -0.9 + ] + }, + { + "kind": "service_platform", + "semanticRole": "filter_maintenance_platform", + "length": 4, + "width": 0.55, + "height": 3.4, + "position": [ + 0, + 3.35, + 1.35 + ] + } + ], + "primarySemanticRole": "baghouse_filter_body", + "qualityRules": "quality.electrolytic_aluminum.dry_scrubber_baghouse", + "visualCues": [ + "large rectangular filter body", + "multiple dust hoppers", + "gas inlet header", + "clean gas stack", + "fan unit", + "maintenance platform" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.vacuum_tapping_ladle", + "name": "Vacuum tapping ladle", + "aliases": [ + "vacuum ladle", + "tapping ladle", + "molten aluminum ladle", + "真空抬包", + "铝液抬包", + "出铝抬包" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "vessel_layout", + "archetypeFamily": "molten_metal_transfer", + "family": "tank", + "defaultDimensions": { + "length": 2.4, + "width": 1.6, + "height": 2 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "ladle_shell", + "required": true, + "height": 1.55, + "radius": 0.62, + "axis": "y", + "position": [ + 0, + 1, + 0 + ], + "primaryColor": "#475569", + "metalColor": "#94a3b8" + }, + { + "kind": "flanged_nozzle", + "semanticRole": "vacuum_suction_nozzle", + "required": true, + "side": "top", + "radius": 0.11, + "length": 0.42, + "position": [ + 0, + 1.92, + 0.2 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "tapping_spout_pipe", + "required": true, + "length": 1.5, + "radius": 0.09, + "count": 2, + "position": [ + 0.95, + 1.25, + 0 + ] + }, + { + "kind": "bar_pair", + "semanticRole": "lifting_trunnion", + "required": true, + "length": 1.7, + "width": 0.18, + "height": 0.12, + "position": [ + 0, + 1.45, + 0 + ], + "color": "#111827" + }, + { + "kind": "generic_base", + "semanticRole": "ladle_transport_saddle", + "required": true, + "length": 1.7, + "width": 1.3, + "height": 0.28, + "position": [ + 0, + 0.22, + 0 + ], + "color": "#1f2937" + } + ], + "primarySemanticRole": "ladle_shell", + "qualityRules": "quality.electrolytic_aluminum.vacuum_tapping_ladle", + "visualCues": [ + "round molten metal ladle", + "top vacuum nozzle", + "side tapping spout", + "lifting trunnions", + "transport saddle" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "vacuum_suction_nozzle": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.anode_assembly_station", + "name": "Anode assembly station", + "aliases": [ + "anode assembly station", + "anode changing preparation", + "阳极组装站", + "阳极组装车间设备", + "残极处理设备" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "anode_handling", + "family": "generic", + "defaultDimensions": { + "length": 5.2, + "width": 2.4, + "height": 2.3 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "anode_assembly_frame", + "required": true, + "length": 4.2, + "width": 1.4, + "height": 1.1, + "position": [ + 0, + 0.85, + 0 + ], + "primaryColor": "#475569" + }, + { + "kind": "pallet_table", + "semanticRole": "carbon_block_table", + "required": true, + "length": 2.4, + "width": 1.3, + "height": 0.72, + "position": [ + -1.1, + 0.62, + 0 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "anode_carbon_block", + "required": true, + "length": 1.35, + "width": 0.7, + "height": 0.42, + "position": [ + -1.1, + 1.15, + 0 + ], + "color": "#111827" + }, + { + "kind": "bar_pair", + "semanticRole": "anode_rodding_bar", + "required": true, + "length": 2.2, + "width": 0.35, + "height": 0.1, + "position": [ + 0.65, + 1.55, + 0 + ], + "color": "#9ca3af" + }, + { + "kind": "operator_panel", + "semanticRole": "assembly_control_panel", + "required": true, + "position": [ + 2.15, + 1.15, + -0.8 + ] + }, + { + "kind": "guard_fence", + "semanticRole": "assembly_safety_guard", + "length": 5, + "width": 2.2, + "height": 1.1, + "count": 4, + "position": [ + 0, + 0.55, + 0 + ] + } + ], + "primarySemanticRole": "anode_assembly_frame", + "qualityRules": "quality.electrolytic_aluminum.anode_assembly_station", + "visualCues": [ + "assembly frame", + "carbon anode block", + "rodding bar", + "work table", + "control panel", + "safety guard" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "anode_carbon_block": { + "detailLevel": "low" + }, + "assembly_control_panel": { + "detailLevel": "low" + }, + "assembly_safety_guard": { + "detailLevel": "low", + "ringCount": 3, + "spokeCount": 12 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.molten_aluminum_holding_furnace", + "name": "Molten aluminum holding furnace", + "aliases": [ + "holding furnace", + "mixing furnace", + "molten aluminum furnace", + "保温炉", + "混合炉", + "铝液保温炉" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "vessel_layout", + "archetypeFamily": "thermal_equipment", + "family": "tank", + "defaultDimensions": { + "length": 4.8, + "width": 2.1, + "height": 2.2 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "furnace_shell", + "required": true, + "length": 3.6, + "radius": 0.72, + "axis": "x", + "position": [ + 0, + 1.2, + 0 + ], + "primaryColor": "#64748b", + "metalColor": "#94a3b8" + }, + { + "kind": "inspection_hatch", + "semanticRole": "furnace_access_door", + "required": true, + "side": "front", + "radius": 0.28, + "position": [ + 0.7, + 1.25, + 0.72 + ] + }, + { + "kind": "flanged_nozzle", + "semanticRole": "tap_spout", + "required": true, + "side": "right", + "radius": 0.12, + "length": 0.55, + "position": [ + 1.9, + 1.15, + 0 + ] + }, + { + "kind": "generic_base", + "semanticRole": "refractory_support_base", + "required": true, + "length": 4.2, + "width": 1.6, + "height": 0.35, + "position": [ + 0, + 0.28, + 0 + ], + "color": "#1f2937" + }, + { + "kind": "chimney_stack", + "semanticRole": "furnace_exhaust_stack", + "height": 2.2, + "radius": 0.16, + "position": [ + -1.5, + 2.35, + 0.45 + ] + }, + { + "kind": "service_platform", + "semanticRole": "furnace_operator_platform", + "length": 3.5, + "width": 0.55, + "height": 1.05, + "position": [ + 0, + 1.05, + -1.1 + ] + } + ], + "primarySemanticRole": "furnace_shell", + "qualityRules": "quality.electrolytic_aluminum.molten_aluminum_holding_furnace", + "visualCues": [ + "horizontal heated vessel", + "front access door", + "tap spout", + "refractory support base", + "exhaust stack", + "operator platform" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "tap_spout": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "horizontal", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "electrolytic_aluminum.continuous_ingot_casting_line", + "name": "Continuous ingot casting line", + "aliases": [ + "ingot casting line", + "continuous casting machine", + "aluminum ingot caster", + "连续铸造机", + "铝锭铸造机", + "铝锭生产线" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "linear_transport_layout", + "archetypeFamily": "casting_line", + "family": "conveyor", + "defaultDimensions": { + "length": 8.5, + "width": 1.8, + "height": 1.8 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "casting_conveyor_frame", + "required": true, + "length": 8.5, + "width": 1.2, + "height": 0.75, + "position": [ + 0, + 0.75, + 0 + ] + }, + { + "kind": "roller_array", + "semanticRole": "ingot_mold_chain", + "required": true, + "length": 8, + "width": 0.95, + "count": 16, + "position": [ + 0, + 1.12, + 0 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "repeated_ingot_mold", + "required": true, + "length": 0.5, + "width": 0.8, + "height": 0.14, + "position": [ + -2, + 1.28, + 0 + ], + "color": "#64748b" + }, + { + "kind": "hopper_body", + "semanticRole": "pouring_tundish", + "required": true, + "length": 0.85, + "width": 0.65, + "height": 0.65, + "position": [ + -4, + 1.75, + 0 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "caster_drive_unit", + "required": true, + "length": 1, + "height": 0.34, + "position": [ + 4.3, + 0.85, + 0.7 + ] + }, + { + "kind": "operator_panel", + "semanticRole": "casting_control_panel", + "required": true, + "position": [ + -3.2, + 1.2, + -1 + ] + }, + { + "kind": "guard_fence", + "semanticRole": "casting_safety_guard", + "length": 8.6, + "width": 1.8, + "height": 1, + "count": 6, + "position": [ + 0, + 0.5, + 0 + ] + } + ], + "primarySemanticRole": "casting_conveyor_frame", + "qualityRules": "quality.electrolytic_aluminum.continuous_ingot_casting_line", + "visualCues": [ + "long casting conveyor", + "repeated ingot molds", + "pouring tundish", + "end drive", + "operator panel", + "safety guard" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "conveyor.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 52, + "parts": { + "ingot_mold_chain": { + "detailLevel": "low", + "count": 5 + }, + "repeated_ingot_mold": { + "detailLevel": "low" + }, + "casting_control_panel": { + "detailLevel": "low" + }, + "casting_safety_guard": { + "detailLevel": "low", + "ringCount": 3, + "spokeCount": 12 + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/profiles/potline-module.json b/cloud/industry.electrolytic-aluminum.basic-0.1.0/profiles/potline-module.json new file mode 100644 index 000000000..262e3dda5 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/profiles/potline-module.json @@ -0,0 +1,490 @@ +[ + { + "id": "electrolytic_aluminum.potline_module", + "name": "Aluminum potline module", + "aliases": [ + "potline", + "potroom module", + "aluminum potline", + "reduction cell line", + "electrolytic cell row", + "铝电解槽列", + "电解槽列", + "电解车间槽列", + "电解铝potline" + ], + "industry": "electrolytic-aluminum", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "electrolysis_equipment", + "family": "generic", + "defaultDimensions": { + "length": 28, + "width": 7.2, + "height": 3.4 + }, + "processPorts": [ + { + "id": "high_current_busbar", + "medium": "power", + "side": "left", + "height": 2.45, + "offset": -2.4 + }, + { + "id": "alumina_feed_manifold", + "medium": "material", + "side": "left", + "height": 2.15, + "offset": 1.6 + }, + { + "id": "pot_hood_panel", + "medium": "gas", + "side": "top", + "height": 3.05, + "offset": 0 + }, + { + "id": "metal_tap", + "medium": "molten_metal", + "side": "right", + "height": 1.1, + "offset": -1.8 + }, + { + "id": "anode_cover_row", + "medium": "material", + "side": "top", + "height": 2.3, + "offset": 0.6 + } + ], + "parts": [ + { + "kind": "generic_body", + "semanticRole": "potroom_floor_base", + "required": true, + "length": 28, + "width": 7.2, + "height": 0.18, + "position": [ + 0, + 0.09, + 0 + ], + "primaryColor": "#334155" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -10.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -7.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -4.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -1.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 1.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 4.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 7.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 10.5, + 0.72, + -1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -10.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -7.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -4.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + -1.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 1.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 4.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 7.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_body", + "semanticRole": "cell_body", + "required": true, + "length": 3, + "width": 1.45, + "height": 0.95, + "position": [ + 10.5, + 0.72, + 1.8 + ], + "primaryColor": "#64748b", + "secondaryColor": "#1f2937" + }, + { + "kind": "generic_panel", + "semanticRole": "anode_cover_row", + "required": true, + "length": 26, + "width": 1.1, + "height": 0.18, + "position": [ + 0, + 1.38, + -1.8 + ], + "color": "#475569" + }, + { + "kind": "generic_panel", + "semanticRole": "anode_cover_row", + "required": true, + "length": 26, + "width": 1.1, + "height": 0.18, + "position": [ + 0, + 1.38, + 1.8 + ], + "color": "#475569" + }, + { + "kind": "generic_panel", + "semanticRole": "pot_hood_panel", + "required": true, + "length": 26.5, + "width": 0.22, + "height": 0.62, + "position": [ + 0, + 1.78, + -2.68 + ], + "color": "#94a3b8" + }, + { + "kind": "generic_panel", + "semanticRole": "pot_hood_panel", + "required": true, + "length": 26.5, + "width": 0.22, + "height": 0.62, + "position": [ + 0, + 1.78, + 2.68 + ], + "color": "#94a3b8" + }, + { + "kind": "bar_pair", + "semanticRole": "high_current_busbar", + "required": true, + "length": 27, + "width": 6.6, + "height": 0.16, + "position": [ + 0, + 2.15, + 0 + ], + "color": "#b45309" + }, + { + "kind": "pipe_manifold", + "semanticRole": "alumina_feed_manifold", + "required": true, + "length": 25, + "radius": 0.07, + "count": 12, + "position": [ + 0, + 2.05, + -0.65 + ], + "metalColor": "#cbd5e1" + }, + { + "kind": "pipe_manifold", + "semanticRole": "fume_collection_header", + "required": true, + "length": 26, + "radius": 0.15, + "count": 6, + "position": [ + 0, + 2.75, + 0 + ], + "metalColor": "#64748b" + }, + { + "kind": "service_platform", + "semanticRole": "potroom_service_platform", + "required": true, + "length": 27.5, + "width": 0.65, + "height": 1.25, + "position": [ + 0, + 1.25, + -3.45 + ], + "metalColor": "#475569", + "color": "#facc15" + }, + { + "kind": "service_platform", + "semanticRole": "potroom_service_platform", + "required": true, + "length": 27.5, + "width": 0.65, + "height": 1.25, + "position": [ + 0, + 1.25, + 3.45 + ], + "metalColor": "#475569", + "color": "#facc15" + }, + { + "kind": "generic_panel", + "semanticRole": "metal_tap", + "required": true, + "length": 0.55, + "width": 5.6, + "height": 0.22, + "position": [ + 13.55, + 1.03, + 0 + ], + "color": "#dc2626" + } + ], + "primarySemanticRole": "cell_body", + "qualityRules": "quality.electrolytic_aluminum.potline_module", + "visualCues": [ + "two long rows of reduction pots", + "repeated covered cells", + "continuous copper busbar pair", + "fume collection header over the pots", + "alumina feed manifold", + "side service platforms" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "anode_cover_row": { + "detailLevel": "low" + }, + "pot_hood_panel": { + "detailLevel": "low" + }, + "metal_tap": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.electrolytic-aluminum.basic-0.1.0/quality-rules/generated-quality.json b/cloud/industry.electrolytic-aluminum.basic-0.1.0/quality-rules/generated-quality.json new file mode 100644 index 000000000..08294c552 --- /dev/null +++ b/cloud/industry.electrolytic-aluminum.basic-0.1.0/quality-rules/generated-quality.json @@ -0,0 +1,213 @@ +[ + { + "id": "quality.electrolytic_aluminum.electrolytic_cell", + "requiredRoles": [ + "cell_body", + "pot_hood_panel", + "anode_cover_row", + "high_current_busbar", + "alumina_feed_manifold", + "potroom_service_platform" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "vehicle_cabin", + "aircraft_wing" + ], + "shapeCount": { + "min": 8, + "max": 80 + } + }, + { + "id": "quality.electrolytic_aluminum.potline_module", + "requiredRoles": [ + "cell_body", + "pot_hood_panel", + "anode_cover_row", + "high_current_busbar", + "alumina_feed_manifold", + "fume_collection_header", + "potroom_service_platform", + "metal_tap" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "vehicle_cabin", + "aircraft_wing" + ], + "shapeCount": { + "min": 28, + "max": 180 + } + }, + { + "id": "quality.electrolytic_aluminum.pot_tending_crane", + "requiredRoles": [ + "crane_bridge_girder", + "crane_runway_rail", + "tool_carriage", + "hoist_drive_unit", + "operator_cabin", + "anode_changing_tool", + "crane_maintenance_walkway" + ], + "forbiddenRoles": [ + "vehicle_tire", + "aircraft_engine" + ], + "shapeCount": { + "min": 10, + "max": 90 + } + }, + { + "id": "quality.electrolytic_aluminum.rectifier_transformer_station", + "requiredRoles": [ + "rectifier_transformer_body", + "cooling_radiator_bank", + "rectifier_cabinet", + "power_control_panel", + "dc_busbar_bridge", + "electrical_safety_fence" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "propeller_blade" + ], + "shapeCount": { + "min": 8, + "max": 70 + } + }, + { + "id": "quality.electrolytic_aluminum.alumina_storage_silo", + "requiredRoles": [ + "silo_shell", + "silo_discharge_cone", + "pneumatic_conveying_pipe", + "silo_access_platform" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "operator_cabin" + ], + "shapeCount": { + "min": 8, + "max": 75 + } + }, + { + "id": "quality.electrolytic_aluminum.alumina_conveying_line", + "requiredRoles": [ + "conveyor_frame", + "roller_bed", + "covered_conveyor_belt", + "feed_hopper", + "conveyor_drive_unit" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "vessel_head" + ], + "shapeCount": { + "min": 12, + "max": 90 + } + }, + { + "id": "quality.electrolytic_aluminum.dry_scrubber_baghouse", + "requiredRoles": [ + "baghouse_filter_body", + "dust_collection_hopper", + "gas_inlet_header", + "clean_gas_stack", + "induced_draft_fan", + "filter_maintenance_platform" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "aircraft_wing" + ], + "shapeCount": { + "min": 12, + "max": 110 + } + }, + { + "id": "quality.electrolytic_aluminum.vacuum_tapping_ladle", + "requiredRoles": [ + "ladle_shell", + "vacuum_suction_nozzle", + "tapping_spout_pipe", + "lifting_trunnion", + "ladle_transport_saddle" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "conveyor_belt" + ], + "shapeCount": { + "min": 8, + "max": 70 + } + }, + { + "id": "quality.electrolytic_aluminum.anode_assembly_station", + "requiredRoles": [ + "anode_assembly_frame", + "carbon_block_table", + "anode_carbon_block", + "anode_rodding_bar", + "assembly_control_panel", + "assembly_safety_guard" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "aircraft_engine" + ], + "shapeCount": { + "min": 8, + "max": 85 + } + }, + { + "id": "quality.electrolytic_aluminum.molten_aluminum_holding_furnace", + "requiredRoles": [ + "furnace_shell", + "furnace_access_door", + "tap_spout", + "refractory_support_base", + "furnace_exhaust_stack", + "furnace_operator_platform" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "conveyor_belt" + ], + "shapeCount": { + "min": 10, + "max": 85 + } + }, + { + "id": "quality.electrolytic_aluminum.continuous_ingot_casting_line", + "requiredRoles": [ + "casting_conveyor_frame", + "ingot_mold_chain", + "repeated_ingot_mold", + "pouring_tundish", + "caster_drive_unit", + "casting_control_panel", + "casting_safety_guard" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "aircraft_wing" + ], + "shapeCount": { + "min": 14, + "max": 105 + } + } +] diff --git a/cloud/industry.fine-chemical.basic-0.1.0.zip b/cloud/industry.fine-chemical.basic-0.1.0.zip new file mode 100644 index 000000000..4ee9391dd Binary files /dev/null and b/cloud/industry.fine-chemical.basic-0.1.0.zip differ diff --git a/cloud/industry.fine-chemical.basic-0.1.0/README.md b/cloud/industry.fine-chemical.basic-0.1.0/README.md new file mode 100644 index 000000000..97b474d2b --- /dev/null +++ b/cloud/industry.fine-chemical.basic-0.1.0/README.md @@ -0,0 +1,14 @@ +# Fine Chemical Basic Equipment Pack + +This pack is the shared base for fine-chemical scenarios. It is intentionally not a single product-line package. + +Fine chemical plants vary by product family, so this base pack keeps common process equipment: + +- batch stirred reactors and mixing tanks +- shell-and-tube exchangers and condensers +- distillation / rectification columns +- centrifuges, filter vessels, and filter presses +- vacuum tray / cone style dryers +- storage tanks, dosing tanks, metering skid units, and solvent recovery skids + +Extension packs such as pharma intermediates, pesticides, dyes / pigments, and electronic chemicals should declare this pack in `dependsOn`. The app installs dependencies automatically when a user installs an extension pack. diff --git a/cloud/industry.fine-chemical.basic-0.1.0/pack.json b/cloud/industry.fine-chemical.basic-0.1.0/pack.json new file mode 100644 index 000000000..683016370 --- /dev/null +++ b/cloud/industry.fine-chemical.basic-0.1.0/pack.json @@ -0,0 +1,150 @@ +{ + "id": "industry.fine-chemical.basic", + "name": "Fine Chemical Basic Equipment Pack", + "industry": "fine-chemical", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Common fine-chemical process equipment profiles for batch reaction, separation, thermal exchange, drying, storage, dosing, and utility skids.", + "profiles": [ + "profiles/reaction.json", + "profiles/separation.json", + "profiles/thermal.json", + "profiles/storage-dosing.json" + ], + "qualityRules": [ + "quality-rules/fine-chemical-quality.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "fine_chemical.stirred_batch_reactor", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.height": "height", + "defaultDimensions.diameter": "length", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "fine_chemical.mixing_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.height": "height", + "defaultDimensions.diameter": "length", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "fine_chemical.nutsche_filter", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.height": "height", + "defaultDimensions.diameter": "length", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "fine_chemical.shell_tube_condenser", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "fine_chemical.distillation_column", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.height": "height", + "defaultDimensions.diameter": "length", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "fine_chemical.solvent_storage_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.height": "height", + "defaultDimensions.diameter": "length", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "fine_chemical.metering_pump_skid", + "recipeId": "factory:centrifugal-pump", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.pumpType": "pumpType", + "equipmentDefaults.flowRate": "flowRate", + "equipmentDefaults.motorPower": "motorPower", + "equipmentDefaults.skidMounted": "skidMounted" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.fine-chemical.basic-0.1.0/profiles/reaction.json b/cloud/industry.fine-chemical.basic-0.1.0/profiles/reaction.json new file mode 100644 index 000000000..143d44193 --- /dev/null +++ b/cloud/industry.fine-chemical.basic-0.1.0/profiles/reaction.json @@ -0,0 +1,174 @@ +[ + { + "id": "fine_chemical.stirred_batch_reactor", + "name": "Stirred batch reactor", + "aliases": [ + "stirred batch reactor", + "reaction kettle", + "glass lined reactor", + "jacketed reactor", + "fine chemical reactor", + "反应釜", + "搅拌反应釜", + "精细化工反应釜" + ], + "industry": "fine-chemical", + "family": "reactor", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "diameter": 1.4, + "height": 2.8 + }, + "primarySemanticRole": "reactor_vessel_shell", + "qualityRules": "quality.fine_chemical.stirred_batch_reactor", + "parts": [ + { + "kind": "agitator_tank", + "semanticRole": "reactor_vessel_shell", + "required": true, + "height": 2.6, + "radius": 0.62 + }, + { + "kind": "inlet_port", + "semanticRole": "feed_nozzle", + "required": true + }, + { + "kind": "outlet_port", + "semanticRole": "bottom_discharge_nozzle", + "required": true + }, + { + "kind": "pipe_manifold", + "semanticRole": "utility_jacket_manifold", + "length": 1, + "radius": 0.045, + "branchCount": 3 + }, + { + "kind": "platform_ladder", + "semanticRole": "access_platform", + "height": 1.5, + "length": 1.1, + "width": 0.65 + } + ], + "visualCues": [ + "vertical dished vessel", + "top agitator motor", + "feed and bottom discharge nozzles", + "side utility manifold", + "access platform" + ], + "status": "stable", + "source": "imported_pack", + "description": "Batch stirred reactor profile for common fine-chemical reaction steps.", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72, + "parts": { + "access_platform": { + "detailLevel": "low", + "count": 6, + "rungCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "fine_chemical.mixing_tank", + "name": "Fine chemical mixing tank", + "aliases": [ + "mixing tank", + "blend tank", + "preparation tank", + "配料罐", + "搅拌罐" + ], + "industry": "fine-chemical", + "family": "reactor", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "diameter": 1.2, + "height": 2.2 + }, + "primarySemanticRole": "reactor_vessel_shell", + "qualityRules": "quality.fine_chemical.mixing_tank", + "parts": [ + { + "kind": "agitator_tank", + "semanticRole": "reactor_vessel_shell", + "required": true, + "height": 2.1, + "radius": 0.52 + }, + { + "kind": "inlet_port", + "semanticRole": "liquid_feed_nozzle" + }, + { + "kind": "outlet_port", + "semanticRole": "transfer_outlet" + }, + { + "kind": "skid_base", + "semanticRole": "support_base", + "length": 1.4, + "width": 1, + "height": 0.14 + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Agitated preparation / blending tank used across fine-chemical production.", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72 + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.fine-chemical.basic-0.1.0/profiles/separation.json b/cloud/industry.fine-chemical.basic-0.1.0/profiles/separation.json new file mode 100644 index 000000000..61b0ed56f --- /dev/null +++ b/cloud/industry.fine-chemical.basic-0.1.0/profiles/separation.json @@ -0,0 +1,243 @@ +[ + { + "id": "fine_chemical.nutsche_filter", + "name": "Nutsche filter vessel", + "aliases": [ + "nutsche filter", + "filter vessel", + "sealed filter", + "过滤器", + "密闭过滤器" + ], + "industry": "fine-chemical", + "family": "generic", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "diameter": 1.2, + "height": 1.8 + }, + "primarySemanticRole": "filter_vessel_shell", + "qualityRules": "quality.fine_chemical.nutsche_filter", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "filter_vessel_shell", + "required": true, + "axis": "y", + "height": 1.6, + "radius": 0.55 + }, + { + "kind": "skid_base", + "semanticRole": "support_base", + "required": true, + "length": 1.4, + "width": 1.1, + "height": 0.12 + }, + { + "kind": "pipe_manifold", + "semanticRole": "filtrate_manifold", + "length": 1.1, + "radius": 0.04 + }, + { + "kind": "outlet_port", + "semanticRole": "filtrate_outlet" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Sealed filter vessel / nutsche style separator for slurry filtration.", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "fine_chemical.filter_press", + "name": "Plate frame filter press", + "aliases": [ + "filter press", + "plate frame filter press", + "板框压滤机", + "压滤机" + ], + "industry": "fine-chemical", + "family": "machine_tool", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "enclosed_machine", + "defaultDimensions": { + "length": 2.8, + "width": 0.9, + "height": 1.4 + }, + "primarySemanticRole": "filter_plate_stack", + "qualityRules": "quality.fine_chemical.filter_press", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "filter_press_frame", + "required": true, + "length": 2.8, + "width": 0.72, + "height": 0.18 + }, + { + "kind": "generic_body", + "semanticRole": "filter_plate_stack", + "required": true, + "length": 1.85, + "width": 0.48, + "height": 0.9, + "position": [ + 0, + 0.72, + 0 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "movable_press_head", + "length": 0.22, + "width": 0.58, + "height": 1.05, + "position": [ + 1.05, + 0.74, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "filtrate_header", + "length": 2.1, + "radius": 0.035 + }, + { + "kind": "operator_panel", + "semanticRole": "hydraulic_control_panel" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Plate frame filter press profile with frame, plate stack, moving head, filtrate header, and controls.", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "filter_plate_stack": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "movable_press_head": { + "detailLevel": "low" + }, + "hydraulic_control_panel": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "fine_chemical.centrifuge", + "name": "Chemical centrifuge", + "aliases": [ + "chemical centrifuge", + "basket centrifuge", + "peeler centrifuge", + "离心机", + "化工离心机" + ], + "industry": "fine-chemical", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "rotating_machine", + "defaultDimensions": { + "length": 1.6, + "width": 1.1, + "height": 1.2 + }, + "primarySemanticRole": "centrifuge_bowl_housing", + "qualityRules": "quality.fine_chemical.centrifuge", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "centrifuge_bowl_housing", + "required": true, + "axis": "y", + "height": 0.9, + "radius": 0.48, + "position": [ + 0, + 0.72, + 0 + ] + }, + { + "kind": "skid_base", + "semanticRole": "machine_base", + "required": true, + "length": 1.55, + "width": 1.05, + "height": 0.12 + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "centrifuge_drive_unit", + "position": [ + 0.78, + 0.42, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "liquid_discharge_port" + }, + { + "kind": "operator_panel", + "semanticRole": "local_control_panel" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Skid-mounted centrifuge profile with bowl housing, drive unit, discharge port, and local controls.", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "local_control_panel": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.fine-chemical.basic-0.1.0/profiles/storage-dosing.json b/cloud/industry.fine-chemical.basic-0.1.0/profiles/storage-dosing.json new file mode 100644 index 000000000..890a58f01 --- /dev/null +++ b/cloud/industry.fine-chemical.basic-0.1.0/profiles/storage-dosing.json @@ -0,0 +1,187 @@ +[ + { + "id": "fine_chemical.solvent_storage_tank", + "name": "Solvent storage tank", + "aliases": [ + "solvent storage tank", + "chemical storage tank", + "溶剂储罐", + "化工储罐" + ], + "industry": "fine-chemical", + "family": "tank", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "diameter": 1.3, + "height": 3 + }, + "primarySemanticRole": "vessel_shell", + "qualityRules": "quality.fine_chemical.solvent_storage_tank", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "vessel_shell", + "required": true, + "axis": "y", + "height": 2.8, + "radius": 0.58 + }, + { + "kind": "skid_base", + "semanticRole": "support_base", + "required": true, + "length": 1.4, + "width": 1.2, + "height": 0.14 + }, + { + "kind": "inlet_port", + "semanticRole": "top_fill_nozzle" + }, + { + "kind": "outlet_port", + "semanticRole": "bottom_transfer_outlet" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_platform", + "height": 1.8, + "length": 1, + "width": 0.55 + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Vertical solvent / chemical storage vessel with access platform and transfer ports.", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "access_platform": { + "detailLevel": "low", + "count": 6, + "rungCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "fine_chemical.metering_pump_skid", + "name": "Metering pump skid", + "aliases": [ + "metering pump skid", + "dosing skid", + "chemical dosing skid", + "计量泵撬", + "加药撬" + ], + "industry": "fine-chemical", + "family": "pump", + "layoutFamily": "rotating_machine_layout", + "archetypeFamily": "rotating_machine", + "defaultDimensions": { + "length": 1.8, + "width": 0.8, + "height": 1 + }, + "primarySemanticRole": "pump_volute", + "qualityRules": "quality.fine_chemical.metering_pump_skid", + "parts": [ + { + "kind": "skid_base", + "semanticRole": "support_base", + "required": true, + "length": 1.8, + "width": 0.78, + "height": 0.12 + }, + { + "kind": "volute_casing", + "semanticRole": "pump_volute", + "required": true, + "position": [ + -0.35, + 0.42, + 0 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "drive_unit", + "required": true, + "position": [ + 0.42, + 0.42, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "dosing_manifold", + "required": true, + "length": 1.4, + "radius": 0.035 + }, + { + "kind": "operator_panel", + "semanticRole": "local_control_panel" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Skid-mounted dosing / metering pump package with pump, drive, manifold, and controls.", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "local_control_panel": { + "detailLevel": "low" + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "left", + "diameter": 0.18 + }, + { + "id": "outlet", + "medium": "material", + "side": "right", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "pumpType": "metering", + "flowRate": 120, + "motorPower": 15, + "skidMounted": true + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.fine-chemical.basic-0.1.0/profiles/thermal.json b/cloud/industry.fine-chemical.basic-0.1.0/profiles/thermal.json new file mode 100644 index 000000000..7018859b5 --- /dev/null +++ b/cloud/industry.fine-chemical.basic-0.1.0/profiles/thermal.json @@ -0,0 +1,249 @@ +[ + { + "id": "fine_chemical.shell_tube_condenser", + "name": "Shell-and-tube condenser", + "aliases": [ + "shell tube condenser", + "condenser", + "heat exchanger", + "冷凝器", + "换热器" + ], + "industry": "fine-chemical", + "family": "heat_exchanger", + "layoutFamily": "vessel_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 2.8, + "width": 0.7, + "height": 0.8, + "diameter": 0.55 + }, + "primarySemanticRole": "heat_exchanger_shell", + "qualityRules": "quality.fine_chemical.shell_tube_condenser", + "parts": [ + { + "kind": "heat_exchanger", + "semanticRole": "heat_exchanger_shell", + "required": true, + "length": 2.6, + "radius": 0.28 + }, + { + "kind": "skid_base", + "semanticRole": "support_base", + "required": true, + "length": 2.7, + "width": 0.72, + "height": 0.12 + }, + { + "kind": "pipe_manifold", + "semanticRole": "cooling_water_manifold", + "length": 1.4, + "radius": 0.04 + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Horizontal shell-and-tube condenser / exchanger with supports and utility manifold.", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "horizontal", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "fine_chemical.distillation_column", + "name": "Fine chemical distillation column", + "aliases": [ + "distillation column", + "rectification column", + "精馏塔", + "蒸馏塔" + ], + "industry": "fine-chemical", + "family": "distillation_tower", + "layoutFamily": "vessel_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "diameter": 0.8, + "height": 5.2 + }, + "primarySemanticRole": "distillation_column_shell", + "qualityRules": "quality.fine_chemical.distillation_column", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "distillation_column_shell", + "required": true, + "axis": "y", + "height": 4.8, + "radius": 0.34 + }, + { + "kind": "flange_ring", + "semanticRole": "tray_level_rings", + "required": true, + "count": 6, + "radius": 0.34 + }, + { + "kind": "inlet_port", + "semanticRole": "feed_nozzle", + "required": true + }, + { + "kind": "outlet_port", + "semanticRole": "overhead_vapor_nozzle", + "required": true + }, + { + "kind": "platform_ladder", + "semanticRole": "access_platform", + "height": 3.8, + "length": 1, + "width": 0.55 + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Tall distillation / rectification column with tray rings, feed and overhead nozzles, and access platform.", + "editableSchemaRef": "tower_frame.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 120, + "parts": { + "tray_level_rings": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "access_platform": { + "detailLevel": "low", + "count": 6, + "rungCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "fine_chemical.vacuum_tray_dryer", + "name": "Vacuum tray dryer", + "aliases": [ + "vacuum tray dryer", + "vacuum dryer", + "真空干燥箱", + "真空干燥机" + ], + "industry": "fine-chemical", + "family": "machine_tool", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "enclosed_machine", + "defaultDimensions": { + "length": 1.8, + "width": 1, + "height": 1.6 + }, + "primarySemanticRole": "dryer_chamber", + "qualityRules": "quality.fine_chemical.vacuum_tray_dryer", + "parts": [ + { + "kind": "generic_base", + "semanticRole": "dryer_base", + "required": true, + "length": 1.8, + "width": 1, + "height": 0.16 + }, + { + "kind": "generic_body", + "semanticRole": "dryer_chamber", + "required": true, + "length": 1.5, + "width": 0.82, + "height": 1.15 + }, + { + "kind": "generic_panel", + "semanticRole": "sealed_front_door", + "required": true, + "length": 1.3, + "width": 0.04, + "height": 0.95, + "position": [ + 0.02, + 0.75, + 0.44 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "vacuum_manifold", + "length": 1.2, + "radius": 0.035 + }, + { + "kind": "operator_panel", + "semanticRole": "temperature_control_panel" + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Box-style vacuum tray dryer with sealed chamber, front door, vacuum manifold, and controls.", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "sealed_front_door": { + "detailLevel": "low" + }, + "temperature_control_panel": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.fine-chemical.basic-0.1.0/quality-rules/fine-chemical-quality.json b/cloud/industry.fine-chemical.basic-0.1.0/quality-rules/fine-chemical-quality.json new file mode 100644 index 000000000..e0f07772b --- /dev/null +++ b/cloud/industry.fine-chemical.basic-0.1.0/quality-rules/fine-chemical-quality.json @@ -0,0 +1,52 @@ +[ + { + "id": "quality.fine_chemical.stirred_batch_reactor", + "requiredRoles": ["reactor_vessel_shell", "agitator_motor", "agitator_shaft", "reactor_impeller"], + "shapeCount": { "min": 10, "max": 80 } + }, + { + "id": "quality.fine_chemical.mixing_tank", + "requiredRoles": ["reactor_vessel_shell", "agitator_motor", "reactor_impeller"], + "shapeCount": { "min": 8, "max": 70 } + }, + { + "id": "quality.fine_chemical.nutsche_filter", + "requiredRoles": ["filter_vessel_shell", "support_base"], + "shapeCount": { "min": 6, "max": 55 } + }, + { + "id": "quality.fine_chemical.filter_press", + "requiredRoles": ["filter_press_frame", "filter_plate_stack", "filtrate_header"], + "shapeCount": { "min": 6, "max": 60 } + }, + { + "id": "quality.fine_chemical.centrifuge", + "requiredRoles": ["centrifuge_bowl_housing", "machine_base", "centrifuge_drive_unit"], + "shapeCount": { "min": 8, "max": 70 } + }, + { + "id": "quality.fine_chemical.shell_tube_condenser", + "requiredRoles": ["heat_exchanger_shell", "support_base"], + "shapeCount": { "min": 8, "max": 70 } + }, + { + "id": "quality.fine_chemical.distillation_column", + "requiredRoles": ["distillation_column_shell", "feed_nozzle", "access_platform"], + "shapeCount": { "min": 10, "max": 90 } + }, + { + "id": "quality.fine_chemical.vacuum_tray_dryer", + "requiredRoles": ["dryer_chamber", "sealed_front_door", "vacuum_manifold"], + "shapeCount": { "min": 6, "max": 60 } + }, + { + "id": "quality.fine_chemical.solvent_storage_tank", + "requiredRoles": ["vessel_shell", "support_base"], + "shapeCount": { "min": 8, "max": 70 } + }, + { + "id": "quality.fine_chemical.metering_pump_skid", + "requiredRoles": ["support_base", "pump_volute", "drive_unit", "dosing_manifold"], + "shapeCount": { "min": 8, "max": 70 } + } +] diff --git a/cloud/industry.fine-chemical.pharma-intermediate-0.1.0.zip b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0.zip new file mode 100644 index 000000000..26180ef5b Binary files /dev/null and b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0.zip differ diff --git a/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/README.md b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/README.md new file mode 100644 index 000000000..f3b9a9063 --- /dev/null +++ b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/README.md @@ -0,0 +1,5 @@ +# Fine Chemical Pharma Intermediate Pack + +This is an extension pack for pharma intermediate production scenarios. + +It depends on `industry.fine-chemical.basic`, which supplies common reactors, exchangers, filtration, drying, storage, and dosing equipment. The app installs that base pack automatically when this extension is installed from the simulated cloud. diff --git a/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/pack.json b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/pack.json new file mode 100644 index 000000000..1a39b876b --- /dev/null +++ b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/pack.json @@ -0,0 +1,48 @@ +{ + "id": "industry.fine-chemical.pharma-intermediate", + "name": "Fine Chemical Pharma Intermediate Pack", + "industry": "fine-chemical.pharma-intermediate", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Pharmaceutical intermediate extension pack. The fine-chemical base pack is installed automatically as a dependency.", + "profiles": [ + "profiles/pharma-intermediate.json" + ], + "qualityRules": [ + "quality-rules/pharma-intermediate-quality.json" + ], + "dependsOn": [ + { + "id": "industry.fine-chemical.basic", + "version": ">=0.1.0" + } + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "fine_chemical.pharma.crystallization_kettle", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.height": "height", + "defaultDimensions.diameter": "length", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/profiles/pharma-intermediate.json b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/profiles/pharma-intermediate.json new file mode 100644 index 000000000..5249c3e12 --- /dev/null +++ b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/profiles/pharma-intermediate.json @@ -0,0 +1,224 @@ +[ + { + "id": "fine_chemical.pharma.crystallization_kettle", + "name": "Pharma intermediate crystallization kettle", + "aliases": [ + "crystallization kettle", + "crystallizer", + "pharma crystallization reactor", + "结晶釜", + "医药中间体结晶釜" + ], + "industry": "fine-chemical.pharma-intermediate", + "family": "reactor", + "layoutFamily": "vessel_layout", + "archetypeFamily": "process_vessel", + "defaultDimensions": { + "diameter": 1.2, + "height": 1.75 + }, + "primarySemanticRole": "reactor_vessel_shell", + "qualityRules": "quality.fine_chemical.pharma.crystallization_kettle", + "parts": [ + { + "kind": "agitator_tank", + "semanticRole": "reactor_vessel_shell", + "required": true, + "height": 1.45, + "radius": 0.56, + "primaryColor": "#c7d0d6", + "shellThickness": 0.035, + "bottomStyle": "conical", + "legStyle": "splayed", + "legCount": 3, + "motorColor": "#2563eb" + }, + { + "kind": "flange_ring", + "semanticRole": "top_clamp_ring", + "position": [ + 0, + 1.305, + 0 + ], + "axis": "y", + "radius": 0.54, + "depth": 0.03, + "includeBolts": false + }, + { + "kind": "inlet_port", + "semanticRole": "hinged_manway_feed_port", + "required": true, + "position": [ + 0.34, + 1.5, + 0 + ], + "axis": "y", + "radius": 0.12, + "length": 0.22 + }, + { + "kind": "flange_ring", + "semanticRole": "offset_manway_lid", + "position": [ + -0.32, + 1.46, + 0.18 + ], + "axis": "y", + "radius": 0.16, + "depth": 0.025, + "boltCount": 8, + "primaryColor": "#b8c2c8" + }, + { + "kind": "outlet_port", + "semanticRole": "bottom_crystal_slurry_discharge", + "required": true, + "position": [ + 0, + -0.34, + 0 + ], + "axis": "y", + "radius": 0.075, + "length": 0.24 + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Compact stainless pharma crystallization kettle with squat jacketed vessel, top agitator motor, hinged manway/feed port, polished shell, bottom discharge, and splayed support legs.", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72, + "parts": { + "top_clamp_ring": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "offset_manway_lid": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "fine_chemical.pharma.solvent_recovery_skid", + "name": "Solvent recovery skid", + "aliases": [ + "solvent recovery skid", + "solvent recovery unit", + "溶剂回收撬", + "溶剂回收单元" + ], + "industry": "fine-chemical.pharma-intermediate", + "family": "generic", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "thermal_equipment", + "defaultDimensions": { + "length": 2.8, + "width": 1.2, + "height": 2.1 + }, + "primarySemanticRole": "solvent_recovery_column", + "qualityRules": "quality.fine_chemical.pharma.solvent_recovery_skid", + "parts": [ + { + "kind": "skid_base", + "semanticRole": "support_base", + "required": true, + "length": 2.8, + "width": 1.2, + "height": 0.14 + }, + { + "kind": "cylindrical_tank", + "semanticRole": "solvent_recovery_column", + "required": true, + "axis": "y", + "height": 1.8, + "radius": 0.22, + "position": [ + -0.75, + 1.02, + 0 + ] + }, + { + "kind": "heat_exchanger", + "semanticRole": "condenser_shell", + "required": true, + "length": 1.3, + "radius": 0.16, + "position": [ + 0.48, + 1.35, + 0 + ] + }, + { + "kind": "cylindrical_tank", + "semanticRole": "receiver_vessel", + "axis": "x", + "height": 0.8, + "radius": 0.18, + "position": [ + 0.62, + 0.48, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "solvent_vapor_manifold", + "length": 1.8, + "radius": 0.035 + } + ], + "status": "stable", + "source": "imported_pack", + "description": "Compact solvent recovery skid with small column, condenser, receiver, piping, and support frame.", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72, + "parts": { + "solvent_recovery_column": { + "detailLevel": "low", + "count": 5 + }, + "solvent_vapor_manifold": { + "detailLevel": "low", + "count": 5 + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/quality-rules/pharma-intermediate-quality.json b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/quality-rules/pharma-intermediate-quality.json new file mode 100644 index 000000000..966c92acc --- /dev/null +++ b/cloud/industry.fine-chemical.pharma-intermediate-0.1.0/quality-rules/pharma-intermediate-quality.json @@ -0,0 +1,13 @@ +[ + { + "id": "quality.fine_chemical.pharma.crystallization_kettle", + "requiredRoles": ["reactor_vessel_shell", "hinged_manway_feed_port", "bottom_crystal_slurry_discharge", "support_leg", "agitator_motor", "conical_discharge_bottom"], + "shapeCount": { "min": 10, "max": 65 }, + "forbiddenRoles": ["clean_access_platform", "platform_ladder", "guard_rail", "ladder_rung", "tilted_top_manway_cover"] + }, + { + "id": "quality.fine_chemical.pharma.solvent_recovery_skid", + "requiredRoles": ["support_base", "solvent_recovery_column", "condenser_shell", "solvent_vapor_manifold"], + "shapeCount": { "min": 8, "max": 80 } + } +] diff --git a/cloud/industry.logistics.basic-0.1.0.zip b/cloud/industry.logistics.basic-0.1.0.zip new file mode 100644 index 000000000..7b6902214 Binary files /dev/null and b/cloud/industry.logistics.basic-0.1.0.zip differ diff --git a/cloud/industry.logistics.basic-0.1.0/README.md b/cloud/industry.logistics.basic-0.1.0/README.md new file mode 100644 index 000000000..c80c66891 --- /dev/null +++ b/cloud/industry.logistics.basic-0.1.0/README.md @@ -0,0 +1,5 @@ +# Logistics Basic Equipment Pack + +Simulated cloud profile pack for intralogistics equipment. + +- `agv_material_cart`: low-profile AGV/AMR material cart using reusable mobile-platform parts. diff --git a/cloud/industry.logistics.basic-0.1.0/pack.json b/cloud/industry.logistics.basic-0.1.0/pack.json new file mode 100644 index 000000000..1ac2c3e56 --- /dev/null +++ b/cloud/industry.logistics.basic-0.1.0/pack.json @@ -0,0 +1,24 @@ +{ + "id": "industry.logistics.basic", + "name": "Logistics Basic Equipment Pack", + "industry": "logistics", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Basic intralogistics equipment profiles for AGV/AMR material-handling platforms.", + "profiles": [ + "profiles/agv.json" + ], + "qualityRules": [ + "quality-rules/agv-quality.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [] +} diff --git a/cloud/industry.logistics.basic-0.1.0/profiles/agv.json b/cloud/industry.logistics.basic-0.1.0/profiles/agv.json new file mode 100644 index 000000000..463580d65 --- /dev/null +++ b/cloud/industry.logistics.basic-0.1.0/profiles/agv.json @@ -0,0 +1,200 @@ +[ + { + "id": "agv_material_cart", + "name": "AGV material cart", + "aliases": [ + "agv", + "agv cart", + "agv vehicle", + "automated guided vehicle", + "amr", + "amr cart", + "autonomous mobile robot", + "mobile robot platform", + "vga cart", + "vga小车", + "agv小车", + "自动导引车", + "自动搬运车", + "搬运小车", + "物流小车" + ], + "industry": "logistics", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "material_handling", + "family": "generic", + "defaultDimensions": { + "length": 1.45, + "width": 0.9, + "height": 0.48 + }, + "parts": [ + { + "kind": "mobile_platform_chassis", + "semanticRole": "vehicle_body", + "required": true, + "length": 1.45, + "width": 0.9, + "height": 0.28, + "position": [ + 0, + 0.17, + 0 + ], + "cornerRadius": 0.2, + "primaryColor": "#e5e7eb", + "secondaryColor": "#334155", + "darkColor": "#111827", + "accentColor": "#38bdf8" + }, + { + "kind": "wheel_set", + "semanticRole": "drive_wheel", + "required": true, + "count": 4, + "length": 1.05, + "width": 0.68, + "radius": 0.095, + "wheelWidth": 0.045, + "position": [ + 0, + 0.1, + 0 + ] + }, + { + "kind": "bar_pair", + "semanticRole": "safety_bumper", + "required": true, + "length": 0.82, + "height": 0.055, + "position": [ + 0.72, + 0.15, + 0 + ], + "color": "#020617" + }, + { + "kind": "lidar_sensor", + "semanticRole": "front_navigation_sensor", + "required": true, + "axis": "x", + "radius": 0.045, + "height": 0.035, + "position": [ + 0.75, + 0.21, + 0 + ], + "darkColor": "#0f172a", + "accentColor": "#38bdf8" + }, + { + "kind": "lidar_sensor", + "semanticRole": "rear_navigation_sensor", + "required": true, + "axis": "x", + "radius": 0.035, + "height": 0.03, + "position": [ + -0.75, + 0.2, + 0 + ], + "darkColor": "#0f172a", + "accentColor": "#38bdf8" + }, + { + "kind": "status_light_strip", + "semanticRole": "left_status_light_strip", + "required": true, + "length": 0.72, + "height": 0.035, + "thickness": 0.012, + "position": [ + 0, + 0.21, + 0.455 + ], + "color": "#38bdf8" + }, + { + "kind": "status_light_strip", + "semanticRole": "right_status_light_strip", + "required": true, + "length": 0.72, + "height": 0.035, + "thickness": 0.012, + "position": [ + 0, + 0.21, + -0.455 + ], + "color": "#38bdf8" + }, + { + "kind": "emergency_stop_button", + "semanticRole": "emergency_stop_button", + "required": true, + "axis": "y", + "radius": 0.035, + "height": 0.032, + "position": [ + 0.42, + 0.37, + 0.22 + ], + "color": "#ef4444" + }, + { + "kind": "warning_label", + "semanticRole": "safety_label", + "length": 0.16, + "width": 0.07, + "position": [ + 0.2, + 0.335, + 0.31 + ] + } + ], + "primarySemanticRole": "vehicle_body", + "qualityRules": "quality.agv_material_cart", + "visualCues": [ + "low rounded rectangular chassis", + "dark bumper skirt", + "flat top load platform", + "hidden low drive wheels", + "blue side status light strips", + "front lidar navigation sensor", + "red emergency stop button" + ], + "status": "stable", + "source": "imported_pack", + "description": "Factory AGV/AMR material-handling platform with low rounded chassis, dark bumper skirt, load deck, hidden drive wheels, side status lights, navigation sensors, and emergency stop.", + "editableSchemaRef": "mobile_platform.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 48, + "parts": { + "front_navigation_sensor": { + "detailLevel": "low" + }, + "rear_navigation_sensor": { + "detailLevel": "low" + }, + "left_status_light_strip": { + "detailLevel": "low" + }, + "right_status_light_strip": { + "detailLevel": "low" + }, + "safety_label": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.logistics.basic-0.1.0/quality-rules/agv-quality.json b/cloud/industry.logistics.basic-0.1.0/quality-rules/agv-quality.json new file mode 100644 index 000000000..5c1487636 --- /dev/null +++ b/cloud/industry.logistics.basic-0.1.0/quality-rules/agv-quality.json @@ -0,0 +1,6 @@ +{ + "id": "quality.agv_material_cart", + "requiredRoles": ["vehicle_body", "cargo_platform", "drive_wheel", "safety_bumper", "front_navigation_sensor", "left_status_light_strip", "emergency_stop_button"], + "forbiddenRoles": ["vehicle_cabin", "vehicle_window", "headlight", "vehicle_roof"], + "shapeCount": { "min": 12, "max": 42 } +} diff --git a/cloud/industry.machine-tools.basic-0.1.0.zip b/cloud/industry.machine-tools.basic-0.1.0.zip new file mode 100644 index 000000000..881bc89fe Binary files /dev/null and b/cloud/industry.machine-tools.basic-0.1.0.zip differ diff --git a/cloud/industry.machine-tools.basic-0.1.0/README.md b/cloud/industry.machine-tools.basic-0.1.0/README.md new file mode 100644 index 000000000..77a5d6487 --- /dev/null +++ b/cloud/industry.machine-tools.basic-0.1.0/README.md @@ -0,0 +1,5 @@ +# Machine Tools Basic Equipment Pack + +Simulated cloud profile pack for common machine tools. + +- `cnc_machining_center`: profile-pack CNC/VMC profile that can override the builtin fallback. diff --git a/cloud/industry.machine-tools.basic-0.1.0/pack.json b/cloud/industry.machine-tools.basic-0.1.0/pack.json new file mode 100644 index 000000000..be6218fe1 --- /dev/null +++ b/cloud/industry.machine-tools.basic-0.1.0/pack.json @@ -0,0 +1,24 @@ +{ + "id": "industry.machine-tools.basic", + "name": "Machine Tools Basic Equipment Pack", + "industry": "machine-tools", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Basic machine-tool profiles that can override builtin generic CNC fallbacks.", + "profiles": [ + "profiles/cnc.json" + ], + "qualityRules": [ + "quality-rules/cnc-quality.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [] +} diff --git a/cloud/industry.machine-tools.basic-0.1.0/profiles/cnc.json b/cloud/industry.machine-tools.basic-0.1.0/profiles/cnc.json new file mode 100644 index 000000000..8782cfca4 --- /dev/null +++ b/cloud/industry.machine-tools.basic-0.1.0/profiles/cnc.json @@ -0,0 +1,169 @@ +[ + { + "id": "cnc_machining_center", + "name": "CNC machining center", + "aliases": [ + "cnc machining center", + "cnc machine", + "cnc mill", + "vertical machining center", + "vmc", + "jia gong zhong xin", + "shu kong ji chuang", + "数控机床", + "加工中心", + "立式加工中心" + ], + "industry": "machine-tools", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "enclosed_machine", + "family": "machine_tool", + "defaultDimensions": { + "length": 2.8, + "width": 1.1, + "height": 1.7 + }, + "parts": [ + { + "kind": "generic_base", + "semanticRole": "machine_base", + "required": true, + "length": 2.8, + "width": 1.1, + "height": 0.18 + }, + { + "kind": "generic_body", + "semanticRole": "machine_enclosure", + "required": true, + "length": 2.45, + "width": 1, + "height": 1.35, + "position": [ + 0, + 0.82, + 0 + ], + "cornerRadius": 0.08, + "primaryColor": "#f8fafc" + }, + { + "kind": "generic_panel", + "semanticRole": "viewing_window", + "required": true, + "length": 0.82, + "height": 0.58, + "position": [ + 0.18, + 1.08, + 0.54 + ], + "rotation": [ + 1.5708, + 0, + 0 + ], + "accentColor": "#38bdf8", + "opacity": 0.48 + }, + { + "kind": "generic_panel", + "semanticRole": "spindle_head", + "required": true, + "length": 0.38, + "height": 0.42, + "position": [ + -0.45, + 1.18, + 0.46 + ], + "color": "#64748b" + }, + { + "kind": "generic_base", + "semanticRole": "work_table", + "required": true, + "length": 0.95, + "width": 0.42, + "height": 0.08, + "position": [ + 0.15, + 0.48, + 0.36 + ], + "color": "#1f2937" + }, + { + "kind": "operator_panel", + "semanticRole": "control_panel", + "required": true, + "width": 0.34, + "depth": 0.16, + "height": 0.62, + "position": [ + 1.12, + 0.92, + 0.62 + ] + }, + { + "kind": "vent_slats", + "semanticRole": "vent_panel", + "length": 0.5, + "height": 0.34, + "count": 5, + "position": [ + -0.72, + 0.8, + 0.54 + ] + }, + { + "kind": "warning_label", + "semanticRole": "safety_label", + "position": [ + 0.86, + 0.56, + 0.55 + ] + } + ], + "primarySemanticRole": "machine_enclosure", + "qualityRules": "quality.cnc_machining_center", + "visualCues": [ + "large enclosed machine body", + "blue viewing window", + "front work table", + "spindle head", + "side control panel", + "vent slats" + ], + "status": "stable", + "source": "imported_pack", + "description": "Profile-pack CNC machining center with explicit enclosure, viewing window, spindle head, work table, controls, vents, and safety label.", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "viewing_window": { + "detailLevel": "low" + }, + "spindle_head": { + "detailLevel": "low" + }, + "control_panel": { + "detailLevel": "low" + }, + "vent_panel": { + "detailLevel": "low", + "count": 5 + }, + "safety_label": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.machine-tools.basic-0.1.0/quality-rules/cnc-quality.json b/cloud/industry.machine-tools.basic-0.1.0/quality-rules/cnc-quality.json new file mode 100644 index 000000000..c4517f283 --- /dev/null +++ b/cloud/industry.machine-tools.basic-0.1.0/quality-rules/cnc-quality.json @@ -0,0 +1,6 @@ +{ + "id": "quality.cnc_machining_center", + "requiredRoles": ["machine_enclosure", "machine_base", "viewing_window", "spindle_head", "work_table", "control_panel", "display_screen"], + "forbiddenRoles": ["vehicle_body", "vehicle_window", "aircraft_fuselage", "aircraft_wing"], + "shapeCount": { "min": 8, "max": 36 } +} diff --git a/cloud/industry.process.basic-0.1.0.zip b/cloud/industry.process.basic-0.1.0.zip new file mode 100644 index 000000000..a5d90086d Binary files /dev/null and b/cloud/industry.process.basic-0.1.0.zip differ diff --git a/cloud/industry.process.basic-0.1.0/README.md b/cloud/industry.process.basic-0.1.0/README.md new file mode 100644 index 000000000..45fcc0001 --- /dev/null +++ b/cloud/industry.process.basic-0.1.0/README.md @@ -0,0 +1,54 @@ +# 流程行业基础包 + +流程行业基础包 :覆盖搅拌、换热、过滤、离心、干燥、包装、储罐、管廊、阀组、粉体料仓、鼓风机和空压机撬等通用流程设备。 + +## Devices + +- Raw material storage tank (process.raw_material_tank) +- Metering pump skid (process.metering_pump_skid) +- Mixing tank (process.mixing_tank) +- Stirred reactor (process.stirred_reactor) +- Shell and tube heat exchanger (process.heat_exchanger) +- Process filter vessel (process.filter_vessel) +- Industrial centrifuge (process.centrifuge) +- Vacuum tray dryer (process.tray_dryer) +- Product storage tank (process.product_storage_tank) +- Process packaging station (process.packaging_station) +- Process pipe corridor (process.pipe_corridor) +- Process control cabinet (process.control_cabinet) +- Bulk material silo (process.bulk_material_silo) +- Valve manifold station (process.valve_station) +- Utility blower (process.utility_blower) +- Air compressor skid (process.air_compressor_skid) + +## Pack Type + +Factory-capable pack: supports factory/process creation through process templates and factory architectures. + +## Factory Creation + +Supported whole-factory/process templates: + +- Process industry basic plant (process_industry_basic_plant) + +Supported factory scopes/modules: + +- 基础流程生产线 (basic_process_line) +- 流程行业基础工厂 (full_process_plant) + +## Factory Architectures + +- 流程行业基础工厂架构 + +## Process Templates + +- Process industry basic plant + +## Validation + +Run: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.process.basic@0.1.0 --validate-only +``` + diff --git a/cloud/industry.process.basic-0.1.0/factory-architectures/generated.json b/cloud/industry.process.basic-0.1.0/factory-architectures/generated.json new file mode 100644 index 000000000..9a16e709f --- /dev/null +++ b/cloud/industry.process.basic-0.1.0/factory-architectures/generated.json @@ -0,0 +1,155 @@ +[ + { + "id": "process.factory.basic_process_plant", + "label": "流程行业基础工厂架构", + "industry": "process", + "processId": "process_industry_basic_plant", + "layoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 58, + "width": 26 + }, + "scopes": [ + { + "id": "basic_process_line", + "label": "基础流程生产线", + "includeModules": [ + "raw_material_storage", + "reaction_preparation", + "thermal_exchange", + "separation", + "product_storage", + "utilities_control", + "bulk_handling", + "valve_manifold", + "utility_air" + ] + }, + { + "id": "full_process_plant", + "label": "流程行业基础工厂", + "includeModules": [ + "raw_material_storage", + "reaction_preparation", + "thermal_exchange", + "separation", + "drying_packaging", + "product_storage", + "utilities_control", + "bulk_handling", + "valve_manifold", + "utility_air" + ] + } + ], + "modules": [ + { + "id": "raw_material_storage", + "displayLabel": "原料储存与计量", + "order": 10, + "stationIds": [ + "raw_material_tank", + "metering_pump_skid" + ] + }, + { + "id": "reaction_preparation", + "displayLabel": "配料搅拌与反应", + "order": 20, + "stationIds": [ + "mixing_tank", + "stirred_reactor" + ] + }, + { + "id": "thermal_exchange", + "displayLabel": "换热调温", + "order": 30, + "stationIds": [ + "heat_exchanger" + ] + }, + { + "id": "separation", + "displayLabel": "过滤与离心分离", + "order": 40, + "stationIds": [ + "filter_vessel", + "centrifuge" + ] + }, + { + "id": "drying_packaging", + "displayLabel": "干燥与包装", + "order": 50, + "stationIds": [ + "tray_dryer", + "packaging_station" + ] + }, + { + "id": "product_storage", + "displayLabel": "成品储存", + "order": 60, + "stationIds": [ + "product_storage_tank" + ] + }, + { + "id": "utilities_control", + "displayLabel": "管廊与控制", + "order": 70, + "stationIds": [ + "pipe_corridor", + "control_cabinet" + ] + }, + { + "id": "bulk_handling", + "displayLabel": "粉体储运模块", + "order": 15, + "stationIds": [ + "bulk_material_silo" + ] + }, + { + "id": "utility_air", + "displayLabel": "公用空气系统", + "order": 72, + "stationIds": [ + "utility_blower", + "air_compressor_skid" + ] + }, + { + "id": "valve_manifold", + "displayLabel": "阀组模块", + "order": 45, + "stationIds": [ + "valve_station" + ] + } + ], + "layoutHints": { + "primaryAxis": "x", + "secondaryAxis": "z", + "indoorWorkshop": false, + "highestStationId": "stirred_reactor", + "longAxisStationId": "pipe_corridor", + "sideBranchStationIds": [ + "metering_pump_skid", + "pipe_corridor", + "control_cabinet", + "utility_blower", + "air_compressor_skid" + ], + "scale": "conceptual", + "notes": [ + "储罐和计量泵放在流程入口侧,反应与分离设备放在中段。", + "换热器靠近反应釜和分离设备,减少管线跨距。", + "管廊沿流程侧边布置,控制柜远离主要管线和高温设备。", + "干燥、成品储罐和包装位于流程下游。" + ] + } + } +] diff --git a/cloud/industry.process.basic-0.1.0/pack.json b/cloud/industry.process.basic-0.1.0/pack.json new file mode 100644 index 000000000..5bb1f7ccf --- /dev/null +++ b/cloud/industry.process.basic-0.1.0/pack.json @@ -0,0 +1,179 @@ +{ + "id": "industry.process.basic", + "name": "流程行业基础包", + "industry": "process", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "流程行业基础包 :覆盖搅拌、换热、过滤、离心、干燥、包装、储罐、管廊、阀组、粉体料仓、鼓风机和空压机撬等通用流程设备。", + "profiles": [ + "profiles/generated.json" + ], + "qualityRules": [ + "quality-rules/generated-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/generated.json" + ], + "processTemplates": [ + "process-templates/generated.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "process.raw_material_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "process.metering_pump_skid", + "recipeId": "factory:centrifugal-pump", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.pumpType": "pumpType", + "equipmentDefaults.flowRate": "flowRate", + "equipmentDefaults.motorPower": "motorPower", + "equipmentDefaults.skidMounted": "skidMounted" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "process.mixing_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "process.stirred_reactor", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "process.heat_exchanger", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "process.filter_vessel", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "process.product_storage_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "process.bulk_material_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.process.basic-0.1.0/process-templates/generated.json b/cloud/industry.process.basic-0.1.0/process-templates/generated.json new file mode 100644 index 000000000..80043e5a8 --- /dev/null +++ b/cloud/industry.process.basic-0.1.0/process-templates/generated.json @@ -0,0 +1,407 @@ +[ + { + "processId": "process_industry_basic_plant", + "processLabel": "Process industry basic plant", + "processDisplayLabel": "流程行业基础工厂", + "domain": "chemical", + "aliases": [ + "流程行业基础包", + "流程行业基础工厂", + "流程工业基础工厂", + "流程工业工厂", + "流程生产线", + "通用流程车间", + "化工流程车间", + "基础流程生产线", + "做一个流程行业基础工厂", + "生成一个流程行业基础工厂", + "process industry plant", + "process plant", + "basic process plant", + "process production line" + ], + "requiredRoles": [ + "raw_material_tank", + "metering_pump_skid", + "mixing_tank", + "stirred_reactor", + "heat_exchanger", + "filter_vessel", + "centrifuge", + "product_storage_tank", + "pipe_corridor", + "control_cabinet", + "utility_blower", + "air_compressor_skid", + "bulk_material_silo", + "valve_station" + ], + "defaultLayoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 58, + "width": 26 + }, + "safetyTags": [ + "process_pipe", + "chemical", + "rotating_equipment", + "utilities" + ], + "stations": [ + { + "id": "raw_material_tank", + "label": "Raw material storage tank", + "displayLabel": "原料储罐", + "role": "raw_material_tank", + "equipmentHint": "process.raw_material_tank vertical raw material storage tank with cylindrical shell, inlet outlet nozzles, ladder, and skid legs", + "footprintHint": "large", + "safetyTags": [ + "storage", + "material" + ], + "profileId": "process.raw_material_tank" + }, + { + "id": "metering_pump_skid", + "label": "Metering pump skid", + "displayLabel": "计量泵撬", + "role": "metering_pump_skid", + "equipmentHint": "process.metering_pump_skid compact metering pump skid with pump casing, drive motor, pipe manifold, base frame, and control panel", + "footprintHint": "medium", + "safetyTags": [ + "pump", + "rotating_equipment" + ], + "profileId": "process.metering_pump_skid" + }, + { + "id": "mixing_tank", + "label": "Mixing tank", + "displayLabel": "配料搅拌罐", + "role": "mixing_tank", + "equipmentHint": "process.mixing_tank vertical mixing tank with cylindrical vessel, top agitator, feed inlet, discharge outlet, and service ladder", + "footprintHint": "large", + "safetyTags": [ + "mixing", + "vessel" + ], + "profileId": "process.mixing_tank" + }, + { + "id": "stirred_reactor", + "label": "Stirred reactor", + "displayLabel": "搅拌反应釜", + "role": "stirred_reactor", + "equipmentHint": "process.stirred_reactor jacketed stirred reactor with rounded cylindrical vessel, top agitator, manway, nozzles, pipe manifold, and service platform", + "footprintHint": "large", + "safetyTags": [ + "reactor", + "pressure_vessel" + ], + "profileId": "process.stirred_reactor" + }, + { + "id": "heat_exchanger", + "label": "Heat exchanger", + "displayLabel": "管壳式换热器", + "role": "heat_exchanger", + "equipmentHint": "process.heat_exchanger horizontal shell and tube heat exchanger on skid with end caps, pipe ports, and support saddles", + "footprintHint": "long", + "safetyTags": [ + "thermal", + "pressure_vessel" + ], + "profileId": "process.heat_exchanger" + }, + { + "id": "filter_vessel", + "label": "Filter vessel", + "displayLabel": "过滤器", + "role": "filter_vessel", + "equipmentHint": "process.filter_vessel vertical process filter vessel with cylindrical body, pipe nozzles, bottom outlet, and skid support", + "footprintHint": "medium", + "safetyTags": [ + "separation", + "filter" + ], + "profileId": "process.filter_vessel" + }, + { + "id": "centrifuge", + "label": "Centrifuge", + "displayLabel": "离心机", + "role": "centrifuge", + "equipmentHint": "process.centrifuge industrial centrifuge with cylindrical bowl housing, motor drive, outlet chute, skid base, and operator panel", + "footprintHint": "medium", + "safetyTags": [ + "separation", + "rotating_equipment" + ], + "profileId": "process.centrifuge" + }, + { + "id": "tray_dryer", + "label": "Tray dryer", + "displayLabel": "真空托盘干燥机", + "role": "tray_dryer", + "equipmentHint": "process.tray_dryer rectangular vacuum tray dryer chamber with front door, pipe manifold, skid base, and control panel", + "footprintHint": "medium", + "safetyTags": [ + "drying", + "thermal" + ], + "profileId": "process.tray_dryer" + }, + { + "id": "product_storage_tank", + "label": "Product storage tank", + "displayLabel": "成品储罐", + "role": "product_storage_tank", + "equipmentHint": "process.product_storage_tank vertical product storage tank with cylindrical shell, outlet nozzle, inlet nozzle, ladder, and support skirt", + "footprintHint": "large", + "safetyTags": [ + "storage", + "product" + ], + "profileId": "process.raw_material_tank" + }, + { + "id": "packaging_station", + "label": "Packaging station", + "displayLabel": "包装工位", + "role": "packaging_station", + "equipmentHint": "process.packaging_station downstream packaging station with conveyor frame, compact packing body, control box, and discharge rollers", + "footprintHint": "large", + "safetyTags": [ + "packaging", + "conveyor" + ], + "profileId": "process.packaging_station" + }, + { + "id": "pipe_corridor", + "label": "Process pipe corridor", + "displayLabel": "工艺管廊", + "role": "pipe_corridor", + "equipmentHint": "process.pipe_corridor long process pipe corridor with parallel pipes, pipe elbows, valves, flanges, and utility header", + "footprintHint": "long", + "safetyTags": [ + "pipe", + "utility" + ], + "profileId": "process.pipe_corridor" + }, + { + "id": "control_cabinet", + "label": "Control cabinet", + "displayLabel": "控制柜", + "role": "control_cabinet", + "equipmentHint": "process.control_cabinet electrical control cabinet with HMI display, cable tray, warning labels, and cabinet doors", + "footprintHint": "medium", + "safetyTags": [ + "electrical", + "control" + ], + "profileId": "process.control_cabinet" + }, + { + "id": "bulk_material_silo", + "label": "Bulk material silo", + "displayLabel": "粉体料仓", + "role": "bulk_material_silo", + "equipmentHint": "process.bulk_material_silo vertical bulk material silo with cylindrical bin, conical hopper bottom, support frame, outlet spout, and level indicator", + "footprintHint": "tall", + "safetyTags": [ + "storage", + "bulk_material" + ], + "profileId": "process.bulk_material_silo" + }, + { + "id": "valve_station", + "label": "Valve manifold station", + "displayLabel": "阀组站", + "role": "valve_station", + "equipmentHint": "process.valve_station process valve manifold station with parallel pipes, valve bodies, handwheels, flanges, and compact support skid", + "footprintHint": "medium", + "safetyTags": [ + "pipe", + "valve" + ], + "profileId": "process.valve_station" + }, + { + "id": "utility_blower", + "label": "Utility blower", + "displayLabel": "公用鼓风机", + "role": "utility_blower", + "equipmentHint": "process.utility_blower industrial utility blower with volute casing, impeller, drive motor, inlet outlet ports, and skid base", + "footprintHint": "medium", + "safetyTags": [ + "rotating_equipment", + "air" + ], + "profileId": "process.utility_blower" + }, + { + "id": "air_compressor_skid", + "label": "Air compressor skid", + "displayLabel": "空压机撬", + "role": "air_compressor_skid", + "equipmentHint": "process.air_compressor_skid compressed air compressor skid with rounded compressor body, ribbed drive motor, receiver pipe ports, and local control box", + "footprintHint": "medium", + "safetyTags": [ + "compressed_air", + "utility" + ], + "profileId": "process.air_compressor_skid" + } + ], + "connections": [ + { + "fromStationId": "raw_material_tank", + "toStationId": "metering_pump_skid", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "raw_outlet", + "toPortId": "pump_suction" + }, + { + "fromStationId": "metering_pump_skid", + "toStationId": "mixing_tank", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "pump_discharge", + "toPortId": "mixing_feed" + }, + { + "fromStationId": "mixing_tank", + "toStationId": "stirred_reactor", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "mixing_outlet", + "toPortId": "reactor_feed" + }, + { + "fromStationId": "stirred_reactor", + "toStationId": "heat_exchanger", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "reactor_outlet", + "toPortId": "exchanger_process_in" + }, + { + "fromStationId": "heat_exchanger", + "toStationId": "filter_vessel", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "exchanger_process_out", + "toPortId": "filter_inlet" + }, + { + "fromStationId": "filter_vessel", + "toStationId": "centrifuge", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "filter_outlet", + "toPortId": "centrifuge_feed" + }, + { + "fromStationId": "centrifuge", + "toStationId": "tray_dryer", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "wet_solid_out", + "toPortId": "dryer_feed" + }, + { + "fromStationId": "tray_dryer", + "toStationId": "product_storage_tank", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "dryer_out", + "toPortId": "product_inlet" + }, + { + "fromStationId": "product_storage_tank", + "toStationId": "packaging_station", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "product_outlet", + "toPortId": "packaging_in" + }, + { + "fromStationId": "pipe_corridor", + "toStationId": "stirred_reactor", + "medium": "cooling", + "visualKind": "pipe", + "fromPortId": "cooling_header", + "toPortId": "reactor_jacket" + }, + { + "fromStationId": "pipe_corridor", + "toStationId": "heat_exchanger", + "medium": "water", + "visualKind": "pipe", + "fromPortId": "utility_header", + "toPortId": "exchanger_utility_in" + }, + { + "fromStationId": "control_cabinet", + "toStationId": "metering_pump_skid", + "medium": "power", + "visualKind": "cable_tray", + "fromPortId": "mcc_out", + "toPortId": "pump_drive" + }, + { + "fromStationId": "control_cabinet", + "toStationId": "stirred_reactor", + "medium": "power", + "visualKind": "cable_tray", + "fromPortId": "mcc_out", + "toPortId": "agitator_drive" + }, + { + "fromStationId": "bulk_material_silo", + "toStationId": "mixing_tank", + "medium": "material", + "visualKind": "material_conveyor", + "fromPortId": "silo_discharge", + "toPortId": "mixing_solid_feed" + }, + { + "fromStationId": "filter_vessel", + "toStationId": "valve_station", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "filter_outlet", + "toPortId": "valve_inlet" + }, + { + "fromStationId": "valve_station", + "toStationId": "centrifuge", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "valve_outlet", + "toPortId": "centrifuge_feed" + }, + { + "fromStationId": "utility_blower", + "toStationId": "pipe_corridor", + "medium": "gas", + "visualKind": "air_duct", + "fromPortId": "blower_discharge", + "toPortId": "air_header" + }, + { + "fromStationId": "air_compressor_skid", + "toStationId": "pipe_corridor", + "medium": "gas", + "visualKind": "pipe", + "fromPortId": "compressed_air_out", + "toPortId": "air_header" + } + ] + } +] diff --git a/cloud/industry.process.basic-0.1.0/profiles/generated.json b/cloud/industry.process.basic-0.1.0/profiles/generated.json new file mode 100644 index 000000000..c35d1765c --- /dev/null +++ b/cloud/industry.process.basic-0.1.0/profiles/generated.json @@ -0,0 +1,1254 @@ +[ + { + "id": "process.raw_material_tank", + "name": "Raw material storage tank", + "aliases": [ + "原料储罐", + "原料罐", + "储罐", + "立式储罐", + "raw material tank", + "storage tank" + ], + "industry": "process", + "layoutFamily": "vessel_layout", + "family": "tank", + "defaultDimensions": { + "length": 2.2, + "width": 2.2, + "height": 4.2 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "vessel_shell", + "required": true, + "radius": 0.95, + "height": 3.6, + "color": "#cbd5e1" + }, + { + "kind": "skid_base", + "semanticRole": "support_skid", + "required": true, + "length": 2.2, + "width": 2.2, + "height": 0.25 + }, + { + "kind": "inlet_port", + "semanticRole": "top_inlet", + "required": true, + "attachToRole": "vessel_shell", + "anchor": "top" + }, + { + "kind": "outlet_port", + "semanticRole": "bottom_outlet", + "required": true, + "attachToRole": "vessel_shell", + "anchor": "bottom" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_ladder", + "attachToRole": "vessel_shell", + "anchor": "service_side" + } + ], + "primarySemanticRole": "vessel_shell", + "qualityRules": "quality.process.raw_material_tank", + "visualCues": [ + "vertical tank", + "top inlet", + "bottom outlet", + "ladder" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.metering_pump_skid", + "name": "Metering pump skid", + "aliases": [ + "计量泵撬", + "泵组", + "加药泵撬", + "metering pump skid", + "pump skid" + ], + "industry": "process", + "layoutFamily": "rotating_machine_layout", + "family": "pump", + "defaultDimensions": { + "length": 2.4, + "width": 1.1, + "height": 1.2 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "pump_skid_base", + "required": true, + "length": 2.3, + "width": 1, + "height": 0.18 + }, + { + "kind": "volute_casing", + "semanticRole": "pump_volute", + "required": true, + "attachToRole": "pump_skid_base", + "anchor": "top" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "drive_motor", + "required": true, + "attachToRole": "pump_volute", + "anchor": "drive_side" + }, + { + "kind": "pipe_manifold", + "semanticRole": "pump_pipe_manifold", + "required": true, + "attachToRole": "pump_volute", + "anchor": "front" + }, + { + "kind": "operator_panel", + "semanticRole": "local_control_panel", + "attachToRole": "pump_skid_base", + "anchor": "service_side" + } + ], + "primarySemanticRole": "pump_volute", + "qualityRules": "quality.process.metering_pump_skid", + "visualCues": [ + "pump skid", + "volute casing", + "motor", + "pipe manifold" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "left", + "diameter": 0.18 + }, + { + "id": "outlet", + "medium": "material", + "side": "right", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "pumpType": "metering", + "flowRate": 120, + "motorPower": 15, + "skidMounted": true + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.mixing_tank", + "name": "Mixing tank", + "aliases": [ + "配料搅拌罐", + "搅拌罐", + "混合罐", + "mixing tank", + "blend tank" + ], + "industry": "process", + "layoutFamily": "vessel_layout", + "family": "reactor", + "defaultDimensions": { + "length": 2.5, + "width": 2.5, + "height": 4 + }, + "parts": [ + { + "kind": "agitator_tank", + "semanticRole": "mixing_vessel_shell", + "required": true, + "radius": 1, + "height": 3.3, + "color": "#d1d5db" + }, + { + "kind": "inlet_port", + "semanticRole": "feed_inlet", + "required": true, + "attachToRole": "mixing_vessel_shell", + "anchor": "top" + }, + { + "kind": "outlet_port", + "semanticRole": "mixing_outlet", + "required": true, + "attachToRole": "mixing_vessel_shell", + "anchor": "bottom" + }, + { + "kind": "skid_base", + "semanticRole": "support_skid", + "required": true, + "attachToRole": "mixing_vessel_shell", + "anchor": "bottom" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_ladder", + "attachToRole": "mixing_vessel_shell", + "anchor": "service_side" + } + ], + "primarySemanticRole": "mixing_vessel_shell", + "qualityRules": "quality.process.mixing_tank", + "visualCues": [ + "vertical mixing vessel", + "top agitator", + "feed inlet", + "bottom outlet" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.stirred_reactor", + "name": "Stirred reactor", + "aliases": [ + "搅拌反应釜", + "反应釜", + "釜式反应器", + "stirred reactor", + "jacketed reactor" + ], + "industry": "process", + "layoutFamily": "vessel_layout", + "family": "reactor", + "defaultDimensions": { + "length": 2.8, + "width": 2.8, + "height": 4.6 + }, + "parts": [ + { + "kind": "agitator_tank", + "semanticRole": "reactor_vessel_shell", + "required": true, + "radius": 1.1, + "height": 3.8, + "color": "#d6d3d1" + }, + { + "kind": "inlet_port", + "semanticRole": "reactor_feed_nozzle", + "required": true, + "attachToRole": "reactor_vessel_shell", + "anchor": "top" + }, + { + "kind": "outlet_port", + "semanticRole": "reactor_discharge_nozzle", + "required": true, + "attachToRole": "reactor_vessel_shell", + "anchor": "bottom" + }, + { + "kind": "pipe_manifold", + "semanticRole": "reactor_pipe_manifold", + "required": true, + "attachToRole": "reactor_vessel_shell", + "anchor": "front" + }, + { + "kind": "platform_ladder", + "semanticRole": "service_platform_ladder", + "required": true, + "attachToRole": "reactor_vessel_shell", + "anchor": "service_side" + } + ], + "primarySemanticRole": "reactor_vessel_shell", + "qualityRules": "quality.process.stirred_reactor", + "visualCues": [ + "jacketed stirred reactor", + "top agitator", + "pipe nozzles", + "service platform" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.heat_exchanger", + "name": "Shell and tube heat exchanger", + "aliases": [ + "管壳式换热器", + "换热器", + "冷凝器", + "shell and tube heat exchanger", + "heat exchanger" + ], + "industry": "process", + "layoutFamily": "vessel_layout", + "family": "heat_exchanger", + "defaultDimensions": { + "length": 4.2, + "width": 1.2, + "height": 1.4 + }, + "parts": [ + { + "kind": "heat_exchanger", + "semanticRole": "heat_exchanger_shell", + "required": true, + "length": 3.8, + "radius": 0.45, + "color": "#cbd5e1" + }, + { + "kind": "skid_base", + "semanticRole": "exchanger_saddle_skid", + "required": true, + "length": 4.1, + "width": 1, + "height": 0.18 + }, + { + "kind": "inlet_port", + "semanticRole": "process_inlet", + "required": true, + "attachToRole": "heat_exchanger_shell", + "anchor": "left" + }, + { + "kind": "outlet_port", + "semanticRole": "process_outlet", + "required": true, + "attachToRole": "heat_exchanger_shell", + "anchor": "right" + }, + { + "kind": "pipe_manifold", + "semanticRole": "utility_pipe_manifold", + "attachToRole": "heat_exchanger_shell", + "anchor": "top" + } + ], + "primarySemanticRole": "heat_exchanger_shell", + "qualityRules": "quality.process.heat_exchanger", + "visualCues": [ + "horizontal cylinder", + "end caps", + "pipe ports", + "support saddle" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "horizontal", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.filter_vessel", + "name": "Process filter vessel", + "aliases": [ + "过滤器", + "过滤罐", + "工艺过滤器", + "process filter", + "filter vessel" + ], + "industry": "process", + "layoutFamily": "vessel_layout", + "family": "tank", + "defaultDimensions": { + "length": 1.8, + "width": 1.8, + "height": 3 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "filter_vessel_shell", + "required": true, + "radius": 0.75, + "height": 2.4, + "color": "#d1d5db" + }, + { + "kind": "skid_base", + "semanticRole": "filter_support_skid", + "required": true, + "length": 1.8, + "width": 1.8, + "height": 0.18 + }, + { + "kind": "inlet_port", + "semanticRole": "filter_inlet", + "required": true, + "attachToRole": "filter_vessel_shell", + "anchor": "top" + }, + { + "kind": "outlet_port", + "semanticRole": "filter_outlet", + "required": true, + "attachToRole": "filter_vessel_shell", + "anchor": "bottom" + }, + { + "kind": "pipe_manifold", + "semanticRole": "filter_pipe_manifold", + "attachToRole": "filter_vessel_shell", + "anchor": "front" + } + ], + "primarySemanticRole": "filter_vessel_shell", + "qualityRules": "quality.process.filter_vessel", + "visualCues": [ + "vertical filter vessel", + "top inlet", + "bottom outlet", + "pipe manifold" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.centrifuge", + "name": "Industrial centrifuge", + "aliases": [ + "离心机", + "工业离心机", + "分离离心机", + "industrial centrifuge", + "centrifuge" + ], + "industry": "process", + "layoutFamily": "generic_industrial_layout", + "family": "generic", + "defaultDimensions": { + "length": 2.2, + "width": 1.3, + "height": 1.4 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "centrifuge_bowl_housing", + "required": true, + "radius": 0.55, + "height": 1.1, + "color": "#cbd5e1" + }, + { + "kind": "skid_base", + "semanticRole": "centrifuge_skid_base", + "required": true, + "length": 2.1, + "width": 1.2, + "height": 0.18 + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "centrifuge_drive_motor", + "required": true, + "attachToRole": "centrifuge_bowl_housing", + "anchor": "drive_side" + }, + { + "kind": "outlet_port", + "semanticRole": "centrifuge_discharge", + "required": true, + "attachToRole": "centrifuge_bowl_housing", + "anchor": "front" + }, + { + "kind": "operator_panel", + "semanticRole": "centrifuge_operator_panel", + "attachToRole": "centrifuge_skid_base", + "anchor": "service_side" + } + ], + "primarySemanticRole": "centrifuge_bowl_housing", + "qualityRules": "quality.process.centrifuge", + "visualCues": [ + "horizontal industrial centrifuge", + "bowl housing", + "drive motor", + "discharge port" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "process.tray_dryer", + "name": "Vacuum tray dryer", + "aliases": [ + "真空托盘干燥机", + "干燥机", + "托盘干燥机", + "vacuum tray dryer", + "tray dryer" + ], + "industry": "process", + "layoutFamily": "box_enclosure_layout", + "family": "machine_tool", + "defaultDimensions": { + "length": 2.2, + "width": 1.4, + "height": 1.8 + }, + "parts": [ + { + "kind": "generic_base", + "semanticRole": "dryer_base", + "required": true, + "length": 2.1, + "width": 1.3, + "height": 0.22 + }, + { + "kind": "generic_body", + "semanticRole": "dryer_chamber", + "required": true, + "attachToRole": "dryer_base", + "anchor": "top", + "length": 1.8, + "width": 1.1, + "height": 1.3, + "color": "#d1d5db" + }, + { + "kind": "generic_panel", + "semanticRole": "front_access_door", + "required": true, + "attachToRole": "dryer_chamber", + "anchor": "front", + "color": "#94a3b8" + }, + { + "kind": "pipe_manifold", + "semanticRole": "vacuum_pipe_manifold", + "required": true, + "attachToRole": "dryer_chamber", + "anchor": "top" + }, + { + "kind": "operator_panel", + "semanticRole": "dryer_control_panel", + "attachToRole": "dryer_chamber", + "anchor": "service_side" + } + ], + "primarySemanticRole": "dryer_chamber", + "qualityRules": "quality.process.tray_dryer", + "visualCues": [ + "rectangular dryer chamber", + "front door", + "vacuum pipe", + "control panel" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "process.product_storage_tank", + "name": "Product storage tank", + "aliases": [ + "成品储罐", + "产品储罐", + "成品罐", + "product storage tank", + "finished product tank" + ], + "industry": "process", + "layoutFamily": "vessel_layout", + "family": "tank", + "defaultDimensions": { + "length": 2.2, + "width": 2.2, + "height": 4 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "vessel_shell", + "required": true, + "radius": 0.95, + "height": 3.4, + "color": "#cbd5e1" + }, + { + "kind": "skid_base", + "semanticRole": "support_skid", + "required": true, + "length": 2.2, + "width": 2.2, + "height": 0.25 + }, + { + "kind": "inlet_port", + "semanticRole": "product_inlet", + "required": true, + "attachToRole": "vessel_shell", + "anchor": "top" + }, + { + "kind": "outlet_port", + "semanticRole": "product_outlet", + "required": true, + "attachToRole": "vessel_shell", + "anchor": "bottom" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_ladder", + "attachToRole": "vessel_shell", + "anchor": "service_side" + } + ], + "primarySemanticRole": "vessel_shell", + "qualityRules": "quality.process.product_storage_tank", + "visualCues": [ + "finished product storage tank", + "vertical cylinder", + "outlet nozzle", + "ladder" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.packaging_station", + "name": "Process packaging station", + "aliases": [ + "包装工位", + "包装站", + "包装机", + "process packaging station", + "packaging station" + ], + "industry": "process", + "layoutFamily": "linear_transport_layout", + "family": "conveyor", + "defaultDimensions": { + "length": 3.2, + "width": 1.2, + "height": 1.6 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "packaging_conveyor", + "required": true, + "length": 3, + "width": 0.9, + "height": 0.55 + }, + { + "kind": "rounded_machine_body", + "semanticRole": "packaging_body", + "required": true, + "attachToRole": "packaging_conveyor", + "anchor": "top", + "length": 1.1, + "width": 0.95, + "height": 0.9, + "color": "#e5e7eb" + }, + { + "kind": "control_box", + "semanticRole": "packaging_control_box", + "required": true, + "attachToRole": "packaging_body", + "anchor": "service_side" + }, + { + "kind": "roller_array", + "semanticRole": "discharge_rollers", + "attachToRole": "packaging_conveyor", + "anchor": "back", + "count": 5 + } + ], + "primarySemanticRole": "packaging_conveyor", + "qualityRules": "quality.process.packaging_station", + "visualCues": [ + "packaging conveyor", + "packing body", + "control box", + "discharge rollers" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "process.pipe_corridor", + "name": "Process pipe corridor", + "aliases": [ + "工艺管廊", + "管廊", + "流程管廊", + "process pipe corridor", + "pipe rack" + ], + "industry": "process", + "layoutFamily": "pipe_valve_layout", + "family": "pipe_system", + "defaultDimensions": { + "length": 9, + "width": 1, + "height": 2.2 + }, + "parts": [ + { + "kind": "pipe_run", + "semanticRole": "process_pipe", + "required": true, + "length": 9, + "radius": 0.06, + "axis": "x" + }, + { + "kind": "pipe_run", + "semanticRole": "utility_pipe", + "required": true, + "position": [ + 0, + 0.18, + 0.16 + ], + "length": 8.8, + "radius": 0.045, + "axis": "x" + }, + { + "kind": "pipe_elbow", + "semanticRole": "pipe_elbow", + "required": true, + "attachToRole": "process_pipe", + "anchor": "right" + }, + { + "kind": "valve_body", + "semanticRole": "valve_body", + "required": true, + "attachToRole": "process_pipe", + "anchor": "service_side" + }, + { + "kind": "flange_ring", + "semanticRole": "pipe_flange", + "attachToRole": "process_pipe", + "anchor": "left" + } + ], + "primarySemanticRole": "process_pipe", + "qualityRules": "quality.process.pipe_corridor", + "visualCues": [ + "long process pipe", + "parallel utility pipe", + "valve", + "flange" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "process.control_cabinet", + "name": "Process control cabinet", + "aliases": [ + "控制柜", + "电控柜", + "PLC控制柜", + "process control cabinet", + "control cabinet" + ], + "industry": "process", + "layoutFamily": "box_enclosure_layout", + "family": "electrical", + "defaultDimensions": { + "length": 2, + "width": 0.55, + "height": 2 + }, + "parts": [ + { + "kind": "electrical_cabinet", + "semanticRole": "control_cabinet", + "required": true, + "length": 1.9, + "width": 0.5, + "height": 1.9 + }, + { + "kind": "generic_display", + "semanticRole": "hmi_panel", + "required": true, + "attachToRole": "control_cabinet", + "anchor": "front" + }, + { + "kind": "cable_tray", + "semanticRole": "cable_tray", + "required": true, + "attachToRole": "control_cabinet", + "anchor": "top" + }, + { + "kind": "warning_label", + "semanticRole": "electrical_warning_label", + "attachToRole": "control_cabinet", + "anchor": "front" + } + ], + "primarySemanticRole": "control_cabinet", + "qualityRules": "quality.process.control_cabinet", + "visualCues": [ + "electrical cabinet", + "HMI display", + "cable tray", + "warning label" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "process.bulk_material_silo", + "name": "Bulk material silo", + "aliases": [ + "粉体料仓", + "料仓", + "粉仓", + "bulk material silo", + "powder silo" + ], + "industry": "process", + "layoutFamily": "vessel_layout", + "family": "tank", + "defaultDimensions": { + "length": 2.2, + "width": 2.2, + "height": 5 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "silo_shell", + "required": true, + "radius": 0.9, + "height": 3.2, + "color": "#cbd5e1" + }, + { + "kind": "hopper_body", + "semanticRole": "conical_hopper_bottom", + "required": true, + "attachToRole": "silo_shell", + "anchor": "bottom", + "color": "#bfc7d5" + }, + { + "kind": "structural_tower_frame", + "semanticRole": "silo_support_frame", + "required": true, + "attachToRole": "silo_shell", + "anchor": "bottom", + "height": 1.4, + "levelCount": 2 + }, + { + "kind": "generic_spout", + "semanticRole": "silo_discharge_spout", + "required": true, + "attachToRole": "conical_hopper_bottom", + "anchor": "bottom" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "level_indicator", + "attachToRole": "silo_shell", + "anchor": "service_side" + } + ], + "primarySemanticRole": "silo_shell", + "qualityRules": "quality.process.bulk_material_silo", + "visualCues": [ + "tall silo", + "cylindrical bin", + "conical hopper bottom", + "support frame" + ], + "status": "stable", + "source": "imported_pack", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "process.valve_station", + "name": "Valve manifold station", + "aliases": [ + "阀组站", + "阀组", + "阀门站", + "valve manifold", + "valve station" + ], + "industry": "process", + "layoutFamily": "pipe_valve_layout", + "family": "pipe_system", + "defaultDimensions": { + "length": 2.6, + "width": 1, + "height": 1.2 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "valve_station_skid", + "required": true, + "length": 2.5, + "width": 0.9, + "height": 0.16 + }, + { + "kind": "pipe_run", + "semanticRole": "manifold_pipe", + "required": true, + "attachToRole": "valve_station_skid", + "anchor": "top", + "length": 2.4, + "radius": 0.055, + "axis": "x" + }, + { + "kind": "valve_body", + "semanticRole": "valve_body", + "required": true, + "attachToRole": "manifold_pipe", + "anchor": "service_side" + }, + { + "kind": "valve_body", + "semanticRole": "valve_body", + "required": true, + "attachToRole": "manifold_pipe", + "anchor": "right" + }, + { + "kind": "flange_ring", + "semanticRole": "manifold_flange", + "attachToRole": "manifold_pipe", + "anchor": "left" + } + ], + "primarySemanticRole": "manifold_pipe", + "qualityRules": "quality.process.valve_station", + "visualCues": [ + "pipe manifold", + "multiple valves", + "flanges", + "compact skid" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "process.utility_blower", + "name": "Utility blower", + "aliases": [ + "公用鼓风机", + "鼓风机", + "工业鼓风机", + "utility blower", + "industrial blower" + ], + "industry": "process", + "layoutFamily": "rotating_machine_layout", + "family": "fan", + "defaultDimensions": { + "length": 2, + "width": 1.2, + "height": 1.4 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "blower_skid_base", + "required": true, + "length": 2, + "width": 1, + "height": 0.18 + }, + { + "kind": "volute_casing", + "semanticRole": "blower_casing", + "required": true, + "attachToRole": "blower_skid_base", + "anchor": "top" + }, + { + "kind": "impeller_blades", + "semanticRole": "blower_impeller", + "required": true, + "attachToRole": "blower_casing", + "anchor": "shell_center", + "bladeCount": 8 + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "drive_motor", + "required": true, + "attachToRole": "blower_casing", + "anchor": "drive_side" + }, + { + "kind": "inlet_port", + "semanticRole": "air_inlet", + "attachToRole": "blower_casing", + "anchor": "front" + }, + { + "kind": "outlet_port", + "semanticRole": "air_outlet", + "required": true, + "attachToRole": "blower_casing", + "anchor": "top" + } + ], + "primarySemanticRole": "blower_casing", + "qualityRules": "quality.process.utility_blower", + "visualCues": [ + "volute blower casing", + "impeller", + "drive motor", + "air outlet" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + }, + { + "id": "process.air_compressor_skid", + "name": "Air compressor skid", + "aliases": [ + "空压机撬", + "压缩空气撬", + "空气压缩机撬", + "air compressor skid", + "compressed air skid" + ], + "industry": "process", + "layoutFamily": "rotating_machine_layout", + "family": "compressor", + "defaultDimensions": { + "length": 2.8, + "width": 1.1, + "height": 1.3 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "compressor_skid_base", + "required": true, + "length": 2.7, + "width": 1, + "height": 0.18 + }, + { + "kind": "rounded_machine_body", + "semanticRole": "compressor_body", + "required": true, + "attachToRole": "compressor_skid_base", + "anchor": "top", + "length": 1.2, + "width": 0.55, + "height": 0.65, + "color": "#d1d5db" + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "drive_motor", + "required": true, + "attachToRole": "compressor_body", + "anchor": "drive_side" + }, + { + "kind": "pipe_manifold", + "semanticRole": "air_pipe_manifold", + "required": true, + "attachToRole": "compressor_body", + "anchor": "front" + }, + { + "kind": "control_box", + "semanticRole": "compressor_control_box", + "attachToRole": "compressor_skid_base", + "anchor": "service_side" + } + ], + "primarySemanticRole": "compressor_body", + "qualityRules": "quality.process.air_compressor_skid", + "visualCues": [ + "compressor skid", + "rounded compressor body", + "ribbed motor", + "air pipe manifold" + ], + "status": "stable", + "source": "imported_pack", + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.process.basic-0.1.0/quality-rules/generated-quality.json b/cloud/industry.process.basic-0.1.0/quality-rules/generated-quality.json new file mode 100644 index 000000000..1fcc9b409 --- /dev/null +++ b/cloud/industry.process.basic-0.1.0/quality-rules/generated-quality.json @@ -0,0 +1,224 @@ +[ + { + "id": "quality.process.raw_material_tank", + "requiredRoles": [ + "vessel_shell", + "support_skid", + "top_inlet", + "bottom_outlet", + "access_ladder" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.process.metering_pump_skid", + "requiredRoles": [ + "pump_volute", + "pump_skid_base", + "drive_motor", + "pump_pipe_manifold", + "local_control_panel" + ], + "shapeCount": { + "min": 5, + "max": 50 + } + }, + { + "id": "quality.process.mixing_tank", + "requiredRoles": [ + "mixing_vessel_shell", + "feed_inlet", + "mixing_outlet", + "support_skid", + "access_ladder" + ], + "shapeCount": { + "min": 5, + "max": 65 + } + }, + { + "id": "quality.process.stirred_reactor", + "requiredRoles": [ + "reactor_vessel_shell", + "reactor_feed_nozzle", + "reactor_discharge_nozzle", + "reactor_pipe_manifold", + "service_platform_ladder" + ], + "shapeCount": { + "min": 5, + "max": 75 + } + }, + { + "id": "quality.process.heat_exchanger", + "requiredRoles": [ + "heat_exchanger_shell", + "exchanger_saddle_skid", + "process_inlet", + "process_outlet", + "utility_pipe_manifold" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.process.filter_vessel", + "requiredRoles": [ + "filter_vessel_shell", + "filter_support_skid", + "filter_inlet", + "filter_outlet", + "filter_pipe_manifold" + ], + "shapeCount": { + "min": 5, + "max": 50 + } + }, + { + "id": "quality.process.centrifuge", + "requiredRoles": [ + "centrifuge_bowl_housing", + "centrifuge_skid_base", + "centrifuge_drive_motor", + "centrifuge_discharge", + "centrifuge_operator_panel" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.process.tray_dryer", + "requiredRoles": [ + "dryer_chamber", + "dryer_base", + "front_access_door", + "vacuum_pipe_manifold", + "dryer_control_panel" + ], + "shapeCount": { + "min": 5, + "max": 50 + } + }, + { + "id": "quality.process.product_storage_tank", + "requiredRoles": [ + "vessel_shell", + "support_skid", + "product_inlet", + "product_outlet", + "access_ladder" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.process.packaging_station", + "requiredRoles": [ + "packaging_conveyor", + "packaging_body", + "packaging_control_box", + "discharge_rollers" + ], + "shapeCount": { + "min": 4, + "max": 50 + } + }, + { + "id": "quality.process.pipe_corridor", + "requiredRoles": [ + "process_pipe", + "utility_pipe", + "pipe_elbow", + "valve_body", + "pipe_flange" + ], + "shapeCount": { + "min": 5, + "max": 60 + } + }, + { + "id": "quality.process.control_cabinet", + "requiredRoles": [ + "control_cabinet", + "hmi_panel", + "cable_tray", + "electrical_warning_label" + ], + "shapeCount": { + "min": 4, + "max": 35 + } + }, + { + "id": "quality.process.bulk_material_silo", + "requiredRoles": [ + "silo_shell", + "conical_hopper_bottom", + "silo_support_frame", + "silo_discharge_spout", + "level_indicator" + ], + "shapeCount": { + "min": 5, + "max": 65 + } + }, + { + "id": "quality.process.valve_station", + "requiredRoles": [ + "manifold_pipe", + "valve_station_skid", + "valve_body", + "manifold_flange" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + }, + { + "id": "quality.process.utility_blower", + "requiredRoles": [ + "blower_casing", + "blower_skid_base", + "blower_impeller", + "drive_motor", + "air_inlet", + "air_outlet" + ], + "shapeCount": { + "min": 6, + "max": 60 + } + }, + { + "id": "quality.process.air_compressor_skid", + "requiredRoles": [ + "compressor_body", + "compressor_skid_base", + "drive_motor", + "air_pipe_manifold", + "compressor_control_box" + ], + "shapeCount": { + "min": 5, + "max": 55 + } + } +] diff --git a/cloud/industry.refinery.basic-0.1.0.zip b/cloud/industry.refinery.basic-0.1.0.zip new file mode 100644 index 000000000..c7ce37a2d Binary files /dev/null and b/cloud/industry.refinery.basic-0.1.0.zip differ diff --git a/cloud/industry.refinery.basic-0.1.0/README.md b/cloud/industry.refinery.basic-0.1.0/README.md new file mode 100644 index 000000000..89bd287a7 --- /dev/null +++ b/cloud/industry.refinery.basic-0.1.0/README.md @@ -0,0 +1,52 @@ +# 炼油厂基础行业包 + +面向一句话创建炼油厂的基础行业包,覆盖原油罐区、常减压、转化/加氢、硫回收、火炬、公用工程和产品罐区。 + +生成后的炼油厂以 Equipment、Data 和 Source 视角为主:用户查看设备能力、编辑语义化部件、绑定实时数据并观察动态变化。内部工艺模板只用于 AI 编排整厂生成,不再在画布上展示全量流程连线。 + +## Devices + +- Crude storage tank (refinery.crude_storage_tank) +- Intermediate storage tank (refinery.intermediate_storage_tank) +- Product storage tank (refinery.product_storage_tank) +- Crude desalter (refinery.desalter) +- Atmospheric distillation unit (refinery.atmospheric_distillation_unit) +- Vacuum distillation unit (refinery.vacuum_distillation_unit) +- Fluid catalytic cracking unit (refinery.fluid_catalytic_cracking_unit) +- Hydrotreating unit (refinery.hydrotreating_unit) +- Catalytic reformer unit (refinery.catalytic_reformer_unit) +- Sulfur recovery unit (refinery.sulfur_recovery_unit) +- Flare system (refinery.flare_system) +- Main pipe rack (refinery.pipe_rack) +- Utility boiler (refinery.utility_boiler) +- Control room and MCC (refinery.control_room) + +## Pack Type + +Factory-capable pack: supports one-prompt refinery creation through equipment bindings, internal generation templates, and factory architectures. + +## Factory Creation + +Supported whole-factory generation templates: + +- Basic oil refinery complex (refinery_basic_complex) + +Supported factory scopes/modules: + +- 基础炼油厂 (basic_refinery) + +## Factory Architectures + +- 炼油厂基础架构 + +## Internal Generation Templates + +- Basic oil refinery complex + +## Validation + +Run: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.refinery.basic@0.1.0 --validate-only +``` diff --git a/cloud/industry.refinery.basic-0.1.0/factory-architectures/generated.json b/cloud/industry.refinery.basic-0.1.0/factory-architectures/generated.json new file mode 100644 index 000000000..32dd34568 --- /dev/null +++ b/cloud/industry.refinery.basic-0.1.0/factory-architectures/generated.json @@ -0,0 +1,222 @@ +[ + { + "id": "refinery.factory.basic_refinery", + "label": "炼油厂基础架构", + "industry": "refinery", + "processId": "refinery_basic_complex", + "layoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 50, + "width": 46 + }, + "scopes": [ + { + "id": "basic_refinery", + "label": "基础炼油厂", + "aliases": [ + "炼油厂", + "完整炼油厂", + "basic refinery", + "oil refinery" + ], + "includeModules": [ + "crude_tank_farm", + "primary_distillation", + "intermediate_tank_farm", + "residue_conversion", + "conversion_treating", + "product_offsites", + "utilities_safety" + ] + }, + { + "id": "cdu_only", + "label": "常减压装置", + "aliases": [ + "常减压", + "常减压装置", + "CDU", + "CDU VDU", + "crude distillation only" + ], + "includeModules": [ + "crude_tank_farm", + "primary_distillation", + "intermediate_tank_farm", + "product_offsites" + ] + }, + { + "id": "conversion_only", + "label": "转化和加氢区", + "aliases": [ + "转化区", + "FCC和加氢", + "conversion area", + "conversion units" + ], + "includeModules": [ + "intermediate_tank_farm", + "residue_conversion", + "conversion_treating", + "product_offsites" + ] + }, + { + "id": "tank_farm", + "label": "炼油罐区", + "aliases": [ + "罐区", + "油罐区", + "tank farm", + "refinery tank farm" + ], + "includeModules": [ + "crude_tank_farm", + "intermediate_tank_farm", + "product_offsites" + ] + } + ], + "modules": [ + { + "id": "crude_tank_farm", + "displayLabel": "原油罐区", + "order": 10, + "stationIds": [ + "crude_storage_tank", + "desalter" + ] + }, + { + "id": "primary_distillation", + "displayLabel": "常减压蒸馏", + "order": 20, + "stationIds": [ + "atmospheric_distillation_unit", + "vacuum_distillation_unit" + ] + }, + { + "id": "intermediate_tank_farm", + "displayLabel": "中间料罐区", + "order": 25, + "stationIds": [ + "intermediate_storage_tank" + ] + }, + { + "id": "residue_conversion", + "displayLabel": "渣油转化", + "order": 28, + "stationIds": [ + "delayed_coker_unit" + ] + }, + { + "id": "conversion_treating", + "displayLabel": "转化与加氢", + "order": 30, + "stationIds": [ + "fluid_catalytic_cracking_unit", + "gas_fractionation_unit", + "hydrotreating_unit", + "catalytic_reformer_unit" + ] + }, + { + "id": "product_offsites", + "displayLabel": "成品与外操", + "order": 40, + "stationIds": [ + "product_storage_tank", + "pipe_rack" + ] + }, + { + "id": "utilities_safety", + "displayLabel": "公用工程与安全", + "order": 50, + "stationIds": [ + "sulfur_recovery_unit", + "flare_system", + "utility_boiler", + "control_room" + ] + } + ], + "layoutHints": { + "primaryAxis": "x", + "secondaryAxis": "z", + "indoorWorkshop": false, + "outdoorHeavyIndustry": true, + "highestStationId": "flare_system", + "longAxisStationId": "pipe_rack", + "scale": "conceptual", + "omitPerimeterWalls": true, + "interStationGap": 1.5, + "interModuleGap": 2.5, + "packingStyle": "dense", + "zones": [ + { + "id": "zone_crude", + "label": "原油罐区", + "normalizedX": [0.0, 0.28], + "normalizedZ": [0.0, 0.5], + "stationIds": ["crude_storage_tank", "desalter"] + }, + { + "id": "zone_distillation", + "label": "常减压蒸馏", + "normalizedX": [0.28, 0.56], + "normalizedZ": [0.0, 0.55], + "stationIds": ["atmospheric_distillation_unit", "vacuum_distillation_unit"] + }, + { + "id": "zone_conversion", + "label": "转化与加氢区", + "normalizedX": [0.28, 0.72], + "normalizedZ": [0.5, 1.0], + "stationIds": ["fluid_catalytic_cracking_unit", "gas_fractionation_unit", "hydrotreating_unit", "catalytic_reformer_unit", "delayed_coker_unit"] + }, + { + "id": "zone_intermediate", + "label": "中间料罐区", + "normalizedX": [0.55, 0.75], + "normalizedZ": [0.0, 0.5], + "stationIds": ["intermediate_storage_tank"] + }, + { + "id": "zone_product", + "label": "成品罐区", + "normalizedX": [0.72, 1.0], + "normalizedZ": [0.5, 1.0], + "stationIds": ["product_storage_tank"] + }, + { + "id": "zone_utilities", + "label": "公用工程与安全", + "normalizedX": [0.75, 1.0], + "normalizedZ": [0.0, 0.5], + "stationIds": ["sulfur_recovery_unit", "utility_boiler", "control_room", "flare_system"] + }, + { + "id": "zone_pipe_rack", + "label": "主管廊", + "normalizedX": [0.0, 1.0], + "normalizedZ": [0.45, 0.55], + "stationIds": ["pipe_rack"] + } + ], + "notes": [ + "CRITICAL: This is a dense industrial plant — all modules must be packed tightly together, interStationGap=1.5, NOT spread across empty ground.", + "Pipe rack runs east-west through the Z-center of the plant connecting all zones; all process units connect laterally to the pipe rack.", + "CDU/VDU are the tallest process columns and the visual centerpiece — place them prominently in the center-north zone.", + "FCC reactor-regenerator is the largest equipment cluster — place it in center-south zone adjacent to pipe rack.", + "Crude tank farm and product tank farm are on opposite ends of the plant (west and east respectively).", + "Flare stack is at the far east or south-east corner for safety separation — it must be clearly separated from process units.", + "Do NOT leave large empty spaces between stations — each station should be within 2 units of its nearest neighbor." + ] + } + } +] diff --git a/cloud/industry.refinery.basic-0.1.0/pack.json b/cloud/industry.refinery.basic-0.1.0/pack.json new file mode 100644 index 000000000..5d53e7761 --- /dev/null +++ b/cloud/industry.refinery.basic-0.1.0/pack.json @@ -0,0 +1,241 @@ +{ + "id": "industry.refinery.basic", + "name": "炼油厂基础行业包", + "industry": "refinery", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "面向一句话创建炼油厂的基础行业包,覆盖原油罐区、常减压、转化/加氢、硫回收、火炬、公用工程和产品罐区;生成结果面向 Equipment/Data/Source 视角使用,工艺模板仅作为 AI 生成编排依据。", + "profiles": [ + "profiles/generated.json" + ], + "qualityRules": [ + "quality-rules/generated-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/generated.json" + ], + "processTemplates": [ + "process-templates/generated.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "refinery.crude_storage_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "refinery.product_storage_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "refinery.intermediate_storage_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "refinery.desalter", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "refinery.atmospheric_distillation_unit", + "recipeId": "factory:distillation-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.columnKind": "columnKind" + }, + "portMap": { + "desalted_crude_in": "feed_inlet", + "naphtha_distillate_out": "overhead_product_outlet", + "middle_distillate_out": "overhead_product_outlet", + "atmospheric_residue_out": "bottoms_outlet" + } + }, + { + "profileId": "refinery.vacuum_distillation_unit", + "recipeId": "factory:distillation-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.columnKind": "columnKind" + }, + "portMap": { + "atmospheric_residue_in": "bottoms_outlet", + "vacuum_gas_oil_out": "overhead_product_outlet", + "vacuum_residue_out": "bottoms_outlet", + "vacuum_overhead_out": "overhead_product_outlet" + } + }, + { + "profileId": "refinery.fluid_catalytic_cracking_unit", + "recipeId": "factory:refinery-reactor-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.variant": "variant" + }, + "portMap": { + "vacuum_gas_oil_in": "vacuum_gas_oil_in", + "cracked_product_out": "cracked_product_out", + "rich_gas_out": "rich_gas_out", + "flue_gas_out": "cracked_product_out" + } + }, + { + "profileId": "refinery.hydrotreating_unit", + "recipeId": "factory:refinery-reactor-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.variant": "variant" + }, + "portMap": { + "hydrotreater_feed_in": "naphtha_in", + "hydrogen_in": "naphtha_in", + "treated_product_out": "reformate_out", + "acid_gas_out": "reformate_out" + } + }, + { + "profileId": "refinery.catalytic_reformer_unit", + "recipeId": "factory:refinery-reactor-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.variant": "variant" + }, + "portMap": { + "naphtha_in": "naphtha_in", + "reformate_out": "reformate_out", + "hydrogen_rich_gas_out": "reformate_out" + } + }, + { + "profileId": "refinery.sulfur_recovery_unit", + "recipeId": "factory:refinery-reactor-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.variant": "variant" + }, + "portMap": { + "acid_gas_in": "acid_gas_in", + "sulfur_out": "sulfur_out" + } + }, + { + "profileId": "refinery.flare_system", + "recipeId": "factory:refinery-auxiliary-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.variant": "variant" + }, + "portMap": { + "relief_gas_in": "relief_gas_in", + "flare_tip": "flare_tip" + } + }, + { + "profileId": "refinery.pipe_rack", + "recipeId": "factory:refinery-auxiliary-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.variant": "variant" + }, + "portMap": { + "rack_in": "rack_in", + "rack_out": "rack_out" + } + }, + { + "profileId": "refinery.utility_boiler", + "recipeId": "factory:refinery-auxiliary-unit", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "equipmentDefaults.variant": "variant" + }, + "portMap": { + "fuel_gas_in": "fuel_gas_in", + "steam_out": "steam_out" + } + } + ] +} diff --git a/cloud/industry.refinery.basic-0.1.0/process-templates/generated.json b/cloud/industry.refinery.basic-0.1.0/process-templates/generated.json new file mode 100644 index 000000000..916707891 --- /dev/null +++ b/cloud/industry.refinery.basic-0.1.0/process-templates/generated.json @@ -0,0 +1,472 @@ +[ + { + "processId": "refinery_basic_complex", + "processLabel": "Basic oil refinery complex", + "processDisplayLabel": "基础炼油厂", + "domain": "chemical", + "aliases": [ + "炼油厂", + "原油炼油厂", + "石油炼化厂", + "炼油装置", + "生成一个炼油厂", + "做一个炼油厂", + "创建一个炼油厂", + "oil refinery", + "refinery plant", + "basic refinery complex" + ], + "requiredRoles": [ + "crude_storage_tank", + "desalter", + "atmospheric_distillation_unit", + "vacuum_distillation_unit", + "delayed_coker_unit", + "intermediate_storage_tank", + "fluid_catalytic_cracking_unit", + "gas_fractionation_unit", + "hydrotreating_unit", + "catalytic_reformer_unit", + "sulfur_recovery_unit", + "flare_system", + "product_storage_tank", + "pipe_rack", + "utility_boiler", + "control_room" + ], + "defaultLayoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 46, + "width": 42 + }, + "safetyTags": [ + "flammable", + "high_temperature", + "pressure_equipment", + "process_pipe" + ], + "stations": [ + { + "id": "crude_storage_tank", + "label": "Crude storage tank farm", + "displayLabel": "原油罐区", + "role": "crude_storage_tank", + "equipmentHint": "refinery.crude_storage_tank large crude oil tank farm: several vertical cylindrical storage tanks with floating-roof impression, inlet/outlet nozzles, access ladders, containment/bund area, and crude transfer pipes feeding the desalter; offsite storage, not a process reactor.", + "footprintHint": "large", + "safetyTags": [ + "storage", + "flammable" + ], + "profileId": "refinery.crude_storage_tank" + }, + { + "id": "desalter", + "label": "Crude desalter", + "displayLabel": "电脱盐器", + "role": "desalter", + "equipmentHint": "refinery.desalter horizontal electrostatic crude desalter vessel on skid: long horizontal pressure vessel, crude inlet, desalted crude outlet, brine/water drain, transformer/control box, and short pipe manifold upstream of atmospheric distillation.", + "footprintHint": "medium", + "safetyTags": [ + "pressure_vessel", + "electrical" + ], + "profileId": "refinery.desalter" + }, + { + "id": "atmospheric_distillation_unit", + "label": "Atmospheric distillation unit", + "displayLabel": "常压蒸馏塔", + "role": "atmospheric_distillation_unit", + "equipmentHint": "refinery.atmospheric_distillation_unit crude CDU: tall atmospheric fractionation column with tray bands and side draw manifolds, fired heater and preheat exchangers at base, overhead naphtha outlet, middle distillate side draws, and bottom atmospheric residue outlet to VDU.", + "footprintHint": "tall", + "safetyTags": [ + "distillation", + "high_temperature" + ], + "profileId": "refinery.atmospheric_distillation_unit" + }, + { + "id": "vacuum_distillation_unit", + "label": "Vacuum distillation unit", + "displayLabel": "减压蒸馏塔", + "role": "vacuum_distillation_unit", + "equipmentHint": "refinery.vacuum_distillation_unit VDU: wide vacuum tower processing atmospheric residue with vacuum heater, overhead vacuum line/condenser, VGO side outlet to FCC, and bottom vacuum residue outlet to delayed coker/residue conversion.", + "footprintHint": "tall", + "safetyTags": [ + "distillation", + "vacuum" + ], + "profileId": "refinery.vacuum_distillation_unit" + }, + { + "id": "delayed_coker_unit", + "label": "Delayed coker unit", + "displayLabel": "延迟焦化装置", + "role": "delayed_coker_unit", + "equipmentHint": "refinery.delayed_coker_unit delayed coker for vacuum residue: paired tall coke drums, coker fractionator, fired heater, overhead vapor line, coke handling base, vacuum residue inlet from VDU bottom and coker distillate outlet back to treating/product handling.", + "footprintHint": "large", + "safetyTags": [ + "residue_conversion", + "high_temperature" + ], + "profileId": "refinery.delayed_coker_unit" + }, + { + "id": "intermediate_storage_tank", + "label": "Intermediate storage tank farm", + "displayLabel": "中间料罐区", + "role": "intermediate_storage_tank", + "equipmentHint": "refinery.intermediate_storage_tank intermediate tankage for straight-run naphtha, kerosene/diesel, VGO and blend components; buffer tank farm between distillation, FCC, hydrotreating, reformer and product blending, not a vacuum residue disposal route.", + "footprintHint": "large", + "safetyTags": [ + "storage", + "flammable", + "intermediate" + ], + "profileId": "refinery.intermediate_storage_tank" + }, + { + "id": "fluid_catalytic_cracking_unit", + "label": "Fluid catalytic cracking unit", + "displayLabel": "催化裂化装置", + "role": "fluid_catalytic_cracking_unit", + "equipmentHint": "refinery.fluid_catalytic_cracking_unit FCC complex: reactor-regenerator pair, riser, catalyst standpipes, regenerator cyclone/stack and main fractionator. VGO feed enters FCC; cracked gasoline/LCO/slurry go to product/intermediate handling; FCC rich gas goes to gas fractionation/LPG recovery, not to flare in normal operation.", + "footprintHint": "large", + "safetyTags": [ + "conversion", + "high_temperature" + ], + "profileId": "refinery.fluid_catalytic_cracking_unit" + }, + { + "id": "gas_fractionation_unit", + "label": "Gas fractionation and LPG recovery", + "displayLabel": "气体分馏装置", + "role": "gas_fractionation_unit", + "equipmentHint": "refinery.gas_fractionation_unit LPG recovery/gas fractionation downstream of FCC rich gas: deethanizer/debutanizer columns, LPG surge drum, overhead condenser, rich gas feed header, dry gas outlet and LPG product outlet.", + "footprintHint": "large", + "safetyTags": [ + "gas_processing", + "flammable" + ], + "profileId": "refinery.gas_fractionation_unit" + }, + { + "id": "hydrotreating_unit", + "label": "Hydrotreating unit", + "displayLabel": "加氢精制装置", + "role": "hydrotreating_unit", + "equipmentHint": "refinery.hydrotreating_unit high-pressure hydrotreater/HDS reactor train: vertical reactor, feed-effluent exchanger, high-pressure separator, visible hydrogen makeup header from catalytic reformer hydrogen, treated product outlet, and acid gas outlet to sulfur recovery.", + "footprintHint": "large", + "safetyTags": [ + "hydrogen", + "pressure_vessel" + ], + "profileId": "refinery.hydrotreating_unit" + }, + { + "id": "catalytic_reformer_unit", + "label": "Catalytic reformer", + "displayLabel": "催化重整装置", + "role": "catalytic_reformer_unit", + "equipmentHint": "refinery.catalytic_reformer_unit catalytic reformer/platformer: naphtha reactor train with fired heaters/interheaters, heat exchanger, separator, reformate product outlet to gasoline blending, and hydrogen-rich gas header supplying hydrotreating units.", + "footprintHint": "large", + "safetyTags": [ + "reformer", + "hydrogen" + ], + "profileId": "refinery.hydrotreating_unit" + }, + { + "id": "sulfur_recovery_unit", + "label": "Sulfur recovery unit", + "displayLabel": "硫回收装置", + "role": "sulfur_recovery_unit", + "equipmentHint": "refinery.sulfur_recovery_unit Claus sulfur recovery: thermal reactor, sulfur condensers, acid gas inlet from hydrotreaters, sulfur outlet, and tail gas stack for sour gas treatment.", + "footprintHint": "medium", + "safetyTags": [ + "sour_gas", + "emissions" + ], + "profileId": "refinery.hydrotreating_unit" + }, + { + "id": "flare_system", + "label": "Flare system", + "displayLabel": "火炬系统", + "role": "flare_system", + "equipmentHint": "refinery.flare_system emergency safety flare only: tall flare stack with knockout drum and relief gas header. It is for emergency/relief disposal, never the normal outlet for FCC rich gas or saleable LPG.", + "footprintHint": "tall", + "safetyTags": [ + "relief", + "flare" + ], + "profileId": "refinery.flare_system" + }, + { + "id": "product_storage_tank", + "label": "Product storage tank farm", + "displayLabel": "成品罐区", + "role": "product_storage_tank", + "equipmentHint": "refinery.product_storage_tank product tank farm for gasoline, diesel, LPG/reformate/blend components with vertical tanks, loading outlet manifold, access ladders, and containment area.", + "footprintHint": "large", + "safetyTags": [ + "storage", + "flammable" + ], + "profileId": "refinery.product_storage_tank" + }, + { + "id": "pipe_rack", + "label": "Main pipe rack", + "displayLabel": "主管廊", + "role": "pipe_rack", + "equipmentHint": "refinery.pipe_rack long elevated refinery pipe rack with multiple parallel process pipes, support frames, cable tray, and branch takeoffs connecting major units without hiding equipment.", + "footprintHint": "long", + "safetyTags": [ + "process_pipe", + "support" + ], + "profileId": "refinery.pipe_rack" + }, + { + "id": "utility_boiler", + "label": "Utility boiler", + "displayLabel": "公用工程锅炉", + "role": "utility_boiler", + "equipmentHint": "refinery.utility_boiler packaged utility boiler with rectangular furnace casing, steam drum, tube bank, steam header, stack, access platform, and control box", + "footprintHint": "medium", + "safetyTags": [ + "steam", + "utilities" + ], + "profileId": "refinery.utility_boiler" + }, + { + "id": "control_room", + "label": "Control room", + "displayLabel": "中控室", + "role": "control_room", + "equipmentHint": "refinery.control_room single-storey blast-resistant control building with walls, flat roof, blast door, windows, MCC cabinets, and cable-tray entry; place away from flare and high temperature units", + "footprintHint": "medium", + "safetyTags": [ + "control", + "occupied_building" + ], + "profileId": "refinery.control_room" + } + ], + "connections": [ + { + "fromStationId": "crude_storage_tank", + "toStationId": "desalter", + "medium": "material", + "visualKind": "pipe", + "label": "原油进料", + "fromPortId": "product_outlet", + "toPortId": "crude_inlet" + }, + { + "fromStationId": "desalter", + "toStationId": "atmospheric_distillation_unit", + "medium": "material", + "visualKind": "pipe", + "label": "脱盐原油", + "fromPortId": "desalted_crude_outlet", + "toPortId": "desalted_crude_in" + }, + { + "fromStationId": "atmospheric_distillation_unit", + "toStationId": "vacuum_distillation_unit", + "medium": "material", + "visualKind": "pipe", + "label": "常压渣油", + "fromPortId": "atmospheric_residue_out", + "toPortId": "atmospheric_residue_in" + }, + { + "fromStationId": "atmospheric_distillation_unit", + "toStationId": "intermediate_storage_tank", + "medium": "material", + "visualKind": "pipe", + "label": "石脑油/柴油等直馏馏分", + "fromPortId": "naphtha_distillate_out", + "toPortId": "distillate_inlet" + }, + { + "fromStationId": "vacuum_distillation_unit", + "toStationId": "fluid_catalytic_cracking_unit", + "medium": "material", + "visualKind": "pipe", + "label": "减压蜡油 VGO", + "fromPortId": "vacuum_gas_oil_out", + "toPortId": "vacuum_gas_oil_in" + }, + { + "fromStationId": "vacuum_distillation_unit", + "toStationId": "delayed_coker_unit", + "medium": "material", + "visualKind": "pipe", + "label": "减压渣油 VR", + "fromPortId": "vacuum_residue_out", + "toPortId": "vacuum_residue_in" + }, + { + "fromStationId": "delayed_coker_unit", + "toStationId": "hydrotreating_unit", + "medium": "material", + "visualKind": "pipe", + "label": "焦化汽柴油/馏分油加氢", + "fromPortId": "coker_distillate_out", + "toPortId": "hydrotreater_feed_in" + }, + { + "fromStationId": "intermediate_storage_tank", + "toStationId": "hydrotreating_unit", + "medium": "material", + "visualKind": "pipe", + "label": "直馏柴油/石脑油加氢进料", + "fromPortId": "conversion_feed_outlet", + "toPortId": "hydrotreater_feed_in" + }, + { + "fromStationId": "intermediate_storage_tank", + "toStationId": "catalytic_reformer_unit", + "medium": "material", + "visualKind": "pipe", + "label": "加氢后石脑油/重整进料", + "fromPortId": "conversion_feed_outlet", + "toPortId": "naphtha_in" + }, + { + "fromStationId": "fluid_catalytic_cracking_unit", + "toStationId": "gas_fractionation_unit", + "medium": "gas", + "visualKind": "pipe", + "label": "FCC富气至气分/LPG回收", + "fromPortId": "rich_gas_out", + "toPortId": "fcc_rich_gas_in" + }, + { + "fromStationId": "fluid_catalytic_cracking_unit", + "toStationId": "product_storage_tank", + "medium": "material", + "visualKind": "pipe", + "label": "FCC汽油/LCO/油浆组分", + "fromPortId": "cracked_product_out", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "gas_fractionation_unit", + "toStationId": "product_storage_tank", + "medium": "material", + "visualKind": "pipe", + "label": "LPG/丙烯/丁烷产品", + "fromPortId": "lpg_product_out", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "hydrotreating_unit", + "toStationId": "product_storage_tank", + "medium": "material", + "visualKind": "pipe", + "label": "加氢产品", + "fromPortId": "treated_product_out", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "catalytic_reformer_unit", + "toStationId": "product_storage_tank", + "medium": "material", + "visualKind": "pipe", + "label": "重整油", + "fromPortId": "reformate_out", + "toPortId": "feed_inlet" + }, + { + "fromStationId": "catalytic_reformer_unit", + "toStationId": "hydrotreating_unit", + "medium": "hydrogen", + "visualKind": "pipe", + "label": "重整副产氢至加氢", + "fromPortId": "hydrogen_rich_gas_out", + "toPortId": "hydrogen_in" + }, + { + "fromStationId": "hydrotreating_unit", + "toStationId": "sulfur_recovery_unit", + "medium": "gas", + "visualKind": "pipe", + "label": "酸性气至硫回收", + "fromPortId": "acid_gas_out", + "toPortId": "acid_gas_inlet" + }, + { + "fromStationId": "pipe_rack", + "toStationId": "flare_system", + "medium": "gas", + "visualKind": "pipe", + "label": "应急泄放总管(非正常工况)", + "toPortId": "relief_gas_inlet" + }, + { + "fromStationId": "pipe_rack", + "toStationId": "atmospheric_distillation_unit", + "medium": "cooling", + "visualKind": "pipe", + "label": "主管廊接入" + }, + { + "fromStationId": "utility_boiler", + "toStationId": "pipe_rack", + "medium": "water", + "visualKind": "pipe", + "label": "蒸汽" + }, + { + "fromStationId": "control_room", + "toStationId": "pipe_rack", + "medium": "power", + "visualKind": "cable_tray", + "label": "仪控电缆" + } + ], + "layoutHints": { + "preferredArchitectureId": "refinery.factory.basic_refinery", + "cameraFocusStationIds": [ + "atmospheric_distillation_unit", + "vacuum_distillation_unit", + "fluid_catalytic_cracking_unit", + "delayed_coker_unit" + ], + "mainProcessStationIds": [ + "desalter", + "atmospheric_distillation_unit", + "vacuum_distillation_unit", + "delayed_coker_unit", + "fluid_catalytic_cracking_unit", + "gas_fractionation_unit", + "hydrotreating_unit", + "catalytic_reformer_unit" + ], + "offsiteStationIds": [ + "crude_storage_tank", + "intermediate_storage_tank", + "product_storage_tank", + "flare_system", + "control_room" + ], + "notes": [ + "CRITICAL: Pack all stations densely — interStationGap must not exceed 2 units. The plant should look like a real industrial site, not scattered islands.", + "Pipe rack is the backbone spine running east-west; every process station connects to it laterally within reach.", + "Process flow direction: crude (west) → desalter → CDU/VDU → coker/FCC → hydrotreating/reformer → product tanks (east).", + "CDU atmospheric tower and VDU are the tallest process columns; place them as the visual center of the plant.", + "FCC rich gas goes to gas fractionation/LPG recovery, not to flare.", + "Vacuum residue leaves VDU bottom to delayed coker.", + "Catalytic reformer hydrogen-rich gas supplies hydrotreating.", + "Flare system is emergency relief only — far corner, clearly separated.", + "CDU atmospheric tower is the tallest process column; VDU has noticeably larger diameter; flare stack is the single tallest element overall." + ] + } + } +] diff --git a/cloud/industry.refinery.basic-0.1.0/profiles/generated.json b/cloud/industry.refinery.basic-0.1.0/profiles/generated.json new file mode 100644 index 000000000..84cc4b3ec --- /dev/null +++ b/cloud/industry.refinery.basic-0.1.0/profiles/generated.json @@ -0,0 +1,2903 @@ +[ + { + "id": "refinery.crude_storage_tank", + "name": "Crude storage tank", + "aliases": [ + "原油储罐", + "原油罐区", + "crude storage tank", + "crude tank farm" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:storage-tank", + "recipeParams": { + "orientation": "vertical", + "shellColor": "#cbd5e1" + }, + "family": "tank", + "defaultDimensions": { + "length": 5.6, + "width": 5.6, + "height": 6.4 + }, + "parts": [ + { + "kind": "storage_tank_shell", + "semanticRole": "vessel_shell", + "height": 6.4, + "radius": 2.4, + "axis": "y", + "primaryColor": "#cbd5e1" + }, + { + "kind": "inlet_port", + "semanticRole": "feed_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "product_outlet" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_ladder" + } + ], + "primarySemanticRole": "vessel_shell", + "editableSchemaRef": "vessel.common", + "qualityRules": "quality.refinery.crude_storage_tank", + "status": "stable", + "source": "imported_pack", + "description": "炼油厂原油罐区储罐。", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "refinery.product_storage_tank", + "name": "Product storage tank", + "aliases": [ + "成品储罐", + "汽柴油储罐", + "product storage tank", + "finished product tank farm" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:storage-tank", + "recipeParams": { + "orientation": "vertical", + "shellColor": "#e5e7eb" + }, + "family": "tank", + "defaultDimensions": { + "length": 5.2, + "width": 5.2, + "height": 5.8 + }, + "parts": [ + { + "kind": "storage_tank_shell", + "semanticRole": "vessel_shell", + "height": 5.8, + "radius": 2.2, + "axis": "y", + "primaryColor": "#e5e7eb" + }, + { + "kind": "inlet_port", + "semanticRole": "feed_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "loading_outlet" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_ladder" + } + ], + "primarySemanticRole": "vessel_shell", + "editableSchemaRef": "vessel.common", + "qualityRules": "quality.refinery.product_storage_tank", + "status": "stable", + "source": "imported_pack", + "description": "炼油厂汽油、柴油等成品油罐区储罐。", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "refinery.intermediate_storage_tank", + "name": "Intermediate storage tank", + "aliases": [ + "中间料储罐", + "半成品罐区", + "中间罐区", + "intermediate storage tank", + "semi finished tank farm", + "intermediate tank farm" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:storage-tank", + "recipeParams": { + "orientation": "vertical", + "shellColor": "#d1d5db" + }, + "family": "tank", + "defaultDimensions": { + "length": 4.8, + "width": 4.8, + "height": 5.4 + }, + "parts": [ + { + "kind": "storage_tank_shell", + "semanticRole": "vessel_shell", + "height": 5.4, + "radius": 2, + "axis": "y", + "primaryColor": "#d1d5db" + }, + { + "kind": "inlet_port", + "semanticRole": "distillate_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "conversion_feed_outlet" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_ladder" + } + ], + "primarySemanticRole": "vessel_shell", + "editableSchemaRef": "vessel.common", + "qualityRules": "quality.refinery.intermediate_storage_tank", + "status": "stable", + "source": "imported_pack", + "description": "Intermediate naphtha, gas oil, and blend-component tankage between distillation and conversion units.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "refinery.desalter", + "name": "Crude desalter", + "aliases": [ + "电脱盐器", + "脱盐罐", + "crude desalter" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "family": "tank", + "defaultDimensions": { + "length": 4.8, + "width": 1.4, + "height": 1.6 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "desalter_vessel", + "length": 4.6, + "radius": 0.62, + "axis": "x", + "primaryColor": "#94a3b8" + }, + { + "kind": "skid_base", + "semanticRole": "support_base" + }, + { + "kind": "inlet_port", + "semanticRole": "crude_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "desalted_crude_outlet" + }, + { + "kind": "control_box", + "semanticRole": "electrical_control_box" + } + ], + "primarySemanticRole": "desalter_vessel", + "qualityRules": "quality.refinery.desalter", + "status": "stable", + "source": "imported_pack", + "description": "常减压前处理的卧式电脱盐容器。", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "horizontal", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "refinery.atmospheric_distillation_unit", + "name": "Atmospheric distillation unit", + "aliases": [ + "常压蒸馏塔", + "常压塔", + "crude distillation unit", + "atmospheric distillation unit" + ], + "industry": "refinery", + "layoutFamily": "distillation_column_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:distillation-unit", + "recipeParams": { + "columnRole": "distillation_column_shell", + "exchangerRole": "heat_exchanger_shell", + "heaterRole": "fired_heater", + "manifoldRole": "side_draw_manifold" + }, + "family": "distillation_column", + "defaultDimensions": { + "length": 10.5, + "width": 6, + "height": 13.5 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "distillation_column_shell", + "height": 13.2, + "radius": 0.78, + "axis": "y", + "position": [ + -1.7, + 3.6, + 0 + ], + "primaryColor": "#d1d5db" + }, + { + "id": "tray-band-1", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + -1.6, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "tray-band-2", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + 0, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "tray-band-3", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + 1.5, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "tray-band-4", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + 3.1, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "tray-band-5", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + 4.7, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "tray-band-6", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + 6.2, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "tray-band-7", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + 7.8, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "tray-band-8", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "distillation_column_shell", + "axis": "y", + "radius": 0.86, + "tubeRadius": 0.048, + "includeBolts": false, + "position": [ + -1.7, + 9.4, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "pre_flash_shell", + "kind": "cylindrical_tank", + "semanticRole": "pre_flash_column_shell", + "height": 7.5, + "radius": 0.55, + "axis": "y", + "position": [ + -3.4, + 0.75, + 0 + ], + "primaryColor": "#d1d5db" + }, + { + "id": "pf-band-1", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "pre_flash_column_shell", + "axis": "y", + "radius": 0.62, + "tubeRadius": 0.042, + "includeBolts": false, + "position": [ + -3.4, + -2.2, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "pf-band-2", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "pre_flash_column_shell", + "axis": "y", + "radius": 0.62, + "tubeRadius": 0.042, + "includeBolts": false, + "position": [ + -3.4, + -0.8, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "pf-band-3", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "pre_flash_column_shell", + "axis": "y", + "radius": 0.62, + "tubeRadius": 0.042, + "includeBolts": false, + "position": [ + -3.4, + 0.6, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "pf-band-4", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "pre_flash_column_shell", + "axis": "y", + "radius": 0.62, + "tubeRadius": 0.042, + "includeBolts": false, + "position": [ + -3.4, + 2, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "pf-band-5", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "pre_flash_column_shell", + "axis": "y", + "radius": 0.62, + "tubeRadius": 0.042, + "includeBolts": false, + "position": [ + -3.4, + 3.4, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "stripper_1_shell", + "kind": "cylindrical_tank", + "semanticRole": "side_stripper_column", + "height": 4, + "radius": 0.28, + "axis": "y", + "position": [ + -2.8, + -1, + 2.2 + ], + "primaryColor": "#cbd5e1" + }, + { + "id": "st1-band-1", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "side_stripper_column", + "axis": "y", + "radius": 0.33, + "tubeRadius": 0.032, + "includeBolts": false, + "position": [ + -2.8, + -2.2, + 2.2 + ], + "metalColor": "#94a3b8" + }, + { + "id": "st1-band-2", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "side_stripper_column", + "axis": "y", + "radius": 0.33, + "tubeRadius": 0.032, + "includeBolts": false, + "position": [ + -2.8, + -0.2, + 2.2 + ], + "metalColor": "#94a3b8" + }, + { + "id": "stripper_2_shell", + "kind": "cylindrical_tank", + "semanticRole": "side_stripper_column", + "height": 4, + "radius": 0.28, + "axis": "y", + "position": [ + -1.2, + -1, + 2.2 + ], + "primaryColor": "#cbd5e1" + }, + { + "id": "st2-band-1", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "side_stripper_column", + "axis": "y", + "radius": 0.33, + "tubeRadius": 0.032, + "includeBolts": false, + "position": [ + -1.2, + -2.2, + 2.2 + ], + "metalColor": "#94a3b8" + }, + { + "id": "st2-band-2", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "side_stripper_column", + "axis": "y", + "radius": 0.33, + "tubeRadius": 0.032, + "includeBolts": false, + "position": [ + -1.2, + -0.2, + 2.2 + ], + "metalColor": "#94a3b8" + }, + { + "kind": "heat_exchanger", + "semanticRole": "preheat_exchanger", + "length": 2.6, + "radius": 0.34, + "axis": "x", + "position": [ + 1.6, + 0.75, + -0.9 + ] + }, + { + "kind": "generic_body", + "semanticRole": "fired_heater", + "length": 1.8, + "width": 1.2, + "height": 2.6, + "position": [ + 1.75, + 1.3, + 0.95 + ], + "primaryColor": "#9ca3af" + }, + { + "kind": "pipe_manifold", + "semanticRole": "side_draw_manifold", + "length": 3.4, + "radius": 0.08, + "position": [ + -1.7, + 5.5, + 0.9 + ] + }, + { + "kind": "service_platform", + "semanticRole": "service_platform", + "attachToRole": "distillation_column_shell", + "anchor": "service_side", + "offset": [ + 0, + 1.8, + 0 + ] + }, + { + "id": "column_access_ladder", + "kind": "helical_ladder", + "semanticRole": "external_spiral_ladder", + "sourcePartKind": "helical_ladder", + "position": [ + -1.7, + 6.6, + 0 + ], + "height": 11.4, + "innerRadius": 0.92, + "outerRadius": 1.28, + "width": 0.36, + "depth": 0.22, + "sweepAngle": 15.08, + "startAngle": 0.31, + "stepCount": 36, + "ringCount": 28, + "railingHeight": 0.42, + "wireRadius": 0.018, + "metalColor": "#94a3b8" + }, + { + "kind": "inlet_port", + "semanticRole": "crude_feed_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "overhead_product_outlet" + }, + { + "kind": "outlet_port", + "semanticRole": "atmospheric_residue_out", + "axis": "x", + "radius": 0.12, + "length": 0.34, + "position": [ + -1.7, + 0.62, + 0.72 + ], + "metalColor": "#9ca3af" + } + ], + "primarySemanticRole": "distillation_column_shell", + "qualityRules": "quality.refinery.atmospheric_distillation_unit", + "status": "stable", + "source": "imported_pack", + "description": "原油初馏常压蒸馏装置,带加热炉、换热器和侧线。", + "visualCues": [ + "CDU is a CLUSTER of towers, not a single column: pre-flash tower (left, medium height ~7.5u) + main atmospheric column (center, tallest ~13.2u with 8 tray-band rings) + 2 short side strippers (front, short ~4u with narrow radius)", + "the three tower types form a staggered group — pre-flash tower and strippers are clearly shorter than the main column", + "each tower has visible flange rings segmenting it into tray sections — NOT smooth cylinders", + "fired heater and preheat exchanger train sit to the right of the main column at ground level", + "side draw manifold runs horizontally along the front face of the main column", + "overhead naphtha line from the main column top; atmospheric residue exits at column base" + ], + "roleAliases": { + "distillation_column_shell": [ + "crude_column", + "atmospheric_tower", + "fractionator_shell", + "cylindrical_tank" + ], + "tray_band": [ + "tray_section", + "fractionation_tray_band", + "flange_ring" + ], + "preheat_exchanger": [ + "crude_preheat_train", + "heat_exchanger" + ], + "fired_heater": [ + "crude_heater", + "charge_heater", + "generic_body" + ], + "side_draw_manifold": [ + "naphtha_kerosene_diesel_side_draws", + "side_draw_piping", + "pipe_manifold" + ], + "crude_feed_inlet": [ + "desalted_crude_in", + "inlet_port" + ], + "overhead_product_outlet": [ + "naphtha_overhead_out", + "outlet_port" + ], + "atmospheric_residue_out": [ + "bottom_residue_out", + "outlet_port" + ] + }, + "layoutHints": { + "axis": "y", + "mainColumnPosition": "upstream_tower", + "heaterSide": "right", + "sideDrawSide": "front" + }, + "processPorts": [ + { + "id": "desalted_crude_in", + "medium": "material", + "side": "left", + "height": 2.1, + "offset": 0 + }, + { + "id": "naphtha_distillate_out", + "medium": "material", + "side": "top", + "height": 13.1, + "offset": 0.2 + }, + { + "id": "middle_distillate_out", + "medium": "material", + "side": "right", + "height": 6.5, + "offset": 0.2 + }, + { + "id": "atmospheric_residue_out", + "medium": "material", + "side": "bottom", + "height": 0.7, + "offset": 0 + } + ], + "detailBudget": { + "detailLevel": "low", + "maxShapes": 160, + "parts": { + "tray-band-1": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "tray-band-2": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "tray-band-3": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "tray-band-4": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "tray-band-5": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "tray-band-6": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "tray-band-7": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "tray-band-8": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "pf-band-1": { + "detailLevel": "low", + "count": 10, + "boltCount": 0 + }, + "pf-band-2": { + "detailLevel": "low", + "count": 10, + "boltCount": 0 + }, + "pf-band-3": { + "detailLevel": "low", + "count": 10, + "boltCount": 0 + }, + "pf-band-4": { + "detailLevel": "low", + "count": 10, + "boltCount": 0 + }, + "pf-band-5": { + "detailLevel": "low", + "count": 10, + "boltCount": 0 + }, + "st1-band-1": { + "detailLevel": "low", + "count": 8, + "boltCount": 0 + }, + "st1-band-2": { + "detailLevel": "low", + "count": 8, + "boltCount": 0 + }, + "st2-band-1": { + "detailLevel": "low", + "count": 8, + "boltCount": 0 + }, + "st2-band-2": { + "detailLevel": "low", + "count": 8, + "boltCount": 0 + }, + "service_platform": { + "detailLevel": "low" + } + } + }, + "equipmentDefaults": { + "columnKind": "atmospheric" + } + }, + { + "id": "refinery.vacuum_distillation_unit", + "name": "Vacuum distillation unit", + "aliases": [ + "减压蒸馏塔", + "减压塔", + "vacuum distillation unit", + "vacuum tower" + ], + "industry": "refinery", + "layoutFamily": "distillation_column_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:distillation-unit", + "recipeParams": { + "columnKind": "vacuum", + "columnRole": "vacuum_column_shell", + "exchangerRole": "heat_exchanger_shell", + "heaterRole": "vacuum_heater", + "manifoldRole": "vacuum_line" + }, + "family": "distillation_column", + "defaultDimensions": { + "length": 6.8, + "width": 4.4, + "height": 11.8 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "vacuum_column_shell", + "height": 11.4, + "radius": 1.05, + "axis": "y", + "position": [ + -1.35, + 3.75, + 0 + ], + "primaryColor": "#cbd5e1" + }, + { + "kind": "heat_exchanger", + "semanticRole": "condenser_exchanger", + "length": 2.4, + "radius": 0.34, + "axis": "x", + "position": [ + 1.4, + 0.75, + -0.85 + ] + }, + { + "kind": "generic_body", + "semanticRole": "vacuum_heater", + "length": 1.7, + "width": 1.1, + "height": 2.4, + "position": [ + 1.55, + 1.2, + 0.9 + ], + "primaryColor": "#9ca3af" + }, + { + "kind": "pipe_run", + "semanticRole": "vacuum_line", + "length": 3.2, + "radius": 0.12, + "position": [ + -1.35, + 5.2, + 0.85 + ] + }, + { + "id": "vdu-band-1", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "vacuum_column_shell", + "axis": "y", + "radius": 1.14, + "tubeRadius": 0.055, + "includeBolts": false, + "position": [ + -1.35, + -1.5, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "vdu-band-2", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "vacuum_column_shell", + "axis": "y", + "radius": 1.14, + "tubeRadius": 0.055, + "includeBolts": false, + "position": [ + -1.35, + 0.3, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "vdu-band-3", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "vacuum_column_shell", + "axis": "y", + "radius": 1.14, + "tubeRadius": 0.055, + "includeBolts": false, + "position": [ + -1.35, + 2.1, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "vdu-band-4", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "vacuum_column_shell", + "axis": "y", + "radius": 1.14, + "tubeRadius": 0.055, + "includeBolts": false, + "position": [ + -1.35, + 3.8, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "vdu-band-5", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "vacuum_column_shell", + "axis": "y", + "radius": 1.14, + "tubeRadius": 0.055, + "includeBolts": false, + "position": [ + -1.35, + 5.5, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "id": "vdu-band-6", + "kind": "flange_ring", + "semanticRole": "tray_band", + "attachToRole": "vacuum_column_shell", + "axis": "y", + "radius": 1.14, + "tubeRadius": 0.055, + "includeBolts": false, + "position": [ + -1.35, + 7.2, + 0 + ], + "metalColor": "#94a3b8" + }, + { + "kind": "service_platform", + "semanticRole": "service_platform", + "attachToRole": "vacuum_column_shell", + "anchor": "service_side", + "offset": [ + 0, + 1.6, + 0 + ] + }, + { + "id": "column_access_ladder", + "kind": "helical_ladder", + "semanticRole": "external_spiral_ladder", + "sourcePartKind": "helical_ladder", + "position": [ + -1.35, + 5.7, + 0 + ], + "height": 10.1, + "innerRadius": 1.24, + "outerRadius": 1.66, + "width": 0.42, + "depth": 0.25, + "sweepAngle": 13.19, + "startAngle": -0.47, + "stepCount": 30, + "ringCount": 24, + "railingHeight": 0.42, + "wireRadius": 0.02, + "metalColor": "#94a3b8" + }, + { + "kind": "inlet_port", + "semanticRole": "atmospheric_residue_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "vacuum_gas_oil_outlet" + }, + { + "kind": "outlet_port", + "semanticRole": "vacuum_residue_outlet", + "axis": "x", + "radius": 0.14, + "length": 0.34, + "position": [ + -1.35, + 0.55, + 0.78 + ], + "metalColor": "#9ca3af" + } + ], + "primarySemanticRole": "vacuum_column_shell", + "qualityRules": "quality.refinery.vacuum_distillation_unit", + "status": "stable", + "source": "imported_pack", + "description": "处理常压渣油的减压蒸馏装置。", + "visualCues": [ + "wide vacuum tower segmented into sections by 6 evenly-spaced flange rings — the tower looks like stacked cylinders, NOT a smooth tube", + "noticeably larger diameter than the atmospheric distillation column — fatter and shorter", + "vacuum heater and condenser/exchanger train near the base", + "large overhead vacuum line and ejector/condenser connection", + "side outlet for vacuum gas oil feed to FCC", + "bottom heavy vacuum residue outlet feeding delayed coker or residue conversion" + ], + "roleAliases": { + "vacuum_column_shell": [ + "vacuum_tower", + "wide_vacuum_fractionator", + "cylindrical_tank" + ], + "condenser_exchanger": [ + "overhead_condenser", + "heat_exchanger" + ], + "vacuum_heater": [ + "vacuum_charge_heater", + "fired_heater", + "generic_body" + ], + "vacuum_line": [ + "overhead_vacuum_line", + "ejector_line", + "pipe_run" + ], + "vacuum_gas_oil_outlet": [ + "vgo_outlet", + "fcc_feed_out", + "outlet_port" + ], + "vacuum_residue_outlet": [ + "bottom_residue_out", + "delayed_coker_feed_out", + "outlet_port" + ] + }, + "layoutHints": { + "axis": "y", + "largerDiameterThan": "atmospheric_distillation_unit", + "residueOutlet": "bottom" + }, + "processPorts": [ + { + "id": "atmospheric_residue_in", + "medium": "material", + "side": "left", + "height": 1.4, + "offset": 0 + }, + { + "id": "vacuum_gas_oil_out", + "medium": "material", + "side": "right", + "height": 5.8, + "offset": 0.2 + }, + { + "id": "vacuum_residue_out", + "medium": "material", + "side": "bottom", + "height": 0.55, + "offset": 0 + }, + { + "id": "vacuum_overhead_out", + "medium": "gas", + "side": "top", + "height": 11.6, + "offset": 0 + } + ], + "detailBudget": { + "detailLevel": "low", + "maxShapes": 110, + "parts": { + "vdu-band-1": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "vdu-band-2": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "vdu-band-3": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "vdu-band-4": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "vdu-band-5": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "vdu-band-6": { + "detailLevel": "low", + "count": 12, + "boltCount": 0 + }, + "service_platform": { + "detailLevel": "low" + } + } + }, + "equipmentDefaults": { + "columnKind": "vacuum" + } + }, + { + "id": "refinery.fluid_catalytic_cracking_unit", + "name": "Fluid catalytic cracking unit", + "aliases": [ + "催化裂化装置", + "FCC装置", + "fluid catalytic cracking", + "FCC unit" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:refinery-reactor-unit", + "recipeParams": { + "variant": "fcc", + "primaryRole": "fcc_reactor", + "secondaryRole": "catalyst_regenerator", + "separatorRole": "main_fractionator", + "pipeRole": "riser_pipe", + "stackRole": "flue_gas_stack", + "cycloneRole": "cyclone_separator" + }, + "family": "reactor", + "defaultDimensions": { + "length": 7.2, + "width": 4.2, + "height": 7.2 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "fcc_reactor", + "height": 5.8, + "radius": 0.65, + "axis": "y", + "position": [ + -1.9, + 2.95, + -0.45 + ], + "primaryColor": "#d1d5db" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "catalyst_regenerator", + "height": 5.2, + "radius": 0.82, + "axis": "y", + "position": [ + 0.2, + 2.65, + 0.35 + ], + "primaryColor": "#bfc7d5" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "main_fractionator", + "height": 6.2, + "radius": 0.58, + "axis": "y", + "position": [ + 2.2, + 3.05, + -0.35 + ], + "primaryColor": "#d9dee8" + }, + { + "kind": "pipe_run", + "semanticRole": "riser_pipe", + "length": 3.2, + "radius": 0.09, + "position": [ + -0.85, + 3.7, + -0.1 + ] + }, + { + "kind": "cyclone_separator_unit", + "semanticRole": "cyclone_separator" + }, + { + "kind": "chimney_stack", + "semanticRole": "flue_gas_stack", + "height": 6, + "radius": 0.42, + "position": [ + 0, + 3, + 0 + ] + }, + { + "kind": "service_platform", + "semanticRole": "service_platform" + }, + { + "kind": "inlet_port", + "semanticRole": "vacuum_gas_oil_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "cracked_product_outlet" + }, + { + "kind": "pipe_run", + "semanticRole": "spent_catalyst_standpipe", + "axis": "y", + "length": 2.4, + "radius": 0.07, + "position": [ + -0.8, + 2.2, + 0.18 + ], + "metalColor": "#64748b" + }, + { + "kind": "pipe_run", + "semanticRole": "regenerated_catalyst_standpipe", + "axis": "y", + "length": 2.2, + "radius": 0.07, + "position": [ + 0.92, + 2.1, + -0.12 + ], + "metalColor": "#64748b" + }, + { + "kind": "outlet_port", + "semanticRole": "rich_gas_outlet", + "axis": "y", + "radius": 0.11, + "length": 0.34, + "position": [ + 2.2, + 6.25, + -0.35 + ], + "metalColor": "#9ca3af" + } + ], + "primarySemanticRole": "fcc_reactor", + "qualityRules": "quality.refinery.fluid_catalytic_cracking_unit", + "status": "stable", + "source": "imported_pack", + "description": "催化裂化反应再生装置,表达反应器、再生器、提升管、旋风分离和烟囱。", + "visualCues": [ + "FCC complex is a reactor-regenerator pair plus main fractionator, not a single box", + "riser pipe climbs from the feed side into the reactor, with catalyst standpipe between reactor and regenerator", + "larger catalyst regenerator vessel has flue gas stack and cyclone separator details near the top", + "main fractionator tower sits beside the reactor-regenerator pair for cracked products", + "rich gas outlet goes to LPG recovery/gas fractionation during normal operation; do not route it directly to flare" + ], + "roleAliases": { + "fcc_reactor": [ + "reactor_vessel", + "riser_reactor", + "cylindrical_tank" + ], + "catalyst_regenerator": [ + "regenerator_vessel", + "catalyst_burner_regenerator", + "cylindrical_tank" + ], + "main_fractionator": [ + "fcc_fractionator", + "product_fractionator", + "cylindrical_tank" + ], + "riser_pipe": [ + "feed_riser", + "catalyst_riser", + "pipe_run" + ], + "spent_catalyst_standpipe": [ + "catalyst_standpipe", + "pipe_run" + ], + "regenerated_catalyst_standpipe": [ + "regenerated_catalyst_line", + "pipe_run" + ], + "cyclone_separator": [ + "regenerator_cyclone", + "cyclone_separator_unit" + ], + "flue_gas_stack": [ + "regenerator_stack", + "chimney_stack" + ], + "rich_gas_outlet": [ + "fcc_rich_gas_out", + "lpg_gas_out", + "outlet_port" + ], + "cracked_product_outlet": [ + "fcc_gasoline_lco_slurry_out", + "outlet_port" + ] + }, + "layoutHints": { + "cluster": "reactor_regenerator_fractionator", + "reactorSide": "left", + "regeneratorSide": "center", + "fractionatorSide": "right" + }, + "processPorts": [ + { + "id": "vacuum_gas_oil_in", + "medium": "material", + "side": "left", + "height": 1.2, + "offset": -0.4 + }, + { + "id": "cracked_product_out", + "medium": "material", + "side": "right", + "height": 3.2, + "offset": 0 + }, + { + "id": "rich_gas_out", + "medium": "gas", + "side": "top", + "height": 6.4, + "offset": 0.25 + }, + { + "id": "flue_gas_out", + "medium": "gas", + "side": "top", + "height": 7, + "offset": -0.25 + } + ], + "detailBudget": { + "detailLevel": "low", + "maxShapes": 80, + "parts": { + "cyclone_separator": { + "detailLevel": "low" + }, + "service_platform": { + "detailLevel": "low" + } + } + }, + "equipmentDefaults": { + "variant": "fcc" + } + }, + { + "id": "refinery.hydrotreating_unit", + "name": "Hydrotreating unit", + "aliases": [ + "加氢精制装置", + "加氢装置", + "hydrotreating unit", + "hydrotreater" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:refinery-reactor-unit", + "recipeParams": { + "variant": "hydrotreating", + "primaryRole": "hydrotreater_reactor", + "exchangerRole": "heat_exchanger_shell", + "separatorRole": "high_pressure_separator", + "pipeRole": "hydrogen_header" + }, + "family": "reactor", + "defaultDimensions": { + "length": 6.4, + "width": 3.2, + "height": 4.6 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "hydrotreater_reactor", + "height": 4.2, + "radius": 0.55, + "axis": "y", + "position": [ + -1.45, + 2.15, + -0.35 + ], + "primaryColor": "#cbd5e1" + }, + { + "kind": "heat_exchanger", + "semanticRole": "feed_effluent_exchanger", + "length": 2.4, + "radius": 0.3, + "axis": "x", + "position": [ + 0.8, + 0.68, + -0.85 + ] + }, + { + "kind": "cylindrical_tank", + "semanticRole": "high_pressure_separator", + "length": 3.4, + "radius": 0.48, + "axis": "x", + "position": [ + 1.4, + 0.85, + 0.75 + ], + "primaryColor": "#dbe3ee" + }, + { + "kind": "pipe_manifold", + "semanticRole": "hydrogen_header", + "length": 3.2, + "radius": 0.07, + "position": [ + 0.15, + 1.8, + 0.9 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "hydrogen_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "treated_product_outlet" + }, + { + "kind": "outlet_port", + "semanticRole": "acid_gas_outlet", + "axis": "y", + "radius": 0.1, + "length": 0.3, + "position": [ + -1.45, + 4.45, + -0.35 + ], + "metalColor": "#9ca3af" + } + ], + "primarySemanticRole": "hydrotreater_reactor", + "qualityRules": "quality.refinery.hydrotreating_unit", + "status": "stable", + "source": "imported_pack", + "description": "加氢精制反应器列,包含反应器、换热器、分离罐和氢气入口。", + "visualCues": [ + "high-pressure hydrotreater reactor train with vertical reactor and horizontal high-pressure separator", + "feed-effluent exchanger and heater/exchanger group ahead of the reactor", + "hydrogen header visibly entering the reactor train from the reformer/hydrogen system", + "acid gas outlet toward sulfur recovery and treated product outlet toward product tankage", + "compact pressure-vessel skid with dense piping, not an atmospheric storage tank" + ], + "roleAliases": { + "hydrotreater_reactor": [ + "hydrotreating_reactor", + "hds_reactor", + "cylindrical_tank" + ], + "feed_effluent_exchanger": [ + "feed_exchanger", + "heat_exchanger" + ], + "high_pressure_separator": [ + "hp_separator", + "separator_drum", + "cylindrical_tank" + ], + "hydrogen_header": [ + "hydrogen_makeup_header", + "pipe_manifold" + ], + "hydrogen_inlet": [ + "makeup_hydrogen_in", + "inlet_port" + ], + "acid_gas_outlet": [ + "h2s_acid_gas_out", + "outlet_port" + ], + "treated_product_outlet": [ + "desulfurized_product_out", + "outlet_port" + ] + }, + "processPorts": [ + { + "id": "hydrotreater_feed_in", + "medium": "material", + "side": "left", + "height": 0.9, + "offset": 0 + }, + { + "id": "hydrogen_in", + "medium": "hydrogen", + "side": "back", + "height": 1.8, + "offset": 0.4 + }, + { + "id": "treated_product_out", + "medium": "material", + "side": "right", + "height": 1, + "offset": 0 + }, + { + "id": "acid_gas_out", + "medium": "gas", + "side": "top", + "height": 4.4, + "offset": 0 + } + ], + "detailBudget": { + "detailLevel": "low", + "maxShapes": 64 + }, + "equipmentDefaults": { + "variant": "reformer" + } + }, + { + "id": "refinery.catalytic_reformer_unit", + "name": "Catalytic reformer unit", + "aliases": [ + "催化重整装置", + "重整装置", + "catalytic reformer", + "reformer unit" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:refinery-reactor-unit", + "recipeParams": { + "variant": "reformer", + "primaryRole": "reformer_reactor_train", + "heaterRole": "reformer_fired_heater", + "exchangerRole": "heat_exchanger_shell", + "separatorRole": "reformer_separator", + "pipeRole": "hydrogen_rich_gas_header" + }, + "family": "reactor", + "defaultDimensions": { + "length": 7.2, + "width": 3.4, + "height": 4.4 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "reformer_reactor_train", + "height": 4, + "radius": 0.48, + "axis": "y", + "position": [ + -1.8, + 2.05, + -0.45 + ], + "primaryColor": "#d1d5db" + }, + { + "kind": "generic_body", + "semanticRole": "reformer_fired_heater", + "length": 1.7, + "width": 1.1, + "height": 2.1, + "position": [ + 0.25, + 1.05, + 0.85 + ], + "primaryColor": "#9ca3af" + }, + { + "kind": "heat_exchanger", + "semanticRole": "reformer_exchanger", + "length": 2.2, + "radius": 0.3, + "axis": "x", + "position": [ + 1.25, + 0.68, + -0.85 + ] + }, + { + "kind": "cylindrical_tank", + "semanticRole": "reformer_separator", + "length": 2.8, + "radius": 0.42, + "axis": "x", + "position": [ + 2.05, + 0.78, + 0.7 + ], + "primaryColor": "#dbe3ee" + }, + { + "kind": "inlet_port", + "semanticRole": "naphtha_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "reformate_outlet" + }, + { + "kind": "pipe_manifold", + "semanticRole": "hydrogen_rich_gas_header", + "length": 3, + "radius": 0.065, + "position": [ + 1.2, + 3.45, + 0.88 + ], + "metalColor": "#38bdf8" + } + ], + "primarySemanticRole": "reformer_reactor_train", + "qualityRules": "quality.refinery.catalytic_reformer_unit", + "status": "stable", + "source": "imported_pack", + "description": "石脑油重整反应器列,包含多段反应器、加热炉、换热器和气液分离。", + "visualCues": [ + "catalytic reformer has multiple vertical reactors in series with fired heaters between stages", + "reformer separator/hydrogen separator produces hydrogen-rich gas", + "naphtha feed enters after hydrotreating; reformate product exits to gasoline/product blending", + "hydrogen-rich gas header should be visible and available to hydrotreating units", + "reactor train and heaters are lower than distillation towers but more complex than a simple vessel" + ], + "roleAliases": { + "reformer_reactor_train": [ + "reformer_reactors", + "platformer_reactors", + "cylindrical_tank" + ], + "reformer_fired_heater": [ + "interheater", + "charge_heater", + "generic_body" + ], + "reformer_exchanger": [ + "feed_effluent_exchanger", + "heat_exchanger" + ], + "reformer_separator": [ + "hydrogen_separator", + "separator_drum", + "cylindrical_tank" + ], + "hydrogen_rich_gas_header": [ + "reformer_hydrogen_out", + "hydrogen_header", + "pipe_manifold" + ], + "naphtha_inlet": [ + "hydrotreated_naphtha_in", + "inlet_port" + ], + "reformate_outlet": [ + "high_octane_reformate_out", + "outlet_port" + ] + }, + "processPorts": [ + { + "id": "naphtha_in", + "medium": "material", + "side": "left", + "height": 0.95, + "offset": 0 + }, + { + "id": "reformate_out", + "medium": "material", + "side": "right", + "height": 0.9, + "offset": 0 + }, + { + "id": "hydrogen_rich_gas_out", + "medium": "hydrogen", + "side": "top", + "height": 3.7, + "offset": 0.25 + } + ], + "detailBudget": { + "detailLevel": "low", + "maxShapes": 68 + }, + "equipmentDefaults": { + "variant": "reformer" + } + }, + { + "id": "refinery.sulfur_recovery_unit", + "name": "Sulfur recovery unit", + "aliases": [ + "硫回收装置", + "Claus装置", + "sulfur recovery unit", + "Claus unit" + ], + "industry": "refinery", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:refinery-reactor-unit", + "recipeParams": { + "variant": "sulfur", + "primaryRole": "claus_reactor", + "exchangerRole": "heat_exchanger_shell", + "stackRole": "tail_gas_stack" + }, + "family": "reactor", + "defaultDimensions": { + "length": 5.4, + "width": 2.8, + "height": 4.6 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "claus_reactor", + "length": 2.2, + "radius": 0.48, + "axis": "x", + "position": [ + -0.8, + 0.48, + -0.4 + ], + "primaryColor": "#9ca3af" + }, + { + "kind": "heat_exchanger", + "semanticRole": "sulfur_condenser", + "length": 1.8, + "radius": 0.3, + "axis": "x", + "position": [ + 1.2, + 0.48, + -0.4 + ] + }, + { + "kind": "chimney_stack", + "semanticRole": "tail_gas_stack", + "height": 3.8, + "radius": 0.18, + "position": [ + 1.6, + 1.9, + 0.6 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "acid_gas_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "sulfur_outlet" + } + ], + "primarySemanticRole": "claus_reactor", + "qualityRules": "quality.refinery.sulfur_recovery_unit", + "status": "stable", + "source": "imported_pack", + "description": "酸性气硫回收单元,包含热反应器、冷凝器和尾气烟囱。", + "processPorts": [ + { + "id": "acid_gas_in", + "medium": "gas", + "side": "left", + "diameter": 0.16 + }, + { + "id": "sulfur_out", + "medium": "material", + "side": "right", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "variant": "sulfur" + } + }, + { + "id": "refinery.flare_system", + "name": "Flare system", + "aliases": [ + "火炬系统", + "火炬塔", + "flare system", + "flare stack" + ], + "industry": "refinery", + "layoutFamily": "generic_industrial_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:refinery-auxiliary-unit", + "recipeParams": { + "variant": "flare", + "primaryRole": "flare_stack", + "vesselRole": "knockout_drum", + "pipeRole": "relief_gas_inlet" + }, + "family": "generic", + "defaultDimensions": { + "length": 4.2, + "width": 2.6, + "height": 14 + }, + "parts": [ + { + "kind": "chimney_stack", + "semanticRole": "flare_stack", + "height": 13.5, + "radius": 0.28, + "warningStripes": true, + "position": [ + 0, + 6.75, + 0 + ] + }, + { + "kind": "cylindrical_tank", + "semanticRole": "knockout_drum", + "length": 2.2, + "radius": 0.36, + "axis": "x", + "position": [ + 1.6, + 0.36, + 0.6 + ], + "primaryColor": "#9ca3af" + }, + { + "kind": "pipe_run", + "semanticRole": "relief_gas_inlet", + "axis": "y", + "length": 1.8, + "radius": 0.06, + "position": [ + 1.6, + 1.3, + 0.6 + ], + "metalColor": "#64748b" + } + ], + "primarySemanticRole": "flare_stack", + "qualityRules": "quality.refinery.flare_system", + "status": "stable", + "source": "imported_pack", + "description": "炼油厂放空火炬系统,包含火炬筒和入口分液罐(分液罐置于火炬塔旁地面侧)。", + "processPorts": [ + { + "id": "relief_gas_in", + "medium": "gas", + "side": "left", + "diameter": 0.18 + }, + { + "id": "flare_tip", + "medium": "gas", + "side": "top", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "variant": "flare" + } + }, + { + "id": "refinery.pipe_rack", + "name": "Main pipe rack", + "aliases": [ + "主管廊", + "管廊", + "pipe rack", + "main pipe rack" + ], + "industry": "refinery", + "layoutFamily": "linear_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:refinery-auxiliary-unit", + "recipeParams": { + "variant": "pipe-rack", + "primaryRole": "main_pipe_header", + "pipeRole": "parallel_pipe_run", + "frameRole": "pipe_rack_support_frame" + }, + "family": "pipe_system", + "defaultDimensions": { + "length": 12, + "width": 1.4, + "height": 2.2 + }, + "parts": [ + { + "kind": "pipe_manifold", + "semanticRole": "main_pipe_header", + "length": 10, + "radius": 0.065, + "count": 6, + "position": [ + 0, + 2.15, + 0.28 + ] + }, + { + "kind": "pipe_run", + "semanticRole": "parallel_pipe_run", + "length": 10, + "radius": 0.055, + "axis": "x", + "position": [ + 0, + 1.35, + -0.28 + ] + }, + { + "kind": "structural_tower_frame", + "semanticRole": "pipe_rack_support_frame", + "height": 2.4, + "levelCount": 2 + } + ], + "primarySemanticRole": "main_pipe_header", + "qualityRules": "quality.refinery.pipe_rack", + "status": "stable", + "source": "imported_pack", + "description": "炼油厂主流程侧边管廊,主管线布置在管廊中层和上层,而不是贴地穿过支架底部。", + "visualCues": [ + "elevated main pipe header on the upper rack tier", + "parallel process pipe run on the middle rack tier", + "open steel support frame keeps the ground bay clear" + ], + "processPorts": [ + { + "id": "rack_in", + "medium": "material", + "side": "left", + "diameter": 0.18 + }, + { + "id": "rack_out", + "medium": "material", + "side": "right", + "diameter": 0.18 + } + ], + "equipmentDefaults": { + "variant": "pipe-rack" + } + }, + { + "id": "refinery.utility_boiler", + "name": "Utility boiler", + "aliases": [ + "公用工程锅炉", + "锅炉房", + "utility boiler", + "steam boiler" + ], + "industry": "refinery", + "layoutFamily": "generic_industrial_layout", + "preferredResolver": "profile-parts", + "recipeId": "factory:refinery-auxiliary-unit", + "recipeParams": { + "variant": "boiler", + "primaryRole": "boiler_body", + "vesselRole": "steam_drum", + "pipeRole": "steam_header", + "stackRole": "boiler_stack", + "controlRole": "boiler_control_box" + }, + "family": "generic", + "defaultDimensions": { + "length": 4.2, + "width": 2.3, + "height": 3.6 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "boiler_body", + "length": 3.8, + "width": 1.35, + "height": 1.55, + "position": [ + 0, + 0.78, + 0 + ], + "primaryColor": "#9ca3af", + "cornerRadius": 0.08 + }, + { + "kind": "cylindrical_tank", + "semanticRole": "steam_drum", + "length": 3.25, + "radius": 0.26, + "axis": "x", + "position": [ + 0, + 1.9, + 0 + ], + "primaryColor": "#cbd5e1" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "mud_drum", + "length": 3.05, + "radius": 0.16, + "axis": "x", + "position": [ + 0, + 0.52, + -0.08 + ], + "primaryColor": "#94a3b8" + }, + { + "kind": "pipe_manifold", + "semanticRole": "boiler_tube_bank", + "length": 3.15, + "radius": 0.048, + "count": 7, + "position": [ + 0, + 1.18, + 0.78 + ] + }, + { + "kind": "chimney_stack", + "semanticRole": "boiler_stack", + "height": 3.6, + "radius": 0.18, + "position": [ + 1.55, + 1.8, + -0.88 + ], + "warningStripes": true + }, + { + "kind": "pipe_manifold", + "semanticRole": "steam_header", + "length": 3.5, + "radius": 0.055, + "count": 3, + "position": [ + 0, + 2.24, + 0.1 + ] + }, + { + "kind": "generic_opening", + "semanticRole": "burner_front", + "length": 0.9, + "height": 0.58, + "position": [ + -1.15, + 0.82, + 0.72 + ], + "color": "#111827" + }, + { + "kind": "generic_body", + "semanticRole": "burner_windbox", + "length": 0.95, + "width": 0.42, + "height": 0.62, + "position": [ + -1.15, + 0.82, + 1.02 + ], + "primaryColor": "#475569", + "cornerRadius": 0.04 + }, + { + "kind": "pipe_run", + "semanticRole": "fuel_gas_train", + "length": 1.3, + "radius": 0.045, + "axis": "x", + "position": [ + -1.15, + 0.86, + 1.34 + ] + }, + { + "kind": "service_platform", + "semanticRole": "boiler_access_platform", + "length": 3.1, + "width": 0.68, + "height": 1.55, + "overallHeight": 0.65, + "position": [ + 0.2, + 1.55, + -1 + ] + }, + { + "kind": "control_box", + "semanticRole": "boiler_control_box", + "length": 0.64, + "width": 0.24, + "height": 0.86, + "position": [ + 1.55, + 0.62, + 0.95 + ] + } + ], + "primarySemanticRole": "boiler_body", + "qualityRules": "quality.refinery.utility_boiler", + "status": "stable", + "source": "imported_pack", + "description": "炼油厂公用工程锅炉和蒸汽总管。", + "visualCues": [ + "packaged rectangular furnace casing", + "visible horizontal steam drum", + "lower mud drum below the tube bank", + "front tube bank and burner opening", + "striped stack", + "service platform and control box" + ], + "processPorts": [ + { + "id": "fuel_gas_in", + "medium": "gas", + "side": "left", + "diameter": 0.14 + }, + { + "id": "steam_out", + "medium": "gas", + "side": "right", + "diameter": 0.16 + } + ], + "equipmentDefaults": { + "variant": "boiler" + } + }, + { + "id": "refinery.control_room", + "name": "Control room and MCC", + "aliases": [ + "中控室", + "控制室", + "MCC", + "control room" + ], + "industry": "refinery", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 4.6, + "width": 2.4, + "height": 2.4 + }, + "parts": [ + { + "kind": "generic_base", + "semanticRole": "control_room_floor_slab", + "length": 4, + "width": 2.4, + "height": 0.12, + "position": [ + 0, + 0.06, + 0 + ], + "primaryColor": "#cbd5e1" + }, + { + "kind": "generic_panel", + "semanticRole": "control_room_front_wall", + "length": 3.8, + "height": 1.8, + "thickness": 0.08, + "position": [ + 0, + 1, + 1.12 + ], + "accentColor": "#d1d5db", + "cornerRadius": 0.02 + }, + { + "kind": "generic_panel", + "semanticRole": "control_room_back_wall", + "length": 3.8, + "height": 1.8, + "thickness": 0.08, + "position": [ + 0, + 1, + -1.12 + ], + "accentColor": "#d1d5db", + "cornerRadius": 0.02 + }, + { + "kind": "generic_panel", + "semanticRole": "control_room_left_wall", + "length": 2.2, + "height": 1.8, + "thickness": 0.08, + "position": [ + -1.92, + 1, + 0 + ], + "rotation": [ + 0, + 1.5707963267948966, + 0 + ], + "accentColor": "#d1d5db", + "cornerRadius": 0.02 + }, + { + "kind": "generic_panel", + "semanticRole": "control_room_right_wall", + "length": 2.2, + "height": 1.8, + "thickness": 0.08, + "position": [ + 1.92, + 1, + 0 + ], + "rotation": [ + 0, + 1.5707963267948966, + 0 + ], + "accentColor": "#d1d5db", + "cornerRadius": 0.02 + }, + { + "kind": "generic_panel", + "semanticRole": "control_room_flat_roof", + "length": 4, + "height": 2.5, + "thickness": 0.14, + "position": [ + 0, + 1.94, + 0 + ], + "rotation": [ + 1.5707963267948966, + 0, + 0 + ], + "accentColor": "#94a3b8", + "cornerRadius": 0.03 + }, + { + "kind": "generic_opening", + "semanticRole": "blast_door", + "length": 0.78, + "height": 1.35, + "position": [ + -1.15, + 0.78, + 1.17 + ] + }, + { + "kind": "generic_detail_accent", + "semanticRole": "control_room_window", + "length": 0.92, + "height": 0.42, + "position": [ + 0.45, + 1.28, + 1.17 + ], + "accentColor": "#88c0d0", + "opacity": 0.42 + }, + { + "kind": "generic_detail_accent", + "semanticRole": "side_observation_window", + "length": 0.72, + "height": 0.38, + "position": [ + 1.97, + 1.24, + 0.3 + ], + "rotation": [ + 0, + 1.5707963267948966, + 0 + ], + "accentColor": "#88c0d0", + "opacity": 0.38 + }, + { + "kind": "control_box", + "semanticRole": "mcc_panel", + "position": [ + 1.2, + 0.75, + -0.95 + ] + }, + { + "kind": "pipe_run", + "semanticRole": "cable_tray_entry", + "length": 1.8, + "radius": 0.035, + "axis": "x", + "position": [ + 0, + 0.42, + -1.35 + ] + } + ], + "primarySemanticRole": "control_room_front_wall", + "qualityRules": "quality.refinery.control_room", + "status": "stable", + "source": "imported_pack", + "description": "炼油厂中控室和电气控制柜区域。", + "visualCues": [ + "small control-room building assembled from separate wall panels and a flat roof", + "front wall with blast door and blue observation window", + "side observation window facing process units", + "floor slab, four walls, and roof read as a small house rather than one equipment box", + "MCC panel and cable-tray entry remain visible" + ] + }, + { + "id": "refinery.gas_fractionation_unit", + "name": "Gas fractionation and LPG recovery unit", + "aliases": [ + "气体分馏装置", + "气分装置", + "LPG回收", + "FCC气分", + "gas fractionation unit", + "LPG recovery unit", + "gas plant" + ], + "industry": "refinery", + "layoutFamily": "distillation_column_layout", + "preferredResolver": "profile-parts", + "family": "distillation_column", + "defaultDimensions": { + "length": 6.8, + "width": 3.4, + "height": 6.8 + }, + "primarySemanticRole": "deethanizer_column", + "qualityRules": "quality.refinery.gas_fractionation_unit", + "status": "stable", + "source": "imported_pack", + "description": "FCC rich gas/LPG recovery and gas fractionation section for dry gas, LPG/propylene/butane streams; normal downstream of FCC rich gas, not a flare.", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "deethanizer_column", + "required": true, + "axis": "y", + "height": 5.8, + "radius": 0.42, + "position": [ + -1.6, + 2.95, + -0.4 + ], + "primaryColor": "#d1d5db" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "debutanizer_column", + "required": true, + "axis": "y", + "height": 5.2, + "radius": 0.46, + "position": [ + 0.15, + 2.65, + 0.2 + ], + "primaryColor": "#cbd5e1" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "lpg_surge_drum", + "required": true, + "axis": "x", + "length": 2.1, + "radius": 0.32, + "position": [ + 1.6, + 0.72, + -0.75 + ], + "primaryColor": "#dbe3ee" + }, + { + "kind": "heat_exchanger", + "semanticRole": "overhead_condenser", + "length": 2.2, + "radius": 0.28, + "axis": "x", + "position": [ + 1.55, + 0.72, + 0.75 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "rich_gas_header", + "length": 3.4, + "radius": 0.06, + "position": [ + -0.1, + 1.5, + 1.05 + ] + }, + { + "kind": "service_platform", + "semanticRole": "fractionator_platform", + "attachToRole": "deethanizer_column", + "anchor": "service_side", + "offset": [ + 0, + 1.4, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "fcc_rich_gas_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "dry_gas_outlet" + }, + { + "kind": "outlet_port", + "semanticRole": "lpg_product_outlet" + } + ], + "visualCues": [ + "two or more narrow fractionation columns near FCC", + "horizontal LPG surge drum and overhead condenser at grade", + "rich gas feed header from FCC, dry gas/LPG product outlets", + "dense small piping but no flare flame or tall emergency stack" + ], + "roleAliases": { + "deethanizer_column": [ + "gas_fractionator_column", + "cylindrical_tank" + ], + "debutanizer_column": [ + "lpg_splitter_column", + "cylindrical_tank" + ], + "lpg_surge_drum": [ + "lpg_receiver", + "cylindrical_tank" + ], + "rich_gas_header": [ + "fcc_rich_gas_feed", + "pipe_manifold" + ] + }, + "processPorts": [ + { + "id": "fcc_rich_gas_in", + "medium": "gas", + "side": "left", + "height": 1.5, + "offset": 0 + }, + { + "id": "dry_gas_out", + "medium": "gas", + "side": "top", + "height": 5.8, + "offset": -0.4 + }, + { + "id": "lpg_product_out", + "medium": "material", + "side": "right", + "height": 0.75, + "offset": -0.75 + } + ], + "detailBudget": { + "detailLevel": "low", + "maxShapes": 62 + } + }, + { + "id": "refinery.delayed_coker_unit", + "name": "Delayed coker unit", + "aliases": [ + "延迟焦化装置", + "焦化装置", + "减压渣油焦化", + "delayed coker", + "coker unit", + "residue coker" + ], + "industry": "refinery", + "layoutFamily": "generic_industrial_layout", + "preferredResolver": "profile-parts", + "family": "reactor", + "defaultDimensions": { + "length": 7.4, + "width": 4.4, + "height": 9 + }, + "primarySemanticRole": "coke_drum_pair", + "qualityRules": "quality.refinery.delayed_coker_unit", + "status": "stable", + "source": "imported_pack", + "description": "Delayed coker for vacuum residue conversion, with paired coke drums, coker fractionator, fired heater, overhead lines, and coke handling base.", + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "coke_drum_pair", + "required": true, + "axis": "y", + "height": 8.2, + "radius": 0.58, + "position": [ + -1.15, + 4.15, + -0.5 + ], + "primaryColor": "#9ca3af" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "coke_drum_pair", + "required": true, + "axis": "y", + "height": 8.2, + "radius": 0.58, + "position": [ + -0.05, + 4.15, + -0.5 + ], + "primaryColor": "#9ca3af" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "coker_fractionator", + "required": true, + "axis": "y", + "height": 6.4, + "radius": 0.52, + "position": [ + 1.65, + 3.25, + 0.2 + ], + "primaryColor": "#cbd5e1" + }, + { + "kind": "generic_body", + "semanticRole": "coker_fired_heater", + "required": true, + "length": 1.7, + "width": 1.2, + "height": 2, + "position": [ + -1, + 1, + 1.1 + ], + "primaryColor": "#78716c" + }, + { + "kind": "pipe_manifold", + "semanticRole": "overhead_vapor_line", + "length": 3.8, + "radius": 0.07, + "position": [ + 0.35, + 7.8, + -0.2 + ], + "metalColor": "#64748b" + }, + { + "kind": "skid_base", + "semanticRole": "coke_handling_base", + "required": true, + "length": 3.2, + "width": 1.6, + "height": 0.22, + "position": [ + -0.6, + 0.11, + -0.5 + ], + "metalColor": "#111827" + }, + { + "kind": "service_platform", + "semanticRole": "coker_drum_platform", + "length": 2.8, + "width": 0.36, + "height": 0.32, + "overallHeight": 0.18, + "position": [ + -0.6, + 7.2, + 0.28 + ], + "metalColor": "#64748b", + "color": "#facc15" + }, + { + "kind": "inlet_port", + "semanticRole": "vacuum_residue_inlet" + }, + { + "kind": "outlet_port", + "semanticRole": "coker_distillate_outlet" + }, + { + "kind": "outlet_port", + "semanticRole": "petroleum_coke_outlet" + } + ], + "visualCues": [ + "two tall coke drums side by side are the signature feature", + "fractionator tower beside drums and fired heater near feed side", + "overhead vapor line from coke drums to fractionator", + "heavy base/coke handling area below drums", + "vacuum residue feed inlet and coker distillate outlet" + ], + "roleAliases": { + "coke_drum_pair": [ + "coke_drum", + "paired_coke_drums", + "cylindrical_tank" + ], + "coker_fractionator": [ + "fractionator", + "cylindrical_tank" + ], + "coker_fired_heater": [ + "coker_heater", + "generic_body" + ], + "overhead_vapor_line": [ + "drum_overhead_line", + "pipe_manifold" + ] + }, + "processPorts": [ + { + "id": "vacuum_residue_in", + "medium": "material", + "side": "left", + "height": 1, + "offset": 1.1 + }, + { + "id": "coker_distillate_out", + "medium": "material", + "side": "right", + "height": 3.2, + "offset": 0.2 + }, + { + "id": "coke_out", + "medium": "material", + "side": "bottom", + "height": 0.25, + "offset": -0.5 + } + ], + "detailBudget": { + "detailLevel": "low", + "maxShapes": 82, + "parts": { + "coker_drum_platform": { + "detailLevel": "low" + } + } + } + } +] diff --git a/cloud/industry.refinery.basic-0.1.0/quality-rules/generated-quality.json b/cloud/industry.refinery.basic-0.1.0/quality-rules/generated-quality.json new file mode 100644 index 000000000..bdc1bf710 --- /dev/null +++ b/cloud/industry.refinery.basic-0.1.0/quality-rules/generated-quality.json @@ -0,0 +1,297 @@ +[ + { + "id": "quality.refinery.crude_storage_tank", + "requiredRoles": [ + "vessel_shell", + "feed_inlet", + "product_outlet", + "access_ladder" + ], + "shapeCount": { + "min": 3, + "max": 32 + } + }, + { + "id": "quality.refinery.product_storage_tank", + "requiredRoles": [ + "vessel_shell", + "feed_inlet", + "loading_outlet", + "access_ladder" + ], + "shapeCount": { + "min": 3, + "max": 32 + } + }, + { + "id": "quality.refinery.intermediate_storage_tank", + "requiredRoles": [ + "vessel_shell", + "distillate_inlet", + "conversion_feed_outlet", + "access_ladder" + ], + "shapeCount": { + "min": 3, + "max": 32 + } + }, + { + "id": "quality.refinery.desalter", + "requiredRoles": [ + "desalter_vessel", + "support_base", + "crude_inlet", + "desalted_crude_outlet", + "electrical_control_box" + ], + "shapeCount": { + "min": 3, + "max": 40 + } + }, + { + "id": "quality.refinery.atmospheric_distillation_unit", + "requiredRoles": [ + "distillation_column_shell", + "pre_flash_column_shell", + "side_stripper_column", + "tray_band", + "heat_exchanger_shell", + "fired_heater", + "side_draw_manifold", + "service_platform", + "crude_feed_inlet", + "overhead_product_outlet", + "atmospheric_residue_out" + ], + "shapeCount": { + "min": 10, + "max": 180 + }, + "forbiddenRoles": [ + "flare_stack_as_process_unit", + "belt_surface", + "vehicle_cabin", + "vehicle_tire" + ] + }, + { + "id": "quality.refinery.vacuum_distillation_unit", + "requiredRoles": [ + "vacuum_column_shell", + "heat_exchanger_shell", + "vacuum_heater", + "vacuum_line", + "service_platform", + "atmospheric_residue_inlet", + "vacuum_gas_oil_outlet", + "vacuum_residue_outlet" + ], + "shapeCount": { + "min": 10, + "max": 120 + }, + "forbiddenRoles": [ + "flare_stack_as_process_unit", + "belt_surface", + "vehicle_cabin", + "vehicle_tire" + ] + }, + { + "id": "quality.refinery.fluid_catalytic_cracking_unit", + "requiredRoles": [ + "fcc_reactor", + "catalyst_regenerator", + "main_fractionator", + "riser_pipe", + "spent_catalyst_standpipe", + "regenerated_catalyst_standpipe", + "cyclone_separator", + "flue_gas_stack", + "rich_gas_outlet", + "cracked_product_outlet" + ], + "shapeCount": { + "min": 12, + "max": 80 + }, + "forbiddenRoles": [ + "flare_stack_as_process_unit", + "belt_surface", + "vehicle_cabin", + "vehicle_tire" + ] + }, + { + "id": "quality.refinery.hydrotreating_unit", + "requiredRoles": [ + "hydrotreater_reactor", + "heat_exchanger_shell", + "high_pressure_separator", + "hydrogen_header", + "hydrogen_inlet", + "acid_gas_outlet", + "treated_product_outlet" + ], + "shapeCount": { + "min": 8, + "max": 76 + }, + "forbiddenRoles": [ + "flare_stack_as_process_unit", + "belt_surface", + "vehicle_cabin", + "vehicle_tire" + ] + }, + { + "id": "quality.refinery.catalytic_reformer_unit", + "requiredRoles": [ + "reformer_reactor_train", + "reformer_fired_heater", + "heat_exchanger_shell", + "reformer_separator", + "hydrogen_rich_gas_header", + "naphtha_inlet", + "reformate_outlet" + ], + "shapeCount": { + "min": 8, + "max": 76 + }, + "forbiddenRoles": [ + "flare_stack_as_process_unit", + "belt_surface", + "vehicle_cabin", + "vehicle_tire" + ] + }, + { + "id": "quality.refinery.sulfur_recovery_unit", + "requiredRoles": [ + "claus_reactor", + "heat_exchanger_shell", + "tail_gas_stack", + "acid_gas_inlet", + "sulfur_outlet" + ], + "shapeCount": { + "min": 3, + "max": 40 + } + }, + { + "id": "quality.refinery.flare_system", + "requiredRoles": [ + "flare_stack", + "knockout_drum", + "relief_gas_inlet" + ], + "shapeCount": { + "min": 2, + "max": 24 + } + }, + { + "id": "quality.refinery.pipe_rack", + "requiredRoles": [ + "main_pipe_header", + "parallel_pipe_run", + "pipe_rack_support_frame" + ], + "shapeCount": { + "min": 2, + "max": 24 + } + }, + { + "id": "quality.refinery.utility_boiler", + "requiredRoles": [ + "boiler_body", + "steam_drum", + "mud_drum", + "boiler_tube_bank", + "boiler_stack", + "steam_header", + "burner_front", + "burner_windbox", + "fuel_gas_train", + "boiler_access_platform", + "boiler_control_box" + ], + "shapeCount": { + "min": 10, + "max": 88 + } + }, + { + "id": "quality.refinery.control_room", + "requiredRoles": [ + "control_room_floor_slab", + "control_room_front_wall", + "control_room_back_wall", + "control_room_left_wall", + "control_room_right_wall", + "control_room_flat_roof", + "blast_door", + "control_room_window", + "side_observation_window", + "mcc_panel", + "cable_tray_entry" + ], + "shapeCount": { + "min": 10, + "max": 64 + } + }, + { + "id": "quality.refinery.gas_fractionation_unit", + "requiredRoles": [ + "deethanizer_column", + "debutanizer_column", + "lpg_surge_drum", + "heat_exchanger_shell", + "rich_gas_header", + "fcc_rich_gas_inlet", + "dry_gas_outlet", + "lpg_product_outlet" + ], + "forbiddenRoles": [ + "flare_stack", + "belt_surface", + "vehicle_cabin", + "vehicle_tire" + ], + "shapeCount": { + "min": 8, + "max": 70 + } + }, + { + "id": "quality.refinery.delayed_coker_unit", + "requiredRoles": [ + "coke_drum_pair", + "coker_fractionator", + "coker_fired_heater", + "overhead_vapor_line", + "coke_handling_base", + "coker_drum_platform", + "vacuum_residue_inlet", + "coker_distillate_outlet" + ], + "forbiddenRoles": [ + "flare_stack", + "belt_surface", + "vehicle_cabin", + "vehicle_tire" + ], + "shapeCount": { + "min": 10, + "max": 82 + } + } +] diff --git a/cloud/industry.robotics.basic-0.1.0.zip b/cloud/industry.robotics.basic-0.1.0.zip new file mode 100644 index 000000000..47b957e0f Binary files /dev/null and b/cloud/industry.robotics.basic-0.1.0.zip differ diff --git a/cloud/industry.robotics.basic-0.1.0/README.md b/cloud/industry.robotics.basic-0.1.0/README.md new file mode 100644 index 000000000..b4fbb5d20 --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/README.md @@ -0,0 +1,12 @@ +# Robotics Basic Equipment Pack + +This is a simulated cloud geometry knowledge pack for industrial robot arms. + +It intentionally contains data only: + +- device profiles +- layout templates +- part presets +- quality rules + +The pack does not execute code. Robot arm geometry is still produced by the trusted application composer. diff --git a/cloud/industry.robotics.basic-0.1.0/editable-schemas/robot-arm-common.json b/cloud/industry.robotics.basic-0.1.0/editable-schemas/robot-arm-common.json new file mode 100644 index 000000000..3cf26d41e --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/editable-schemas/robot-arm-common.json @@ -0,0 +1,84 @@ +{ + "id": "robot_arm.common", + "name": "Common robot arm editable properties", + "description": "Shared editable controls for articulated industrial robot arms in the robotics pack.", + "properties": { + "axisCount": { + "type": "integer", + "min": 3, + "max": 7, + "default": 6, + "aliases": ["axis count", "axes", "joint count", "轴数", "几轴"], + "role": "structure" + }, + "reach": { + "type": "number", + "min": 0.8, + "max": 8, + "default": 1.58, + "unit": "m", + "aliases": ["reach", "arm span", "working radius", "臂展", "工作半径"], + "role": "dimension" + }, + "height": { + "type": "number", + "min": 0.8, + "max": 4, + "default": 2.2, + "unit": "m", + "aliases": ["height", "overall height", "高度"], + "role": "dimension" + }, + "pose": { + "type": "enum", + "values": ["work-ready", "reach-forward", "rest"], + "default": "work-ready", + "aliases": ["pose", "posture", "姿态"], + "role": "pose" + }, + "endEffector": { + "type": "enum", + "values": ["tool-flange", "gripper", "suction"], + "default": "tool-flange", + "aliases": ["end effector", "tool", "末端", "末端工具", "夹爪", "吸盘", "法兰"], + "role": "structure" + }, + "primaryColor": { + "type": "color", + "default": "#facc15", + "aliases": ["main color", "body color", "主体颜色", "颜色"], + "role": "material" + }, + "secondaryColor": { + "type": "color", + "default": "#111827", + "aliases": ["joint color", "secondary color", "关节颜色"], + "role": "material" + }, + "metalColor": { + "type": "color", + "default": "#cbd5e1", + "aliases": ["metal color", "flange color", "金属色"], + "role": "material" + }, + "includeCableHarness": { + "type": "boolean", + "default": true, + "aliases": ["cable", "cable harness", "线缆", "线束"], + "role": "detail" + }, + "includeWorkcell": { + "type": "boolean", + "default": false, + "aliases": ["workcell", "station", "工作站", "工位"], + "role": "workcell" + }, + "detailLevel": { + "type": "enum", + "values": ["low", "medium", "high"], + "default": "medium", + "aliases": ["detail", "complexity", "细节", "精细度"], + "role": "detail" + } + } +} diff --git a/cloud/industry.robotics.basic-0.1.0/layouts/articulated-robot-four-axis.json b/cloud/industry.robotics.basic-0.1.0/layouts/articulated-robot-four-axis.json new file mode 100644 index 000000000..f0a879c10 --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/layouts/articulated-robot-four-axis.json @@ -0,0 +1,24 @@ +{ + "id": "articulated_robot.four_axis", + "family": "robot_workcell_layout", + "description": "Four-axis articulated robot skeleton for compact pick-and-place arms. The executor consumes robotArmDefaults.axisCount=4 and can use these anchors in future layout resolver versions.", + "anchors": [ + { "id": "base", "role": "robot_base", "position": [0, 0, 0] }, + { "id": "shoulder", "role": "shoulder_joint", "relativeTo": "base", "offset": [0, 0.55, 0] }, + { "id": "elbow", "role": "elbow_joint", "relativeTo": "shoulder", "offset": [-0.28, 0.52, 0] }, + { "id": "wrist", "role": "wrist_joint", "relativeTo": "elbow", "offset": [0.68, 0.06, 0] }, + { "id": "flange", "role": "tool_flange", "relativeTo": "wrist", "offset": [0.24, 0, 0] } + ], + "placements": [ + { "role": "robot_base", "anchor": "base" }, + { "role": "base_joint", "anchor": "base" }, + { "role": "shoulder_joint", "anchor": "shoulder" }, + { "role": "upper_arm", "between": ["shoulder", "elbow"] }, + { "role": "elbow_joint", "anchor": "elbow" }, + { "role": "forearm", "between": ["elbow", "wrist"] }, + { "role": "wrist_roll_joint", "anchor": "wrist" }, + { "role": "wrist_joint", "anchor": "wrist" }, + { "role": "tool_flange", "anchor": "flange" }, + { "role": "end_effector", "anchor": "flange" } + ] +} diff --git a/cloud/industry.robotics.basic-0.1.0/layouts/articulated-robot-six-axis.json b/cloud/industry.robotics.basic-0.1.0/layouts/articulated-robot-six-axis.json new file mode 100644 index 000000000..c2072153d --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/layouts/articulated-robot-six-axis.json @@ -0,0 +1,23 @@ +{ + "id": "articulated_robot.six_axis", + "family": "robot_workcell_layout", + "description": "Six-axis articulated robot skeleton. The current executor consumes metadata and robotArmDefaults, while future template resolver versions can use anchors and placements directly.", + "anchors": [ + { "id": "base", "role": "robot_base", "position": [0, 0, 0] }, + { "id": "shoulder", "role": "shoulder_joint", "relativeTo": "base", "offset": [0, 0.6, 0] }, + { "id": "elbow", "role": "elbow_joint", "relativeTo": "shoulder", "offset": [-0.35, 0.62, 0] }, + { "id": "wrist", "role": "wrist_joint", "relativeTo": "elbow", "offset": [0.82, 0.08, 0] }, + { "id": "flange", "role": "tool_flange", "relativeTo": "wrist", "offset": [0.3, 0, 0] } + ], + "placements": [ + { "role": "robot_base", "anchor": "base" }, + { "role": "base_joint", "anchor": "base" }, + { "role": "shoulder_joint", "anchor": "shoulder" }, + { "role": "upper_arm", "between": ["shoulder", "elbow"] }, + { "role": "elbow_joint", "anchor": "elbow" }, + { "role": "forearm", "between": ["elbow", "wrist"] }, + { "role": "wrist_joint", "anchor": "wrist" }, + { "role": "tool_flange", "anchor": "flange" }, + { "role": "end_effector", "anchor": "flange" } + ] +} diff --git a/cloud/industry.robotics.basic-0.1.0/pack.json b/cloud/industry.robotics.basic-0.1.0/pack.json new file mode 100644 index 000000000..388fa6990 --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/pack.json @@ -0,0 +1,35 @@ +{ + "id": "industry.robotics.basic", + "name": "Robotics Basic Equipment Pack", + "industry": "robotics", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Basic industrial robotics geometry knowledge pack with articulated robot arm profiles, layouts, presets, and quality rules.", + "profiles": [ + "profiles/robot-arms.json" + ], + "layouts": [ + "layouts/articulated-robot-six-axis.json", + "layouts/articulated-robot-four-axis.json" + ], + "partPresets": [ + "part-presets/robot-arm-parts.json" + ], + "qualityRules": [ + "quality-rules/robot-arm-quality.json", + "quality-rules/robot-arm-four-axis-quality.json" + ], + "editableSchemas": [ + "editable-schemas/robot-arm-common.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [] +} diff --git a/cloud/industry.robotics.basic-0.1.0/part-presets/robot-arm-parts.json b/cloud/industry.robotics.basic-0.1.0/part-presets/robot-arm-parts.json new file mode 100644 index 000000000..929bf5433 --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/part-presets/robot-arm-parts.json @@ -0,0 +1,89 @@ +[ + { + "id": "robot_arm.base.heavy", + "partKind": "generic_base", + "defaults": { + "primaryColor": "#111827", + "secondaryColor": "#facc15" + }, + "parameters": { + "length": { "from": "height", "scale": 0.3, "min": 0.42, "max": 0.72 }, + "width": { "from": "height", "scale": 0.3, "min": 0.42, "max": 0.72 }, + "height": { "from": "height", "scale": 0.18, "min": 0.26, "max": 0.5 } + } + }, + { + "id": "robot_arm.joint.large", + "partKind": "generic_body", + "defaults": { + "primaryColor": "#facc15", + "secondaryColor": "#111827" + }, + "parameters": { + "length": { "from": "height", "scale": 0.24, "min": 0.32, "max": 0.62 }, + "width": { "from": "height", "scale": 0.2, "min": 0.28, "max": 0.5 }, + "height": { "from": "height", "scale": 0.22, "min": 0.3, "max": 0.56 } + } + }, + { + "id": "robot_arm.joint.medium", + "partKind": "generic_body", + "defaults": { + "primaryColor": "#facc15" + }, + "parameters": { + "length": { "from": "height", "scale": 0.2, "min": 0.26, "max": 0.48 }, + "width": { "from": "height", "scale": 0.16, "min": 0.22, "max": 0.42 }, + "height": { "from": "height", "scale": 0.18, "min": 0.24, "max": 0.44 } + } + }, + { + "id": "robot_arm.joint.small", + "partKind": "generic_panel", + "defaults": { + "primaryColor": "#111827" + }, + "parameters": { + "length": { "from": "height", "scale": 0.14, "min": 0.16, "max": 0.32 }, + "width": { "from": "height", "scale": 0.12, "min": 0.14, "max": 0.28 }, + "height": { "from": "height", "scale": 0.12, "min": 0.14, "max": 0.28 } + } + }, + { + "id": "robot_arm.link.upper", + "partKind": "generic_body", + "defaults": { + "primaryColor": "#facc15" + }, + "parameters": { + "length": { "from": "height", "scale": 0.42, "min": 0.55, "max": 1.05 }, + "width": { "from": "height", "scale": 0.12, "min": 0.16, "max": 0.32 }, + "height": { "from": "height", "scale": 0.16, "min": 0.22, "max": 0.42 } + } + }, + { + "id": "robot_arm.link.forearm", + "partKind": "generic_body", + "defaults": { + "primaryColor": "#facc15" + }, + "parameters": { + "length": { "from": "height", "scale": 0.48, "min": 0.62, "max": 1.15 }, + "width": { "from": "height", "scale": 0.11, "min": 0.15, "max": 0.3 }, + "height": { "from": "height", "scale": 0.14, "min": 0.2, "max": 0.38 } + } + }, + { + "id": "robot_arm.tool_flange", + "partKind": "generic_panel", + "defaults": { + "primaryColor": "#cbd5e1", + "secondaryColor": "#111827" + }, + "parameters": { + "length": { "from": "height", "scale": 0.12, "min": 0.14, "max": 0.28 }, + "width": { "from": "height", "scale": 0.08, "min": 0.1, "max": 0.2 }, + "height": { "from": "height", "scale": 0.08, "min": 0.1, "max": 0.2 } + } + } +] diff --git a/cloud/industry.robotics.basic-0.1.0/profiles/robot-arms.json b/cloud/industry.robotics.basic-0.1.0/profiles/robot-arms.json new file mode 100644 index 000000000..fb3c23003 --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/profiles/robot-arms.json @@ -0,0 +1,348 @@ +[ + { + "id": "robotics.six_axis_industrial_robot_arm", + "name": "Six-axis industrial robot arm", + "aliases": [ + "six-axis industrial robot arm", + "6-axis industrial robot arm", + "six axis robot", + "6 axis robot", + "industrial robot arm", + "articulated robot arm", + "fanuc robot arm", + "kuka robot arm", + "abb robot arm", + "六轴机器臂", + "六轴工业机器人", + "工业机器人", + "机器臂", + "机械臂", + "liu zhou ji qi bi", + "ji qi bi", + "robot arm" + ], + "industry": "robotics", + "family": "robot_arm", + "layoutFamily": "robot_workcell_layout", + "layoutTemplate": "articulated_robot.six_axis", + "archetypeFamily": "robotic_workcell", + "defaultDimensions": { + "length": 2.2, + "width": 1.2, + "height": 2.2 + }, + "primarySemanticRole": "robot_base", + "partPresets": { + "robot_base": "robot_arm.base.heavy", + "base_joint": "robot_arm.joint.large", + "shoulder_joint": "robot_arm.joint.large", + "upper_arm": "robot_arm.link.upper", + "elbow_joint": "robot_arm.joint.medium", + "forearm": "robot_arm.link.forearm", + "wrist_joint": "robot_arm.joint.small", + "tool_flange": "robot_arm.tool_flange" + }, + "qualityRules": "quality.robot_arm.six_axis", + "layoutHints": { + "robotArmDefaults": { + "axisCount": 6, + "scope": "arm_only", + "includeWorkcell": false, + "reach": 1.58, + "primaryColor": "#facc15", + "secondaryColor": "#111827", + "metalColor": "#cbd5e1" + } + }, + "editableSchemaRef": "robot_arm.common", + "editableOverrides": { + "axisCount": { + "default": 6, + "min": 6, + "max": 6 + }, + "endEffector": { + "default": "tool-flange", + "values": [ + "tool-flange", + "gripper", + "suction" + ] + } + }, + "parts": [ + { + "kind": "generic_base", + "semanticRole": "robot_base", + "required": true, + "preset": "robot_arm.base.heavy" + }, + { + "kind": "generic_body", + "semanticRole": "base_joint", + "required": true, + "preset": "robot_arm.joint.large" + }, + { + "kind": "generic_body", + "semanticRole": "shoulder_joint", + "required": true, + "preset": "robot_arm.joint.large" + }, + { + "kind": "generic_body", + "semanticRole": "upper_arm", + "required": true, + "preset": "robot_arm.link.upper" + }, + { + "kind": "generic_body", + "semanticRole": "elbow_joint", + "required": true, + "preset": "robot_arm.joint.medium" + }, + { + "kind": "generic_body", + "semanticRole": "forearm", + "required": true, + "preset": "robot_arm.link.forearm" + }, + { + "kind": "generic_panel", + "semanticRole": "wrist_joint", + "required": true, + "preset": "robot_arm.joint.small" + }, + { + "kind": "generic_panel", + "semanticRole": "tool_flange", + "required": true, + "preset": "robot_arm.tool_flange" + }, + { + "kind": "generic_panel", + "semanticRole": "end_effector", + "required": true, + "preset": "robot_arm.tool_flange" + } + ], + "visualCues": [ + "black cylindrical base", + "large shoulder and elbow housings", + "posed upper arm and forearm", + "compact triple wrist", + "front tool flange", + "visible dark cable harness" + ], + "roleAliases": { + "wrist_joint": [ + "wrist_roll_joint", + "wrist_pitch_joint", + "wrist_yaw_joint" + ], + "tool_flange": [ + "flange", + "end_effector" + ], + "robot_base": [ + "base_joint" + ] + }, + "forbiddenRoles": [ + "work_table", + "control_panel", + "safety_barrier" + ], + "status": "stable", + "source": "imported_pack", + "description": "Arm-only six-axis industrial articulated robot profile. The pack supplies recognition, defaults, quality rules, and metadata while the app executes the trusted robot arm composer.", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 48, + "parts": { + "wrist_joint": { + "detailLevel": "low" + }, + "tool_flange": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "end_effector": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "robotics.four_axis_industrial_robot_arm", + "name": "Four-axis industrial robot arm", + "aliases": [ + "four-axis industrial robot arm", + "4-axis industrial robot arm", + "four axis robot", + "4 axis robot", + "four-axis robot arm", + "4-axis robot arm", + "scara robot arm", + "pick and place robot arm", + "四轴机器臂", + "四轴工业机器人", + "si zhou ji qi bi" + ], + "industry": "robotics", + "family": "robot_arm", + "layoutFamily": "robot_workcell_layout", + "layoutTemplate": "articulated_robot.four_axis", + "archetypeFamily": "robotic_workcell", + "defaultDimensions": { + "length": 1.9, + "width": 1, + "height": 1.9 + }, + "primarySemanticRole": "robot_base", + "partPresets": { + "robot_base": "robot_arm.base.heavy", + "base_joint": "robot_arm.joint.large", + "shoulder_joint": "robot_arm.joint.large", + "upper_arm": "robot_arm.link.upper", + "elbow_joint": "robot_arm.joint.medium", + "forearm": "robot_arm.link.forearm", + "wrist_joint": "robot_arm.joint.small", + "tool_flange": "robot_arm.tool_flange" + }, + "qualityRules": "quality.robot_arm.four_axis", + "layoutHints": { + "robotArmDefaults": { + "axisCount": 4, + "scope": "arm_only", + "includeWorkcell": false, + "reach": 1.36, + "primaryColor": "#facc15", + "secondaryColor": "#111827", + "metalColor": "#cbd5e1" + } + }, + "editableSchemaRef": "robot_arm.common", + "editableOverrides": { + "axisCount": { + "default": 4, + "min": 4, + "max": 4 + }, + "endEffector": { + "default": "tool-flange", + "values": [ + "tool-flange", + "gripper", + "suction" + ] + } + }, + "parts": [ + { + "kind": "generic_base", + "semanticRole": "robot_base", + "required": true, + "preset": "robot_arm.base.heavy" + }, + { + "kind": "generic_body", + "semanticRole": "base_joint", + "required": true, + "preset": "robot_arm.joint.large" + }, + { + "kind": "generic_body", + "semanticRole": "shoulder_joint", + "required": true, + "preset": "robot_arm.joint.large" + }, + { + "kind": "generic_body", + "semanticRole": "upper_arm", + "required": true, + "preset": "robot_arm.link.upper" + }, + { + "kind": "generic_body", + "semanticRole": "elbow_joint", + "required": true, + "preset": "robot_arm.joint.medium" + }, + { + "kind": "generic_body", + "semanticRole": "forearm", + "required": true, + "preset": "robot_arm.link.forearm" + }, + { + "kind": "generic_panel", + "semanticRole": "wrist_joint", + "required": true, + "preset": "robot_arm.joint.small" + }, + { + "kind": "generic_panel", + "semanticRole": "tool_flange", + "required": true, + "preset": "robot_arm.tool_flange" + }, + { + "kind": "generic_panel", + "semanticRole": "end_effector", + "required": true, + "preset": "robot_arm.tool_flange" + } + ], + "visualCues": [ + "black cylindrical base", + "shoulder and elbow housings", + "posed upper arm and forearm", + "single wrist roll module", + "front tool flange", + "no wrist pitch module" + ], + "roleAliases": { + "wrist_joint": [ + "wrist_roll_joint", + "wrist_yaw_joint" + ], + "tool_flange": [ + "flange", + "end_effector" + ], + "robot_base": [ + "base_joint" + ] + }, + "forbiddenRoles": [ + "wrist_pitch_joint", + "work_table", + "control_panel", + "safety_barrier" + ], + "status": "stable", + "source": "imported_pack", + "description": "Arm-only four-axis industrial articulated robot profile for pick-and-place style requests. It preserves a simpler wrist than six-axis robots.", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 48, + "parts": { + "wrist_joint": { + "detailLevel": "low" + }, + "tool_flange": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "end_effector": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.robotics.basic-0.1.0/quality-rules/robot-arm-four-axis-quality.json b/cloud/industry.robotics.basic-0.1.0/quality-rules/robot-arm-four-axis-quality.json new file mode 100644 index 000000000..05c179943 --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/quality-rules/robot-arm-four-axis-quality.json @@ -0,0 +1,27 @@ +{ + "id": "quality.robot_arm.four_axis", + "requiredRoles": [ + "robot_base", + "base_joint", + "shoulder_joint", + "upper_arm", + "elbow_joint", + "forearm", + "wrist_roll_joint", + "wrist_joint", + "tool_flange", + "end_effector" + ], + "forbiddenRoles": [ + "wrist_pitch_joint", + "work_table", + "control_panel", + "safety_barrier", + "vehicle_body", + "wheel" + ], + "shapeCount": { + "min": 8, + "max": 24 + } +} diff --git a/cloud/industry.robotics.basic-0.1.0/quality-rules/robot-arm-quality.json b/cloud/industry.robotics.basic-0.1.0/quality-rules/robot-arm-quality.json new file mode 100644 index 000000000..919f27495 --- /dev/null +++ b/cloud/industry.robotics.basic-0.1.0/quality-rules/robot-arm-quality.json @@ -0,0 +1,19 @@ +{ + "id": "quality.robot_arm.six_axis", + "requiredRoles": [ + "robot_base", + "base_joint", + "shoulder_joint", + "upper_arm", + "elbow_joint", + "forearm", + "wrist_joint", + "tool_flange", + "end_effector" + ], + "forbiddenRoles": ["work_table", "control_panel", "safety_barrier", "vehicle_body", "wheel"], + "shapeCount": { + "min": 9, + "max": 28 + } +} diff --git a/cloud/industry.thermal-power.basic-0.1.0.zip b/cloud/industry.thermal-power.basic-0.1.0.zip new file mode 100644 index 000000000..fc7823fd8 Binary files /dev/null and b/cloud/industry.thermal-power.basic-0.1.0.zip differ diff --git a/cloud/industry.thermal-power.basic-0.1.0/README.md b/cloud/industry.thermal-power.basic-0.1.0/README.md new file mode 100644 index 000000000..7b71ec0cc --- /dev/null +++ b/cloud/industry.thermal-power.basic-0.1.0/README.md @@ -0,0 +1,57 @@ +# Thermal Power Plant Basic Equipment Pack + +Factory-capable coal-fired thermal power plant profile pack based on a campus-style plant layout with cooling towers, boiler and turbine island, coal handling, flue-gas cleanup, chimney, switchyard, and auxiliary buildings. + +## Devices + +- Natural draft cooling tower (thermal_power.natural_draft_cooling_tower) +- Circulating water pump house (thermal_power.circulating_water_pump_house) +- Coal yard stockpile (thermal_power.coal_yard_stockpile) +- Coal handling conveyor (thermal_power.coal_handling_conveyor) +- Coal pulverizer mill (thermal_power.coal_pulverizer_mill) +- Coal-fired boiler island (thermal_power.boiler_island) +- Steam turbine generator (thermal_power.steam_turbine_generator) +- Surface condenser (thermal_power.surface_condenser) +- Electrostatic precipitator (thermal_power.electrostatic_precipitator) +- Wet FGD absorber tower (thermal_power.fgd_absorber) +- Plant chimney stack (thermal_power.chimney_stack) +- Fly ash silo (thermal_power.fly_ash_silo) +- Water treatment building (thermal_power.water_treatment_building) +- Generator step-up transformer (thermal_power.generator_step_up_transformer) +- High voltage switchyard (thermal_power.switchyard) +- Control room and DCS building (thermal_power.control_room) +- Warehouse and maintenance workshop (thermal_power.warehouse_workshop) + +## Pack Type + +Factory-capable pack: supports factory/process creation through process templates and factory architectures. + +## Factory Creation + +Supported whole-factory/process templates: + +- Coal-fired thermal power station (thermal_power_coal_fired_station) + +Supported factory scopes/modules: + +- Coal-fired thermal power station (coal_fired_station) + +## Factory Architectures + +- Coal-fired thermal power station architecture + +## Process Templates + +- Coal-fired thermal power station + +## Authoring Review + +- thermal_power.steam_turbine_generator: boiler_missing_process_features - Boiler profiles should show more than a plain box: include a stack, visible steam drum or tube bank, steam header, and control/service details. + +## Validation + +Run: + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.thermal-power.basic@0.1.0 --validate-only +``` diff --git a/cloud/industry.thermal-power.basic-0.1.0/factory-architectures/generated.json b/cloud/industry.thermal-power.basic-0.1.0/factory-architectures/generated.json new file mode 100644 index 000000000..e825f86a8 --- /dev/null +++ b/cloud/industry.thermal-power.basic-0.1.0/factory-architectures/generated.json @@ -0,0 +1,207 @@ +[ + { + "id": "thermal_power.factory.coal_fired_station", + "label": "Coal-fired thermal power station architecture", + "industry": "thermal-power", + "processId": "thermal_power_coal_fired_station", + "layoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 72, + "width": 72 + }, + "scopes": [ + { + "id": "coal_fired_station", + "label": "Coal-fired thermal power station", + "includeModules": [ + "cooling_island", + "power_block", + "flue_gas_cleanup", + "fuel_handling", + "ash_and_water", + "electrical_export", + "auxiliary_buildings" + ] + } + ], + "modules": [ + { + "id": "cooling_island", + "displayLabel": "Cooling island", + "order": 10, + "stationIds": [ + "natural_draft_cooling_tower", + "circulating_water_pump_house" + ] + }, + { + "id": "power_block", + "displayLabel": "Boiler and turbine island", + "order": 20, + "stationIds": [ + "coal_pulverizer_mill", + "boiler_island", + "steam_turbine_generator", + "surface_condenser" + ] + }, + { + "id": "flue_gas_cleanup", + "displayLabel": "Flue-gas cleanup", + "order": 30, + "stationIds": [ + "electrostatic_precipitator", + "fgd_absorber", + "chimney_stack" + ] + }, + { + "id": "fuel_handling", + "displayLabel": "Coal handling", + "order": 40, + "stationIds": [ + "coal_yard_stockpile", + "coal_handling_conveyor" + ] + }, + { + "id": "ash_and_water", + "displayLabel": "Ash and water treatment", + "order": 50, + "stationIds": [ + "fly_ash_silo", + "water_treatment_building" + ] + }, + { + "id": "electrical_export", + "displayLabel": "Electrical export", + "order": 60, + "stationIds": [ + "generator_step_up_transformer", + "switchyard" + ] + }, + { + "id": "auxiliary_buildings", + "displayLabel": "Auxiliary buildings", + "order": 70, + "stationIds": [ + "control_room", + "warehouse_workshop" + ] + } + ], + "layoutHints": { + "primaryAxis": "x", + "secondaryAxis": "z", + "indoorWorkshop": false, + "highestStationId": "chimney_stack", + "longAxisStationId": "coal_handling_conveyor", + "sideBranchStationIds": [ + "natural_draft_cooling_tower", + "coal_yard_stockpile", + "switchyard", + "water_treatment_building", + "control_room", + "warehouse_workshop" + ], + "scale": "conceptual", + "notes": [ + "Open-air square plant yard; omit perimeter walls so the site reads as an outdoor power station campus.", + "Reference-image layout: four large natural draft cooling towers in a 2x2 block on the left side, with the pump house immediately below them.", + "Do not lay the station out as two empty parallel rows; use stationPositions to form a dense campus block with equipment in the middle.", + "Central power block runs from coal mill to boiler, turbine hall, condenser, electrostatic precipitator, FGD absorber, and chimney.", + "Transformer and switchyard sit to the right and rear, while low auxiliary buildings occupy the foreground edge of the square yard." + ], + "omitPerimeterWalls": true, + "stationPositions": { + "natural_draft_cooling_tower": { + "x": -24, + "z": -8, + "rotationY": 0 + }, + "circulating_water_pump_house": { + "x": -24, + "z": 5, + "rotationY": 0 + }, + "coal_yard_stockpile": { + "x": -22, + "z": 20, + "rotationY": 0.08 + }, + "coal_handling_conveyor": { + "x": -12, + "z": 12, + "rotationY": -0.32 + }, + "coal_pulverizer_mill": { + "x": -9, + "z": 1, + "rotationY": 0 + }, + "boiler_island": { + "x": -2, + "z": -2, + "rotationY": 0 + }, + "steam_turbine_generator": { + "x": 8, + "z": -2, + "rotationY": 0 + }, + "surface_condenser": { + "x": 8, + "z": 6, + "rotationY": 0 + }, + "electrostatic_precipitator": { + "x": 18, + "z": -1, + "rotationY": 0 + }, + "fgd_absorber": { + "x": 25, + "z": 3, + "rotationY": 0 + }, + "chimney_stack": { + "x": 28, + "z": -6, + "rotationY": 0 + }, + "fly_ash_silo": { + "x": 19, + "z": 8, + "rotationY": 0 + }, + "water_treatment_building": { + "x": -6, + "z": 21, + "rotationY": 0 + }, + "generator_step_up_transformer": { + "x": 18, + "z": -11, + "rotationY": 0 + }, + "switchyard": { + "x": 27, + "z": -17, + "rotationY": 0 + }, + "control_room": { + "x": 4, + "z": 20, + "rotationY": 0 + }, + "warehouse_workshop": { + "x": 16, + "z": 20, + "rotationY": 0 + } + } + } + } +] diff --git a/cloud/industry.thermal-power.basic-0.1.0/pack.json b/cloud/industry.thermal-power.basic-0.1.0/pack.json new file mode 100644 index 000000000..c42f3bd34 --- /dev/null +++ b/cloud/industry.thermal-power.basic-0.1.0/pack.json @@ -0,0 +1,180 @@ +{ + "id": "industry.thermal-power.basic", + "name": "Thermal Power Plant Basic Equipment Pack", + "industry": "thermal-power", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Factory-capable coal-fired thermal power plant profile pack based on a campus-style plant layout with cooling towers, boiler and turbine island, coal handling, flue-gas cleanup, chimney, switchyard, and auxiliary buildings.", + "profiles": [ + "profiles/generated.json" + ], + "qualityRules": [ + "quality-rules/generated-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/generated.json" + ], + "processTemplates": [ + "process-templates/generated.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "thermal_power.natural_draft_cooling_tower", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "thermal_power.circulating_water_pump_house", + "recipeId": "factory:centrifugal-pump", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.pumpType": "pumpType", + "equipmentDefaults.flowRate": "flowRate", + "equipmentDefaults.motorPower": "motorPower", + "equipmentDefaults.skidMounted": "skidMounted" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "thermal_power.coal_yard_stockpile", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "thermal_power.surface_condenser", + "recipeId": "factory:centrifugal-pump", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.pumpType": "pumpType", + "equipmentDefaults.flowRate": "flowRate", + "equipmentDefaults.motorPower": "motorPower", + "equipmentDefaults.skidMounted": "skidMounted" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "thermal_power.fgd_absorber", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "thermal_power.fly_ash_silo", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "thermal_power.water_treatment_building", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "thermal_power.generator_step_up_transformer", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.thermal-power.basic-0.1.0/process-templates/generated.json b/cloud/industry.thermal-power.basic-0.1.0/process-templates/generated.json new file mode 100644 index 000000000..9c00acf9f --- /dev/null +++ b/cloud/industry.thermal-power.basic-0.1.0/process-templates/generated.json @@ -0,0 +1,416 @@ +[ + { + "processId": "thermal_power_coal_fired_station", + "processLabel": "Coal-fired thermal power station", + "processDisplayLabel": "Coal-fired thermal power station", + "domain": "power-generation", + "aliases": [ + "火电站", + "火电厂", + "火力发电厂", + "燃煤电厂", + "煤电厂", + "发电厂", + "生成一个火电站", + "生成一个火电厂", + "做一个火电站", + "做一个火电厂", + "thermal power plant", + "coal fired power plant", + "coal-fired power station", + "steam electric power plant" + ], + "requiredRoles": [ + "natural_draft_cooling_tower", + "circulating_water_pump_house", + "coal_yard_stockpile", + "coal_handling_conveyor", + "coal_pulverizer_mill", + "boiler_island", + "steam_turbine_generator", + "surface_condenser", + "electrostatic_precipitator", + "fgd_absorber", + "chimney_stack", + "fly_ash_silo", + "water_treatment_building", + "generator_step_up_transformer", + "switchyard", + "control_room", + "warehouse_workshop" + ], + "defaultLayoutStyle": "parallel_bays", + "defaultDimensions": { + "length": 72, + "width": 72 + }, + "safetyTags": [ + "high_temperature", + "steam", + "coal_handling", + "flue_gas", + "high_voltage", + "cooling_water" + ], + "stations": [ + { + "id": "natural_draft_cooling_tower", + "label": "Natural draft cooling tower", + "displayLabel": "Natural draft cooling tower", + "role": "natural_draft_cooling_tower", + "equipmentHint": "thermal_power.natural_draft_cooling_tower hyperboloid natural draft cooling tower cluster with flared shell, top opening, basin, and cooling water pipe", + "footprintHint": "large", + "safetyTags": [ + "cooling_water", + "tall_structure" + ], + "profileId": "thermal_power.natural_draft_cooling_tower" + }, + { + "id": "boiler_island", + "label": "Boiler island", + "displayLabel": "Boiler island", + "role": "boiler_island", + "equipmentHint": "thermal_power.boiler_island tall coal-fired boiler island with furnace casing, steam drum, burner openings, forced draft blower, steam header, and flue gas duct", + "footprintHint": "tall", + "safetyTags": [ + "high_temperature", + "steam", + "pressure_equipment" + ], + "profileId": "thermal_power.boiler_island" + }, + { + "id": "steam_turbine_generator", + "label": "Steam turbine generator", + "displayLabel": "Steam turbine generator", + "role": "steam_turbine_generator", + "equipmentHint": "thermal_power.steam_turbine_generator turbine hall equipment with long turbine casing, generator body, shaft coupling, steam inlet pipe, and lube oil skid", + "footprintHint": "long", + "safetyTags": [ + "steam", + "rotating_equipment", + "electrical" + ], + "profileId": "thermal_power.steam_turbine_generator" + }, + { + "id": "surface_condenser", + "label": "Surface condenser", + "displayLabel": "Surface condenser", + "role": "surface_condenser", + "equipmentHint": "thermal_power.surface_condenser horizontal surface condenser shell and tube heat exchanger with cooling water inlet outlet, condensate pump, and vacuum pipe", + "footprintHint": "long", + "safetyTags": [ + "cooling_water", + "vacuum" + ], + "profileId": "thermal_power.surface_condenser" + }, + { + "id": "electrostatic_precipitator", + "label": "Electrostatic precipitator", + "displayLabel": "Electrostatic precipitator", + "role": "electrostatic_precipitator", + "equipmentHint": "thermal_power.electrostatic_precipitator rectangular flue gas dust collector with box casing, ash hoppers, inlet outlet ducts, transformer cabinets, and access platforms", + "footprintHint": "large", + "safetyTags": [ + "flue_gas", + "ash", + "electrical" + ], + "profileId": "thermal_power.electrostatic_precipitator" + }, + { + "id": "fgd_absorber", + "label": "FGD absorber", + "displayLabel": "FGD absorber", + "role": "fgd_absorber", + "equipmentHint": "thermal_power.fgd_absorber wet flue gas desulfurization absorber tower with cylindrical vessel, inlet duct, outlet duct, recirculation pipe manifold, and service platform", + "footprintHint": "tall", + "safetyTags": [ + "flue_gas", + "wet_fgd" + ], + "profileId": "thermal_power.fgd_absorber" + }, + { + "id": "chimney_stack", + "label": "Plant chimney stack", + "displayLabel": "Plant chimney stack", + "role": "chimney_stack", + "equipmentHint": "thermal_power.chimney_stack tall plant chimney stack with concrete shell, warning bands, flue inlet duct, and base plinth", + "footprintHint": "tall", + "safetyTags": [ + "flue_gas", + "tall_structure" + ], + "profileId": "thermal_power.chimney_stack" + }, + { + "id": "generator_step_up_transformer", + "label": "Generator step-up transformer", + "displayLabel": "Generator step-up transformer", + "role": "generator_step_up_transformer", + "equipmentHint": "thermal_power.generator_step_up_transformer large power transformer with radiator fins, oil tank body, bushings, cable tray, and containment base", + "footprintHint": "medium", + "safetyTags": [ + "high_voltage", + "electrical" + ], + "profileId": "thermal_power.generator_step_up_transformer" + }, + { + "id": "switchyard", + "label": "High voltage switchyard", + "displayLabel": "High voltage switchyard", + "role": "switchyard", + "equipmentHint": "thermal_power.switchyard outdoor high voltage switchyard with gantry frames, bus bars, insulators, breaker cabinets, and outgoing transmission lines", + "footprintHint": "large", + "safetyTags": [ + "high_voltage", + "electrical" + ], + "profileId": "thermal_power.switchyard" + }, + { + "id": "warehouse_workshop", + "label": "Warehouse and maintenance workshop", + "displayLabel": "Warehouse and maintenance workshop", + "role": "warehouse_workshop", + "equipmentHint": "thermal_power.warehouse_workshop maintenance workshop building with long hall body, roof panels, access doors, vent slats, and loading apron", + "footprintHint": "large", + "safetyTags": [ + "maintenance", + "building", + "occupied_building" + ], + "profileId": "thermal_power.warehouse_workshop" + }, + { + "id": "control_room", + "label": "Control room and DCS building", + "displayLabel": "Control room and DCS building", + "role": "control_room", + "equipmentHint": "thermal_power.control_room occupied control room and DCS building placed away from boiler and switchyard, with building body, roof, doors, windows, panels, and cable entry", + "footprintHint": "medium", + "safetyTags": [ + "control", + "occupied_building" + ], + "profileId": "thermal_power.switchyard" + }, + { + "id": "water_treatment_building", + "label": "Water treatment building", + "displayLabel": "Water treatment building", + "role": "water_treatment_building", + "equipmentHint": "thermal_power.water_treatment_building low water treatment building with tanks, pipe manifold, pump skid, control panel, doors, and roof vents", + "footprintHint": "medium", + "safetyTags": [ + "water_treatment", + "chemical", + "occupied_building" + ], + "profileId": "thermal_power.water_treatment_building" + }, + { + "id": "fly_ash_silo", + "label": "Fly ash silo", + "displayLabel": "Fly ash silo", + "role": "fly_ash_silo", + "equipmentHint": "thermal_power.fly_ash_silo fly ash storage silo with cylindrical bin, conical hopper bottom, support frame, discharge spout, and dust vent", + "footprintHint": "tall", + "safetyTags": [ + "ash", + "bulk_material" + ], + "profileId": "thermal_power.fly_ash_silo" + }, + { + "id": "coal_pulverizer_mill", + "label": "Coal pulverizer mill", + "displayLabel": "Coal pulverizer mill", + "role": "coal_pulverizer_mill", + "equipmentHint": "thermal_power.coal_pulverizer_mill coal pulverizer mill with grinding body, feeder inlet, pulverized fuel outlet pipe, classifier, and drive motor", + "footprintHint": "medium", + "safetyTags": [ + "coal_handling", + "rotating_equipment" + ], + "profileId": "thermal_power.coal_pulverizer_mill" + }, + { + "id": "coal_handling_conveyor", + "label": "Coal handling conveyor", + "displayLabel": "Coal handling conveyor", + "role": "coal_handling_conveyor", + "equipmentHint": "thermal_power.coal_handling_conveyor long elevated belt conveyor gallery with rollers, transfer chute, drive motor, and enclosed support frame", + "footprintHint": "long", + "safetyTags": [ + "coal_handling", + "conveyor" + ], + "profileId": "thermal_power.coal_handling_conveyor" + }, + { + "id": "coal_yard_stockpile", + "label": "Coal yard stockpile", + "displayLabel": "Coal yard stockpile", + "role": "coal_yard_stockpile", + "equipmentHint": "thermal_power.coal_yard_stockpile coal storage yard stockpile with long dark coal pile, stacker conveyor, reclaim hopper, and runoff trench", + "footprintHint": "large", + "safetyTags": [ + "coal_handling", + "dust" + ], + "profileId": "thermal_power.coal_yard_stockpile" + }, + { + "id": "circulating_water_pump_house", + "label": "Circulating water pump house", + "displayLabel": "Circulating water pump house", + "role": "circulating_water_pump_house", + "equipmentHint": "thermal_power.circulating_water_pump_house low pump house with pump casings, intake pipe manifold, discharge pipe, and electrical control cabinet", + "footprintHint": "medium", + "safetyTags": [ + "cooling_water", + "pump", + "occupied_building" + ], + "profileId": "thermal_power.circulating_water_pump_house" + } + ], + "connections": [ + { + "fromStationId": "coal_yard_stockpile", + "toStationId": "coal_handling_conveyor", + "medium": "solid_fuel", + "visualKind": "material_conveyor" + }, + { + "fromStationId": "coal_handling_conveyor", + "toStationId": "coal_pulverizer_mill", + "medium": "solid_fuel", + "visualKind": "material_conveyor" + }, + { + "fromStationId": "coal_pulverizer_mill", + "toStationId": "boiler_island", + "medium": "pulverized_fuel", + "visualKind": "pipe" + }, + { + "fromStationId": "boiler_island", + "toStationId": "steam_turbine_generator", + "medium": "steam", + "visualKind": "pipe" + }, + { + "fromStationId": "steam_turbine_generator", + "toStationId": "surface_condenser", + "medium": "exhaust_steam", + "visualKind": "pipe" + }, + { + "fromStationId": "surface_condenser", + "toStationId": "boiler_island", + "medium": "feedwater", + "visualKind": "pipe" + }, + { + "fromStationId": "natural_draft_cooling_tower", + "toStationId": "circulating_water_pump_house", + "medium": "cooling_water", + "visualKind": "pipe" + }, + { + "fromStationId": "circulating_water_pump_house", + "toStationId": "surface_condenser", + "medium": "cooling_water", + "visualKind": "pipe" + }, + { + "fromStationId": "boiler_island", + "toStationId": "electrostatic_precipitator", + "medium": "flue_gas", + "visualKind": "air_duct" + }, + { + "fromStationId": "electrostatic_precipitator", + "toStationId": "fgd_absorber", + "medium": "flue_gas", + "visualKind": "air_duct" + }, + { + "fromStationId": "fgd_absorber", + "toStationId": "chimney_stack", + "medium": "flue_gas", + "visualKind": "air_duct" + }, + { + "fromStationId": "electrostatic_precipitator", + "toStationId": "fly_ash_silo", + "medium": "fly_ash", + "visualKind": "material_conveyor" + }, + { + "fromStationId": "water_treatment_building", + "toStationId": "boiler_island", + "medium": "demineralized_water", + "visualKind": "pipe" + }, + { + "fromStationId": "steam_turbine_generator", + "toStationId": "generator_step_up_transformer", + "medium": "power", + "visualKind": "cable_tray" + }, + { + "fromStationId": "generator_step_up_transformer", + "toStationId": "switchyard", + "medium": "power", + "visualKind": "cable_tray" + }, + { + "fromStationId": "control_room", + "toStationId": "boiler_island", + "medium": "control", + "visualKind": "cable_tray" + }, + { + "fromStationId": "control_room", + "toStationId": "switchyard", + "medium": "control", + "visualKind": "cable_tray" + } + ], + "layoutHints": { + "preferredArchitectureId": "thermal_power.factory.coal_fired_station", + "cameraFocusStationIds": [ + "natural_draft_cooling_tower", + "boiler_island", + "steam_turbine_generator", + "chimney_stack", + "switchyard" + ], + "mainProcessStationIds": [ + "coal_handling_conveyor", + "coal_pulverizer_mill", + "boiler_island", + "steam_turbine_generator", + "surface_condenser", + "electrostatic_precipitator", + "fgd_absorber", + "chimney_stack" + ], + "offsiteStationIds": [ + "natural_draft_cooling_tower", + "coal_yard_stockpile", + "switchyard", + "control_room", + "warehouse_workshop" + ] + } + } +] diff --git a/cloud/industry.thermal-power.basic-0.1.0/profiles/generated.json b/cloud/industry.thermal-power.basic-0.1.0/profiles/generated.json new file mode 100644 index 000000000..4030a586f --- /dev/null +++ b/cloud/industry.thermal-power.basic-0.1.0/profiles/generated.json @@ -0,0 +1,1633 @@ +[ + { + "id": "thermal_power.natural_draft_cooling_tower", + "name": "Natural draft cooling tower", + "aliases": [ + "冷却塔", + "自然通风冷却塔", + "双曲线冷却塔", + "cooling tower", + "natural draft cooling tower", + "hyperboloid cooling tower" + ], + "industry": "thermal-power", + "layoutFamily": "generic_industrial_layout", + "preferredResolver": "profile-parts", + "family": "tank", + "defaultDimensions": { + "length": 11.6, + "width": 11.6, + "height": 7.6 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "cold_water_basin", + "required": true, + "length": 11.2, + "width": 11.2, + "height": 0.3, + "position": [ + 0, + 0.15, + 0 + ], + "primaryColor": "#dbeafe" + }, + { + "kind": "cooling_tower_shell", + "id": "cooling_tower_shell_1", + "semanticRole": "cooling_tower_shell", + "required": true, + "height": 7.2, + "baseRadius": 1.58, + "waistRadius": 1.02, + "topRadius": 1.42, + "position": [ + -2.85, + 3.75, + -2.85 + ], + "primaryColor": "#f8fafc" + }, + { + "kind": "cooling_tower_shell", + "id": "cooling_tower_shell_2", + "semanticRole": "cooling_tower_shell", + "required": true, + "height": 7.2, + "baseRadius": 1.58, + "waistRadius": 1.02, + "topRadius": 1.42, + "position": [ + 2.85, + 3.75, + -2.85 + ], + "primaryColor": "#f8fafc" + }, + { + "kind": "cooling_tower_shell", + "id": "cooling_tower_shell_3", + "semanticRole": "cooling_tower_shell", + "required": true, + "height": 7.2, + "baseRadius": 1.58, + "waistRadius": 1.02, + "topRadius": 1.42, + "position": [ + -2.85, + 3.75, + 2.85 + ], + "primaryColor": "#f8fafc" + }, + { + "kind": "cooling_tower_shell", + "id": "cooling_tower_shell_4", + "semanticRole": "cooling_tower_shell", + "required": true, + "height": 7.2, + "baseRadius": 1.58, + "waistRadius": 1.02, + "topRadius": 1.42, + "position": [ + 2.85, + 3.75, + 2.85 + ], + "primaryColor": "#f8fafc" + }, + { + "kind": "cooling_tower_rim", + "id": "cooling_tower_rim_1", + "semanticRole": "top_steam_opening", + "required": true, + "majorRadius": 1.42, + "tubeRadius": 0.07, + "position": [ + -2.85, + 7.36, + -2.85 + ], + "primaryColor": "#e2e8f0" + }, + { + "kind": "cooling_tower_rim", + "id": "cooling_tower_rim_2", + "semanticRole": "top_steam_opening", + "required": true, + "majorRadius": 1.42, + "tubeRadius": 0.07, + "position": [ + 2.85, + 7.36, + -2.85 + ], + "primaryColor": "#e2e8f0" + }, + { + "kind": "cooling_tower_rim", + "id": "cooling_tower_rim_3", + "semanticRole": "top_steam_opening", + "required": true, + "majorRadius": 1.42, + "tubeRadius": 0.07, + "position": [ + -2.85, + 7.36, + 2.85 + ], + "primaryColor": "#e2e8f0" + }, + { + "kind": "cooling_tower_rim", + "id": "cooling_tower_rim_4", + "semanticRole": "top_steam_opening", + "required": true, + "majorRadius": 1.42, + "tubeRadius": 0.07, + "position": [ + 2.85, + 7.36, + 2.85 + ], + "primaryColor": "#e2e8f0" + }, + { + "kind": "pipe_manifold", + "semanticRole": "cooling_water_header", + "required": true, + "length": 10.6, + "radius": 0.12, + "position": [ + 0, + 0.55, + 0 + ], + "primaryColor": "#93c5fd" + }, + { + "kind": "platform_ladder", + "semanticRole": "access_ladder", + "required": false, + "position": [ + -4.7, + 2.2, + 0 + ], + "height": 4.4 + } + ], + "primarySemanticRole": "cooling_tower_shell", + "qualityRules": "quality.thermal_power.natural_draft_cooling_tower", + "visualCues": [ + "four cooling towers only", + "2x2 block cluster", + "fat flared hyperboloid shells", + "wide top openings", + "shared square basin and water header" + ], + "status": "stable", + "source": "imported_pack", + "description": "Four squat, wide hyperboloid natural-draft cooling towers arranged as a 2x2 block on the left side of the thermal power plant, matching the provided reference image more closely than the earlier 3x3 cluster.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "thermal_power.circulating_water_pump_house", + "name": "Circulating water pump house", + "aliases": [ + "循环水泵房", + "循环水泵站", + "冷却水泵房", + "circulating water pump house", + "cooling water pump house" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 4.4, + "width": 2.2, + "height": 1.8 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "pump_house_body", + "required": true, + "length": 4.2, + "width": 2.1, + "height": 1.35, + "primaryColor": "#dbeafe" + }, + { + "kind": "generic_panel", + "semanticRole": "pump_house_roof", + "required": true, + "attachToRole": "pump_house_body", + "anchor": "top", + "length": 4.4, + "width": 2.3, + "height": 0.14 + }, + { + "kind": "volute_casing", + "semanticRole": "circulating_water_pump", + "required": true, + "attachToRole": "pump_house_body", + "anchor": "front" + }, + { + "kind": "pipe_manifold", + "semanticRole": "cooling_water_pipe_manifold", + "required": true, + "attachToRole": "circulating_water_pump", + "anchor": "front" + }, + { + "kind": "electrical_cabinet", + "semanticRole": "pump_mcc_panel", + "attachToRole": "pump_house_body", + "anchor": "service_side" + } + ], + "primarySemanticRole": "pump_house_body", + "qualityRules": "quality.thermal_power.circulating_water_pump_house", + "visualCues": [ + "low pump building", + "pump casing", + "cooling water manifold", + "MCC panel" + ], + "status": "stable", + "source": "imported_pack", + "description": "Low utility building and pump skid that sends cooling water between condenser and cooling towers.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "left", + "diameter": 0.18 + }, + { + "id": "outlet", + "medium": "material", + "side": "right", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "pumpType": "centrifugal", + "flowRate": 120, + "motorPower": 15, + "skidMounted": true + } + }, + { + "id": "thermal_power.coal_yard_stockpile", + "name": "Coal yard stockpile", + "aliases": [ + "煤场", + "煤堆", + "储煤场", + "coal yard", + "coal stockpile", + "coal storage yard" + ], + "industry": "thermal-power", + "layoutFamily": "generic_industrial_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 10, + "width": 4.2, + "height": 1.8 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "coal_stockpile", + "required": true, + "length": 9.5, + "width": 3.8, + "height": 1.3, + "primaryColor": "#1f2933" + }, + { + "kind": "conveyor_frame", + "semanticRole": "stacker_conveyor", + "required": true, + "attachToRole": "coal_stockpile", + "anchor": "top", + "length": 6.8, + "width": 0.55, + "height": 0.45 + }, + { + "kind": "hopper_body", + "semanticRole": "reclaim_hopper", + "required": true, + "attachToRole": "coal_stockpile", + "anchor": "front" + }, + { + "kind": "pipe_run", + "semanticRole": "runoff_drain", + "attachToRole": "coal_stockpile", + "anchor": "bottom", + "length": 8.5, + "radius": 0.035, + "axis": "x" + } + ], + "primarySemanticRole": "coal_stockpile", + "qualityRules": "quality.thermal_power.coal_yard_stockpile", + "visualCues": [ + "long dark coal pile", + "stacker conveyor on top", + "reclaim hopper", + "yard runoff drain" + ], + "status": "stable", + "source": "imported_pack", + "description": "Coal storage yard with dark stockpile mass and reclaim/stacking equipment.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "thermal_power.coal_handling_conveyor", + "name": "Coal handling conveyor", + "aliases": [ + "输煤栈桥", + "输煤皮带", + "煤输送廊", + "coal conveyor", + "coal handling conveyor", + "belt conveyor gallery" + ], + "industry": "thermal-power", + "layoutFamily": "linear_transport_layout", + "preferredResolver": "profile-parts", + "family": "conveyor", + "defaultDimensions": { + "length": 12, + "width": 1.2, + "height": 1.4 + }, + "parts": [ + { + "kind": "conveyor_frame", + "semanticRole": "coal_conveyor_gallery", + "required": true, + "length": 11.5, + "width": 1, + "height": 0.7 + }, + { + "kind": "belt_surface", + "semanticRole": "coal_belt_surface", + "required": true, + "attachToRole": "coal_conveyor_gallery", + "anchor": "top" + }, + { + "kind": "roller_array", + "semanticRole": "idler_rollers", + "required": true, + "attachToRole": "coal_conveyor_gallery", + "anchor": "top", + "count": 10 + }, + { + "kind": "hopper_body", + "semanticRole": "transfer_chute", + "required": true, + "attachToRole": "coal_conveyor_gallery", + "anchor": "front" + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "conveyor_drive", + "required": true, + "attachToRole": "coal_conveyor_gallery", + "anchor": "drive_side" + } + ], + "primarySemanticRole": "coal_conveyor_gallery", + "qualityRules": "quality.thermal_power.coal_handling_conveyor", + "visualCues": [ + "long elevated gallery", + "dark belt surface", + "roller array", + "transfer chute" + ], + "status": "stable", + "source": "imported_pack", + "description": "Long elevated belt conveyor gallery feeding coal from yard to the central fuel preparation area." + }, + { + "id": "thermal_power.coal_pulverizer_mill", + "name": "Coal pulverizer mill", + "aliases": [ + "磨煤机", + "煤粉磨", + "煤粉制备磨", + "coal pulverizer", + "coal mill", + "pulverizer mill" + ], + "industry": "thermal-power", + "layoutFamily": "rotating_machine_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 3.4, + "width": 1.8, + "height": 2.2 + }, + "parts": [ + { + "kind": "rounded_machine_body", + "semanticRole": "pulverizer_mill_body", + "required": true, + "length": 2.4, + "width": 1.25, + "height": 1.35, + "primaryColor": "#9ca3af" + }, + { + "kind": "hopper_body", + "semanticRole": "raw_coal_feeder", + "required": true, + "attachToRole": "pulverizer_mill_body", + "anchor": "top" + }, + { + "kind": "pipe_run", + "semanticRole": "pulverized_fuel_pipe", + "required": true, + "attachToRole": "pulverizer_mill_body", + "anchor": "top", + "length": 2.8, + "radius": 0.08, + "axis": "x" + }, + { + "kind": "ribbed_motor_body", + "semanticRole": "mill_drive_motor", + "required": true, + "attachToRole": "pulverizer_mill_body", + "anchor": "drive_side" + }, + { + "kind": "control_box", + "semanticRole": "mill_local_control_box", + "attachToRole": "pulverizer_mill_body", + "anchor": "service_side" + } + ], + "primarySemanticRole": "pulverizer_mill_body", + "qualityRules": "quality.thermal_power.coal_pulverizer_mill", + "visualCues": [ + "compact heavy mill", + "top raw coal feeder", + "pulverized fuel outlet pipe", + "drive motor" + ], + "status": "stable", + "source": "imported_pack", + "description": "Mill that grinds coal to fine powder before it is sent to the furnace burners." + }, + { + "id": "thermal_power.boiler_island", + "name": "Coal-fired boiler island", + "aliases": [ + "锅炉岛", + "燃煤锅炉", + "锅炉本体", + "boiler island", + "coal-fired boiler", + "steam generator" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 5.8, + "width": 4.2, + "height": 9.2 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "boiler_furnace_casing", + "required": true, + "length": 4.6, + "width": 3.4, + "height": 8.2, + "primaryColor": "#cbd5e1" + }, + { + "kind": "cylindrical_tank", + "semanticRole": "steam_drum", + "required": true, + "attachToRole": "boiler_furnace_casing", + "anchor": "top", + "length": 3.6, + "radius": 0.28, + "axis": "x" + }, + { + "kind": "pipe_manifold", + "semanticRole": "main_steam_header", + "required": true, + "attachToRole": "steam_drum", + "anchor": "front" + }, + { + "kind": "generic_opening", + "semanticRole": "burner_opening", + "required": true, + "attachToRole": "boiler_furnace_casing", + "anchor": "front" + }, + { + "kind": "volute_casing", + "semanticRole": "forced_draft_blower", + "required": true, + "attachToRole": "boiler_furnace_casing", + "anchor": "service_side" + }, + { + "kind": "pipe_run", + "semanticRole": "flue_gas_duct", + "required": true, + "attachToRole": "boiler_furnace_casing", + "anchor": "back", + "length": 4.2, + "radius": 0.16, + "axis": "x" + }, + { + "kind": "chimney_stack", + "semanticRole": "boiler_flue_stack_stub", + "required": false, + "attachToRole": "flue_gas_duct", + "anchor": "top", + "height": 2.2, + "radius": 0.18 + }, + { + "kind": "service_platform", + "semanticRole": "boiler_service_platform", + "required": false, + "attachToRole": "boiler_furnace_casing", + "anchor": "service_side" + } + ], + "primarySemanticRole": "boiler_furnace_casing", + "qualityRules": "quality.thermal_power.boiler_island", + "visualCues": [ + "tall rectangular boiler casing", + "steam drum on top", + "burner openings", + "flue gas duct to cleanup train" + ], + "status": "stable", + "source": "imported_pack", + "description": "Tall central boiler block with furnace casing, steam drum/header, burners, draft blower, and flue gas outlet." + }, + { + "id": "thermal_power.steam_turbine_generator", + "name": "Steam turbine generator", + "aliases": [ + "汽轮发电机", + "汽轮机发电机组", + "汽机房设备", + "steam turbine generator", + "turbine generator", + "turbo generator" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 9.2, + "width": 3.8, + "height": 3.2 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "turbine_hall_body", + "required": true, + "length": 9, + "width": 3.6, + "height": 2.1, + "position": [ + 0, + 1.05, + 0 + ], + "primaryColor": "#dbeafe" + }, + { + "kind": "generic_panel", + "semanticRole": "turbine_hall_roof", + "required": true, + "attachToRole": "turbine_hall_body", + "anchor": "top", + "length": 9.2, + "width": 3.8, + "height": 0.16, + "primaryColor": "#7aa7c7" + }, + { + "kind": "vent_slats", + "semanticRole": "turbine_hall_roof_vents", + "required": true, + "attachToRole": "turbine_hall_roof", + "anchor": "top", + "arrayAlong": "length", + "count": 6 + }, + { + "kind": "cylindrical_tank", + "semanticRole": "steam_turbine_casing", + "required": true, + "attachToRole": "turbine_hall_body", + "anchor": "front", + "length": 3.9, + "radius": 0.38, + "axis": "x", + "primaryColor": "#94a3b8" + }, + { + "kind": "rounded_machine_body", + "semanticRole": "generator_body", + "required": true, + "attachToRole": "steam_turbine_casing", + "anchor": "right", + "length": 2.8, + "width": 1.2, + "height": 1.2 + }, + { + "kind": "coupling_guard", + "semanticRole": "shaft_coupling_guard", + "required": true, + "attachToRole": "steam_turbine_casing", + "anchor": "right" + }, + { + "kind": "pipe_run", + "semanticRole": "main_steam_inlet", + "required": true, + "attachToRole": "steam_turbine_casing", + "anchor": "top", + "length": 2.6, + "radius": 0.12, + "axis": "x" + }, + { + "kind": "skid_base", + "semanticRole": "turbine_pedestal", + "required": true, + "attachToRole": "steam_turbine_casing", + "anchor": "bottom", + "length": 6.8, + "width": 1.4, + "height": 0.35 + }, + { + "kind": "generic_opening", + "semanticRole": "turbine_hall_door", + "required": true, + "attachToRole": "turbine_hall_body", + "anchor": "front" + }, + { + "kind": "control_box", + "semanticRole": "lube_oil_control_skid", + "attachToRole": "turbine_pedestal", + "anchor": "service_side" + } + ], + "primarySemanticRole": "turbine_hall_body", + "qualityRules": "quality.thermal_power.steam_turbine_generator", + "visualCues": [ + "blue-gray turbine hall building", + "long turbine and generator train visible at the hall face", + "steam inlet pipe", + "roof vents and generator pedestal", + "simple walls and roof massing like the reference image low industrial buildings" + ], + "status": "stable", + "source": "imported_pack", + "description": "Long turbine-generator train inside a turbine hall, forming the main generation block beside the boiler island." + }, + { + "id": "thermal_power.surface_condenser", + "name": "Surface condenser", + "aliases": [ + "凝汽器", + "表面式凝汽器", + "冷凝器", + "surface condenser", + "steam condenser" + ], + "industry": "thermal-power", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "family": "tank", + "defaultDimensions": { + "length": 5.6, + "width": 1.8, + "height": 1.7 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "condenser_shell", + "required": true, + "length": 5.2, + "radius": 0.62, + "axis": "x" + }, + { + "kind": "inlet_port", + "semanticRole": "cooling_water_inlet", + "required": true, + "attachToRole": "condenser_shell", + "anchor": "front" + }, + { + "kind": "outlet_port", + "semanticRole": "cooling_water_outlet", + "required": true, + "attachToRole": "condenser_shell", + "anchor": "back" + }, + { + "kind": "volute_casing", + "semanticRole": "condensate_pump", + "required": true, + "attachToRole": "condenser_shell", + "anchor": "bottom" + }, + { + "kind": "pipe_run", + "semanticRole": "vacuum_exhaust_line", + "attachToRole": "condenser_shell", + "anchor": "top", + "length": 1.8, + "radius": 0.07, + "axis": "x" + } + ], + "primarySemanticRole": "condenser_shell", + "qualityRules": "quality.thermal_power.surface_condenser", + "visualCues": [ + "horizontal condenser shell", + "cooling water inlet and outlet", + "condensate pump", + "vacuum line", + "simple walls and roof massing like the reference image low industrial buildings" + ], + "status": "stable", + "source": "imported_pack", + "description": "Horizontal condenser vessel below the turbine that condenses exhaust steam using circulating cooling water.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "left", + "diameter": 0.18 + }, + { + "id": "outlet", + "medium": "material", + "side": "right", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "pumpType": "centrifugal", + "flowRate": 120, + "motorPower": 15, + "skidMounted": true + } + }, + { + "id": "thermal_power.electrostatic_precipitator", + "name": "Electrostatic precipitator", + "aliases": [ + "静电除尘器", + "电除尘", + "除尘器", + "electrostatic precipitator", + "ESP", + "flue gas dust collector" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 5.6, + "width": 2.4, + "height": 3.2 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "esp_casing", + "required": true, + "length": 5.2, + "width": 2.2, + "height": 2.6, + "primaryColor": "#d1d5db" + }, + { + "kind": "hopper_body", + "semanticRole": "ash_hopper", + "required": true, + "attachToRole": "esp_casing", + "anchor": "bottom", + "arrayAlong": "length", + "count": 3 + }, + { + "kind": "pipe_run", + "semanticRole": "flue_gas_inlet_duct", + "required": true, + "attachToRole": "esp_casing", + "anchor": "front", + "length": 2.4, + "radius": 0.22, + "axis": "x" + }, + { + "kind": "pipe_run", + "semanticRole": "clean_gas_outlet_duct", + "required": true, + "attachToRole": "esp_casing", + "anchor": "back", + "length": 2.4, + "radius": 0.22, + "axis": "x" + }, + { + "kind": "electrical_cabinet", + "semanticRole": "esp_transformer_rectifier_cabinet", + "required": true, + "attachToRole": "esp_casing", + "anchor": "service_side" + }, + { + "kind": "service_platform", + "semanticRole": "esp_access_platform", + "attachToRole": "esp_casing", + "anchor": "service_side" + } + ], + "primarySemanticRole": "esp_casing", + "qualityRules": "quality.thermal_power.electrostatic_precipitator", + "visualCues": [ + "large rectangular casing", + "multiple ash hoppers underneath", + "inlet and outlet flue gas ducts", + "transformer rectifier cabinets" + ], + "status": "stable", + "source": "imported_pack", + "description": "Rectangular flue-gas dust collector placed between the furnace flue outlet and desulfurization system." + }, + { + "id": "thermal_power.fgd_absorber", + "name": "Wet FGD absorber tower", + "aliases": [ + "脱硫塔", + "湿法脱硫吸收塔", + "FGD吸收塔", + "FGD absorber", + "wet scrubber", + "flue gas desulfurization absorber" + ], + "industry": "thermal-power", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "family": "tank", + "defaultDimensions": { + "length": 3.2, + "width": 3.2, + "height": 7.4 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "fgd_absorber_shell", + "required": true, + "radius": 1.35, + "height": 6.5, + "axis": "y", + "primaryColor": "#dbeafe" + }, + { + "kind": "pipe_run", + "semanticRole": "flue_gas_inlet_duct", + "required": true, + "attachToRole": "fgd_absorber_shell", + "anchor": "front", + "length": 2.2, + "radius": 0.18, + "axis": "x" + }, + { + "kind": "outlet_port", + "semanticRole": "clean_gas_outlet", + "required": true, + "attachToRole": "fgd_absorber_shell", + "anchor": "top" + }, + { + "kind": "pipe_manifold", + "semanticRole": "slurry_recirculation_header", + "required": true, + "attachToRole": "fgd_absorber_shell", + "anchor": "service_side" + }, + { + "kind": "service_platform", + "semanticRole": "absorber_service_platform", + "attachToRole": "fgd_absorber_shell", + "anchor": "service_side" + } + ], + "primarySemanticRole": "fgd_absorber_shell", + "qualityRules": "quality.thermal_power.fgd_absorber", + "visualCues": [ + "tall absorber vessel", + "flue gas inlet duct", + "top clean gas outlet", + "recirculation pipe manifold" + ], + "status": "stable", + "source": "imported_pack", + "description": "Wet flue-gas desulfurization absorber tower between precipitator and chimney.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "thermal_power.chimney_stack", + "name": "Plant chimney stack", + "aliases": [ + "烟囱", + "主烟囱", + "排烟筒", + "chimney stack", + "plant stack", + "flue gas stack" + ], + "industry": "thermal-power", + "layoutFamily": "generic_industrial_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 1.8, + "width": 1.8, + "height": 13.5 + }, + "parts": [ + { + "kind": "chimney_stack", + "semanticRole": "chimney_stack", + "required": true, + "height": 13, + "radius": 0.48, + "warningStripes": true + }, + { + "kind": "generic_base", + "semanticRole": "stack_base_plinth", + "required": true, + "attachToRole": "chimney_stack", + "anchor": "bottom", + "length": 1.8, + "width": 1.8, + "height": 0.35 + }, + { + "kind": "pipe_run", + "semanticRole": "flue_inlet_duct", + "required": true, + "attachToRole": "chimney_stack", + "anchor": "front", + "length": 2.4, + "radius": 0.18, + "axis": "x" + }, + { + "kind": "warning_label", + "semanticRole": "aviation_warning_band", + "attachToRole": "chimney_stack", + "anchor": "top" + } + ], + "primarySemanticRole": "chimney_stack", + "qualityRules": "quality.thermal_power.chimney_stack", + "visualCues": [ + "very tall cylindrical stack", + "base plinth", + "flue inlet duct", + "warning bands" + ], + "status": "stable", + "source": "imported_pack", + "description": "Tall flue gas stack visible near the central power block in the reference image." + }, + { + "id": "thermal_power.fly_ash_silo", + "name": "Fly ash silo", + "aliases": [ + "粉煤灰仓", + "灰库", + "飞灰仓", + "fly ash silo", + "ash silo", + "fly ash storage silo" + ], + "industry": "thermal-power", + "layoutFamily": "vessel_layout", + "preferredResolver": "profile-parts", + "family": "tank", + "defaultDimensions": { + "length": 2.6, + "width": 2.6, + "height": 5.8 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "fly_ash_silo_shell", + "required": true, + "radius": 1.05, + "height": 3.6, + "axis": "y", + "primaryColor": "#cbd5e1" + }, + { + "kind": "hopper_body", + "semanticRole": "conical_discharge_hopper", + "required": true, + "attachToRole": "fly_ash_silo_shell", + "anchor": "bottom" + }, + { + "kind": "structural_tower_frame", + "semanticRole": "silo_support_frame", + "required": true, + "attachToRole": "fly_ash_silo_shell", + "anchor": "bottom", + "height": 1.4, + "levelCount": 2 + }, + { + "kind": "generic_spout", + "semanticRole": "ash_unloading_spout", + "required": true, + "attachToRole": "conical_discharge_hopper", + "anchor": "bottom" + }, + { + "kind": "vent_grill", + "semanticRole": "silo_dust_vent", + "attachToRole": "fly_ash_silo_shell", + "anchor": "top" + } + ], + "primarySemanticRole": "fly_ash_silo_shell", + "qualityRules": "quality.thermal_power.fly_ash_silo", + "visualCues": [ + "tall fly ash silo", + "conical discharge hopper", + "support frame", + "dust vent" + ], + "status": "stable", + "source": "imported_pack", + "description": "Bulk fly ash storage silo downstream of electrostatic precipitator.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "thermal_power.water_treatment_building", + "name": "Water treatment building", + "aliases": [ + "水处理车间", + "化学水处理站", + "除盐水站", + "water treatment building", + "demineralized water plant", + "water treatment station" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 5.2, + "width": 2.8, + "height": 2.4 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "water_treatment_building_body", + "required": true, + "length": 5, + "width": 2.6, + "height": 1.8, + "primaryColor": "#e5e7eb" + }, + { + "kind": "generic_panel", + "semanticRole": "roof_cap", + "required": true, + "attachToRole": "water_treatment_building_body", + "anchor": "top", + "length": 5.2, + "width": 2.8, + "height": 0.18 + }, + { + "kind": "cylindrical_tank", + "semanticRole": "demineralized_water_tank", + "required": true, + "attachToRole": "water_treatment_building_body", + "anchor": "service_side", + "height": 1.5, + "radius": 0.42, + "axis": "y" + }, + { + "kind": "pipe_manifold", + "semanticRole": "water_pipe_manifold", + "required": true, + "attachToRole": "water_treatment_building_body", + "anchor": "front" + }, + { + "kind": "generic_opening", + "semanticRole": "service_door", + "required": true, + "attachToRole": "water_treatment_building_body", + "anchor": "front" + }, + { + "kind": "control_box", + "semanticRole": "local_control_panel", + "attachToRole": "water_treatment_building_body", + "anchor": "service_side" + } + ], + "primarySemanticRole": "water_treatment_building_body", + "qualityRules": "quality.thermal_power.water_treatment_building", + "visualCues": [ + "low auxiliary building", + "small process tanks", + "pipe manifold", + "service door", + "simple walls and roof massing like the reference image low industrial buildings" + ], + "status": "stable", + "source": "imported_pack", + "description": "Auxiliary water treatment building for demineralized makeup water and plant wastewater streams.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "thermal_power.generator_step_up_transformer", + "name": "Generator step-up transformer", + "aliases": [ + "主变压器", + "升压变压器", + "发电机升压变", + "generator step-up transformer", + "GSU transformer", + "power transformer" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 3.2, + "width": 2.1, + "height": 2.8 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "transformer_tank", + "required": true, + "length": 2.7, + "width": 1.5, + "height": 1.8, + "primaryColor": "#93a4b8" + }, + { + "kind": "vent_slats", + "semanticRole": "radiator_fins", + "required": true, + "attachToRole": "transformer_tank", + "anchor": "service_side", + "count": 8 + }, + { + "kind": "vertical_pole", + "semanticRole": "high_voltage_bushing", + "required": true, + "attachToRole": "transformer_tank", + "anchor": "top", + "arrayAlong": "width", + "count": 3 + }, + { + "kind": "generic_base", + "semanticRole": "oil_containment_base", + "required": true, + "attachToRole": "transformer_tank", + "anchor": "bottom", + "length": 3.1, + "width": 2, + "height": 0.25 + }, + { + "kind": "cable_tray", + "semanticRole": "generator_bus_duct", + "required": true, + "attachToRole": "transformer_tank", + "anchor": "front" + } + ], + "primarySemanticRole": "transformer_tank", + "qualityRules": "quality.thermal_power.generator_step_up_transformer", + "visualCues": [ + "large transformer tank", + "radiator fins", + "three bushings", + "bus duct to switchyard" + ], + "status": "stable", + "source": "imported_pack", + "description": "Large transformer between generator output and the outdoor switchyard.", + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + } + }, + { + "id": "thermal_power.switchyard", + "name": "High voltage switchyard", + "aliases": [ + "升压站", + "开关站", + "高压开关场", + "switchyard", + "high voltage switchyard", + "substation yard" + ], + "industry": "thermal-power", + "layoutFamily": "generic_industrial_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 8.5, + "width": 4.6, + "height": 3.6 + }, + "parts": [ + { + "kind": "structural_tower_frame", + "semanticRole": "switchyard_gantry_frame", + "required": true, + "height": 3.2, + "levelCount": 2, + "arrayAlong": "length", + "count": 3 + }, + { + "kind": "pipe_run", + "semanticRole": "bus_bar", + "required": true, + "attachToRole": "switchyard_gantry_frame", + "anchor": "top", + "length": 7.8, + "radius": 0.035, + "axis": "x" + }, + { + "kind": "vertical_pole", + "semanticRole": "porcelain_insulator", + "required": true, + "attachToRole": "bus_bar", + "anchor": "bottom", + "arrayAlong": "length", + "count": 6 + }, + { + "kind": "electrical_cabinet", + "semanticRole": "breaker_cabinet", + "required": true, + "attachToRole": "switchyard_gantry_frame", + "anchor": "front" + }, + { + "kind": "cable_tray", + "semanticRole": "outgoing_transmission_line", + "required": true, + "attachToRole": "bus_bar", + "anchor": "right", + "length": 7.5 + } + ], + "primarySemanticRole": "switchyard_gantry_frame", + "qualityRules": "quality.thermal_power.switchyard", + "visualCues": [ + "outdoor gantry frames", + "bus bars", + "insulators", + "outgoing transmission lines" + ], + "status": "stable", + "source": "imported_pack", + "description": "Outdoor switchyard and transmission export area visible around the plant site." + }, + { + "id": "thermal_power.control_room", + "name": "Control room and DCS building", + "aliases": [ + "中控室", + "集控楼", + "DCS控制楼", + "control room", + "DCS building", + "central control building" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 5.8, + "width": 2.8, + "height": 2.8 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "control_building_body", + "required": true, + "length": 5.5, + "width": 2.6, + "height": 2.2, + "primaryColor": "#e5edf7" + }, + { + "kind": "generic_panel", + "semanticRole": "roof_cap", + "required": true, + "attachToRole": "control_building_body", + "anchor": "top", + "length": 5.8, + "width": 2.8, + "height": 0.18 + }, + { + "kind": "generic_opening", + "semanticRole": "main_door", + "required": true, + "attachToRole": "control_building_body", + "anchor": "front" + }, + { + "kind": "generic_detail_accent", + "semanticRole": "control_room_windows", + "required": true, + "attachToRole": "control_building_body", + "anchor": "front", + "arrayAlong": "length", + "count": 4 + }, + { + "kind": "electrical_cabinet", + "semanticRole": "dcs_panel_row", + "required": true, + "attachToRole": "control_building_body", + "anchor": "service_side" + }, + { + "kind": "cable_tray", + "semanticRole": "control_cable_entry", + "attachToRole": "control_building_body", + "anchor": "back" + } + ], + "primarySemanticRole": "control_building_body", + "qualityRules": "quality.thermal_power.control_room", + "visualCues": [ + "occupied control building", + "roof cap", + "door and window band", + "DCS panels", + "simple walls and roof massing like the reference image low industrial buildings" + ], + "status": "stable", + "source": "imported_pack", + "description": "Occupied control building for plant operations and distributed control systems." + }, + { + "id": "thermal_power.warehouse_workshop", + "name": "Warehouse and maintenance workshop", + "aliases": [ + "检修车间", + "仓库", + "维修车间", + "warehouse workshop", + "maintenance workshop", + "plant warehouse" + ], + "industry": "thermal-power", + "layoutFamily": "box_enclosure_layout", + "preferredResolver": "profile-parts", + "family": "generic", + "defaultDimensions": { + "length": 7, + "width": 3.6, + "height": 2.6 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "workshop_hall_body", + "required": true, + "length": 6.6, + "width": 3.4, + "height": 2, + "primaryColor": "#dbeafe" + }, + { + "kind": "generic_panel", + "semanticRole": "roof_panels", + "required": true, + "attachToRole": "workshop_hall_body", + "anchor": "top", + "length": 6.8, + "width": 3.6, + "height": 0.16 + }, + { + "kind": "generic_opening", + "semanticRole": "rollup_door", + "required": true, + "attachToRole": "workshop_hall_body", + "anchor": "front" + }, + { + "kind": "vent_slats", + "semanticRole": "roof_vent_slats", + "required": true, + "attachToRole": "roof_panels", + "anchor": "top", + "count": 5 + }, + { + "kind": "generic_base", + "semanticRole": "loading_apron", + "attachToRole": "workshop_hall_body", + "anchor": "front", + "length": 6.2, + "width": 1.2, + "height": 0.12 + } + ], + "primarySemanticRole": "workshop_hall_body", + "qualityRules": "quality.thermal_power.warehouse_workshop", + "visualCues": [ + "long low workshop hall", + "roof panels", + "rollup door", + "vent slats", + "simple walls and roof massing like the reference image low industrial buildings" + ], + "status": "stable", + "source": "imported_pack", + "description": "Auxiliary workshop and warehouse building in the foreground of the reference layout." + } +] diff --git a/cloud/industry.thermal-power.basic-0.1.0/quality-rules/generated-quality.json b/cloud/industry.thermal-power.basic-0.1.0/quality-rules/generated-quality.json new file mode 100644 index 000000000..6f7b7e64e --- /dev/null +++ b/cloud/industry.thermal-power.basic-0.1.0/quality-rules/generated-quality.json @@ -0,0 +1,262 @@ +[ + { + "id": "quality.thermal_power.natural_draft_cooling_tower", + "requiredRoles": [ + "cooling_tower_shell", + "cold_water_basin", + "top_steam_opening", + "cooling_water_header" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "office_desk" + ], + "shapeCount": { + "min": 9, + "max": 45 + } + }, + { + "id": "quality.thermal_power.circulating_water_pump_house", + "requiredRoles": [ + "pump_house_body", + "pump_house_roof", + "circulating_water_pump", + "cooling_water_pipe_manifold", + "pump_mcc_panel" + ], + "shapeCount": { + "min": 5, + "max": 60 + } + }, + { + "id": "quality.thermal_power.coal_yard_stockpile", + "requiredRoles": [ + "coal_stockpile", + "stacker_conveyor", + "reclaim_hopper", + "runoff_drain" + ], + "forbiddenRoles": [ + "water_tank", + "vehicle_cabin" + ], + "shapeCount": { + "min": 4, + "max": 45 + } + }, + { + "id": "quality.thermal_power.coal_handling_conveyor", + "requiredRoles": [ + "coal_conveyor_gallery", + "coal_belt_surface", + "idler_rollers", + "transfer_chute", + "conveyor_drive" + ], + "shapeCount": { + "min": 5, + "max": 70 + } + }, + { + "id": "quality.thermal_power.coal_pulverizer_mill", + "requiredRoles": [ + "pulverizer_mill_body", + "raw_coal_feeder", + "pulverized_fuel_pipe", + "mill_drive_motor", + "mill_local_control_box" + ], + "shapeCount": { + "min": 5, + "max": 60 + } + }, + { + "id": "quality.thermal_power.boiler_island", + "requiredRoles": [ + "boiler_furnace_casing", + "steam_drum", + "main_steam_header", + "burner_opening", + "forced_draft_blower", + "flue_gas_duct" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "desk_leg" + ], + "shapeCount": { + "min": 8, + "max": 95 + } + }, + { + "id": "quality.thermal_power.steam_turbine_generator", + "requiredRoles": [ + "turbine_hall_body", + "turbine_hall_roof", + "turbine_hall_roof_vents", + "steam_turbine_casing", + "generator_body", + "shaft_coupling_guard", + "main_steam_inlet", + "turbine_pedestal", + "turbine_hall_door", + "lube_oil_control_skid" + ], + "shapeCount": { + "min": 10, + "max": 90 + } + }, + { + "id": "quality.thermal_power.surface_condenser", + "requiredRoles": [ + "condenser_shell", + "cooling_water_inlet", + "cooling_water_outlet", + "condensate_pump", + "vacuum_exhaust_line" + ], + "shapeCount": { + "min": 5, + "max": 65 + } + }, + { + "id": "quality.thermal_power.electrostatic_precipitator", + "requiredRoles": [ + "esp_casing", + "ash_hopper", + "flue_gas_inlet_duct", + "clean_gas_outlet_duct", + "esp_transformer_rectifier_cabinet", + "esp_access_platform" + ], + "shapeCount": { + "min": 6, + "max": 85 + } + }, + { + "id": "quality.thermal_power.fgd_absorber", + "requiredRoles": [ + "fgd_absorber_shell", + "flue_gas_inlet_duct", + "clean_gas_outlet", + "slurry_recirculation_header", + "absorber_service_platform" + ], + "shapeCount": { + "min": 5, + "max": 75 + } + }, + { + "id": "quality.thermal_power.chimney_stack", + "requiredRoles": [ + "chimney_stack", + "stack_base_plinth", + "flue_inlet_duct", + "aviation_warning_band" + ], + "forbiddenRoles": [ + "water_tank", + "vehicle_cabin" + ], + "shapeCount": { + "min": 4, + "max": 45 + } + }, + { + "id": "quality.thermal_power.fly_ash_silo", + "requiredRoles": [ + "fly_ash_silo_shell", + "conical_discharge_hopper", + "silo_support_frame", + "ash_unloading_spout", + "silo_dust_vent" + ], + "shapeCount": { + "min": 5, + "max": 70 + } + }, + { + "id": "quality.thermal_power.water_treatment_building", + "requiredRoles": [ + "water_treatment_building_body", + "roof_cap", + "demineralized_water_tank", + "water_pipe_manifold", + "service_door", + "local_control_panel" + ], + "shapeCount": { + "min": 6, + "max": 75 + } + }, + { + "id": "quality.thermal_power.generator_step_up_transformer", + "requiredRoles": [ + "transformer_tank", + "radiator_fins", + "high_voltage_bushing", + "oil_containment_base", + "generator_bus_duct" + ], + "shapeCount": { + "min": 5, + "max": 65 + } + }, + { + "id": "quality.thermal_power.switchyard", + "requiredRoles": [ + "switchyard_gantry_frame", + "bus_bar", + "porcelain_insulator", + "breaker_cabinet", + "outgoing_transmission_line" + ], + "shapeCount": { + "min": 5, + "max": 85 + } + }, + { + "id": "quality.thermal_power.control_room", + "requiredRoles": [ + "control_building_body", + "roof_cap", + "main_door", + "control_room_windows", + "dcs_panel_row", + "control_cable_entry" + ], + "shapeCount": { + "min": 6, + "max": 75 + } + }, + { + "id": "quality.thermal_power.warehouse_workshop", + "requiredRoles": [ + "workshop_hall_body", + "roof_panels", + "rollup_door", + "roof_vent_slats", + "loading_apron" + ], + "shapeCount": { + "min": 5, + "max": 65 + } + } +] diff --git a/cloud/industry.water-treatment.basic-0.1.0.zip b/cloud/industry.water-treatment.basic-0.1.0.zip new file mode 100644 index 000000000..c7413abe3 Binary files /dev/null and b/cloud/industry.water-treatment.basic-0.1.0.zip differ diff --git a/cloud/industry.water-treatment.basic-0.1.0/README.md b/cloud/industry.water-treatment.basic-0.1.0/README.md new file mode 100644 index 000000000..ffe72ea0a --- /dev/null +++ b/cloud/industry.water-treatment.basic-0.1.0/README.md @@ -0,0 +1,32 @@ +# Water Treatment Basic Equipment Pack + +水处理行业基础资源包,覆盖水处理厂整厂,以及沉淀、加药、过滤、泵送、管廊和污泥脱水设备。 + +## Pack Type + +Factory-capable pack: supports whole-factory/process creation through process templates and factory architectures. + +## Factory Creation + +支持整厂 / 工序: + +- Water treatment plant (`water_treatment_plant_basic`) + +支持范围: + +- Water treatment plant basic architecture (`water_treatment.factory.basic`) + +## Devices + +- Sedimentation tank (`water_treatment.sedimentation_tank`) +- Chemical dosing unit (`water_treatment.chemical_dosing_unit`) +- Filter vessel (`water_treatment.filter_vessel`) +- Pump skid (`water_treatment.pump_skid`) +- Pipe corridor (`water_treatment.pipe_corridor`) +- Sludge dewatering machine (`water_treatment.sludge_dewatering_machine`) + +## Validation + +```bash +bun apps/editor/scripts/profile-pack-qa.ts industry.water-treatment.basic@0.1.0 --validate-only +``` diff --git a/cloud/industry.water-treatment.basic-0.1.0/factory-architectures/generated.json b/cloud/industry.water-treatment.basic-0.1.0/factory-architectures/generated.json new file mode 100644 index 000000000..28ebaf23a --- /dev/null +++ b/cloud/industry.water-treatment.basic-0.1.0/factory-architectures/generated.json @@ -0,0 +1,33 @@ +[ + { + "id": "water_treatment.factory.basic", + "label": "Water treatment plant basic architecture", + "industry": "water-treatment", + "processId": "water_treatment_plant_basic", + "layoutStyle": "linear", + "defaultDimensions": { + "length": 48, + "width": 22 + }, + "modules": [ + { + "id": "water_line", + "displayLabel": "水处理主线", + "order": 10, + "stationIds": [ + "sedimentation_tank", + "chemical_dosing_unit", + "filter_vessel", + "pump_skid", + "pipe_corridor", + "sludge_dewatering_machine" + ] + } + ], + "notes": [ + "Place the sedimentation tank as the largest upstream basin.", + "Keep the dosing unit near the inlet side of the clarifier.", + "Use the pipe corridor as a visible utility spine connecting filtered water and pump discharge." + ] + } +] diff --git a/cloud/industry.water-treatment.basic-0.1.0/pack.json b/cloud/industry.water-treatment.basic-0.1.0/pack.json new file mode 100644 index 000000000..1804f4dbe --- /dev/null +++ b/cloud/industry.water-treatment.basic-0.1.0/pack.json @@ -0,0 +1,107 @@ +{ + "id": "industry.water-treatment.basic", + "name": "Water Treatment Basic Equipment Pack", + "industry": "water-treatment", + "version": "0.1.0", + "schemaVersion": "2.0", + "knowledgeSchemaVersion": "1.0", + "appCompatibility": ">=0.8.0", + "locale": [ + "zh-CN", + "en-US" + ], + "description": "Basic water and wastewater treatment equipment profiles for sedimentation, chemical dosing, filtration, pumping, pipe corridors, and sludge dewatering.", + "profiles": [ + "profiles/generated.json" + ], + "qualityRules": [ + "quality-rules/generated-quality.json" + ], + "capabilities": [ + "factory_creation" + ], + "factoryArchitectures": [ + "factory-architectures/generated.json" + ], + "processTemplates": [ + "process-templates/generated.json" + ], + "dependsOnPlugins": [ + "pascal:factory-equipment" + ], + "equipmentBindings": [ + { + "profileId": "water_treatment.sedimentation_tank", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "water_treatment.chemical_dosing_unit", + "recipeId": "factory:centrifugal-pump", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.pumpType": "pumpType", + "equipmentDefaults.flowRate": "flowRate", + "equipmentDefaults.motorPower": "motorPower", + "equipmentDefaults.skidMounted": "skidMounted" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "water_treatment.filter_vessel", + "recipeId": "factory:storage-tank", + "paramMap": { + "defaultDimensions.height": "height", + "defaultDimensions.diameter": "length", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.orientation": "orientation", + "equipmentDefaults.capacity": "capacity", + "equipmentDefaults.liquidLevel": "liquidLevel" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + }, + { + "profileId": "water_treatment.pump_skid", + "recipeId": "factory:centrifugal-pump", + "paramMap": { + "defaultDimensions.length": "length", + "defaultDimensions.width": "width", + "defaultDimensions.height": "height", + "processPorts.inlet.diameter": "inletDiameter", + "processPorts.outlet.diameter": "outletDiameter", + "equipmentDefaults.pumpType": "pumpType", + "equipmentDefaults.flowRate": "flowRate", + "equipmentDefaults.motorPower": "motorPower", + "equipmentDefaults.skidMounted": "skidMounted" + }, + "portMap": { + "inlet": "inlet", + "outlet": "outlet" + } + } + ] +} diff --git a/cloud/industry.water-treatment.basic-0.1.0/process-templates/generated.json b/cloud/industry.water-treatment.basic-0.1.0/process-templates/generated.json new file mode 100644 index 000000000..1ad4f825d --- /dev/null +++ b/cloud/industry.water-treatment.basic-0.1.0/process-templates/generated.json @@ -0,0 +1,172 @@ +[ + { + "processId": "water_treatment_plant_basic", + "processLabel": "Water treatment plant", + "processDisplayLabel": "水处理厂", + "domain": "generic", + "aliases": [ + "水处理厂", + "污水处理厂", + "净水厂", + "自来水处理站", + "water treatment plant", + "wastewater treatment plant", + "sewage treatment plant" + ], + "requiredRoles": [ + "sedimentation_tank", + "chemical_dosing_unit", + "filter_vessel", + "pump_skid", + "pipe_corridor", + "sludge_dewatering_machine" + ], + "defaultLayoutStyle": "linear", + "defaultDimensions": { + "length": 48, + "width": 22 + }, + "safetyTags": [ + "water", + "chemical_dosing", + "sludge", + "pump" + ], + "stations": [ + { + "id": "sedimentation_tank", + "label": "Sedimentation tank", + "displayLabel": "沉淀池", + "role": "sedimentation_tank", + "equipmentHint": "water_treatment.sedimentation_tank rectangular sedimentation clarifier basin with open water surface, inlet channel, outlet weir, sludge hopper, scraper bridge, and access walkway", + "footprintHint": "large", + "safetyTags": [ + "water", + "basin", + "sludge" + ], + "profileId": "water_treatment.sedimentation_tank" + }, + { + "id": "chemical_dosing_unit", + "label": "Chemical dosing unit", + "displayLabel": "加药装置", + "role": "chemical_dosing_unit", + "equipmentHint": "water_treatment.chemical_dosing_unit chemical dosing skid with chemical tank, metering pump pair, dosing manifold, control cabinet, and containment base", + "footprintHint": "medium", + "safetyTags": [ + "chemical", + "dosing", + "pump" + ], + "profileId": "water_treatment.chemical_dosing_unit" + }, + { + "id": "filter_vessel", + "label": "Filter vessel", + "displayLabel": "过滤罐", + "role": "filter_vessel", + "equipmentHint": "water_treatment.filter_vessel vertical pressure filter tank with cylindrical shell, top manway, inlet outlet nozzles, bottom drain, and access ladder", + "footprintHint": "tall", + "safetyTags": [ + "pressure", + "water", + "filter" + ], + "profileId": "water_treatment.filter_vessel" + }, + { + "id": "pump_skid", + "label": "Pump skid", + "displayLabel": "泵组", + "role": "pump_skid", + "equipmentHint": "water_treatment.pump_skid parallel pump group with skid base, two centrifugal pumps, electric motors, inlet outlet headers, valves, and control panel", + "footprintHint": "large", + "safetyTags": [ + "rotating_equipment", + "water", + "power" + ], + "profileId": "water_treatment.pump_skid" + }, + { + "id": "pipe_corridor", + "label": "Pipe corridor", + "displayLabel": "管廊", + "role": "pipe_corridor", + "equipmentHint": "water_treatment.pipe_corridor pipe rack corridor with steel supports, multiple parallel pipelines, cable tray, valves, and walkway", + "footprintHint": "long", + "safetyTags": [ + "pipe", + "utility", + "walkway" + ], + "profileId": "water_treatment.pipe_corridor" + }, + { + "id": "sludge_dewatering_machine", + "label": "Sludge dewatering machine", + "displayLabel": "污泥脱水机", + "role": "sludge_dewatering_machine", + "equipmentHint": "water_treatment.sludge_dewatering_machine screw press sludge dewatering machine with feed hopper, enclosed screw drum, filtrate tray, cake discharge chute, and drive unit", + "footprintHint": "large", + "safetyTags": [ + "sludge", + "rotating_equipment", + "dewatering" + ], + "profileId": "water_treatment.sludge_dewatering_machine" + } + ], + "connections": [ + { + "fromStationId": "chemical_dosing_unit", + "toStationId": "sedimentation_tank", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "dosing_outlet", + "toPortId": "chemical_inlet" + }, + { + "fromStationId": "sedimentation_tank", + "toStationId": "filter_vessel", + "medium": "water", + "visualKind": "pipe", + "fromPortId": "clarified_water_outlet", + "toPortId": "filter_inlet" + }, + { + "fromStationId": "filter_vessel", + "toStationId": "pump_skid", + "medium": "water", + "visualKind": "pipe", + "fromPortId": "filtered_water_outlet", + "toPortId": "pump_suction_header" + }, + { + "fromStationId": "pump_skid", + "toStationId": "pipe_corridor", + "medium": "water", + "visualKind": "pipe", + "fromPortId": "pump_discharge_header", + "toPortId": "process_water_header" + }, + { + "fromStationId": "sedimentation_tank", + "toStationId": "sludge_dewatering_machine", + "medium": "material", + "visualKind": "pipe", + "fromPortId": "sludge_draw_off", + "toPortId": "sludge_feed_inlet" + }, + { + "fromStationId": "sludge_dewatering_machine", + "toStationId": "pipe_corridor", + "medium": "water", + "visualKind": "pipe", + "fromPortId": "filtrate_return_outlet", + "toPortId": "filtrate_return_header" + } + ] + } +] diff --git a/cloud/industry.water-treatment.basic-0.1.0/profiles/generated.json b/cloud/industry.water-treatment.basic-0.1.0/profiles/generated.json new file mode 100644 index 000000000..b2124a265 --- /dev/null +++ b/cloud/industry.water-treatment.basic-0.1.0/profiles/generated.json @@ -0,0 +1,945 @@ +[ + { + "id": "water_treatment.sedimentation_tank", + "name": "Sedimentation tank", + "aliases": [ + "sedimentation tank", + "clarifier basin", + "settling tank", + "沉淀池", + "澄清池", + "沉沙池" + ], + "industry": "water-treatment", + "layoutFamily": "generic_industrial_layout", + "archetypeFamily": "water_basin", + "family": "generic", + "defaultDimensions": { + "length": 8.5, + "width": 4.2, + "height": 1.5 + }, + "parts": [ + { + "kind": "generic_body", + "semanticRole": "clarifier_basin", + "required": true, + "length": 8.5, + "width": 4.2, + "height": 1, + "position": [ + 0, + 0.5, + 0 + ], + "primaryColor": "#94a3b8" + }, + { + "kind": "generic_panel", + "semanticRole": "open_water_surface", + "required": true, + "length": 8.1, + "width": 3.8, + "height": 0.04, + "position": [ + 0, + 1.04, + 0 + ], + "color": "#38bdf8", + "opacity": 0.56 + }, + { + "kind": "generic_panel", + "semanticRole": "inlet_channel", + "required": true, + "length": 0.5, + "width": 3.8, + "height": 0.24, + "position": [ + -4.25, + 1.18, + 0 + ], + "color": "#64748b" + }, + { + "kind": "generic_panel", + "semanticRole": "outlet_weir", + "required": true, + "length": 0.36, + "width": 3.9, + "height": 0.18, + "position": [ + 4.15, + 1.18, + 0 + ], + "color": "#475569" + }, + { + "kind": "conical_hopper", + "semanticRole": "sludge_hopper", + "required": true, + "length": 2.1, + "width": 1.6, + "height": 0.7, + "position": [ + 0, + 0.18, + 0 + ], + "metalColor": "#64748b" + }, + { + "kind": "bar_pair", + "semanticRole": "scraper_bridge", + "required": true, + "length": 8.2, + "width": 3.9, + "height": 0.08, + "position": [ + 0, + 1.32, + 0 + ], + "color": "#334155" + }, + { + "kind": "service_platform", + "semanticRole": "access_walkway", + "required": true, + "length": 8.8, + "width": 0.55, + "height": 1.05, + "position": [ + 0, + 1.05, + 2.35 + ], + "metalColor": "#475569", + "color": "#facc15" + }, + { + "kind": "inlet_port", + "semanticRole": "chemical_inlet", + "position": [ + -4.2, + 0.9, + -1.5 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "clarified_water_outlet", + "position": [ + 4.25, + 0.85, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "sludge_draw_off", + "position": [ + 0, + 0.25, + -2.1 + ] + } + ], + "primarySemanticRole": "clarifier_basin", + "qualityRules": "quality.water_treatment.sedimentation_tank", + "visualCues": [ + "large open rectangular basin", + "blue water surface", + "outlet weir", + "sludge hopper", + "scraper bridge" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "open_water_surface": { + "detailLevel": "low" + }, + "inlet_channel": { + "detailLevel": "low" + }, + "outlet_weir": { + "detailLevel": "low" + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "water_treatment.chemical_dosing_unit", + "name": "Chemical dosing unit", + "aliases": [ + "chemical dosing unit", + "dosing skid", + "chemical dosing skid", + "加药装置", + "加药撬", + "计量加药装置" + ], + "industry": "water-treatment", + "layoutFamily": "rotating_machine_layout", + "archetypeFamily": "dosing_skid", + "family": "pump", + "defaultDimensions": { + "length": 2.6, + "width": 1.2, + "height": 1.8 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "containment_base", + "required": true, + "length": 2.6, + "width": 1.15, + "height": 0.12 + }, + { + "kind": "cylindrical_tank", + "semanticRole": "chemical_storage_tank", + "required": true, + "axis": "y", + "height": 1.35, + "radius": 0.42, + "position": [ + -0.75, + 0.82, + 0 + ] + }, + { + "kind": "volute_casing", + "semanticRole": "metering_pump_head", + "required": true, + "position": [ + 0.25, + 0.45, + -0.22 + ] + }, + { + "kind": "volute_casing", + "semanticRole": "standby_metering_pump_head", + "required": true, + "position": [ + 0.25, + 0.45, + 0.26 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "pump_drive_unit", + "required": true, + "position": [ + 0.78, + 0.45, + 0 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "dosing_manifold", + "required": true, + "length": 1.55, + "radius": 0.04, + "count": 4, + "position": [ + 0.45, + 0.9, + 0 + ] + }, + { + "kind": "electrical_cabinet", + "semanticRole": "dosing_control_cabinet", + "required": true, + "position": [ + 1.05, + 0.78, + 0.42 + ], + "height": 1.1, + "width": 0.35, + "depth": 0.25 + }, + { + "kind": "outlet_port", + "semanticRole": "dosing_outlet", + "position": [ + 1.25, + 0.9, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "chemical_makeup_inlet", + "position": [ + -1.05, + 1.35, + 0 + ] + } + ], + "primarySemanticRole": "chemical_storage_tank", + "qualityRules": "quality.water_treatment.chemical_dosing_unit", + "visualCues": [ + "chemical tank", + "metering pump pair", + "dosing manifold", + "control cabinet", + "containment base" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56 + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "left", + "diameter": 0.18 + }, + { + "id": "outlet", + "medium": "material", + "side": "right", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "pumpType": "metering", + "flowRate": 120, + "motorPower": 15, + "skidMounted": true + }, + "preferredResolver": "profile-parts" + }, + { + "id": "water_treatment.filter_vessel", + "name": "Filter vessel", + "aliases": [ + "filter vessel", + "pressure filter tank", + "sand filter tank", + "过滤罐", + "过滤器", + "砂滤罐" + ], + "industry": "water-treatment", + "layoutFamily": "vessel_layout", + "archetypeFamily": "pressure_filter", + "family": "generic", + "defaultDimensions": { + "diameter": 1.6, + "height": 3.2 + }, + "parts": [ + { + "kind": "cylindrical_tank", + "semanticRole": "filter_vessel_shell", + "required": true, + "axis": "y", + "height": 2.75, + "radius": 0.72, + "position": [ + 0, + 1.55, + 0 + ] + }, + { + "kind": "skid_base", + "semanticRole": "filter_support_base", + "required": true, + "length": 1.8, + "width": 1.6, + "height": 0.14 + }, + { + "kind": "manway_lid", + "semanticRole": "top_manhole", + "required": true, + "position": [ + 0, + 3.08, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "filter_inlet", + "required": true, + "position": [ + -0.78, + 1.95, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "filtered_water_outlet", + "required": true, + "position": [ + 0.78, + 0.75, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "backwash_drain", + "position": [ + 0, + 0.45, + -0.82 + ] + }, + { + "kind": "platform_ladder", + "semanticRole": "filter_access_ladder", + "height": 2.4, + "length": 0.8, + "width": 0.5, + "position": [ + 0.9, + 1.2, + 0.7 + ] + } + ], + "primarySemanticRole": "filter_vessel_shell", + "qualityRules": "quality.water_treatment.filter_vessel", + "visualCues": [ + "vertical pressure filter tank", + "top manway", + "side nozzles", + "access ladder", + "support base" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "vessel.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "filter_access_ladder": { + "detailLevel": "low", + "count": 6, + "rungCount": 6 + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "top", + "diameter": 0.16 + }, + { + "id": "outlet", + "medium": "material", + "side": "front", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "orientation": "vertical", + "capacity": 10, + "liquidLevel": 0.5 + }, + "preferredResolver": "profile-parts" + }, + { + "id": "water_treatment.pump_skid", + "name": "Pump skid", + "aliases": [ + "pump skid", + "pump group", + "parallel pump set", + "泵组", + "泵房泵组", + "并联泵组" + ], + "industry": "water-treatment", + "layoutFamily": "rotating_machine_layout", + "archetypeFamily": "rotating_machine", + "family": "pump", + "defaultDimensions": { + "length": 3.6, + "width": 1.6, + "height": 1.2 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "pump_baseplate", + "required": true, + "length": 3.6, + "width": 1.45, + "height": 0.14 + }, + { + "kind": "volute_casing", + "semanticRole": "pump_volute", + "required": true, + "position": [ + -0.85, + 0.48, + -0.36 + ] + }, + { + "kind": "volute_casing", + "semanticRole": "standby_pump_volute", + "required": true, + "position": [ + -0.85, + 0.48, + 0.36 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "main_drive_motor", + "required": true, + "position": [ + 0.25, + 0.5, + -0.36 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "standby_drive_motor", + "required": true, + "position": [ + 0.25, + 0.5, + 0.36 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "pump_suction_header", + "required": true, + "length": 3.2, + "radius": 0.08, + "count": 2, + "position": [ + -0.65, + 0.75, + -0.78 + ] + }, + { + "kind": "pipe_manifold", + "semanticRole": "pump_discharge_header", + "required": true, + "length": 3.2, + "radius": 0.08, + "count": 2, + "position": [ + -0.65, + 0.95, + 0.78 + ] + }, + { + "kind": "valve_body", + "semanticRole": "valve_body", + "required": true, + "position": [ + -1.35, + 0.82, + 0 + ] + }, + { + "kind": "operator_panel", + "semanticRole": "pump_control_panel", + "position": [ + 1.45, + 0.75, + 0.55 + ] + } + ], + "primarySemanticRole": "pump_volute", + "qualityRules": "quality.water_treatment.pump_skid", + "visualCues": [ + "two parallel centrifugal pumps", + "shared suction header", + "shared discharge header", + "motors", + "valves" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "pump_baseplate": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "pump_control_panel": { + "detailLevel": "low" + } + } + }, + "processPorts": [ + { + "id": "inlet", + "medium": "material", + "side": "left", + "diameter": 0.18 + }, + { + "id": "outlet", + "medium": "material", + "side": "right", + "diameter": 0.12 + } + ], + "equipmentDefaults": { + "pumpType": "centrifugal", + "flowRate": 120, + "motorPower": 15, + "skidMounted": true + }, + "preferredResolver": "profile-parts" + }, + { + "id": "water_treatment.pipe_corridor", + "name": "Pipe corridor", + "aliases": [ + "pipe corridor", + "pipe rack", + "utility pipe rack", + "管廊", + "管架", + "管道廊" + ], + "industry": "water-treatment", + "layoutFamily": "pipe_valve_layout", + "archetypeFamily": "pipe_rack", + "family": "pipe_system", + "defaultDimensions": { + "length": 8, + "width": 1.6, + "height": 2.4 + }, + "parts": [ + { + "kind": "pipe_run", + "semanticRole": "pipe_rack_frame", + "required": true, + "length": 8, + "radius": 0.035, + "position": [ + 0, + 1.8, + -0.72 + ], + "metalColor": "#334155" + }, + { + "kind": "pipe_run", + "semanticRole": "process_water_header", + "required": true, + "length": 7.6, + "radius": 0.08, + "position": [ + 0, + 1.55, + -0.36 + ] + }, + { + "kind": "pipe_run", + "semanticRole": "filtrate_return_header", + "required": true, + "length": 7.6, + "radius": 0.06, + "position": [ + 0, + 1.25, + 0.05 + ] + }, + { + "kind": "pipe_run", + "semanticRole": "chemical_dosing_line", + "required": true, + "length": 7.6, + "radius": 0.04, + "position": [ + 0, + 0.95, + 0.42 + ] + }, + { + "kind": "pipe_run", + "semanticRole": "instrument_air_line", + "length": 7.6, + "radius": 0.03, + "position": [ + 0, + 1.9, + 0.58 + ] + }, + { + "kind": "valve_body", + "semanticRole": "valve_body", + "required": true, + "position": [ + 2.2, + 1.35, + -0.36 + ] + }, + { + "kind": "flange_ring", + "semanticRole": "header_flange_set", + "position": [ + -3.6, + 1.55, + -0.36 + ] + }, + { + "kind": "flange_ring", + "semanticRole": "return_header_flange_set", + "position": [ + 3.6, + 1.25, + 0.05 + ] + } + ], + "primarySemanticRole": "pipe_rack_frame", + "qualityRules": "quality.water_treatment.pipe_corridor", + "visualCues": [ + "long steel pipe rack", + "parallel pipes", + "cable tray", + "maintenance walkway", + "valve group" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "enclosure.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 56, + "parts": { + "header_flange_set": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + }, + "return_header_flange_set": { + "detailLevel": "low", + "count": 6, + "boltCount": 6 + } + } + }, + "preferredResolver": "profile-parts" + }, + { + "id": "water_treatment.sludge_dewatering_machine", + "name": "Sludge dewatering machine", + "aliases": [ + "sludge dewatering machine", + "screw press", + "sludge screw press", + "污泥脱水机", + "叠螺机", + "螺旋压滤脱水机" + ], + "industry": "water-treatment", + "layoutFamily": "box_enclosure_layout", + "archetypeFamily": "dewatering_machine", + "family": "machine_tool", + "defaultDimensions": { + "length": 3.4, + "width": 1.2, + "height": 1.5 + }, + "parts": [ + { + "kind": "skid_base", + "semanticRole": "machine_base", + "required": true, + "length": 3.4, + "width": 1.05, + "height": 0.14 + }, + { + "kind": "generic_body", + "semanticRole": "dewatering_screw_drum", + "required": true, + "length": 2.2, + "width": 0.62, + "height": 0.72, + "position": [ + 0, + 0.72, + 0 + ], + "primaryColor": "#94a3b8" + }, + { + "kind": "hopper_body", + "semanticRole": "sludge_feed_hopper", + "required": true, + "length": 0.85, + "width": 0.72, + "height": 0.65, + "position": [ + -1.2, + 1.08, + 0 + ] + }, + { + "kind": "inlet_port", + "semanticRole": "sludge_feed_inlet", + "required": true, + "position": [ + -1.65, + 1.28, + 0 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "filtrate_tray", + "required": true, + "length": 2.2, + "width": 0.78, + "height": 0.08, + "position": [ + 0, + 0.32, + 0 + ] + }, + { + "kind": "generic_panel", + "semanticRole": "cake_discharge_chute", + "required": true, + "length": 0.72, + "width": 0.52, + "height": 0.22, + "position": [ + 1.48, + 0.58, + 0 + ] + }, + { + "kind": "outlet_port", + "semanticRole": "filtrate_return_outlet", + "required": true, + "position": [ + 0.8, + 0.35, + -0.55 + ] + }, + { + "kind": "motor_gearbox_unit", + "semanticRole": "screw_drive_unit", + "required": true, + "position": [ + 1.15, + 0.74, + 0 + ] + }, + { + "kind": "operator_panel", + "semanticRole": "dewatering_control_panel", + "position": [ + 1.45, + 0.9, + 0.48 + ] + } + ], + "primarySemanticRole": "dewatering_screw_drum", + "qualityRules": "quality.water_treatment.sludge_dewatering_machine", + "visualCues": [ + "enclosed screw drum", + "feed hopper", + "filtrate tray", + "cake discharge chute", + "drive unit" + ], + "status": "stable", + "source": "imported_pack", + "editableSchemaRef": "rotary_equipment.common", + "detailBudget": { + "detailLevel": "low", + "maxShapes": 72, + "parts": { + "filtrate_tray": { + "detailLevel": "low" + }, + "cake_discharge_chute": { + "detailLevel": "low" + }, + "dewatering_control_panel": { + "detailLevel": "low" + } + } + }, + "preferredResolver": "profile-parts" + } +] diff --git a/cloud/industry.water-treatment.basic-0.1.0/quality-rules/generated-quality.json b/cloud/industry.water-treatment.basic-0.1.0/quality-rules/generated-quality.json new file mode 100644 index 000000000..b5f830534 --- /dev/null +++ b/cloud/industry.water-treatment.basic-0.1.0/quality-rules/generated-quality.json @@ -0,0 +1,129 @@ +[ + { + "id": "quality.water_treatment.sedimentation_tank", + "requiredRoles": [ + "clarifier_basin", + "open_water_surface", + "inlet_channel", + "outlet_weir", + "sludge_hopper", + "scraper_bridge", + "access_walkway", + "chemical_inlet", + "clarified_water_outlet", + "sludge_draw_off" + ], + "forbiddenRoles": [ + "vehicle_cabin", + "robot_joint" + ], + "shapeCount": { + "min": 8, + "max": 90 + } + }, + { + "id": "quality.water_treatment.chemical_dosing_unit", + "requiredRoles": [ + "chemical_storage_tank", + "containment_base", + "metering_pump_head", + "standby_metering_pump_head", + "pump_drive_unit", + "dosing_manifold", + "dosing_control_cabinet", + "dosing_outlet", + "chemical_makeup_inlet" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 8, + "max": 100 + } + }, + { + "id": "quality.water_treatment.filter_vessel", + "requiredRoles": [ + "filter_vessel_shell", + "filter_support_base", + "top_manhole", + "filter_inlet", + "filtered_water_outlet", + "backwash_drain", + "filter_access_ladder" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "robot_joint" + ], + "shapeCount": { + "min": 7, + "max": 90 + } + }, + { + "id": "quality.water_treatment.pump_skid", + "requiredRoles": [ + "pump_volute", + "pump_baseplate", + "standby_pump_volute", + "main_drive_motor", + "standby_drive_motor", + "pump_suction_header", + "pump_discharge_header", + "valve_body", + "pump_control_panel" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 10, + "max": 130 + } + }, + { + "id": "quality.water_treatment.pipe_corridor", + "requiredRoles": [ + "pipe_rack_frame", + "process_water_header", + "filtrate_return_header", + "chemical_dosing_line", + "instrument_air_line", + "valve_body", + "header_flange_set", + "return_header_flange_set" + ], + "forbiddenRoles": [ + "vehicle_cabin" + ], + "shapeCount": { + "min": 9, + "max": 140 + } + }, + { + "id": "quality.water_treatment.sludge_dewatering_machine", + "requiredRoles": [ + "dewatering_screw_drum", + "machine_base", + "sludge_feed_hopper", + "sludge_feed_inlet", + "filtrate_tray", + "cake_discharge_chute", + "filtrate_return_outlet", + "screw_drive_unit", + "dewatering_control_panel" + ], + "forbiddenRoles": [ + "vehicle_wheel", + "robot_joint" + ], + "shapeCount": { + "min": 8, + "max": 120 + } + } +] diff --git a/docs/3d-factory-product-workflow-design.md b/docs/3d-factory-product-workflow-design.md new file mode 100644 index 000000000..e413ca68c --- /dev/null +++ b/docs/3d-factory-product-workflow-design.md @@ -0,0 +1,796 @@ +# 3D Factory Product Workflow Design + +## Baseline + +Current implementation is backed up as: + +- Commit: `b4ade4d5` +- Tag: `202607043Dfactory1.0` +- Backup branch: `codex/backup-202607043Dfactory1.0` + +This document designs the next product layer on top of the 1.0 baseline. The goal is not to add more isolated tools. The goal is to make the existing abilities feel like one intelligent industrial scene workflow. + +## Product Positioning + +The product should feel like an industrial 3D scene copilot: + +- It can place normal built-in objects. +- It can generate objects through AI geometry construction. +- It can generate from image references. +- It can generate articulated or joint-aware assets. +- It can install industry packs and generate factory scenes. +- It can preserve semantic assemblies so users can edit equipment and subparts. +- It can bind real-time WebSocket data to equipment, parts, visual states, and dashboards. +- It can still let users manually adjust canvas objects when automation is not enough. + +The interaction model should hide tool complexity by default. Users should start from intent, not from choosing the correct generator. + +## Product Problem + +The current capability set is powerful, but it risks becoming a toolbox: + +- Users must understand whether to use normal assets, AI geometry, image modeling, industry pack generation, or data binding. +- The canvas organization is still biased toward building floors, while many factories are not floor-based. +- Generated results can be hard to understand unless the user can see why the system chose a profile, recipe, or fallback. +- WebSocket binding can feel disconnected from geometry if it is not part of the same semantic object model. +- Manual editing and AI editing can fight each other if selected equipment, selected subpart, and selected data binding are not clearly scoped. + +## Design Principle + +Use one product principle: + +```txt +Intent first, semantic scene second, tool choice third. +``` + +The user should say what they want. The system should choose the route: + +- built-in object +- industry pack +- recipe-backed semantic assembly +- semantic profile-parts assembly +- image-to-model +- joint asset generator +- free geometry fallback +- data binding flow +- local edit to selected object or selected subpart + +The UI should show the chosen route only when it helps the user trust or adjust the result. + +## Core Experience Model + +### 1. Unified Intent Entry + +Replace scattered generation entry points with one primary command surface: + +```txt +What do you want to create, change, or connect? +``` + +Examples: + +- `生成一个炼油厂` +- `生成一个带液位的储罐` +- `按这张图生成一台设备` +- `给这个机械臂加关节` +- `把 tank_01.level 绑定到储罐液位` +- `选中这个蒸馏塔,给它加塔外螺旋梯` +- `把罐区内壁透明度调到 0.3` + +The command system classifies the intent into: + +- `create_scene` +- `create_equipment` +- `create_asset_from_image` +- `create_joint_asset` +- `edit_selected_equipment` +- `edit_selected_part` +- `bind_data` +- `inspect_or_explain` +- `repair_or_optimize_scene` + +### 2. Generation Plan Preview + +Before applying a large change, show a plan preview: + +```txt +Request: 生成一个炼油厂 +Industry pack: refinery.basic +Stations: 16 +Recipe-backed assemblies: 13 +Semantic profile-parts assemblies: 3 +Generic fallback: 0 +Ports: generated +Data binding: not configured +Action: Preview / Apply / Edit plan +``` + +The preview turns AI from a black box into a controlled assistant. + +### 3. Scene Structure Instead Of Floor Tree + +Floors should become one kind of scene organization, not the main mental model. + +Introduce a `Scene Structure` panel with switchable grouping modes: + +- Spatial: `Site > Zone > Area > Equipment` +- System: `Mechanical / Piping / Electrical / Instrument / Building / Safety` +- Data: `Bound / Unbound / Alarm / Offline` +- Asset Source: `Built-in / Industry pack / AI generated / Image generated / Imported` +- Elevation: `Ground / Platform / Floor / Pipe rack level` + +For building design, the old floor tree still exists as an `Elevation/Floor` structure mode. + +For factories, the default should be `System` or `Spatial`, not `Floor`. + +### 4. Canvas Display Annotations + +The canvas should stay in one normal editing mode. Display settings decide which annotations are visible without changing scene data or editing behavior. + +Main user-facing annotations: + +- 区名: spatial zone names. +- 设备标注: semantic equipment names, footprints, editable parts, and port counts. +- 数据绑定标注: bound/unbound data status, binding fields, sample/current cached values, and drag/drop binding targets. + +Removed from the main toolbar after product review: + +- Layout: normal canvas editing is already the default. +- Equipment View: folded into Display settings as equipment annotations. +- Data View: renamed to data binding annotations because real-time movement belongs to Preview/Run mode. +- Process: dense factory-wide route overlays were confusing and duplicated Data-driven flow visuals. +- Maintenance: deferred until there is real work-order, alarm, or inspection-route data. +- Elevation: still exists in Scene Structure for building/floor projects, but is not a factory canvas view. + +### 5. Capability-Driven Inspector + +The right inspector should be generated from the selected object's capability profile. + +Object routing: + +- Plain geometry shows the generic node property panel. +- Plain user-created assemblies show a lightweight group panel. +- Semantic equipment assemblies show an equipment panel. +- Semantic equipment parts route back to the parent equipment panel and expose part-specific controls. +- Image, AI, and joint assets show their asset-specific controls. + +For semantic equipment, use task-based sections instead of many peer tabs: + +- Equipment settings: identity, profile, recipe, device-level params. +- Appearance and parts: exposed semantic parts, material, color, opacity. +- Data and dynamics: binding target, current value, visual effect such as level, flow, color, pulse, or visibility. +- Connections and source: ports, route links, industry pack, AI/source metadata. + +Default behavior: + +- If semantic equipment is selected, edit equipment-level params first. +- If a semantic equipment part is selected, keep the parent equipment context visible. +- If user says "all ladders" or "all tanks", expand scope intentionally. + +### 6. Data Binding Flow + +WebSocket binding should be a guided workflow, not only a technical setting. + +Recommended steps: + +1. Connect: enter WebSocket URL, auth if needed, preview messages. +2. Parse: detect JSON fields, units, timestamps, equipment ids. +3. Match: suggest equipment or ports by id/name/alias. +4. Bind: map field to semantic target. +5. Visualize: choose liquid level, color, label, animation, alarm, trend. +6. Validate: show sample live update on canvas. + +Binding targets should include: + +- equipment parameter, for example `liquidLevel` +- semantic part material, for example `shell.opacity` +- port value, for example `flowRate` +- visual state, for example `running`, `alarm`, `offline` +- transform or animation, for example conveyor speed +- label and dashboard trend + +### 7. Workflow Graph For Advanced Users + +Borrow the idea of node workflow systems, but keep it hidden by default. + +The internal workflow for a factory request can be represented as: + +```txt +Prompt + -> Intent classifier + -> Pack resolver + -> Process template resolver + -> Equipment compiler + -> Semantic assembly patch builder + -> Port and route composer + -> Quality report + -> Apply to canvas +``` + +Advanced users can open this as a `Workflow` panel: + +- See what happened. +- Re-run only one stage. +- Replace one station profile. +- Save the workflow as a template. +- Share it with another project. + +This should feel like "explain and tune the automation", not like forced visual programming. + +## Detailed Interaction Flows + +### Flow A: Generate A Factory From One Sentence + +1. User enters `生成一个炼油厂`. +2. System classifies `create_scene`. +3. Pack resolver checks installed packs. +4. If missing, show pack install gate. +5. If installed, resolve factory template. +6. Show generation plan preview. +7. User applies. +8. Canvas opens in Layout with Equipment/Data available as focused views. +9. Scene Structure defaults to System for factory scenes. +10. Quality report lists recipe-backed and profile-parts equipment. + +Success criteria: + +- User does not need to choose a generator manually. +- No hidden generic fallback unless explicitly reported. +- User can immediately select a station and edit its semantic assembly. + +### Flow B: Generate A Single Equipment + +1. User enters `生成一个离心泵`. +2. System classifies `create_equipment`. +3. Resolver chooses recipe-backed semantic assembly if possible. +4. If no recipe exists, resolver chooses semantic profile-parts. +5. If no profile exists, system creates a generic draft with a clear warning. +6. Inspector opens to equipment params. + +Success criteria: + +- Known industrial equipment is stable and editable. +- Unknown equipment remains possible but visibly marked as draft. + +### Flow C: Edit Selected Equipment + +1. User selects a tank assembly. +2. User says `液位调到 60%, 外壳透明一点`. +3. AI routes to `edit_selected_equipment`. +4. It updates `liquidLevel` and shell opacity. +5. It does not regenerate the whole tank. + +Success criteria: + +- Existing manual adjustments are preserved where possible. +- Edit scope is visible in the run result. + +### Flow D: Edit Selected Part + +1. User selects a distillation tower helical ladder tread or ladder group. +2. User says `把这个梯子颜色改成黄色`. +3. AI routes to `edit_selected_part`. +4. It modifies only semantic roles under `sourcePartKind: helical_ladder`. + +Success criteria: + +- Selection scope is respected. +- The user can switch between instance edit and profile edit. + +### Flow E: Image-To-Model + +1. User drops an image and asks for a device. +2. System classifies `create_asset_from_image`. +3. If the image matches a known profile, ask whether to use semantic profile-parts. +4. If not, use image modeling path and mark result as imported/generated asset. +5. Offer "convert visible parts to semantic assembly" when possible. + +Success criteria: + +- Image generation does not bypass the semantic scene model. +- The result can still be organized, selected, and bound to data. + +### Flow F: Bind Real-Time Data + +1. User opens Display and enables data binding annotations. +2. User connects a WebSocket source. +3. System previews fields. +4. User drags `tank_01.level` to a tank. +5. System suggests `liquidLevel`. +6. User confirms. +7. Canvas shows live liquid level and trend. + +Success criteria: + +- Binding is visual and reversible. +- Bound fields are visible in data binding annotations and the equipment inspector. + +## Data And Domain Model Additions + +### Scene Organization + +```ts +type SceneStructureMode = + | 'spatial' + | 'system' + | 'data' + | 'asset-source' + | 'elevation' +``` + +Scene nodes should be grouped through metadata, not moved into incompatible parent types only for UI grouping. + +Recommended metadata: + +```ts +type SceneOrganizationMetadata = { + siteId?: string + zoneId?: string + processId?: string + stationId?: string + systemKind?: 'mechanical' | 'piping' | 'electrical' | 'instrument' | 'building' | 'safety' + elevationBand?: string + sourceKind?: 'builtin' | 'industry-pack' | 'ai-generated' | 'image-generated' | 'imported' +} +``` + +### Canvas Display Annotations + +```ts +type CanvasAnnotationOverlay = 'equipment' | 'data-binding' +``` + +Each annotation overlay defines: + +- visible overlays +- selectable targets +- default inspector focus +- label density +- equipment or data emphasis + +### Semantic Target + +```ts +type SemanticTarget = { + nodeId: string + assemblyId?: string + semanticRole?: string + sourcePartKind?: string + sourcePartId?: string + portId?: string + dataBindingId?: string +} +``` + +This is the shared object for selection, AI edits, inspector, and data binding. + +### Data Binding + +```ts +type SceneDataBinding = { + id: string + sourceId: string + sourceField: string + target: SemanticTarget + targetProperty: string + transform?: { + scale?: number + offset?: number + clamp?: [number, number] + unit?: string + } + visualization?: { + label?: boolean + colorRamp?: string + alarmThreshold?: number + animation?: 'none' | 'pulse' | 'rotate' | 'flow' + trend?: boolean + } +} +``` + +## Development Phases + +### Phase 1: Intent Router And Plan Preview + +Status: complete for the v2 first release. + +Goal: + +- One command entry can route to factory generation, single equipment generation, selected edit, image generation, joint asset, or data binding. + +Deliverables: + +- Intent classifier contract. +- Generation plan preview model. +- UI panel for preview and apply. +- Logging of chosen route and fallback reason. + +Validation: + +- `生成一个炼油厂` routes to industry pack generation. +- `生成一个离心泵` routes to equipment generation. +- With selected tank, `液位 60%` routes to selected edit. +- Missing industry pack shows install gate instead of generic generation. + +Phase 1 delivery status: + +- Done: factory intent preview builds a blocked preview when a known prompt requires a missing industry pack. +- Done: installed industry-pack factory requests produce a confirmable preview instead of a generic fallback. +- Done: single-equipment prompts route through semantic equipment compilation before primitive fallback. +- Done: selected-object edits produce scoped update patches instead of regenerating whole equipment. +- Done: factory run summaries include route, fallback, apply, and missing-pack evidence for the user-facing preview/result surface. + +Verification: + +- `bun test apps/editor/lib/ai-harness-runs/intent-preview-service.test.ts` +- `bun test apps/editor/lib/ai-harness-runs/single-equipment-compiler.test.ts` +- `bun test apps/editor/lib/ai-harness-runs/factory-runner.test.ts` +- `bun test apps/editor/lib/ai-harness-runs/workflow-summary.test.ts` + +Deferred to later polish: + +- Rich editable plan UI before apply. The first release has preview/apply evidence and install blocking, while deeper plan editing belongs with later workflow-template work. + +### Phase 2: Scene Structure Panel + +Status: delivered for v2 foundation. + +Goal: + +- Replace floor-only mental model with switchable scene structures. + +Deliverables: + +- Scene Structure panel. +- Group by spatial, system, data, asset source, elevation. +- Preserve existing floor/elevation behavior as one mode. + +Validation: + +- Refinery defaults to system/spatial grouping. +- Building/floor projects can still use floor grouping. +- Selecting a station row selects the correct assembly on canvas. + +Delivered: + +- Site panel now opens with Scene Structure as the primary structure view. +- Auto mode chooses System for industry/factory scenes, Elevation for building/floor scenes, and Spatial for general scenes. +- System mode collapses refinery/factory stations to representative assemblies instead of listing every primitive, pipe, and detail part. +- Spatial, System, Data, Asset Source, and Elevation modes are available from the same panel. +- Elevation mode preserves the previous floor/level content instead of deleting the old building workflow. +- Structure rows synchronize selection with canvas/inspector and scroll selected rows into view. +- Inspector and AI now share object capability profiles, so selected-object edits can see semantic parts, editable params, and read-only ports. + +Verification: + +- `bun test packages/editor/src/lib/scene-structure.test.ts` +- `bun test packages/editor/src/lib/object-capabilities.test.ts packages/editor/src/lib/ai-chat-harness/context-builder.test.ts` +- `bunx playwright test e2e/scene-structure.spec.ts` +- `bun run --cwd apps/editor check-types` + +Deferred to later phases: + +- Equipment footprints and data labels belong to Phase 3 Canvas Display Annotations. +- Capability-driven equipment configuration belongs to Phase 4 Inspector. +- Scene Structure search, large-scene virtualization, and saved per-project structure preferences are product polish items after the v2 foundation is stable. + +### Phase 3: Canvas Display Annotations + +Status: MVP closed and product scope tightened after product review. + +Goal: + +- Let users turn helpful annotations on/off without switching the canvas into confusing modes. + +Deliverables: + +- Display menu entries. +- Equipment footprints and part affordances. +- Data binding labels and binding affordances. +- Annotation-specific selection targets. + +Validation: + +- Default canvas keeps normal editing uncluttered. +- Equipment annotations show semantic equipment affordances and preserve selection. +- Data binding annotations show bound/unbound status, cached/sample values, and binding drop targets. +- Process, Maintenance, and Elevation are not factory canvas views. + +Foundation delivered: + +- Editor state now has persisted `showEquipmentOverlay` and `showDataBindingOverlay` values. +- The bottom canvas toolbar no longer exposes Layout/Equipment/Data view modes. +- The top-right Display menu owns 区名, 设备标注, and 数据绑定标注 so all canvas labels live in one place. +- Scene Structure e2e verifies annotation toggles can happen while station selection remains stable. +- Equipment annotations render semantic equipment cards, footprint outlines, editable part chips, and port counts from the shared object capability resolver; clicking an equipment card selects the same assembly used by Scene Structure and Inspector. +- Data binding annotations render bound and ready-to-bind equipment cards from live-data and dynamic binding metadata; bound cards show binding summaries and sample/cached values while preserving normal selection behavior. +- Canvas annotation helpers now centralize safe metadata parsing, equipment identity detection, base positioning, station ids, and rough equipment height estimates so future overlays do not fork the same rules. + +Post-MVP polish: + +- Equipment annotation polish: true assembly bounds, part-side anchors, and direct affordances for editable semantic parts. +- Data binding annotation polish: freshness, alarm severity colors, and direct binding entry points. Real motion remains a Preview/Run-mode concern. +- Maintenance/elevation-specific overlays should only return when backed by real inspection or building workflows, not as always-on factory modes. + +### Phase 4: Capability-Driven Inspector + +Status: Complete. + +Goal: + +- Make semantic assemblies and subparts understandable and editable. + +Deliverables: + +- Equipment settings section. +- Appearance and parts section. +- Data and dynamics section. +- Connections and source section. +- Instance edit versus profile edit affordance. + +Validation completed: + +- Selecting equipment shows recipe/profile identity and instance parameters in the equipment settings section. +- Selecting semantic parts shows part selection plus generic material and opacity controls. +- Connections and source shows port connection, target equipment, target port, route node, source pack, and unconnected state. +- Data and dynamics shows live data and dynamic binding status. + +Delivered: + +- Basic Inspector now routes by object capability profile. +- Plain geometry keeps the generic node panel. +- Plain user-created assemblies show a lightweight group panel instead of fake equipment settings. +- Semantic equipment assemblies show equipment settings, appearance/parts, data/dynamics, and connections/source sections. +- Semantic equipment parts route back to the parent equipment panel and expose part-specific controls without duplicating the generic material panel. +- Equipment settings separates instance edit affordance from read-only profile/industry-pack identity. +- Appearance and parts lists semantic parts and can select exposed part nodes; editable parts expose material and opacity controls in place. +- Connections and source lists declared connection anchors with medium/side metadata and resolves route/pipe metadata back into each port. +- Data and dynamics summarizes live-data and dynamic binding metadata and can bind fixed data fields to semantic targets. + +Deferred extension: + +- Add ladder/platform-specific part controls once refinery/cement recipes expose those part contracts beyond generic material controls. + +### Phase 5: Data Binding Workflow + +Goal: + +- Make WebSocket binding visual, safe, and semantic. + +Deliverables: + +- Fixed/demo live data source for Phase 5 validation. +- Message preview and semantic field detector. +- Assisted field-to-equipment binding from Inspector, AI generation preview, and fixed-field drag/drop. +- Data binding inspector and data binding annotation cards. +- Dynamic preview runtime for semantic level, flow, and alarm pulse bindings. + +Validation: + +- Bind `level` to `liquidLevel`. +- Bind `flow` to pipe or pump port label. +- Bind `alarm` to color or pulse state. +- Disconnect and reconnect source without losing binding config. + +Phase 5 delivery status: + +- Done: fixed factory live data source is seeded automatically for editor scenes. +- Done: selected semantic equipment exposes binding targets in the AI context and Inspector data/dynamics section. +- Done: AI data-binding preview can write deterministic `dynamicBindings` to the selected node. +- Done: fixed live data fields can be dragged onto data binding annotation cards to create semantic bindings. +- Done: data binding annotations and the equipment inspector read the same binding contract and show current cached/sample values. +- Done: browser coverage verifies tank level binding, alarm pulse binding, dynamic preview animation, and reset/reseed persistence. +- Deferred: user-managed WebSocket source add/remove UI. For the current product phase, data sources remain fixed while the binding workflow is hardened. +- Deferred: freeform drop-to-raw-3D-surface targeting and multi-source field browsing. Current drag/drop is scoped to fixed fields and data binding annotation cards. + +### Phase 6: Workflow Graph And Run History + +Goal: + +- Let advanced users inspect, tune, and reuse generated workflows. + +Deliverables: + +- Run history panel. +- Workflow graph view for a generation run. +- Re-run from a stage. +- Save generation plan as template. + +Validation: + +- Factory generation run shows pack resolver, template resolver, equipment compiler, route composer, quality report. +- User can re-run only one station after changing its profile. + +Phase 6 delivery status: + +- Done: AI harness runs expose a derived workflow graph from existing run/result/event data. +- Done: recent runs expose compact workflow summaries for a run history panel. +- Done: the AI panel includes a minimal Runs inspector that opens the workflow stages for a selected run. +- Done: station-scoped equipment compiler re-run can create a new focused run from a saved process plan. +- Done: station rerun results replace the old matching station nodes on the canvas during apply. +- Deferred: save generation plan as reusable template. + +### Phase 7: Image And Joint Asset Integration + +Goal: + +- Bring image-to-model and joint asset generation into the same semantic scene model. + +Deliverables: + +- Image-generated asset source metadata. +- Joint asset semantic controls. +- Optional conversion to semantic assembly when recognizable. + +Validation: + +- Image-generated equipment appears in Asset Source grouping. +- Joint asset exposes articulation controls. +- AI edits can target generated asset without losing source metadata. + +Phase 7 delivery status: + +- Done: placed image-to-3D and Articraft assets write a standard `metadata.assetSource` contract. +- Done: object capability reads and Scene Structure Source grouping recognize the standard asset source contract. +- Done: Articraft joint assets expose asset-level Inspector controls for record-wide joint preview, reset, selection, and per-joint edits. +- Done: AI geometry placement and revision replacement preserve standard asset source metadata on the new root assembly. +- Done: recognizable image-to-3D and Articraft model assets receive semantic equipment contracts during placement. + +### Phase 8: Product Polish And Safety + +Goal: + +- Make the experience feel predictable. + +Deliverables: + +- Undo/redo grouped by workflow run. +- Before/after preview. +- Fallback warnings. +- Missing industry pack install guidance. +- Quality report summary. + +Phase 8 delivery status: + +- Done: factory run results now share a tested experience summary for quality, fallback warnings, missing pack guidance, apply state, and debug details. +- Done: factory run apply results include a before/after change preview with node counts and created/updated/deleted samples. +- Done: factory run canvas application now writes one scene state change so undo/redo treats the whole run as a single step. +- Done: live data snapshots now sanitize bad WebSocket values before they reach rendering, data binding annotations, or Inspector consumers. +- Done: Phase 8 QA verifies installed industry-pack factory runs resolve templates from the editor server cwd and render refinery smoke screenshots. + +Validation: + +- Large generation can be undone in one step. +- User sees why a fallback happened. +- Bad WebSocket data does not break scene rendering. + +### Phase 9: Release Readiness And Pack Operations + +Goal: + +- Turn the v2 factory workflow into a repeatable release candidate, not a set of manually verified demos. + +Deliverables: + +- Factory release-readiness gate for installed/cloud industry packs. +- Server-cwd-safe pack and template resolution checks. +- Repeatable smoke evidence for one-sentence factory generation. +- Clear separation between fast release checks and heavier browser visual QA. +- Product-facing release notes for what a user can trust in v2. + +Phase 9 delivery status: + +- Done: factory release QA script verifies installed intent-routed packs resolve process templates from the editor server cwd. +- Done: factory release QA can write JSON readiness evidence and product-facing v2 release notes with `--out-dir`. +- Done: factory release-candidate command sequences readiness, core tests, typecheck, and Biome into one local RC gate. +- Done: product-facing Factory V2 release notes describe the user experience, V1-to-V2 changes, current boundaries, and RC commands. +- Done: final refinery visual smoke reached quality 100 with canvas render evidence, distinct isometric/top/side screenshots, and zero browser console/page/request failures. + +Validation: + +- `bun run --cwd apps/editor factory:release-qa` +- `bun run --cwd apps/editor factory:release-qa -- --out-dir apps/editor/qa-artifacts/factory-release-readiness/latest` +- `bun run --cwd apps/editor factory:release-candidate` +- `bun run --cwd apps/editor factory:release-candidate -- --with-visual-smoke --base-url http://localhost:3002` +- `bun test apps/editor/scripts/factory-release-readiness.test.ts` +- `docs/factory-v2-release-notes.md` +- `docs/factory-v2-release-evidence.md` + +## Implementation Priority + +Recommended first slice: + +1. Intent Router And Plan Preview. +2. Scene Structure Panel. +3. Capability-Driven Inspector. +4. Data Binding Workflow. + +These four create the biggest product jump because they make existing capabilities feel unified. + +Canvas views should follow immediately after because they turn the factory scene from a static model into an understandable operating workspace. + +## Non-Goals For The First Release + +- Full OpenUSD import/export. +- Full node-graph workflow editor. +- Real physics simulation. +- Multi-user collaboration. +- Automatic promotion of generated profile drafts to stable industry packs. +- Making every industry device recipe-backed. + +The first release should keep semantic profile-parts as a valid high-quality path. + +## Risks + +### Too Many Modes + +Risk: + +- Canvas views, structure modes, inspector sections, and workflow graph can become overwhelming. + +Mitigation: + +- Default to the right mode based on intent. +- Hide advanced workflow graph until the user asks to inspect details. + +### AI Overwrites Manual Work + +Risk: + +- AI edits may regenerate an assembly and destroy user changes. + +Mitigation: + +- Selection-aware edit scope. +- Prefer param patch and semantic part patch. +- Show before/after and support run-level undo. + +### Industry Packs Become Data Dumps + +Risk: + +- Packs may include many profiles but still feel generic. + +Mitigation: + +- Require semantic parts, primary roles, quality rules, and station resolution. +- Keep recipe binding optional but valuable. +- Report profile-parts versus fallback clearly. + +### Data Binding Becomes Technical + +Risk: + +- Users may need to understand JSON paths and WebSocket protocols. + +Mitigation: + +- Provide field detection, suggestions, drag/drop binding, and live preview. + +## External Product References + +- ComfyUI: node-based AI workflow and reusable generation pipelines. + https://github.com/comfy-org/comfyui +- tldraw Make Real: intent from sketch or selected canvas area to generated result. + https://github.com/tldraw/make-real +- Node-RED: low-code event and IoT flow binding. + https://nodered.org/ +- NVIDIA Omniverse and OpenUSD: semantic industrial digital twin direction. + https://www.nvidia.com/en-us/omniverse/ + https://www.nvidia.com/en-us/glossary/openusd/ +- Rerun: time-aware multimodal spatial data visualization. + https://github.com/rerun-io/rerun +- Cesium Digital Twins: large scene streaming and contextual data overlays. + https://cesium.com/use-cases/digital-twins/ + +## Done Definition + +This product redesign is successful when: + +- A new user can generate a factory without knowing what an industry pack is. +- A technical user can see exactly which pack, profile, recipe, or fallback was used. +- A user can select equipment or a subpart and edit it without regenerating everything. +- A user can bind live data to semantic equipment without writing code. +- A factory scene can be viewed as layout, equipment, or data without exposing confusing route clutter. +- Floor-based interaction remains available but is no longer the default for non-building factories. diff --git a/docs/3d-factory-v2-phase1-development-tasks.md b/docs/3d-factory-v2-phase1-development-tasks.md new file mode 100644 index 000000000..65f4707be --- /dev/null +++ b/docs/3d-factory-v2-phase1-development-tasks.md @@ -0,0 +1,558 @@ +# 3D Factory V2 Phase 1 Development Tasks + +## Phase Goal + +Phase 1 turns the current multi-entry AI experience into one intent-first workflow: + +```txt +User intent -> route decision -> generation/edit plan preview -> user applies -> existing run pipeline +``` + +This phase does not implement Scene Structure, Canvas Lenses, or the full Semantic Inspector. It creates the product foundation that later phases will use. + +## Baseline + +V1 backup: + +- Commit: `b4ade4d5` +- Tag: `202607043Dfactory1.0` +- Branch: `codex/backup-202607043Dfactory1.0` + +V2 starts from current branch after: + +- `docs/3d-factory-product-workflow-design.md` +- semantic assembly / industry pack v2 architecture +- all cloud industry packs migrated to schema v2 + +## Existing Local Anchors + +Important existing files: + +- `packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/index.tsx` + - current AI chat UI + - current mode switcher + - current factory/primitive/image/articraft send paths + - current auto-route helper `shouldRouteAssetPromptToFactory` +- `apps/editor/lib/ai-harness-runs/types.ts` + - current run modes: `articraft`, `image-to-3d`, `primitive`, `factory` +- `apps/editor/lib/ai-harness-runs/factory-runner.ts` + - factory generation and selection edit path +- `apps/editor/lib/ai-harness-runs/primitive-runner.ts` + - geometry generation path +- `apps/editor/lib/ai-harness-runs/single-equipment-compiler.ts` + - single equipment route to semantic assembly +- `apps/editor/lib/profile-packs.ts` + - installed/cloud pack validation and install state +- `apps/editor/lib/industry-pack-v2.ts` + - v2 pack validation and station resolution +- `packages/editor/src/components/ui/action-menu/live-data-panel.tsx` + - current live data/WebSocket surface + +## Product Decisions For Phase 1 + +### Decision 1: One Primary Submit Path + +The chat panel should stop asking the user to choose the correct technical route first. The primary send action should call an intent router. + +Old technical routes remain internally: + +- factory +- primitive +- image-to-3d +- articraft + +But they become implementation routes, not the main product choice. + +### Decision 2: Preview Before Large Scene Mutation + +Factory-scale scene creation and ambiguous high-impact edits should show a plan preview before applying. + +Small single-object generation can run directly in this phase, but the router must still produce a plan object. + +### Decision 3: Industry Pack Gate Is Product-Level + +If the intent needs an industry pack that is not installed, do not fall back to generic generation. Show an install gate and let the user continue after installation. + +### Decision 4: Selection Controls Intent Scope + +If the user has an equipment assembly or semantic part selected, edits should default to selected scope. + +Examples: + +- selected tank + `液位 60%` -> selected equipment edit +- selected helical ladder + `改成黄色` -> selected part edit +- no selection + `生成一个炼油厂` -> factory creation + +### Decision 5: No Compatibility-Driven UI Fork + +Do not keep adding mode-specific product UI branches. The Phase 1 work should introduce shared router and preview contracts, then adapt current routes behind them. + +## New Contracts + +### Intent Route + +Add a shared route type, likely under: + +```txt +apps/editor/lib/ai-harness-runs/intent-router.ts +``` + +Suggested type: + +```ts +export type AiIntentRouteKind = + | 'create-factory' + | 'create-equipment' + | 'create-asset-from-image' + | 'create-joint-asset' + | 'edit-selected-equipment' + | 'edit-selected-part' + | 'bind-live-data' + | 'generic-geometry' + | 'ask-or-explain' + +export type AiIntentRoute = { + kind: AiIntentRouteKind + confidence: number + prompt: string + reason: string + requiresPreview: boolean + requiredPack?: { + id: string + version?: string + installed: boolean + reason: string + } + selectionScope?: { + nodeIds: string[] + semanticRole?: string + sourcePartKind?: string + assemblyId?: string + } + execution: + | { mode: 'factory'; params?: Record } + | { mode: 'primitive'; params?: Record } + | { mode: 'image-to-3d'; params?: Record } + | { mode: 'articraft'; params?: Record } + | { mode: 'data-binding'; params?: Record } + | { mode: 'none'; params?: Record } +} +``` + +### Plan Preview + +Add a plan preview model, likely under: + +```txt +apps/editor/lib/ai-harness-runs/generation-plan-preview.ts +``` + +Suggested type: + +```ts +export type GenerationPlanPreview = { + id: string + route: AiIntentRoute + title: string + summary: string + impact: 'low' | 'medium' | 'high' + applyMode: 'direct' | 'confirm' + steps: Array<{ + id: string + label: string + status: 'ready' | 'blocked' | 'warning' + detail?: string + }> + metrics?: Array<{ label: string; value: string }> + blockers: Array<{ + code: string + message: string + action?: 'install-pack' | 'select-target' | 'connect-data-source' + }> + warnings: string[] +} +``` + +### Chat Message Extension + +Current chat messages already carry `factoryRunSummary`. Add a preview-bearing message state: + +```ts +type ChatMessage = { + ... + generationPlanPreview?: GenerationPlanPreview +} +``` + +The UI should render a `GenerationPlanPreviewCard` before a run is started. + +## Work Package 1: Intent Router + +### Scope + +Create deterministic first-pass routing. Keep it local and testable. Do not call an LLM in the first implementation. + +### Files + +Create: + +- `apps/editor/lib/ai-harness-runs/intent-router.ts` +- `apps/editor/lib/ai-harness-runs/intent-router.test.ts` + +Likely touch: + +- `apps/editor/lib/ai-harness-runs/types.ts` + +### Rules + +Routing priority: + +1. Image attachment + image mode words -> `create-asset-from-image` +2. Articraft / joint / robot arm motion asset words -> `create-joint-asset` +3. Selected semantic part + edit words -> `edit-selected-part` +4. Selected equipment + param edit words -> `edit-selected-equipment` +5. Factory/plant/industry-pack intent -> `create-factory` +6. Known single equipment -> `create-equipment` +7. WebSocket/data/bind words -> `bind-live-data` +8. Normal object generation -> `generic-geometry` +9. Question/explanation -> `ask-or-explain` + +### Required Test Cases + +- `生成一个炼油厂` -> `create-factory` +- `生成一个水泥厂` -> `create-factory` +- `生成一个离心泵` -> `create-equipment` +- `生成一个储罐,液位 60%` -> `create-equipment` +- selected tank + `液位调到 60%` -> `edit-selected-equipment` +- selected ladder + `改成黄色` -> `edit-selected-part` +- image attachment + `按图生成` -> `create-asset-from-image` +- `生成一个有关节的机械臂资产` -> `create-joint-asset` +- `绑定 websocket 数据` -> `bind-live-data` +- `这个设备来自哪个资源包` -> `ask-or-explain` + +### Done Criteria + +- Tests pass without React or browser. +- Router returns reason and confidence. +- Router marks factory scene creation as `requiresPreview: true`. +- Router marks missing data target as blocked instead of silently picking primitive generation. + +## Work Package 2: Industry Pack Install Gate + +### Scope + +Detect pack requirement before generation. If missing, return a blocked preview with install action. + +### Files + +Create or extend: + +- `apps/editor/lib/ai-harness-runs/industry-pack-intent-resolver.ts` +- `apps/editor/lib/ai-harness-runs/industry-pack-intent-resolver.test.ts` + +Likely reuse: + +- `apps/editor/lib/profile-packs.ts` +- `apps/editor/lib/ai-harness-runs/industry-factory-knowledge.ts` + +### Rules + +Known mappings: + +- 炼油厂/refinery -> `industry.refinery.basic@0.1.0` +- 水泥厂/cement -> `industry.cement.basic@0.1.0` +- 火电厂/thermal power -> `industry.thermal-power.basic@0.1.0` +- 水处理/water treatment -> `industry.water-treatment.basic@0.1.0` +- 离散制造/discrete manufacturing -> `industry.discrete-manufacturing.basic@0.1.0` +- 电解铝/electrolytic aluminum -> `industry.electrolytic-aluminum.basic@0.1.0` +- 家电装配/appliance assembly -> `industry.appliance-assembly.basic@0.1.0` +- 通用流程/process -> `industry.process.basic@0.1.0` + +### Required Test Cases + +- Installed refinery pack -> route unblocked. +- Missing cement pack -> preview blocker `install-pack`. +- Unknown factory -> route to `generic-geometry` only if no industry intent match exists. + +### Done Criteria + +- Missing industry pack never falls back to free geometry silently. +- Preview includes pack id, version, display name if available. + +## Work Package 3: Generation Plan Preview Builder + +### Scope + +Convert a route into a user-visible preview. + +### Files + +Create: + +- `apps/editor/lib/ai-harness-runs/generation-plan-preview.ts` +- `apps/editor/lib/ai-harness-runs/generation-plan-preview.test.ts` + +### Preview Types + +Factory preview should show: + +- industry pack +- process template if resolved +- station count when available +- recipe-backed count when available +- semantic profile-parts count when available +- generic fallback count +- expected action: preview/apply + +Single equipment preview should show: + +- route kind +- equipment type/profile if known +- whether semantic assembly or generic draft + +Selected edit preview should show: + +- selected target +- edit scope +- whether it will patch params or subpart material/dimensions + +Data binding preview should show: + +- source status +- target status +- blockers if no WebSocket source or no semantic target + +### Required Test Cases + +- refinery route creates high-impact confirm preview. +- single pump route creates low/medium direct preview. +- selected tank edit creates direct preview. +- missing pack creates blocked preview. + +### Done Criteria + +- Preview builder is pure and testable. +- Preview does not require React. + +## Work Package 4: Chat Panel Integration + +### Scope + +Replace the current direct send decision with: + +```txt +submit -> intent router -> plan preview or direct execution -> existing run subscription +``` + +### Files + +Touch: + +- `packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/index.tsx` + +Create if useful: + +- `packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/generation-plan-preview-card.tsx` + +### UI Behavior + +On send: + +1. Build routing context: + - input text + - image attachment exists + - current generation mode + - conversation purpose + - selected node ids + - selected semantic metadata if available + - installed pack state if already loaded +2. Call router. +3. Build preview. +4. If preview has blocker, render preview card only. +5. If `requiresPreview`, render preview card with Apply. +6. If direct, immediately call existing internal route. + +### Required UI States + +Preview card buttons: + +- Apply +- Install pack +- Edit prompt +- Cancel + +Initial implementation can make `Install pack` call existing install path or open pack panel if direct install is not ready. + +### Done Criteria + +- Existing primitive/image/articraft/factory flows still run through their existing APIs. +- Factory flow no longer depends on a separate visible "factory purpose" click before routing. +- Large factory intent does not start applying until the user confirms preview. + +## Work Package 5: Run Logging And Route Evidence + +### Scope + +Every run should record route evidence so the result can explain itself. + +### Files + +Touch: + +- `apps/editor/lib/ai-harness-runs/types.ts` +- `apps/editor/lib/ai-harness-runs/run-store.ts` +- route creators in `ai-chat-panel` + +### Data + +Add to run `context` or explicit field: + +```ts +intentRoute: { + kind: string + confidence: number + reason: string + requiredPack?: ... + previewId?: string +} +``` + +### Done Criteria + +- Run result cards can display chosen route. +- Debugging can answer "why did this go to factory instead of primitive?" + +## Work Package 6: E2E And Regression Tests + +### Scope + +Add enough tests to prove Phase 1 behavior. + +### Test Layers + +Unit: + +- intent router +- industry pack resolver +- preview builder + +Integration: + +- `ai-chat-panel` submit helper if extracted +- factory run still completes from routed path + +E2E: + +- User enters `生成一个炼油厂` +- Preview appears +- User clicks Apply +- Factory run starts +- Canvas receives patches + +Missing pack E2E can be deferred if pack install UI is not stable, but the route/preview unit test must exist. + +### Required Commands + +At minimum: + +```bash +bun test apps/editor/lib/ai-harness-runs/intent-router.test.ts +bun test apps/editor/lib/ai-harness-runs/generation-plan-preview.test.ts +bun test apps/editor/lib/ai-harness-runs/industry-pack-intent-resolver.test.ts +bun test apps/editor/lib/ai-harness-runs/factory-runner.test.ts +bun run --cwd apps/editor check-types +``` + +If UI is touched: + +```bash +bun test packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/ +``` + +If Playwright coverage is added: + +```bash +bun run --cwd apps/editor e2e:factory +``` + +## Suggested Implementation Order + +### Step 1: Pure Router Foundation + +- Add router types and deterministic router. +- Add unit tests. + +### Step 2: Pack Gate + +- Add pack intent resolver. +- Add missing-pack blocked route. +- Add tests against cloud catalog and installed pack state. + +### Step 3: Preview Builder + +- Add preview model and tests. +- Build preview from route. + +### Step 4: UI Card + +- Add `GenerationPlanPreviewCard`. +- Render preview messages in chat. + +### Step 5: Submit Path + +- Replace `handleAssetSubmit` and factory submit branching with router pipeline. +- Keep old execution functions private. + +### Step 6: Apply Preview + +- Add Apply action that calls the chosen internal route. +- Store preview id in run context. + +### Step 7: Validation + +- Unit tests. +- Typecheck. +- Manual smoke: + - `生成一个炼油厂` + - `生成一个离心泵` + - selected tank edit + - image attachment route + +## What To Avoid In Phase 1 + +- Do not build Scene Structure panel yet. +- Do not build Canvas Lens toolbar yet. +- Do not rebuild data binding UI yet. +- Do not add another visible top-level mode selector. +- Do not remove existing run modes until the router integration is stable. +- Do not add LLM-based routing before deterministic routing is tested. +- Do not silently downgrade missing industry packs to generic geometry. + +## Acceptance Checklist + +- One submit path exists for normal AI input. +- Intent route is visible in code and tests. +- Factory intent shows preview before apply. +- Missing industry pack shows install gate preview. +- Single equipment intent can still create semantic assembly. +- Existing image-to-3D and articulated asset paths remain callable. +- Route evidence is stored with runs. +- Typecheck passes. + +## Phase 1 Exit Criteria + +Phase 1 is complete when a user can type into one input: + +```txt +生成一个炼油厂 +``` + +and the product responds with: + +1. a generation plan preview, +2. the required industry pack status, +3. expected station/equipment route summary, +4. an Apply button, +5. a factory run that uses the existing semantic industry-pack path after Apply. + +At that point Phase 2 can safely start replacing the floor-first tree with Scene Structure. diff --git a/docs/ai-geometry-generation.md b/docs/ai-geometry-generation.md new file mode 100644 index 000000000..4c3842aba --- /dev/null +++ b/docs/ai-geometry-generation.md @@ -0,0 +1,475 @@ +# AI Geometry Generation + +This document captures the design intent for the primitive/parts generation flow so future sessions do not have to recover the context from chat history. + +## Current strategy + +Geometry generation has four active levels: + +1. `compose_assembly` for supported complete object families that do not have a dedicated parts family. +2. `compose_recipe` for built-in deterministic primitive recipe packs. +3. `compose_parts` for reusable mechanical or industrial part blueprints and dedicated parts families. +4. `compose_primitive` for fully custom low-level geometry and derived primitive aliases. + +Industrial equipment is now profile-first. Concrete equipment knowledge belongs in +device profiles, while family ids describe reusable layout/execution capability. +When no stable profile matches, Stage 1 should produce a runtime +`deviceProfileDraft`; the executor validates it, executes it through +`compose_parts`, and saves a generated candidate only when profile-aware quality is +high enough. Generated candidates remain below workspace/imported/builtin profiles +in priority and are never promoted to stable automatically. + +`compose_object` is retired from active AI tool routing. Object-like requests should +route through family assembly, reusable parts, recipes, or the controlled freeform +fallback. + +Prefer `compose_recipe` for known high-friction closed-form parts. Prefer +`compose_parts` when the requested object is a recognizable assembly made of +reusable physical components, especially when a device profile or generated +candidate exists. Do not add a new family for each industrial device; add profile +data unless the object needs a genuinely new reusable layout capability. + +See `docs/device-profile-architecture.md` for the profile source, lifecycle, +validator, quality, and migration rules. + +Primitive "high fidelity" means editable, stylized fidelity: stronger proportions, +clearer silhouettes, rounded/tapered manufactured forms, and stable semantic +subassemblies. It does not mean Articraft/GLB photorealism. Keep Articraft assets +separate from primitive repair and recipe flows. + +## Internal Recipe Registry + +The internal registry lives in: + +```txt +packages/core/src/lib/primitive-recipes.ts +``` + +It is not a user-facing plugin system yet. It is a deterministic routing layer that +lets the model choose `recipeId + params` instead of hand-authoring large schemas +for families where procedural defaults matter. + +Current recipe ids: + +```txt +vehicle.sedan +vehicle.suv +vehicle.sports +vehicle.van +vehicle.truck +valve.gate +valve.ball +robotArm.threeAxis +``` + +The registry expands recipes into existing core builders (`compose_parts` or +`compose_robot_arm`), then reuses the same semantic validation, visual quality +scoring, repair memory, and generated assembly output as other primitive tools. + +## Hard rule: no generated motion + +Primitive and parts generation must create static editable geometry. Do not reintroduce automatic motion prompts, continuous rotations, animated assemblies, or viewer-side motion systems for generated primitives. + +## Core boundaries + +`packages/core/src/lib/part-compose.ts` may contain pure procedural geometry logic and shape blueprints. It must stay independent from Three.js, viewer systems, editor tools, React, UI state, floorplan state, or canvas-specific behavior. + +Editor prompt/schema wiring lives in: + +```txt +packages/editor/src/components/ui/sidebar/panels/ai-chat-panel/index.tsx +``` + +## `compose_parts` concepts + +Important input fields: + +- `parts`: ordered reusable part requests. +- `autoComplete`: defaults to enabled. Recognized equipment families can add missing essentials. +- `position`: object origin; part positions are local offsets. +- `rotation`: part-level Euler rotation. +- `axis`: physical axis for cylinders, rings, ports, flanges, rollers, and tanks. +- `side`: semantic side for ports/flanges (`left`, `right`, `top`, `bottom`, `front`, `back`). +- `outletAngle`: volute casing discharge angle in the XY plane. +- `includeBolts`: `flange_ring` defaults to bolted; set false for a plain flange or when adding a separate `bolt_pattern`. +- `connectTo`: part id, name, kind, or prior part index to snap this part to another part. +- `connectPoint` / `childPoint`: semantic parent/child connection points used with `connectTo`. +- `anchor` / `childAnchor`: legacy geometric anchors used with `connectTo` for simple top/front/back/left/right snapping. +- `enhanceVisualDetails`: adds recommended non-essential visual details when explicitly enabled or when the object name asks for realism/detail. + +## Supported part families + +### Industrial family registries + +Industrial equipment is parts-first. For these requests, route through `compose_parts` +with a family id and top-level dimensions, then let the registry fill required parts +and clamp unsafe values: + +```txt +family: pump +required: skid_base, ribbed_motor_body, volute_casing, inlet_port, outlet_port +optional: flange_ring, impeller_blades, control_box, nameplate, warning_label + +family: conveyor +required: conveyor_frame, roller_array, belt_surface +optional: ribbed_motor_body, warning_label, nameplate + +family: electrical +required: electrical_cabinet +optional: cable_tray, nameplate, warning_label, vent_slats + +family: pipe_system +required: pipe_run +optional: pipe_elbow, flange_ring, valve_body +``` + +Each part exposes LLM-safe params in `part-registry.ts`, such as `length`, `width`, +`height`, `radius`, `count`, `axis`, and color fields. The normalizer maps overall +`length`/`width`/`height`/`diameter` to sensible part dimensions while preserving +explicit `parts[].params`. + +### Fans + +Recommended blueprint: + +```txt +circular_base +vertical_pole +support_bracket +motor_housing +radial_blades +protective_grill +optional control_knob +``` + +Use `protective_grill` for a real fan cage. It creates a shallow bowl-like guard with concentric rings, radial spokes, side ribs, and a rear outer ring. Use `radial_blades` for swept airfoil-like blades instead of rectangular panels. + +### Pumps and centrifugal blowers + +Recommended blueprint: + +```txt +skid_base +ribbed_motor_body or rounded_machine_body +volute_casing +inlet_port +outlet_port +flange_ring +optional impeller_blades +optional control_box +optional vent_slats +``` + +Use `outletAngle` to point the discharge neck. `flange_ring` already includes bolts by default. + +### Conveyors + +Recommended blueprint: + +```txt +conveyor_frame +roller_array +belt_surface +``` + +This gives a readable belt conveyor with side rails, legs, rollers, and belt. + +### Tanks and vessels + +Use: + +```txt +cylindrical_tank +pipe_port / inlet_port / outlet_port +flange_ring +``` + +Use `axis: "x"` for horizontal vessels and `axis: "y"` for vertical vessels where supported by the part. + +### Valves + +Recommended blueprint: + +```txt +valve_body +handwheel +flange_ring +``` + +### Additional factory equipment + +Use these parts for broader plant scenes: + +- `gearbox_body` for gearboxes and reducers. +- `filter_vessel` for cartridge filters and vertical pressure filters. +- `heat_exchanger` for shell-and-tube exchangers. +- `agitator_tank` for mixing tanks. +- `pipe_rack` for pipe corridors. +- `platform_ladder` for access platforms and ladders. + +### Office desks + +Use `compose_parts` instead of the generic table template when the request needs visible drawers, metal legs, or a more explicit structural breakdown: + +```txt +desk_top +leg_set +optional drawer_stack +``` + +`desk_top.length` is the X footprint and `desk_top.width` is the Z/front-back depth. Match `leg_set.length` / `leg_set.width` to the desktop footprint and set `leg_set.height` so the desktop top reaches the requested height. + +### Electrical cabinets and cable trays + +Use: + +```txt +electrical_cabinet +cable_tray +optional nameplate +optional warning_label +optional vent_slats +``` + +`electrical_cabinet` creates the manufactured cabinet silhouette with a door panel, seam, handle, vent slats, label, and nameplate. `cable_tray` creates ladder-style tray rails and rungs for plant electrical routing. + +### Process piping + +Use: + +```txt +pipe_run +pipe_elbow +flange_ring +optional valve_body +``` + +`pipe_run` creates a straight hollow pipe with end couplings. `pipe_elbow` creates a 90-degree swept bend. Use `connectTo` plus `connectPoint` / `childPoint` for flanges and valves where possible. + +### Bicycles + +Recommended blueprint: + +```txt +bicycle_wheels +bicycle_frame +bicycle_fork +handlebar +saddle +chain_loop +``` + +The bicycle family is a structural side-view approximation: tires/rims/spokes, triangular frame tubes, front fork, handlebar, saddle, and chain loop. + +### Aircraft + +Complete aircraft are generated through the family/parts registry instead of a +single opaque object template. Use `compose_parts` with `family:"aircraft"` and +top-level dimensions/colors, then tune optional `parts[].params` when needed: + +```txt +aircraft_fuselage +aircraft_wing +aircraft_engine +aircraft_vertical_stabilizer +aircraft_horizontal_stabilizer +aircraft_landing_gear +``` + +`aircraft_fuselage.length` is the overall X length. The composer derives the +scaled wing, engine, tail, window, and landing-gear placement from that length. +Use `aircraft_fuselage.count` for cabin window count, `aircraft_engine.count` +for one to four engines, `aircraft_engine.radius` for nacelle size, and +`aircraft_wing.bladeSweep` / `verticalCurve` for the wing silhouette. If the LLM +uses aliases such as `fuselage`, `wing`, `jet_engine`, `t_tail`, or +`landing_gear`, the registry normalizes them to the aircraft part kinds and +fills any missing required parts. + +### Kiosks, booths, and small buildings + +Small, single-room public-facing structures should use the dedicated kiosk +family before falling back to generic parts: + +```txt +kiosk_body +kiosk_roof +kiosk_opening +optional kiosk_counter +optional kiosk_sign +optional kiosk_awning +``` + +This covers ticket booths, vendor stalls, newsstands, small pavilions, sheds, +guard booths, and compact booth-like buildings. Top-level `length`, `width`, +and `height` control the overall footprint and height; individual +`parts[].params` can override specific pieces such as the service-window size, +counter width, roof style, sign size, or awning dimensions. The generated roles +include `kiosk_body`, `roof`, `opening`, `service_counter`, `sign_panel`, and +`awning`, which makes follow-up revisions more precise than the generic +`main_body` / `support_base` fallback. + +### Generic long-tail objects + +When no dedicated family/recipe matches, the fallback path should still prefer a +controlled parts plan before raw `compose_primitive`. Use `family:"generic"` and +semantic generic parts: + +```txt +generic_body +generic_base +generic_panel +generic_handle +generic_spout +generic_control_panel +generic_display +generic_foot_set +generic_opening +generic_detail_accent +``` + +This is the preferred bridge for coffee machines, simple devices, display +fixtures, and other long-tail requests that do not have a dedicated family. The executor can +infer a generic plan automatically from the prompt and top-level dimensions; for +example a coffee/espresso machine gets a `generic_body`, support base, control +panel, spout, and cup platform. These generic parts expose safe dimensions such +as `length`, `width`, `height`, `thickness`, `radius`, `cornerRadius`, and color +fields, so follow-up revisions can address semantic parts instead of editing +anonymous primitive boxes. + +### Cars and small vehicles + +Preferred path: `compose_recipe` with one of `vehicle.sedan`, `vehicle.suv`, +`vehicle.sports`, `vehicle.van`, or `vehicle.truck`. Use compact params such as +`color`, `size`, `sizeScale`, `length`, `width`, `height`, and `highFidelity`. + +Fallback `compose_parts` blueprint when the recipe does not cover the requested +vehicle: + +```txt +vehicle_body +vehicle_wheels +vehicle_windows +headlights +bumper +``` + +Use extra `nameplate`, `warning_label`, and `seam_ring` only when the prompt asks for industrial labels or panel seams. +For small cars without exact dimensions, recipe params can use `size:"small"` or +`sizeScale:0.8`. In fallback `compose_parts`, put `sizeScale` on `vehicle_body` (for example +`sizeScale: 0.8`) and use either top-level `primaryColor` or `vehicle_body.primaryColor` +for the body color. The composer accepts these part-local aliases because the model often +keeps the color and scale with the semantic body part. + +Vehicle primitive quality is checked in two layers: + +- semantic validation: exactly one body, four tires, windows, headlights, and bumpers +- visual quality scoring: wheel/body proportions, non-boxy cabin, separated windows, + front/rear deck layering, rocker/sill shadow, and subtle wheel-arch/fender hints + +For "high fidelity", "好看", "真实", "别太方", or smoothness follow-ups, keep the +vehicle in `compose_parts`. Tune `vehicle_body.cornerRadius`, `cornerSegments`, +`cabinTopScale`/`roofCornerAngle`, `detail:"high"`, and +`enhanceVisualDetails:true`; do not rebuild the car as raw primitive boxes. + +Vehicle style presets are supported through `vehicle_body.vehicleStyle` (or +style/variant intent): `sedan`, `suv`, `sports`, `van`, and `truck`. They change +default length/width/height, cabin footprint/taper, wheel radius, wheelbase, +track width, and ground clearance so "SUV", "跑车", "面包车", and "皮卡" do not all +share the same silhouette. + +### Robot arms + +Use `compose_recipe({recipeId:"robotArm.threeAxis"})` for 3-axis robot arm +requests. Use `compose_robot_arm` directly for robot arm, cobot, manipulator, +FANUC arm, or 6-axis arm requests that are not covered by the 3-axis recipe. Both +paths produce an editable primitive assembly with semantic +roles such as `robot_base`, `base_joint`, `shoulder_joint`, `upper_arm`, +`elbow_joint`, `forearm`, `wrist_joint`, `tool_flange`, and `end_effector`. + +For "圆形底盘 / round base" pass `baseShape:"round"`; for "3轴/3-axis" pass +`axisCount:3`; default to `pose:"work-ready"` and `endEffector:"gripper"` for a +readable bent silhouette. Robot arms also participate in semantic and visual +quality checks so missing joints/links are repairable before the result reaches +the canvas. + +## Connection and self-check + +`compose_parts` supports semantic connection points so parts can attach to named mechanical interfaces instead of raw offsets: + +```json +{ + "parts": [ + { "id": "outlet", "kind": "pipe_port", "axis": "z" }, + { + "kind": "flange_ring", + "connectTo": "outlet", + "connectPoint": "open", + "childPoint": "back" + } + ] +} +``` + +Supported first-pass points include pipe `open/base`, volute `inlet/outlet`, motor or gearbox `shaft`, valve `inlet/outlet/stem`, vessel `top/nozzle/left/right`, and flange `front/back`. Legacy `anchor` / `childAnchor` still works for simple geometric snapping. This resolves the child part center from approximate parent/child extents and is intended for common mechanical snapping such as flange-to-port or port-to-volute, not full CAD constraints. + +`assessPartBlueprint(input)` scores recognized families and reports missing required parts, optional parts, recommended details, missing detail parts, and user-facing recommendations. Required checks support alternatives; for example pump motor/body can be `ribbed_motor_body`, `rounded_machine_body`, or `motor_housing`. + +`autoComplete` uses the same family specs to add essentials for fans, pumps, conveyors, bicycles, cars, aircraft, valves, desks, electrical cabinets, and pipe systems. It only fills required structure; visual-detail suggestions such as nameplates or warning labels are reported by assessment and should be added when the prompt asks for detail or when a later visual scoring pass decides the object is too plain. + +## Visual detail scoring + +`assessPartVisualDetails(input)` computes a rule-based detail score for recognized families. It checks whether expected detail parts are present, such as: + +- pump: impeller, nameplate, warning label, flange +- fan: control knob and protective grill +- conveyor: drive motor and warning label +- vehicle: windows, lights, bumper, seam/nameplate details +- aircraft: fuselage, wings, engines, tail stabilizers, and landing gear +- valve: flanged ends and handwheel +- desk: drawer stack +- pipe system: elbow, flange, and valve details +- electrical: cable tray, nameplate, warning label, and vent details + +`composePartPrimitives` can call `enhancePartBlueprintWithVisualDetails` internally when `enhanceVisualDetails` is true or the object name asks for realistic/detailed output. This pass only adds visual details, not required structural parts; required structure remains owned by `autoComplete`. + +## Dimension semantics + +Stage 1 dimension semantics live in: + +```txt +packages/core/src/lib/dimension-semantics.ts +``` + +The parser recognizes labeled and compact dimensions such as: + +- `长120cm 宽60cm 高75cm` +- `120x60x75cm` +- `直径300mm 高1.2m` + +Values are normalized to meters. For table/desk/furniture generation, user `长/length` maps to the X footprint and user `宽/width` maps to Z front-back depth. For vehicles, user length maps to the vehicle length/depth axis. + +## Memory handling for long chats + +When a conversation gets long, rely on repo artifacts rather than chat history: + +1. This document records product and generation rules. +2. Tests in `packages/core/src/lib/part-compose.test.ts` capture expected structure. +3. The editor tool schema/prompt describes what the model should call. +4. The implementation in `part-compose.ts` is the authoritative behavior. + +If a future session loses context, read this document first, then inspect the tests and implementation. + +## Planned module split + +`part-compose.ts` is intentionally still a single implementation file while the part library is evolving quickly. Once the APIs settle, split it without changing public imports: + +```txt +packages/core/src/lib/part-compose/common.ts +packages/core/src/lib/part-compose/fan.ts +packages/core/src/lib/part-compose/vehicles.ts +packages/core/src/lib/part-compose/factory.ts +packages/core/src/lib/part-compose/index.ts +packages/core/src/lib/part-compose.ts # compatibility re-export if needed +``` + +Do the split as a separate refactor after behavior tests are stable. diff --git a/docs/device-profile-architecture.md b/docs/device-profile-architecture.md new file mode 100644 index 000000000..4f94ab180 --- /dev/null +++ b/docs/device-profile-architecture.md @@ -0,0 +1,168 @@ +# Device Profile Architecture + +AI geometry uses four layers: + +```mermaid +flowchart TD + P["Primitive registry"] --> R["Recipe registry"] + P --> Part["Part registry"] + Part --> LF["Layout family"] + DP["Device profile"] --> LF + DP --> Part + Draft["Runtime draft profile"] --> V["3-stage validator"] + V --> C["Generated candidate"] + C --> DP +``` + +## Responsibilities + +- **Primitive**: geometry-only shapes and parameters. +- **Recipe**: deterministic closed-form parts such as gears, flanges, bolts, and elbows. +- **Part**: reusable semantic industrial components with LLM-safe parameters. +- **Layout family**: stable spatial layout capability, such as `rotating_machine_layout`, `vessel_layout`, `linear_transport_layout`, and `box_enclosure_layout`. +- **Device profile**: concrete equipment knowledge. Profiles map device names and aliases to a layout family, executable family, primary semantic role, dimensions, and parts. + +Family ids should stay small and stable. New industrial devices should normally be added as profile data, not as new family code. + +## Profile Sources + +Profiles are merged by predictable priority: + +```txt +workspace > imported_pack > builtin > generated_candidate +``` + +Source locations: + +```txt +apps/editor/data/device-profiles/*.json|yaml +apps/editor/data/device-profile-packs/**/*.json|yaml +apps/editor/.generated/device-profile-candidates/**/*.json|yaml +``` + +Generated candidates are intentionally lowest priority and cannot override workspace or builtin profiles. + +## Lifecycle + +```txt +runtime_draft -> candidate -> pending_review -> stable +``` + +- `runtime_draft`: produced during a run when no stable profile matches. +- `candidate`: saved after successful execution and profile-aware quality scoring. +- `pending_review`: reserved for human review workflows. +- `stable`: trusted profile data, either builtin or workspace. + +Candidates are saved under: + +```txt +apps/editor/.generated/device-profile-candidates/ +``` + +They record the original prompt, draft profile, family, primary semantic role, generated roles, shape count, quality score, and creation time. + +## Validation + +Draft and candidate profiles pass three gates: + +- **Schema**: required fields and field types. +- **Registry**: executable family exists, part kinds exist, primary semantic role is represented. +- **Execution smoke**: `compose_parts` can execute, shape count is bounded, bounding dimensions are plausible, and required roles are covered. + +## Quality + +Profile-aware quality produces: + +```ts +{ + semanticScore, + geometryScore, + editabilityScore, + visualCompletenessScore, + overallScore +} +``` + +Low scores trigger repair/fallback. High-quality runtime drafts can become generated candidates, but they are never promoted to `stable` automatically. + +## Subpart Selection And Editing + +Factory editing should support three selection scopes: + +```txt +factory/process scope -> equipment assembly scope -> subpart/shape scope +``` + +The default user experience is: + +- If nothing is selected, natural-language edits operate on the factory or the latest generated object. +- If an assembly is selected, edits operate on that equipment assembly and can expand to its editable child nodes. +- If a generated subpart or shape is selected, edits operate only on that selected semantic part unless the user asks for all matching parts. + +Example: + +```txt +Select one fan blade -> "make this blade longer" +Select fan assembly -> "make all blades longer" +Select water treatment plant -> "add another filter vessel" +``` + +Generated primitive shapes already carry the metadata needed for subpart targeting: + +```ts +type PrimitiveShapeSelector = { + index?: number + occurrence?: number + semanticRole?: string + semanticGroup?: string + sourcePartKind?: string + sourcePartId?: string + kind?: string + nameIncludes?: string +} +``` + +The renderer and selection context should preserve this metadata when converting generated artifacts into scene nodes. A selected subpart should be represented as a stable edit target: + +```ts +type SelectedGeneratedSubpart = { + assemblyId: string + nodeId: string + shapeIndex?: number + selector: PrimitiveShapeSelector + label?: string + editableHints?: { + canScale?: string[] + primaryDimension?: string + minFactor?: number + maxFactor?: number + } +} +``` + +Natural-language revision should prefer the selected subpart selector over broad role matching. This prevents requests such as "make it larger" from modifying every `fan_blade` when the user intentionally selected one blade. + +Subpart edits are divided into two paths: + +- **Instance edit**: modifies only the selected scene node or generated shape. Use this for one-off changes such as color, length, offset, rotation, or material. +- **Profile/preset edit**: modifies the source profile, part preset, or layout data. Use this only when the user explicitly asks for future generated equipment of the same type to inherit the change. + +The first implementation target should cover: + +- select a child shape inside a generated assembly, +- show its semantic label in the UI, +- pass `SelectedGeneratedSubpart` into the AI harness run context, +- route color, material, scale, resize, rotate, move, remove, and replace requests through `revise_geometry`, +- keep assembly-level edits working as the fallback. + +## Migration Rule + +Legacy executable ids remain supported as aliases and execution capabilities: + +```txt +pump -> rotating_machine_layout + centrifugal_pump profile +compressor -> rotating_machine_layout + screw_compressor/default profile +tank -> vessel_layout + vertical_storage_tank profile +``` + +When adding equipment, first decide whether an existing layout family can place it. If yes, add a profile JSON. Add a new family only when the system needs a genuinely new reusable layout/execution capability. diff --git a/docs/equipment-plugin-migration-baseline.md b/docs/equipment-plugin-migration-baseline.md new file mode 100644 index 000000000..d38fbb677 --- /dev/null +++ b/docs/equipment-plugin-migration-baseline.md @@ -0,0 +1,131 @@ +# Equipment Plugin Migration Baseline + +This baseline locks the before/after behavior for migrating factory equipment +generation from ad hoc primitive assemblies to plugin-owned equipment nodes. + +## Scope + +Phase 0 captures repeatable inputs and acceptance checks. It does not require +the new factory equipment plugin to exist yet. + +Phase 1 introduces pure core contracts only: + +- equipment contracts and ports +- industry pack manifests with equipment bindings +- node-definition metadata for equipment nodes +- tests for normalization and binding-to-spec resolution + +No editor UI, viewer rendering, or AI orchestration should depend on Phase 1. + +## Baseline Cases + +### B1 Centrifugal Pump + +Input intent: + +```json +{ + "industry": "chemical", + "equipment": "centrifugal pump", + "params": { + "pumpType": "centrifugal", + "flowRate": 120, + "motorPower": 15, + "inletDiameter": 0.15, + "outletDiameter": 0.1, + "skidMounted": true + } +} +``` + +Current expected behavior: + +- generated as a multi-primitive assembly +- recognizable pump silhouette is desirable but not guaranteed +- inlet/outlet are visual or metadata hints, not first-class node ports +- inspector edits mostly target primitive dimensions, not equipment-level intent + +Migration expected behavior: + +- generated as one `factory:pump` node +- renderer owns pump body, motor, skid, flanges, and connection markers +- node exposes `inlet` and `outlet` ports from a pure contract +- inspector edits equipment parameters directly +- floor plan footprint reflects envelope dimensions and port direction + +### B2 Pump Line Segment + +Input intent: + +```json +{ + "industry": "chemical", + "stations": [ + { "id": "feed_tank", "equipment": "feed tank" }, + { "id": "transfer_pump", "equipment": "centrifugal pump" }, + { "id": "filter", "equipment": "cartridge filter" } + ], + "connections": [ + { "from": "feed_tank.outlet", "to": "transfer_pump.inlet", "medium": "water" }, + { "from": "transfer_pump.outlet", "to": "filter.inlet", "medium": "water" } + ] +} +``` + +Current expected behavior: + +- resolver may choose catalog, native tank, profile parts, or primitive fallback +- routing is inferred around generated shapes +- station geometry can vary between runs + +Migration expected behavior: + +- profile contracts select equipment node kinds before geometry is generated +- routing snaps to declared equipment ports +- unknown equipment can still fall back to primitive assembly +- station identity remains stable when the user changes equipment parameters + +### B3 Unknown Custom Skid + +Input intent: + +```json +{ + "industry": "generic", + "equipment": "custom dosing skid with two vessels and a metering pump", + "params": { + "skidMounted": true + } +} +``` + +Current expected behavior: + +- generated as primitives using profile or prompt-derived parts +- result quality depends heavily on prompt interpretation + +Migration expected behavior: + +- use an explicit fallback path when no equipment binding exists +- preserve the generated assembly under a stable container or assembly node +- emit a contract gap so pack authors can add a future binding + +## Verification Matrix + +| Claim | Current baseline | Migration acceptance | +| --- | --- | --- | +| Stable equipment identity | Primitive assembly may change shape and child count | One device-level node kind per bound equipment profile | +| Port semantics | Optional visual markers or metadata | Ports come from `EquipmentContract` / `NodeDefinition.ports` | +| Inspector edits | Primitive dimensions and material fields | Equipment-level schema drives user edits | +| Floor plan | Generic footprint from primitive bounds | Envelope and port side drive footprint and connection orientation | +| AI generation | LLM composes geometry directly | LLM chooses profile and parameters for a constrained generator | +| Fallback | Primitive generation is the default path | Primitive generation is only unbound/custom fallback | + +## Phase 1 Done Criteria + +- `@pascal-app/core` exports equipment contracts from the package root. +- `@pascal-app/core/equipment` is an explicit package export. +- `NodeDefinition` can declare equipment metadata and port resolution without + importing editor, viewer, or Three.js code. +- Tests prove contract normalization, invalid input rejection, manifest binding + normalization, and binding-to-spec parameter resolution. diff --git a/docs/factory-v2-release-evidence.md b/docs/factory-v2-release-evidence.md new file mode 100644 index 000000000..eb3fd821a --- /dev/null +++ b/docs/factory-v2-release-evidence.md @@ -0,0 +1,56 @@ +# Factory V2 Release Evidence + +Last verified: 2026-07-05 + +## Release Candidate Gate + +Command: + +```bash +bun run --cwd apps/editor factory:release-candidate -- --with-visual-smoke --base-url http://localhost:3002 --out-dir qa-artifacts/factory-release-candidate/final-phase9 +``` + +Result: passed. + +Steps: + +- `factory-release-readiness`: passed. +- `factory-core-tests`: passed, 62 tests, 167 expectations. +- `editor-typecheck`: passed. +- `factory-biome-check`: passed. +- `refinery-visual-smoke`: passed. + +## Refinery Smoke Evidence + +Prompt: `generate a refinery` + +Result: + +- Run status: `succeeded`. +- Quality score: `100`. +- Static quality score: `100`. +- Issue count: 0 errors, 0 warnings, 0 info. +- Patch count: 1110. +- Node count: 1110. +- Root node count: 273. +- Station assembly count: 15. +- Canvas count: 1. +- Captured views: isometric, top, side. +- Views distinct: true. +- Browser console errors: 0. +- Browser page errors: 0. +- Browser request failures: 0. + +Required station coverage: + +- `crude_storage_tank`: present. +- `atmospheric_distillation_unit`: present. +- `fluid_catalytic_cracking_unit`: present. +- `hydrotreating_unit`: present. +- `catalytic_reformer_unit`: present. +- `flare_system`: present. +- `pipe_rack`: present. + +## Artifact Policy + +The visual run writes local QA JSON and screenshots for inspection. Those generated artifacts are not committed; this document keeps only the release evidence summary needed for review. diff --git a/docs/factory-v2-release-notes.md b/docs/factory-v2-release-notes.md new file mode 100644 index 000000000..8e01a16d9 --- /dev/null +++ b/docs/factory-v2-release-notes.md @@ -0,0 +1,56 @@ +# Factory V2 Release Notes + +Factory V2 turns industrial scene generation into a guided workflow rather than a loose collection of AI tools. A user can still type a simple request such as "generate a refinery", but the product now routes that request through industry-pack intent, process templates, semantic equipment assemblies, source metadata, quality reporting, and release-candidate checks. + +## User Experience + +- One-sentence factory prompts route through installed industry packs when the industry is known. +- Missing or disabled industry packs show an install gate instead of silently falling back to generic geometry. +- Generated factories are semantic assemblies: stations know their process role, equipment exposes editable parts, ports remain visible, routes are generated from process connections, and source metadata explains where the object came from. +- Users can inspect factory runs through workflow stages: intent router, pack resolver, template resolver, equipment compiler, route composer, and quality report. +- Applying a factory run writes one canvas change so undo/redo treats the generated factory as one workflow step. +- Live data binding remains part of the same semantic object model, so Data Lens and Inspector read the same equipment binding contract. + +## What Changed From V1 + +- V1 generation could look like a large pile of primitives with limited explanation. V2 prefers industry-pack process templates and semantic assemblies for known factories. +- V1 required more manual interpretation after generation. V2 exposes source, station, profile, editable parts, ports, and data-binding capability in the shared Inspector and lenses. +- V1 quality was mostly judged by visual output. V2 adds release-readiness checks, quality summaries, fallback warnings, missing-pack gates, and optional visual smoke artifacts. +- V1 paths could diverge between AI factory generation, generated geometry, and data binding. V2 keeps them aligned around intent routing and semantic scene contracts. + +## What Users Can Trust + +- Known installed industry packs resolve process templates from both repository root and editor server working directories. +- Known factory prompts plan through `process_line` before heavier browser QA runs. +- The release gate checks installed intent-routed packs, cloud-pack coverage, server-cwd-safe template resolution, core factory tests, typecheck, and targeted Biome checks. +- Optional visual smoke can capture refinery screenshots and validate that generated scenes render to the canvas. + +## Current Boundaries + +- The simulated cloud is still local under `cloud/`; it models the future on-demand cloud pack download flow. +- Not every equipment type must be recipe-backed. Semantic profile-parts remain a valid high-quality path for industry packs. +- Unknown or highly custom equipment can fall back to generic editable geometry drafts. +- User-managed WebSocket source add/remove UI is deferred; fixed data sources are the current validation path. +- Full OpenUSD, real physics simulation, node-graph authoring, and multi-user collaboration are not part of this first release. + +## Release Candidate Commands + +Fast release-candidate gate: + +```bash +bun run --cwd apps/editor factory:release-candidate +``` + +Write release-readiness JSON and product-facing notes: + +```bash +bun run --cwd apps/editor factory:release-qa -- --out-dir apps/editor/qa-artifacts/factory-release-readiness/latest +``` + +Run visual smoke when a local editor server is available: + +```bash +bun run --cwd apps/editor factory:release-candidate -- --with-visual-smoke --base-url http://localhost:3002 +``` + +Generated QA artifacts are local release evidence and are intentionally ignored by git under `apps/editor/qa-artifacts/`. diff --git a/package.json b/package.json index bb9acd33c..cabe3190b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "scripts": { "build": "turbo run build", "dev": "set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose", + "dev:editor": "bun --cwd apps/editor dev", + "smoke:dev": "bun --cwd apps/editor smoke:dev", + "dev:articraft": "node scripts/dev-with-articraft.mjs", + "dev:aircraft": "node scripts/dev-with-articraft.mjs", "lint": "biome lint", "lint:fix": "biome lint --write", "format": "biome format --write", @@ -11,6 +15,7 @@ "check": "biome check", "check:fix": "biome check --write", "check-types": "turbo run check-types", + "qa:primitive-visual": "bun --conditions=react-server apps/editor/scripts/primitive-visual-qa.ts", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "release": "gh workflow run release.yml -f package=all -f bump=patch", "release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch", @@ -18,14 +23,14 @@ "release:editor": "gh workflow run release.yml -f package=editor -f bump=patch", "release:mcp": "gh workflow run release.yml -f package=mcp -f bump=patch", "release:minor": "gh workflow run release.yml -f package=all -f bump=minor", - "release:major": "gh workflow run release.yml -f package=all -f bump=major" + "release:major": "gh workflow run release.yml -f package=all -f bump=major", + "check:mojibake": "node scripts/check-mojibake.mjs" }, "devDependencies": { "@biomejs/biome": "^2.4.6", "dotenv-cli": "^11.0.0", "turbo": "^2.8.15", - "typescript": "6.0.2", - "ultracite": "^7.2.5" + "typescript": "6.0.2" }, "engines": { "node": ">=18" diff --git a/packages/articraft-bridge/package.json b/packages/articraft-bridge/package.json new file mode 100644 index 000000000..b68166bef --- /dev/null +++ b/packages/articraft-bridge/package.json @@ -0,0 +1,52 @@ +{ + "name": "@pascal-app/articraft-bridge", + "version": "0.1.0", + "description": "Bridge between Articraft (Python) articulated model generation and the Pascal 3D editor", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + }, + "./scene-converter": { + "types": "./src/scene-converter.ts", + "import": "./src/scene-converter.ts", + "default": "./src/scene-converter.ts" + }, + "./types": { + "types": "./src/types.ts", + "import": "./src/types.ts", + "default": "./src/types.ts" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@pascal-app/core": "*" + }, + "devDependencies": { + "@pascal-app/core": "*", + "@pascal/typescript-config": "*", + "@types/node": "^25.5.0", + "typescript": "5.9.3" + }, + "keywords": [ + "articraft", + "articulated", + "3d", + "editor", + "ai", + "generation" + ], + "license": "MIT" +} diff --git a/packages/articraft-bridge/src/cli.ts b/packages/articraft-bridge/src/cli.ts new file mode 100644 index 000000000..10be4225b --- /dev/null +++ b/packages/articraft-bridge/src/cli.ts @@ -0,0 +1,830 @@ +import { type ChildProcess, spawn } from 'node:child_process' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' +import type { + ArticraftLink, + ArticraftModelData, + ArticraftOrigin, + ArticraftVisual, + ArticraftVisualGeometry, + GenerateOptions, + Vec3, + Vec4, +} from './types' + +const _dirname = path.dirname(fileURLToPath(import.meta.url)) + +const DEFAULT_REPO_ROOT = path.resolve(_dirname, '..', '..', '..', 'articraft') +const BRIDGE_SCRIPT_RELATIVE_PATH = path.join('python', 'bridge.py') +const MODERN_CLI_RELATIVE_PATH = path.join('cli', 'main.py') + +type CommandInvocation = { + command: string + args: string[] +} + +function bridgeScriptPath(repoRoot: string): string { + return path.join(repoRoot, BRIDGE_SCRIPT_RELATIVE_PATH) +} + +function isArticraftRepoRoot(repoRoot: string): boolean { + return ( + existsSync(bridgeScriptPath(repoRoot)) || + existsSync(path.join(repoRoot, MODERN_CLI_RELATIVE_PATH)) + ) +} + +function candidateRepoRoots(startDir: string): string[] { + const candidates: string[] = [] + let current = path.resolve(startDir) + for (let i = 0; i < 8; i += 1) { + candidates.push(path.join(current, 'articraft')) + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + candidates.push(DEFAULT_REPO_ROOT) + return [...new Set(candidates.map((candidate) => path.resolve(candidate)))] +} + +export function resolveRepoRoot(repoRoot?: string): string { + const configuredRoot = repoRoot || process.env.ARTICRAFT_REPO_ROOT + if (configuredRoot) { + const resolved = path.resolve(configuredRoot) + if (isArticraftRepoRoot(resolved)) return resolved + throw new Error( + `Articraft repo root is invalid: ${resolved}. Expected ${BRIDGE_SCRIPT_RELATIVE_PATH} or ${MODERN_CLI_RELATIVE_PATH}.`, + ) + } + + for (const candidate of candidateRepoRoots(process.cwd())) { + if (isArticraftRepoRoot(candidate)) return candidate + } + + throw new Error( + `Articraft repo root not found. Set ARTICRAFT_REPO_ROOT to a checkout containing ${BRIDGE_SCRIPT_RELATIVE_PATH} or ${MODERN_CLI_RELATIVE_PATH}.`, + ) +} + +function hasLegacyBridge(repoRoot: string): boolean { + return existsSync(bridgeScriptPath(repoRoot)) +} + +function venvPythonPath(repoRoot: string): string { + return path.join( + repoRoot, + '.venv', + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', + ) +} + +export function modernCliInvocation(repoRoot: string, args: string[]): CommandInvocation { + const python = venvPythonPath(repoRoot) + const cliEntry = path.join(repoRoot, MODERN_CLI_RELATIVE_PATH) + if (existsSync(python) && existsSync(cliEntry)) { + return { + command: python, + args: [cliEntry, ...args], + } + } + return { + command: 'uv', + args: ['run', '--directory', repoRoot, 'articraft', ...args], + } +} + +function parseBridgeLine(line: string) { + try { + return JSON.parse(line) + } catch { + return null + } +} + +/** + * Generate an articulated model by spawning the Articraft Python bridge. + * + * The bridge script writes JSON-lines to stdout: + * {"type": "progress", "message": "..."} + * {"type": "result", "data": } + * {"type": "error", "message": "..."} + * + * On success, resolves with the parsed model data. + * On failure, rejects with the error message. + */ +export function generateModel(options: GenerateOptions): Promise { + const repoRoot = resolveRepoRoot(options.repoRoot) + if (!hasLegacyBridge(repoRoot)) { + return generateModelWithModernCli(repoRoot, options) + } + const bridgeScript = bridgeScriptPath(repoRoot) + const { signal, onProgress } = options + + return new Promise((resolve, reject) => { + const args = [bridgeScript, 'generate', '--prompt', options.prompt, '--mode', options.mode] + if (options.model) args.push('--model', options.model) + if (options.provider) args.push('--provider', options.provider) + if (options.maxTurns !== undefined) args.push('--max-turns', String(options.maxTurns)) + if (options.imagePath) args.push('--image', options.imagePath) + + const env = { ...process.env } + if (!env.ARTICRAFT_REPO_ROOT) { + env.ARTICRAFT_REPO_ROOT = repoRoot + } + env.ARTICRAFT_MODEL ||= env.AI_MODEL || env.NEXT_PUBLIC_AI_MODEL || '' + env.DEEPSEEK_API_KEY ||= env.AI_API_KEY || env.NEXT_PUBLIC_AI_API_KEY || '' + env.DEEPSEEK_BASE_URL ||= env.AI_BASE_URL || env.NEXT_PUBLIC_AI_BASE_URL || '' + env.PYTHONUTF8 ??= '1' + env.PYTHONIOENCODING ??= 'utf-8' + + let proc: ChildProcess + try { + proc = spawn('uv', ['run', '--directory', repoRoot, 'python', ...args], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + } catch (err) { + reject( + new Error( + `Failed to spawn articraft bridge: ${err instanceof Error ? err.message : String(err)}`, + ), + ) + return + } + + const rl = createInterface({ input: proc.stdout! }) + let resolved = false + const progressLines: string[] = [] + + rl.on('line', (line: string) => { + if (resolved) return + const parsed = parseBridgeLine(line) + if (!parsed) { + progressLines.push(line) + return + } + + if (parsed.type === 'progress') { + const msg = parsed.message ?? '' + progressLines.push(msg) + onProgress?.(msg) + } else if (parsed.type === 'result') { + resolved = true + rl.close() + resolve(parsed.data as ArticraftModelData) + } else if (parsed.type === 'error') { + resolved = true + rl.close() + const allProgress = progressLines.join('\n') + reject(new Error(parsed.message + (allProgress ? `\n\nOutput:\n${allProgress}` : ''))) + } + }) + + let stderr = '' + proc.stderr!.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + + proc.on('close', (code) => { + if (!resolved) { + const allProgress = progressLines.join('\n') + const msg = `articraft bridge exited with code ${code}${stderr ? `: ${stderr}` : ''}` + reject(new Error(allProgress ? `${msg}\n\nOutput:\n${allProgress}` : msg)) + } + }) + + proc.on('error', (err) => { + if (!resolved) { + resolved = true + rl.close() + reject(new Error(`articraft bridge process error: ${err.message}`)) + } + }) + + signal?.addEventListener('abort', () => { + if (!resolved) { + resolved = true + rl.close() + proc.kill('SIGTERM') + reject(new DOMException('Generation cancelled', 'AbortError')) + } + }) + }) +} + +function spawnAndCollect( + command: string, + args: string[], + options: { + cwd: string + env: NodeJS.ProcessEnv + signal?: AbortSignal + onProgress?: (message: string) => void + }, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + let proc: ChildProcess + try { + proc = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + } catch (err) { + reject( + new Error( + `Failed to spawn ${command}: ${err instanceof Error ? err.message : String(err)}`, + ), + ) + return + } + + let stdout = '' + let stderr = '' + let settled = false + const onChunk = (chunk: Buffer, stream: 'stdout' | 'stderr') => { + const text = chunk.toString() + if (stream === 'stdout') stdout += text + else stderr += text + for (const line of text.split(/\r?\n/)) { + const message = line.trim() + if (message) options.onProgress?.(message) + } + } + + proc.stdout?.on('data', (chunk: Buffer) => onChunk(chunk, 'stdout')) + proc.stderr?.on('data', (chunk: Buffer) => onChunk(chunk, 'stderr')) + + proc.on('close', (code) => { + if (settled) return + settled = true + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`${command} exited with code ${code}${stderr ? `: ${stderr}` : ''}`)) + }) + proc.on('error', (err) => { + if (settled) return + settled = true + reject(new Error(`${command} process error: ${err.message}`)) + }) + options.signal?.addEventListener('abort', () => { + if (settled) return + settled = true + proc.kill('SIGTERM') + reject(new DOMException('Generation cancelled', 'AbortError')) + }) + }) +} + +function listRecordIds(repoRoot: string): Set { + const recordsRoot = path.join(repoRoot, 'data', 'records') + try { + return new Set( + readdirSync(recordsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name), + ) + } catch { + return new Set() + } +} + +function extractRecordId(output: string): string | null { + const patterns = [ + /\brecord_id=([A-Za-z0-9_.-]+)/, + /\brecord_id["':\s]+([A-Za-z0-9_.-]+)/, + /(?:data[\\/]+records[\\/]+)([A-Za-z0-9_.-]+)/, + ] + for (const pattern of patterns) { + const match = pattern.exec(output) + if (match?.[1]) return match[1] + } + return null +} + +function newestCreatedRecordId( + repoRoot: string, + before: Set, + startedAtMs: number, +): string | null { + const recordsRoot = path.join(repoRoot, 'data', 'records') + try { + const candidates = readdirSync(recordsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !before.has(entry.name)) + .map((entry) => { + const recordPath = path.join(recordsRoot, entry.name) + return { id: entry.name, mtimeMs: statSync(recordPath).mtimeMs } + }) + .filter((entry) => entry.mtimeMs >= startedAtMs - 5000) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + return candidates[0]?.id ?? null + } catch { + return null + } +} + +function jsonFile(pathname: string): unknown { + return JSON.parse(readFileSync(pathname, 'utf8')) +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function xmlDecode(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') +} + +function attr(xml: string, name: string): string | undefined { + const match = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`).exec(xml) + return match?.[1] ? xmlDecode(match[1]) : undefined +} + +function numbers(value: string | undefined, fallback: number[]): number[] { + if (!value) return fallback + const parsed = value + .trim() + .split(/\s+/) + .map((part) => Number(part)) + return parsed.length + ? parsed.map((part, index) => (Number.isFinite(part) ? part : (fallback[index] ?? 0))) + : fallback +} + +function vec3(value: string | undefined): [number, number, number] { + const parsed = numbers(value, [0, 0, 0]) + return [parsed[0] ?? 0, parsed[1] ?? 0, parsed[2] ?? 0] +} + +function vec4(value: string | undefined): Vec4 { + const parsed = numbers(value, [1, 1, 1, 1]) + return [parsed[0] ?? 1, parsed[1] ?? 1, parsed[2] ?? 1, parsed[3] ?? 1] +} + +function origin(xml: string) { + const match = /]*\/?>/s.exec(xml) + const tag = match?.[0] ?? '' + return { xyz: vec3(attr(tag, 'xyz')), rpy: vec3(attr(tag, 'rpy')) } +} + +function tagBlocks(xml: string, tag: string): string[] { + return [...xml.matchAll(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'g'))].map( + (match) => match[0], + ) +} + +function parseUrdfMaterials(xml: string): Map { + const materials = new Map() + for (const materialXml of tagBlocks(xml, 'material')) { + const materialTag = /]*>/s.exec(materialXml)?.[0] ?? '' + const name = attr(materialTag, 'name') + const colorTag = /]*\/?>/s.exec(materialXml)?.[0] + if (name && colorTag) materials.set(name, vec4(attr(colorTag, 'rgba'))) + } + return materials +} + +function visualMaterial( + visualXml: string, + materialByName: Map, +): ArticraftVisual['material'] | undefined { + const materialTag = /]*(?:\/>|>[\s\S]*?<\/material>)/s.exec(visualXml)?.[0] + if (!materialTag) return undefined + const name = attr(materialTag, 'name') ?? 'material' + const colorTag = /]*\/?>/s.exec(materialTag)?.[0] + return { + name, + rgba: colorTag ? vec4(attr(colorTag, 'rgba')) : (materialByName.get(name) ?? vec4(undefined)), + } +} + +function addVec3(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function scaleVec3(a: Vec3, b: Vec3): Vec3 { + return [a[0] * b[0], a[1] * b[1], a[2] * b[2]] +} + +function rotateRpyVector(vector: Vec3, rpy: Vec3): Vec3 { + const [roll, pitch, yaw] = rpy + const cr = Math.cos(roll) + const sr = Math.sin(roll) + const cp = Math.cos(pitch) + const sp = Math.sin(pitch) + const cy = Math.cos(yaw) + const sy = Math.sin(yaw) + + const x1 = vector[0] + const y1 = cr * vector[1] - sr * vector[2] + const z1 = sr * vector[1] + cr * vector[2] + + const x2 = cp * x1 + sp * z1 + const y2 = y1 + const z2 = -sp * x1 + cp * z1 + + return [cy * x2 - sy * y2, sy * x2 + cy * y2, z2] +} + +function readObjBounds(objPath: string): { center: Vec3; size: Vec3 } | null { + let min: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + let max: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY] + let vertexCount = 0 + + try { + const text = readFileSync(objPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + const match = + /^v\s+([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s+([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s+([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/i.exec( + line.trim(), + ) + if (!match) continue + const vertex: Vec3 = [Number(match[1]), Number(match[2]), Number(match[3])] + if (!vertex.every(Number.isFinite)) continue + vertexCount += 1 + min = [Math.min(min[0], vertex[0]), Math.min(min[1], vertex[1]), Math.min(min[2], vertex[2])] + max = [Math.max(max[0], vertex[0]), Math.max(max[1], vertex[1]), Math.max(max[2], vertex[2])] + } + } catch { + return null + } + + if (vertexCount === 0) return null + const center: Vec3 = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] + const size: Vec3 = [ + Math.max(0.01, max[0] - min[0]), + Math.max(0.01, max[1] - min[1]), + Math.max(0.01, max[2] - min[2]), + ] + return { center, size } +} + +function meshGeometryFromTag( + meshTag: string | undefined, + urdfDir: string, + parsedOrigin: ReturnType, +): { origin: ArticraftOrigin; geometry: ArticraftVisualGeometry } { + const meshPath = attr(meshTag ?? '', 'filename') + const meshScale = vec3(attr(meshTag ?? '', 'scale') ?? '1 1 1') + const resolvedMeshPath = meshPath + ? path.resolve(urdfDir, path.normalize(meshPath.replace(/\\/g, path.sep))) + : null + const bounds = + resolvedMeshPath && existsSync(resolvedMeshPath) ? readObjBounds(resolvedMeshPath) : null + if (!bounds) { + return { + origin: parsedOrigin, + geometry: { + type: 'mesh' as const, + params: {}, + meshPath, + }, + } + } + + const center = scaleVec3(bounds.center, meshScale) + const size = scaleVec3(bounds.size, [ + Math.abs(meshScale[0]), + Math.abs(meshScale[1]), + Math.abs(meshScale[2]), + ]) + return { + origin: { + xyz: addVec3(parsedOrigin.xyz, rotateRpyVector(center, parsedOrigin.rpy)), + rpy: parsedOrigin.rpy, + }, + geometry: { + type: 'mesh' as const, + params: { + sx: size[0], + sy: size[1], + sz: size[2], + }, + meshPath, + }, + } +} + +export function buildModelDataFromUrdf( + urdfPath: string, +): Pick { + const xml = readFileSync(urdfPath, 'utf8') + const urdfDir = path.dirname(urdfPath) + const robotTag = /]*>/s.exec(xml)?.[0] ?? '' + const materialByName = parseUrdfMaterials(xml) + const links: ArticraftLink[] = tagBlocks(xml, 'link').map((linkXml) => { + const linkTag = /]*>/s.exec(linkXml)?.[0] ?? '' + const name = attr(linkTag, 'name') ?? 'link' + return { + name, + visuals: tagBlocks(linkXml, 'visual').map((visualXml, index): ArticraftVisual => { + const geometryXml = tagBlocks(visualXml, 'geometry')[0] ?? '' + const boxTag = /]*\/?>/s.exec(geometryXml)?.[0] + const cylinderTag = /]*\/?>/s.exec(geometryXml)?.[0] + const sphereTag = /]*\/?>/s.exec(geometryXml)?.[0] + const meshTag = /]*\/?>/s.exec(geometryXml)?.[0] + const visualTag = /]*>/s.exec(visualXml)?.[0] ?? '' + const parsedOrigin = origin(visualXml) + const material = visualMaterial(visualXml, materialByName) + if (boxTag) { + const size = numbers(attr(boxTag, 'size'), [1, 1, 1]) + return { + name: attr(visualTag, 'name') ?? `${name}_visual_${index}`, + origin: parsedOrigin, + geometry: { + type: 'box' as const, + params: { length: size[0] ?? 1, width: size[1] ?? 1, height: size[2] ?? 1 }, + }, + material, + } + } + if (cylinderTag) { + return { + name: attr(visualTag, 'name') ?? `${name}_visual_${index}`, + origin: parsedOrigin, + geometry: { + type: 'cylinder' as const, + params: { + radius: Number(attr(cylinderTag, 'radius') ?? 0.5), + length: Number(attr(cylinderTag, 'length') ?? 1), + }, + }, + material, + } + } + if (sphereTag) { + return { + name: attr(visualTag, 'name') ?? `${name}_visual_${index}`, + origin: parsedOrigin, + geometry: { + type: 'sphere' as const, + params: { radius: Number(attr(sphereTag, 'radius') ?? 0.5) }, + }, + material, + } + } + const mesh = meshGeometryFromTag(meshTag, urdfDir, parsedOrigin) + return { + name: attr(visualTag, 'name') ?? `${name}_visual_${index}`, + origin: mesh.origin, + geometry: mesh.geometry, + material, + } + }), + } + }) + const joints = tagBlocks(xml, 'joint').map((jointXml) => { + const jointTag = /]*>/s.exec(jointXml)?.[0] ?? '' + const limitTag = /]*\/?>/s.exec(jointXml)?.[0] + const mimicTag = /]*\/?>/s.exec(jointXml)?.[0] + return { + name: attr(jointTag, 'name') ?? 'joint', + type: (attr(jointTag, 'type') ?? 'fixed') as ArticraftModelData['joints'][number]['type'], + parent: attr(/]*\/?>/s.exec(jointXml)?.[0] ?? '', 'link') ?? '', + child: attr(/]*\/?>/s.exec(jointXml)?.[0] ?? '', 'link') ?? '', + origin: origin(jointXml), + axis: vec3(attr(/]*\/?>/s.exec(jointXml)?.[0] ?? '', 'xyz') ?? '1 0 0'), + limits: limitTag + ? { + effort: Number(attr(limitTag, 'effort') ?? 0), + velocity: Number(attr(limitTag, 'velocity') ?? 0), + lower: + attr(limitTag, 'lower') === undefined ? undefined : Number(attr(limitTag, 'lower')), + upper: + attr(limitTag, 'upper') === undefined ? undefined : Number(attr(limitTag, 'upper')), + } + : undefined, + mimic: mimicTag + ? { + joint: attr(mimicTag, 'joint') ?? '', + multiplier: Number(attr(mimicTag, 'multiplier') ?? 1), + offset: Number(attr(mimicTag, 'offset') ?? 0), + } + : undefined, + } + }) + return { name: attr(robotTag, 'name') ?? 'Articraft model', links, joints } +} + +function buildModelDataFromRecord(repoRoot: string, recordId: string): ArticraftModelData { + const recordPath = path.join(repoRoot, 'data', 'records', recordId) + const record = asRecord(jsonFile(path.join(recordPath, 'record.json'))) + const revisionId = String(record.active_revision_id || 'rev_000001') + const modelPyPath = path.join(recordPath, 'revisions', revisionId, 'model.py') + const materializationPath = path.join( + repoRoot, + 'data', + 'cache', + 'record_materialization', + recordId, + ) + const urdfPath = path.join(materializationPath, 'model.urdf') + const compileReportPath = path.join(materializationPath, 'compile_report.json') + const parsed = buildModelDataFromUrdf(urdfPath) + const display = asRecord(record.display) + const compileReport = existsSync(compileReportPath) ? asRecord(jsonFile(compileReportPath)) : {} + const warnings = Array.isArray(compileReport.warnings) + ? compileReport.warnings.map((warning) => { + if (typeof warning === 'string') return warning + const record = asRecord(warning) + return String(record.message ?? JSON.stringify(warning)) + }) + : [] + const meshesDir = path.join(materializationPath, 'assets', 'meshes') + const meshes = existsSync(meshesDir) + ? readdirSync(meshesDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.obj')) + .map((entry) => ({ + name: path.basename(entry.name, path.extname(entry.name)), + objPath: path.join( + 'data', + 'cache', + 'record_materialization', + recordId, + 'assets', + 'meshes', + entry.name, + ), + })) + : [] + return { + recordId, + name: String(display.title ?? parsed.name ?? recordId), + links: parsed.links, + joints: parsed.joints, + meshes, + modelPyPath, + recordPath, + warnings, + } +} + +async function generateModelWithModernCli( + repoRoot: string, + options: GenerateOptions, +): Promise { + const env = { ...process.env } + env.ARTICRAFT_REPO_ROOT ??= repoRoot + env.ARTICRAFT_MODEL ||= env.AI_MODEL || env.NEXT_PUBLIC_AI_MODEL || '' + env.DEEPSEEK_API_KEY ||= env.AI_API_KEY || env.NEXT_PUBLIC_AI_API_KEY || '' + env.DEEPSEEK_BASE_URL ||= env.AI_BASE_URL || env.NEXT_PUBLIC_AI_BASE_URL || '' + env.PYTHONUTF8 ??= '1' + env.PYTHONIOENCODING ??= 'utf-8' + + const before = listRecordIds(repoRoot) + const startedAtMs = Date.now() + const args = ['generate', '--repo-root', repoRoot, options.prompt] + if (options.model) args.push('--model', options.model) + if (options.provider) args.push('--provider', options.provider) + if (options.maxTurns !== undefined) args.push('--max-turns', String(options.maxTurns)) + if (options.imagePath) args.push('--image', options.imagePath) + + const generateCommand = modernCliInvocation(repoRoot, args) + const output = await spawnAndCollect(generateCommand.command, generateCommand.args, { + cwd: repoRoot, + env, + signal: options.signal, + onProgress: options.onProgress, + }) + const combinedOutput = `${output.stdout}\n${output.stderr}` + const recordId = + extractRecordId(combinedOutput) ?? newestCreatedRecordId(repoRoot, before, startedAtMs) + if (!recordId) { + throw new Error( + `Articraft generation completed but no record id was found.\n\nOutput:\n${combinedOutput}`, + ) + } + + const urdfPath = path.join( + repoRoot, + 'data', + 'cache', + 'record_materialization', + recordId, + 'model.urdf', + ) + if (!existsSync(urdfPath)) { + const compileCommand = modernCliInvocation(repoRoot, [ + 'compile', + '--repo-root', + repoRoot, + recordId, + '--target', + 'full', + ]) + await spawnAndCollect(compileCommand.command, compileCommand.args, { + cwd: repoRoot, + env, + signal: options.signal, + onProgress: options.onProgress, + }) + } + return buildModelDataFromRecord(repoRoot, recordId) +} + +/** + * Regenerate a model by modifying parameters in model.py and recompiling. + */ +export function regenerateModel( + recordPath: string, + paramChanges: Record, + options?: { repoRoot?: string; signal?: AbortSignal; onProgress?: (msg: string) => void }, +): Promise { + const repoRoot = resolveRepoRoot(options?.repoRoot) + const bridgeScript = bridgeScriptPath(repoRoot) + + return new Promise((resolve, reject) => { + const args = [ + bridgeScript, + 'regenerate', + '--record-path', + recordPath, + '--params', + JSON.stringify(paramChanges), + ] + + const env = { ...process.env } + if (!env.ARTICRAFT_REPO_ROOT) { + env.ARTICRAFT_REPO_ROOT = repoRoot + } + + let proc: ChildProcess + try { + proc = spawn('uv', ['run', '--directory', repoRoot, 'python', ...args], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + } catch (err) { + reject( + new Error( + `Failed to spawn articraft bridge: ${err instanceof Error ? err.message : String(err)}`, + ), + ) + return + } + + const rl = createInterface({ input: proc.stdout! }) + let resolved = false + + rl.on('line', (line: string) => { + if (resolved) return + const parsed = parseBridgeLine(line) + if (!parsed) { + options?.onProgress?.(line) + return + } + + if (parsed.type === 'progress') { + options?.onProgress?.(parsed.message ?? '') + } else if (parsed.type === 'result') { + resolved = true + rl.close() + resolve(parsed.data as ArticraftModelData) + } else if (parsed.type === 'error') { + resolved = true + rl.close() + reject(new Error(parsed.message)) + } + }) + + let stderr = '' + proc.stderr!.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + + proc.on('close', (code) => { + if (!resolved) { + reject(new Error(`articraft bridge exited with code ${code}${stderr ? `: ${stderr}` : ''}`)) + } + }) + + proc.on('error', (err) => { + if (!resolved) { + resolved = true + rl.close() + reject(err) + } + }) + + options?.signal?.addEventListener('abort', () => { + if (!resolved) { + resolved = true + rl.close() + proc.kill('SIGTERM') + reject(new DOMException('Regeneration cancelled', 'AbortError')) + } + }) + }) +} diff --git a/packages/articraft-bridge/src/index.ts b/packages/articraft-bridge/src/index.ts new file mode 100644 index 000000000..a561ad9fc --- /dev/null +++ b/packages/articraft-bridge/src/index.ts @@ -0,0 +1,19 @@ +// ─── Articraft Bridge — Public API ─────────────────────────────────────── + +export type { + ArticraftJoint, + ArticraftJointType, + ArticraftLink, + ArticraftMeshAsset, + ArticraftModelData, + ArticraftOrigin, + ArticraftVisual, + ArticraftVisualGeometry, + GenerateOptions, + SceneNodeResult, + Vec3, + Vec4, +} from './types' + +export { generateModel, regenerateModel } from './cli' +export { convertToSceneNodes, createModelNodes } from './scene-converter' diff --git a/packages/articraft-bridge/src/scene-converter.ts b/packages/articraft-bridge/src/scene-converter.ts new file mode 100644 index 000000000..8a76f3517 --- /dev/null +++ b/packages/articraft-bridge/src/scene-converter.ts @@ -0,0 +1,448 @@ +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { AssemblyNode, BoxNode, CylinderNode, SphereNode } from '@pascal-app/core/schema' +import type { + ArticraftJoint, + ArticraftModelData, + ArticraftVisual, + SceneNodeResult, + Vec3, + Vec4, +} from './types' + +type BridgeNodeRole = 'link' | 'visual' + +type BridgeNodeInfo = { + role?: BridgeNodeRole + linkName?: string + parentLink?: string | null +} + +type BridgeNodeMetadata = Record & { + articraftBridge?: BridgeNodeInfo +} + +const MIN_PASCAL_PRIMITIVE_SIZE = 0.01 + +function pascalPrimitiveSize(value: number | undefined, fallback: number): number { + return Math.max(MIN_PASCAL_PRIMITIVE_SIZE, value ?? fallback) +} + +function urdfPosToEditor(pos: Vec3): Vec3 { + return [pos[0], pos[2], -pos[1]] +} + +type Mat3 = [Vec3, Vec3, Vec3] + +const URDF_TO_EDITOR_BASIS: Mat3 = [ + [1, 0, 0], + [0, 0, 1], + [0, -1, 0], +] + +const EDITOR_TO_URDF_BASIS: Mat3 = [ + [1, 0, 0], + [0, 0, -1], + [0, 1, 0], +] + +function multiplyMat3(a: Mat3, b: Mat3): Mat3 { + return a.map((row) => + b[0]!.map((_, col) => + row.reduce((sum, value, index) => sum + value * b[index]![col]!, 0), + ), + ) as Mat3 +} + +function rpyToMatrix([roll, pitch, yaw]: Vec3): Mat3 { + const cr = Math.cos(roll) + const sr = Math.sin(roll) + const cp = Math.cos(pitch) + const sp = Math.sin(pitch) + const cy = Math.cos(yaw) + const sy = Math.sin(yaw) + + return [ + [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], + [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], + [-sp, cp * sr, cp * cr], + ] +} + +function clampUnit(value: number) { + return Math.max(-1, Math.min(1, value)) +} + +function matrixToXyzEuler(matrix: Mat3): Vec3 { + const m11 = matrix[0]![0]! + const m12 = matrix[0]![1]! + const m13 = matrix[0]![2]! + const m22 = matrix[1]![1]! + const m23 = matrix[1]![2]! + const m32 = matrix[2]![1]! + const m33 = matrix[2]![2]! + + const y = Math.asin(clampUnit(m13)) + if (Math.abs(m13) < 0.9999999) { + return [Math.atan2(-m23, m33), y, Math.atan2(-m12, m11)] + } + return [Math.atan2(m32, m22), y, 0] +} + +function urdfRpyToEditorRotation(rpy: Vec3): Vec3 { + return matrixToXyzEuler( + multiplyMat3( + URDF_TO_EDITOR_BASIS, + multiplyMat3(rpyToMatrix(rpy), EDITOR_TO_URDF_BASIS), + ), + ) +} + +function urdfAxisToEditor(axis: Vec3): Vec3 { + return urdfPosToEditor(axis) +} + +function toByte(value: number) { + const normalized = value <= 1 ? value * 255 : value + return Math.max(0, Math.min(255, Math.round(normalized))) +} + +function rgbaToHex(rgba: Vec4): string { + return `#${[rgba[0], rgba[1], rgba[2]] + .map((value) => toByte(value).toString(16).padStart(2, '0')) + .join('')}` +} + +function materialFromVisual(visual: ArticraftVisual) { + if (!visual.material) return undefined + const opacity = Number.isFinite(visual.material.rgba[3]) ? visual.material.rgba[3] : 1 + return { + preset: 'custom' as const, + properties: { + color: rgbaToHex(visual.material.rgba), + roughness: 0.45, + metalness: /metal|steel|iron|aluminum|aluminium|chrome/i.test(visual.material.name) + ? 0.75 + : 0, + opacity, + transparent: opacity < 1, + side: 'front' as const, + }, + } +} + +function bridgeMetadata( + role: BridgeNodeRole, + linkName: string, + parentLink?: string | null, +): BridgeNodeMetadata { + return { + disablePrimitiveBatch: role === 'visual', + articraftBridge: { + role, + linkName, + parentLink: parentLink ?? null, + }, + } +} + +function readBridgeInfo(node: AnyNode): BridgeNodeInfo | null { + const metadata = (node.metadata ?? {}) as BridgeNodeMetadata + const bridge = metadata.articraftBridge + if (!bridge || typeof bridge.linkName !== 'string') return null + return bridge +} + +function visualToBoxNode( + visual: ArticraftVisual, + nodeName: string, + metadata: BridgeNodeMetadata, + materialPreset?: string, +): ReturnType { + const size = visual.geometry.params + return BoxNode.parse({ + name: nodeName, + position: urdfPosToEditor(visual.origin.xyz), + rotation: urdfRpyToEditorRotation(visual.origin.rpy), + length: pascalPrimitiveSize(size.length ?? size.sx, 1.0), + width: pascalPrimitiveSize(size.width ?? size.sy, 1.0), + height: pascalPrimitiveSize(size.height ?? size.sz, 1.0), + material: materialFromVisual(visual), + materialPreset, + metadata, + }) +} + +function visualToCylinderNode( + visual: ArticraftVisual, + nodeName: string, + metadata: BridgeNodeMetadata, + materialPreset?: string, +): ReturnType { + const size = visual.geometry.params + return CylinderNode.parse({ + name: nodeName, + position: urdfPosToEditor(visual.origin.xyz), + rotation: urdfRpyToEditorRotation(visual.origin.rpy), + radius: pascalPrimitiveSize(size.radius, 0.5), + height: pascalPrimitiveSize(size.length ?? size.height, 1.0), + material: materialFromVisual(visual), + materialPreset, + metadata, + }) +} + +function visualToSphereNode( + visual: ArticraftVisual, + nodeName: string, + metadata: BridgeNodeMetadata, + materialPreset?: string, +): ReturnType { + const size = visual.geometry.params + return SphereNode.parse({ + name: nodeName, + position: urdfPosToEditor(visual.origin.xyz), + rotation: urdfRpyToEditorRotation(visual.origin.rpy), + radius: pascalPrimitiveSize(size.radius, 0.5), + material: materialFromVisual(visual), + materialPreset, + metadata, + }) +} + +function visualToPrimitiveNode( + visual: ArticraftVisual, + nodeName: string, + metadata: BridgeNodeMetadata, + materialPreset?: string, +): AnyNode | null { + const geomType = visual.geometry.type + try { + switch (geomType) { + case 'box': + return visualToBoxNode(visual, nodeName, metadata, materialPreset) + case 'cylinder': + return visualToCylinderNode(visual, nodeName, metadata, materialPreset) + case 'sphere': + return visualToSphereNode(visual, nodeName, metadata, materialPreset) + default: + return null + } + } catch { + return null + } +} + +function buildJointMetadata(joint: ArticraftJoint): SceneNodeResult['jointMetadata'][string] { + return { + jointName: joint.name, + jointType: joint.type, + parentLink: joint.parent, + childLink: joint.child, + axis: urdfAxisToEditor(joint.axis), + origin: { + xyz: urdfPosToEditor(joint.origin.xyz), + rpy: urdfRpyToEditorRotation(joint.origin.rpy), + }, + limits: joint.limits, + mimic: joint.mimic, + currentValue: 0, + } +} + +interface ConvertOptions { + /** Create joint metadata on nodes (for property panel controls) */ + articulationMode: boolean + /** Optional material preset to apply to all primitives */ + materialPreset?: string + /** Optional level/site parent ID for the root nodes */ + parentId?: string + /** Optional position offset applied to root link nodes when converting a placed asset */ + rootPosition?: Vec3 +} + +export function convertToSceneNodes( + data: ArticraftModelData, + options: ConvertOptions, +): { + nodes: AnyNode[] + nodeIdByLink: Map + jointMetadata: SceneNodeResult['jointMetadata'] + rootLinks: string[] +} { + const nodes: AnyNode[] = [] + const nodeIdByLink = new Map() + const jointMetadata: SceneNodeResult['jointMetadata'] = {} + const rootLinks: string[] = [] + + const jointByChild = new Map() + for (const joint of data.joints) { + jointByChild.set(joint.child, joint) + } + + for (const link of data.links) { + const parentJoint = jointByChild.get(link.name) + const parentLink = parentJoint?.parent ?? null + if (!parentLink) rootLinks.push(link.name) + + const linkFrame = AssemblyNode.parse({ + name: link.name, + position: parentJoint ? urdfPosToEditor(parentJoint.origin.xyz) : [0, 0, 0], + rotation: parentJoint ? urdfRpyToEditorRotation(parentJoint.origin.rpy) : [0, 0, 0], + metadata: bridgeMetadata('link', link.name, parentLink), + }) + nodes.push(linkFrame) + nodeIdByLink.set(link.name, linkFrame.id) + + for (let vi = 0; vi < link.visuals.length; vi++) { + const visual = link.visuals[vi]! + const nodeName = link.visuals.length > 1 ? `${link.name}_v${vi}` : `${link.name}_visual` + const materialPreset = visual.material?.name ?? options.materialPreset + const metadata = bridgeMetadata('visual', link.name, parentLink) + + let node: AnyNode | null = null + if (visual.geometry.type === 'mesh') { + const p = visual.geometry.params + if (p.radius !== undefined && (p.length !== undefined || p.height !== undefined)) { + node = visualToPrimitiveNode( + { ...visual, geometry: { ...visual.geometry, type: 'cylinder' } }, + nodeName, + metadata, + materialPreset, + ) + } else if (p.size !== undefined || p.length !== undefined || p.sx !== undefined) { + node = visualToPrimitiveNode( + { ...visual, geometry: { ...visual.geometry, type: 'box' } }, + nodeName, + metadata, + materialPreset, + ) + } else if (!visual.geometry.meshPath) { + node = visualToPrimitiveNode( + { + ...visual, + geometry: { ...visual.geometry, type: 'sphere', params: { radius: 0.05 } }, + }, + nodeName, + metadata, + materialPreset, + ) + } + } else { + node = visualToPrimitiveNode(visual, nodeName, metadata, materialPreset) + } + + if (node) { + if (visual.geometry.meshPath) { + node.metadata = { + ...((node.metadata as Record) ?? {}), + articraftMeshPath: visual.geometry.meshPath, + } + } + nodes.push(node) + } + } + } + + if (options.articulationMode) { + for (const joint of data.joints) { + const childNodeId = nodeIdByLink.get(joint.child) + if (childNodeId && nodeIdByLink.has(joint.parent)) { + jointMetadata[childNodeId] = buildJointMetadata(joint) + } + } + } + + return { nodes, nodeIdByLink, jointMetadata, rootLinks } +} + +export function createModelNodes( + data: ArticraftModelData, + createNode: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId, + options: ConvertOptions, +): SceneNodeResult { + const { nodes, nodeIdByLink, jointMetadata } = convertToSceneNodes(data, options) + + const createdIds: string[] = [] + const rootNodeIds: string[] = [] + const createdNodeIdByLink = new Map() + + const rememberCreatedLinkNode = (linkName: string, node: AnyNode, id: AnyNodeId) => { + if (nodeIdByLink.get(linkName) === node.id) { + createdNodeIdByLink.set(linkName, id as string) + } + } + + const withRootOffset = (node: AnyNode): AnyNode => { + if (!options.rootPosition) return node + const position = (node as { position?: unknown }).position + if (!Array.isArray(position) || position.length < 3) return node + return { + ...node, + position: [ + Number(position[0] ?? 0) + options.rootPosition[0], + Number(position[1] ?? 0) + options.rootPosition[1], + Number(position[2] ?? 0) + options.rootPosition[2], + ], + } as AnyNode + } + + const linkNodes = nodes.filter((node) => readBridgeInfo(node)?.role === 'link') + const visualNodes = nodes.filter((node) => readBridgeInfo(node)?.role === 'visual') + const otherNodes = nodes.filter((node) => !readBridgeInfo(node)) + + let remaining = linkNodes + let iterations = 0 + const maxIterations = data.links.length + 1 + + while (remaining.length > 0 && iterations < maxIterations) { + iterations++ + const stillPending: AnyNode[] = [] + + for (const node of remaining) { + const bridge = readBridgeInfo(node) + if (!bridge?.linkName) continue + + const parentLink = typeof bridge.parentLink === 'string' ? bridge.parentLink : null + const parentNodeId = parentLink ? createdNodeIdByLink.get(parentLink) : null + if (!parentLink || parentNodeId) { + const nodeToCreate = parentLink ? node : withRootOffset(node) + const id = createNode( + nodeToCreate, + (parentNodeId ?? options.parentId) as AnyNodeId | undefined, + ) + createdIds.push(id as string) + rememberCreatedLinkNode(bridge.linkName, nodeToCreate, id) + if (!parentLink) rootNodeIds.push(id as string) + } else { + stillPending.push(node) + } + } + + if (stillPending.length === remaining.length) { + for (const node of stillPending) { + const bridge = readBridgeInfo(node) + if (!bridge?.linkName) continue + const id = createNode(node, options.parentId as AnyNodeId | undefined) + createdIds.push(id as string) + rememberCreatedLinkNode(bridge.linkName, node, id) + rootNodeIds.push(id as string) + } + break + } + remaining = stillPending + } + + for (const node of visualNodes) { + const bridge = readBridgeInfo(node) + const parentNodeId = bridge?.linkName ? createdNodeIdByLink.get(bridge.linkName) : null + const id = createNode(node, parentNodeId as AnyNodeId | undefined) + createdIds.push(id as string) + } + + for (const node of otherNodes) { + const id = createNode(node, options.parentId as AnyNodeId | undefined) + createdIds.push(id as string) + } + + return { nodeIds: createdIds, rootNodeIds, jointMetadata } +} diff --git a/packages/articraft-bridge/src/types.ts b/packages/articraft-bridge/src/types.ts new file mode 100644 index 000000000..4e83d0c39 --- /dev/null +++ b/packages/articraft-bridge/src/types.ts @@ -0,0 +1,142 @@ +// ─── Articraft bridge types ─────────────────────────────────────────────── + +export type Vec3 = [number, number, number] +export type Vec4 = [number, number, number, number] + +export interface ArticraftOrigin { + xyz: Vec3 + rpy: Vec3 +} + +export interface ArticraftVisualGeometry { + type: 'box' | 'cylinder' | 'sphere' | 'mesh' + /** Box: [length, width, height]; Cylinder: [radius, length]; Sphere: [radius] */ + params: Record + /** OBJ/glb file path, relative to the articraft repo root (for mesh type) */ + meshPath?: string +} + +export interface ArticraftVisual { + geometry: ArticraftVisualGeometry + origin: ArticraftOrigin + material?: { + name: string + rgba: Vec4 + } + name?: string +} + +export interface ArticraftLink { + /** Link name (matches URDF link/@name) */ + name: string + /** Visual geometry attached to this link */ + visuals: ArticraftVisual[] + /** Inertial origin (for simulation reference) */ + inertialOrigin?: ArticraftOrigin +} + +export type ArticraftJointType = + | 'revolute' + | 'continuous' + | 'prismatic' + | 'fixed' + | 'floating' + +export interface ArticraftJoint { + /** Joint name */ + name: string + /** Joint type */ + type: ArticraftJointType + /** Parent link name */ + parent: string + /** Child link name */ + child: string + /** Transform from parent link frame to joint frame */ + origin: ArticraftOrigin + /** Motion axis in joint frame */ + axis: Vec3 + /** Joint limits (required for revolute/prismatic) */ + limits?: { + effort: number + velocity: number + lower?: number + upper?: number + } + /** Mimic relationship to another joint */ + mimic?: { + joint: string + multiplier: number + offset: number + } +} + +export interface ArticraftMeshAsset { + /** Logical mesh name */ + name: string + /** OBJ file path relative to the record directory */ + objPath: string + /** Optional converted glb path */ + glbPath?: string +} + +/** Structured output from the Python bridge script */ +export interface ArticraftModelData { + /** Record ID from articraft storage */ + recordId: string + /** Human-readable model name */ + name: string + /** Links (URDF links with visual geometry) */ + links: ArticraftLink[] + /** Joints (URDF joints between links) */ + joints: ArticraftJoint[] + /** Mesh asset files produced by the compile step */ + meshes: ArticraftMeshAsset[] + /** Path to the generated model.py (for regeneration) */ + modelPyPath: string + /** Path to the record directory */ + recordPath: string + /** Warnings from the compile step */ + warnings: string[] +} + +/** Options for generating an articulated model */ +export interface GenerateOptions { + /** Prompt text for the model */ + prompt: string + /** Optional reference image path */ + imagePath?: string + /** Generation mode: 'articulated' or 'static' */ + mode: 'articulated' | 'static' + /** Optional model override */ + model?: string + /** Optional provider override */ + provider?: string + /** Maximum generation turns */ + maxTurns?: number + /** Working directory for articraft (repo root) */ + repoRoot?: string + /** Signal for cancellation */ + signal?: AbortSignal + /** Callback for progress updates */ + onProgress?: (message: string) => void +} + +/** Result of converting articraft model data to editor scene nodes */ +export interface SceneNodeResult { + /** Created node IDs */ + nodeIds: string[] + /** Root node IDs (should be placed under a level) */ + rootNodeIds: string[] + /** Joint metadata stored on nodes (joint name → metadata) */ + jointMetadata: Record +} diff --git a/packages/articraft-bridge/tests/cli.test.ts b/packages/articraft-bridge/tests/cli.test.ts new file mode 100644 index 000000000..d252aa813 --- /dev/null +++ b/packages/articraft-bridge/tests/cli.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { buildModelDataFromUrdf, modernCliInvocation, resolveRepoRoot } from '../src/cli' + +let tempRoot: string | undefined +const originalCwd = process.cwd() +const originalArticraftRepoRoot = process.env.ARTICRAFT_REPO_ROOT + +function makeArticraftCheckout(repoRoot: string) { + const bridgeDir = path.join(repoRoot, 'articraft', 'python') + mkdirSync(bridgeDir, { recursive: true }) + writeFileSync(path.join(bridgeDir, 'bridge.py'), '', 'utf8') +} + +function makeModernArticraftCheckout(repoRoot: string) { + const cliDir = path.join(repoRoot, 'articraft', 'cli') + mkdirSync(cliDir, { recursive: true }) + writeFileSync(path.join(cliDir, 'main.py'), '', 'utf8') +} + +function makeModernArticraftVenv(repoRoot: string) { + const pythonPath = path.join( + repoRoot, + 'articraft', + '.venv', + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', + ) + mkdirSync(path.dirname(pythonPath), { recursive: true }) + writeFileSync(pythonPath, '', 'utf8') + return pythonPath +} + +afterEach(() => { + process.chdir(originalCwd) + if (originalArticraftRepoRoot === undefined) { + delete process.env.ARTICRAFT_REPO_ROOT + } else { + process.env.ARTICRAFT_REPO_ROOT = originalArticraftRepoRoot + } + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }) + tempRoot = undefined +}) + +describe('resolveRepoRoot', () => { + test('finds an Articraft checkout above a bundled app working directory', () => { + tempRoot = mkdtempSync(path.join(tmpdir(), 'pascal-articraft-root-')) + makeArticraftCheckout(tempRoot) + const bundledCwd = path.join(tempRoot, 'apps', 'editor', '.next', 'dev', 'server', 'chunks') + mkdirSync(bundledCwd, { recursive: true }) + + delete process.env.ARTICRAFT_REPO_ROOT + process.chdir(bundledCwd) + + expect(resolveRepoRoot()).toBe(path.join(tempRoot, 'articraft')) + }) + + test('rejects an explicit root without the bridge script or modern CLI', () => { + tempRoot = mkdtempSync(path.join(tmpdir(), 'pascal-articraft-invalid-')) + + expect(() => resolveRepoRoot(tempRoot)).toThrow('Expected python') + }) + + test('accepts a modern Articraft checkout with cli/main.py', () => { + tempRoot = mkdtempSync(path.join(tmpdir(), 'pascal-articraft-modern-')) + makeModernArticraftCheckout(tempRoot) + + delete process.env.ARTICRAFT_REPO_ROOT + expect(resolveRepoRoot(path.join(tempRoot, 'articraft'))).toBe(path.join(tempRoot, 'articraft')) + }) + + test('uses modern CLI venv python instead of uv when available', () => { + tempRoot = mkdtempSync(path.join(tmpdir(), 'pascal-articraft-modern-venv-')) + makeModernArticraftCheckout(tempRoot) + const pythonPath = makeModernArticraftVenv(tempRoot) + const repoRoot = path.join(tempRoot, 'articraft') + + expect(modernCliInvocation(repoRoot, ['generate', 'robot'])).toEqual({ + command: pythonPath, + args: [path.join(repoRoot, 'cli', 'main.py'), 'generate', 'robot'], + }) + }) +}) + +describe('buildModelDataFromUrdf', () => { + test('uses OBJ bounds and global URDF material colors for mesh visuals', () => { + tempRoot = mkdtempSync(path.join(tmpdir(), 'pascal-articraft-urdf-')) + const meshesDir = path.join(tempRoot, 'assets', 'meshes') + mkdirSync(meshesDir, { recursive: true }) + writeFileSync( + path.join(meshesDir, 'link.obj'), + ['v 0 0 0', 'v 2 4 6', 'f 1 2 2'].join('\n'), + 'utf8', + ) + const urdfPath = path.join(tempRoot, 'model.urdf') + writeFileSync( + urdfPath, + ` + + + + + + + + + `, + 'utf8', + ) + + const parsed = buildModelDataFromUrdf(urdfPath) + const visual = parsed.links[0]!.visuals[0]! + + expect(visual.geometry.type).toBe('mesh') + expect(visual.geometry.params).toEqual({ sx: 2, sy: 4, sz: 6 }) + expect(visual.origin.xyz).toEqual([2, 3, 4]) + expect(visual.material?.rgba).toEqual([0.9, 0.4, 0.1, 1]) + }) +}) diff --git a/packages/articraft-bridge/tests/scene-converter.test.ts b/packages/articraft-bridge/tests/scene-converter.test.ts new file mode 100644 index 000000000..3613e6225 --- /dev/null +++ b/packages/articraft-bridge/tests/scene-converter.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { convertToSceneNodes, createModelNodes } from '../src/scene-converter' +import type { ArticraftModelData } from '../src/types' + +const model: ArticraftModelData = { + recordId: 'rec-1', + name: 'Test articulated asset', + recordPath: '/tmp/articraft/rec-1', + modelPyPath: '/tmp/articraft/rec-1/model.py', + warnings: [], + meshes: [], + links: [ + { + name: 'claw', + visuals: [ + { + geometry: { type: 'sphere', params: { radius: 0.1 } }, + origin: { xyz: [1, 0, 1], rpy: [0, 0, 0] }, + material: { name: 'warning_yellow', rgba: [1, 0.8, 0, 1] }, + }, + ], + }, + { + name: 'arm', + visuals: [ + { + geometry: { type: 'box', params: { sx: 0.2, sy: 0.3, sz: 1 } }, + origin: { xyz: [0.5, 0, 0.5], rpy: [0, 0, 0] }, + material: { name: 'paint_red', rgba: [0.8, 0.1, 0.05, 1] }, + }, + ], + }, + { + name: 'base', + visuals: [ + { + geometry: { type: 'cylinder', params: { radius: 0.3, length: 0.2 } }, + origin: { xyz: [0, 0, 0], rpy: [0, 0, 0] }, + material: { name: 'dark_steel', rgba: [0.2, 0.22, 0.24, 1] }, + }, + ], + }, + ], + joints: [ + { + name: 'base_to_arm', + type: 'revolute', + parent: 'base', + child: 'arm', + origin: { xyz: [0, 0, 0.2], rpy: [0, 0, 0] }, + axis: [0, 0, 1], + limits: { effort: 1, velocity: 1, lower: -1, upper: 1 }, + }, + { + name: 'arm_to_claw', + type: 'fixed', + parent: 'arm', + child: 'claw', + origin: { xyz: [1, 0, 1], rpy: [0, 0, 0] }, + axis: [1, 0, 0], + }, + ], +} + +function createdByName(created: Map, name: string) { + return [...created.values()].find(({ node }) => node.name === name) +} + +function expectVecClose(actual: unknown, expected: [number, number, number]) { + expect(Array.isArray(actual)).toBe(true) + const values = actual as number[] + expect(values.length).toBe(3) + for (let i = 0; i < expected.length; i += 1) { + expect(values[i]).toBeCloseTo(expected[i]!, 5) + } +} + +describe('Articraft scene converter', () => { + test('keeps joint metadata on link frames when child links appear before parents', () => { + const converted = convertToSceneNodes(model, { articulationMode: true }) + const armId = converted.nodeIdByLink.get('arm') + const clawId = converted.nodeIdByLink.get('claw') + + expect(armId).toBeDefined() + expect(clawId).toBeDefined() + expect(converted.jointMetadata[armId!]?.jointName).toBe('base_to_arm') + expect(converted.jointMetadata[clawId!]?.jointName).toBe('arm_to_claw') + }) + + test('creates articulated link frames before visual children', () => { + const created = new Map() + const externalParentId = 'level-1' as AnyNodeId + + const result = createModelNodes( + model, + (node, parentId) => { + if (parentId && parentId !== externalParentId) { + expect(created.has(parentId)).toBe(true) + } + created.set(node.id, { node, parentId }) + return node.id + }, + { articulationMode: true, parentId: externalParentId }, + ) + + const base = createdByName(created, 'base') + const arm = createdByName(created, 'arm') + const claw = createdByName(created, 'claw') + const baseVisual = createdByName(created, 'base_visual') + const armVisual = createdByName(created, 'arm_visual') + const clawVisual = createdByName(created, 'claw_visual') + + expect(base?.parentId).toBe(externalParentId) + expect(arm?.parentId).toBe(base?.node.id) + expect(claw?.parentId).toBe(arm?.node.id) + expect(baseVisual?.parentId).toBe(base?.node.id) + expect(armVisual?.parentId).toBe(arm?.node.id) + expect(clawVisual?.parentId).toBe(claw?.node.id) + expect(result.rootNodeIds).toEqual([base?.node.id]) + }) + + test('applies joint origin to child link frames and rootPosition to root frames only', () => { + const created = new Map() + + createModelNodes( + model, + (node, parentId) => { + created.set(node.id, { node, parentId }) + return node.id + }, + { articulationMode: true, parentId: 'level-1' as AnyNodeId, rootPosition: [10, 2, -3] }, + ) + + const base = createdByName(created, 'base') + const arm = createdByName(created, 'arm') + const armVisual = createdByName(created, 'arm_visual') + + expect(base?.node.position).toEqual([10, 2, -3]) + expect(arm?.node.position).toEqual([0, 0.2, -0]) + expect(armVisual?.node.position).toEqual([0.5, 0.5, -0]) + }) + + test('preserves visual colors and maps URDF box dimensions into editor axes', () => { + const converted = convertToSceneNodes(model, { articulationMode: true }) + const armVisual = converted.nodes.find((node) => node.name === 'arm_visual') as AnyNode & { + length?: number + width?: number + height?: number + material?: { properties?: { color?: string } } + metadata?: Record + } + + expect(armVisual.length).toBe(0.2) + expect(armVisual.width).toBe(0.3) + expect(armVisual.height).toBe(1) + expect(armVisual.material?.properties?.color).toBe('#cc1a0d') + expect(armVisual.metadata?.disablePrimitiveBatch).toBe(true) + }) + + test('maps compound URDF RPY rotations through the editor coordinate basis', () => { + const compoundRpyModel: ArticraftModelData = { + ...model, + links: [ + { + name: 'root', + visuals: [ + { + geometry: { type: 'cylinder', params: { radius: 0.02, length: 0.4 } }, + origin: { xyz: [0, 0, 0], rpy: [0, Math.PI / 2, Math.PI / 2] }, + }, + ], + }, + ], + joints: [], + } + + const converted = convertToSceneNodes(compoundRpyModel, { articulationMode: true }) + const visual = converted.nodes.find((node) => node.name === 'root_visual') as AnyNode & { + rotation?: [number, number, number] + } + + expectVecClose(visual.rotation, [-Math.PI / 2, Math.PI / 2, 0]) + }) + + test('keeps sub-centimeter Articraft rods by clamping them to Pascal primitive limits', () => { + const fineRodModel: ArticraftModelData = { + ...model, + links: [ + { + name: 'root', + visuals: [ + { + geometry: { type: 'cylinder', params: { radius: 0.0055, length: 0.18 } }, + origin: { xyz: [0, 0, 0], rpy: [0, 0, 0] }, + }, + ], + }, + ], + joints: [], + } + + const converted = convertToSceneNodes(fineRodModel, { articulationMode: true }) + const visual = converted.nodes.find((node) => node.name === 'root_visual') as AnyNode & { + radius?: number + height?: number + } + + expect(visual).toBeDefined() + expect(visual.type).toBe('cylinder') + expect(visual.radius).toBe(0.01) + expect(visual.height).toBe(0.18) + }) +}) diff --git a/packages/articraft-bridge/tsconfig.json b/packages/articraft-bridge/tsconfig.json new file mode 100644 index 000000000..b238fc466 --- /dev/null +++ b/packages/articraft-bridge/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@pascal/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }] +} diff --git a/packages/core/README.md b/packages/core/README.md index 3def8fe11..1db6da238 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,7 +11,7 @@ npm install @pascal-app/core ## Peer Dependencies ```bash -npm install react three @react-three/fiber @react-three/drei +npm install react ``` ## What's Included @@ -19,7 +19,7 @@ npm install react three @react-three/fiber @react-three/drei - **Node Schemas** - Zod schemas for all building primitives (walls, slabs, items, etc.) - **Scene State** - Zustand store with IndexedDB persistence and undo/redo - **Systems** - Geometry generation for walls, floors, ceilings, roofs -- **Scene Registry** - Fast lookup from node IDs to Three.js objects +- **Scene Registry** - Fast lookup from node IDs to mounted scene object refs - **Spatial Grid** - Collision detection and placement validation - **Event Bus** - Typed event emitter for inter-component communication - **Asset Storage** - IndexedDB-based file storage for user-uploaded assets diff --git a/packages/core/package.json b/packages/core/package.json index 0f43e81c6..cae9c982d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,11 @@ "import": "./dist/utils/clone-scene-graph.js", "default": "./dist/utils/clone-scene-graph.js" }, + "./equipment": { + "types": "./dist/equipment/index.d.ts", + "import": "./dist/equipment/index.js", + "default": "./dist/equipment/index.js" + }, "./registry": { "types": "./dist/registry/index.d.ts", "import": "./dist/registry/index.js", @@ -36,6 +41,11 @@ "import": "./dist/material-library.js", "default": "./dist/material-library.js" }, + "./lib/*": { + "types": "./dist/lib/*.d.ts", + "import": "./dist/lib/*.js", + "default": "./dist/lib/*.js" + }, "./spatial-grid": { "types": "./dist/hooks/spatial-grid/spatial-grid-manager.d.ts", "import": "./dist/hooks/spatial-grid/spatial-grid-manager.js", @@ -59,15 +69,12 @@ "scripts": { "build": "tsc --build", "dev": "tsc --build --watch", - "test": "bun test", + "test": "bun test src", "bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts", "prepublishOnly": "npm run build" }, "peerDependencies": { - "@react-three/drei": "^10", - "@react-three/fiber": "^9", - "react": "^18 || ^19", - "three": "^0.184" + "react": "^18 || ^19" }, "dependencies": { "dedent": "^1.7.1", @@ -82,7 +89,6 @@ "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/react": "^19.2.2", - "@types/three": "^0.184.0", "typescript": "6.0.2" }, "keywords": [ @@ -90,8 +96,7 @@ "building", "editor", "architecture", - "webgpu", - "three.js" + "webgpu" ], "repository": { "type": "git", diff --git a/packages/core/src/dynamics/capabilities.test.ts b/packages/core/src/dynamics/capabilities.test.ts new file mode 100644 index 000000000..3e1daff3c --- /dev/null +++ b/packages/core/src/dynamics/capabilities.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema/types' +import { + getDynamicTypesForNode, + getNodeSemanticType, + getRecommendedDynamicTypeForNode, + isDynamicTypeSupportedByNode, +} from './capabilities' + +function node(metadata: Record, type = 'box'): AnyNode { + return { + object: 'node', + id: `${type}_dynamic_semantic_test`, + type, + name: 'generated device', + parentId: null, + position: [0, 0, 0], + rotation: [0, 0, 0], + visible: true, + metadata, + } as AnyNode +} + +describe('dynamic capability semantic inference', () => { + test('infers conveyor dynamics from AI generated belt metadata', () => { + const belt = node({ + semanticRole: 'belt_surface', + sourcePartKind: 'roller_array', + family: 'conveyor_line', + }) + + expect(getNodeSemanticType(belt)).toBe('conveyor') + expect(getDynamicTypesForNode(belt)).toContain('conveyorFlow') + }) + + test('uses conveyor semantics for the built-in conveyor belt node type', () => { + const belt = node({}, 'conveyor-belt') + + expect(getNodeSemanticType(belt)).toBe('conveyor') + expect(getDynamicTypesForNode(belt)).toContain('conveyorFlow') + }) + + test('keeps conveyor assembly parts semantically separate', () => { + const frame = node({ semanticRole: 'conveyor_frame', sourcePartKind: 'conveyor_frame' }) + const roller = node({ semanticRole: 'roller', sourcePartKind: 'roller_array' }) + const motor = node({ semanticRole: 'drive_motor', sourcePartKind: 'ribbed_motor_body' }) + + expect(getNodeSemanticType(frame)).toBe('conveyor') + expect(getDynamicTypesForNode(frame)).toContain('conveyorFlow') + + expect(getNodeSemanticType(roller)).toBe('roller') + expect(getDynamicTypesForNode(roller)).toContain('rotate') + expect(getDynamicTypesForNode(roller)).not.toContain('conveyorFlow') + + expect(getNodeSemanticType(motor)).toBe('motor') + expect(getDynamicTypesForNode(motor)).toContain('speed') + expect(getDynamicTypesForNode(motor)).not.toContain('conveyorFlow') + }) + + test('infers fan, tank, and pipe dynamics from generated metadata', () => { + expect( + getDynamicTypesForNode(node({ semanticRole: 'impeller', sourcePartKind: 'fan' })), + ).toContain('speed') + expect( + getDynamicTypesForNode(node({ semanticRole: 'vessel_shell', family: 'reactor_vessel' })), + ).toContain('level') + expect( + getDynamicTypesForNode(node({ semanticRole: 'pipe_run', sourcePartKind: 'duct' })), + ).toContain('flow') + }) + + test('classifies catalog tank items as tanks even when geometry mentions pipe ports', () => { + const tankItem = { + ...node( + { + family: 'tank', + sourceArgs: { family: 'tank', object: 'storage_tank' }, + geometryBrief: 'storage tank with inlet outlet nozzles and pipe flange details', + }, + 'item', + ), + asset: { + id: 'storage_tank', + category: 'tank', + name: 'Storage Tank', + thumbnail: '/tank.png', + src: '/tank.glb', + dimensions: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + source: 'library', + }, + } as AnyNode + + expect(getNodeSemanticType(tankItem)).toBe('tank') + expect(getDynamicTypesForNode(tankItem)).toContain('level') + expect(getDynamicTypesForNode(tankItem)).not.toContain('flow') + expect(getRecommendedDynamicTypeForNode(tankItem)).toBe('fill') + }) + + test('uses factory equipment contracts before port wording for catalog tank items', () => { + const refineryCatalogTank = { + ...node( + { + equipmentRole: 'crude_storage_tank', + catalogItemId: 'factory-barrel', + equipmentContract: { + profileId: 'refinery.crude_storage_tank', + equipmentFamily: 'tank', + primarySemanticRole: 'vessel_shell', + ports: [ + { id: 'feed_inlet', medium: 'material', side: 'west' }, + { id: 'product_outlet', medium: 'material', side: 'east' }, + ], + }, + }, + 'item', + ), + asset: { + id: 'factory-barrel', + category: 'equipment', + name: 'Factory Barrel', + thumbnail: '/icons/shelf.webp', + src: '/items/factory-barrel/model.glb', + dimensions: [0.6, 0.9, 0.6], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + source: 'library', + }, + } as AnyNode + + expect(getNodeSemanticType(refineryCatalogTank)).toBe('tank') + expect(getDynamicTypesForNode(refineryCatalogTank)).toContain('level') + expect(getDynamicTypesForNode(refineryCatalogTank)).not.toContain('flow') + expect(getRecommendedDynamicTypeForNode(refineryCatalogTank)).toBe('fill') + }) + + test('keeps native tank dynamic types aligned with the toolbar tank', () => { + const tank = node( + { + dynamicCapabilities: { + semanticType: 'tank', + supportedTypes: ['flow', 'level'], + recommendedTypes: ['flow'], + source: 'generated-geometry', + }, + }, + 'tank', + ) + + expect(getNodeSemanticType(tank)).toBe('tank') + expect(getDynamicTypesForNode(tank)).toContain('fill') + expect(getDynamicTypesForNode(tank)).toContain('level') + expect(getDynamicTypesForNode(tank)).not.toContain('flow') + expect(getRecommendedDynamicTypeForNode(tank)).toBe('fill') + expect(isDynamicTypeSupportedByNode(tank, 'flow')).toBe(false) + expect(isDynamicTypeSupportedByNode(tank, 'level')).toBe(true) + }) + + test('keeps loading dynamics only on container-like semantics', () => { + const wheel = node({ semanticRole: 'wheel', sourcePartKind: 'wheel' }) + const cabinet = node({ semanticRole: 'electrical_cabinet', sourcePartKind: 'cabinet' }) + + expect(getNodeSemanticType(wheel)).toBe('roller') + expect(getDynamicTypesForNode(wheel)).not.toContain('fill') + expect(getDynamicTypesForNode(wheel)).not.toContain('level') + expect(getDynamicTypesForNode(wheel)).toContain('rotate') + + expect(getNodeSemanticType(cabinet)).toBe('cabinet') + expect(getDynamicTypesForNode(cabinet)).toContain('fill') + }) + + test('ignores stale declared loading dynamics on non-container nodes', () => { + const wheel = node({ + semanticType: 'roller', + dynamicCapabilities: { + semanticType: 'roller', + supportedTypes: ['rotate', 'fill', 'level'], + recommendedTypes: ['rotate'], + source: 'generated-geometry', + }, + }) + + expect(getDynamicTypesForNode(wheel)).toContain('rotate') + expect(getDynamicTypesForNode(wheel)).not.toContain('fill') + expect(getDynamicTypesForNode(wheel)).not.toContain('level') + }) + + test('ignores stale declared conveyor dynamics on roller parts', () => { + const roller = node({ + semanticRole: 'roller', + sourcePartKind: 'roller_array', + dynamicCapabilities: { + semanticType: 'conveyor', + supportedTypes: ['conveyorFlow', 'rotate'], + recommendedTypes: ['conveyorFlow'], + source: 'generated-geometry', + }, + }) + + expect(getNodeSemanticType(roller)).toBe('roller') + expect(getDynamicTypesForNode(roller)).toContain('rotate') + expect(getDynamicTypesForNode(roller)).not.toContain('conveyorFlow') + }) + + test('explicit semanticType overrides inference', () => { + const generated = node({ + semanticType: 'generic', + semanticRole: 'belt_surface', + sourcePartKind: 'roller_array', + }) + + expect(getNodeSemanticType(generated)).toBe('generic') + expect(getDynamicTypesForNode(generated)).not.toContain('conveyorFlow') + }) +}) + +test('uses declared dynamic capability metadata from generated nodes', () => { + const generated = node({ + dynamicCapabilities: { + semanticType: 'conveyor', + supportedTypes: ['conveyorFlow', 'speed', 'not-real'], + recommendedTypes: ['conveyorFlow'], + source: 'generated-geometry', + }, + }) + + expect(getNodeSemanticType(generated)).toBe('conveyor') + expect(getDynamicTypesForNode(generated)).toContain('conveyorFlow') + expect(getDynamicTypesForNode(generated)).toContain('speed') +}) diff --git a/packages/core/src/dynamics/capabilities.ts b/packages/core/src/dynamics/capabilities.ts new file mode 100644 index 000000000..ca95746f6 --- /dev/null +++ b/packages/core/src/dynamics/capabilities.ts @@ -0,0 +1,333 @@ +import type { AnyNode, AnyNodeType } from '../schema/types' +import type { DynamicCapabilityMetadata, DynamicType } from './types' + +export const COMMON_DYNAMIC_TYPES: readonly DynamicType[] = [ + 'visible', + 'move', + 'blink', + 'scale', + 'color', + 'rotate', +] + +export const SEMANTIC_DYNAMIC_TYPES: Record = { + pipe: ['flow'], + conveyor: ['conveyorFlow'], + tank: ['fill', 'level'], + container: ['fill'], + cabinet: ['fill'], + silo: ['fill', 'level'], + battery: ['fill'], + fan: ['speed'], + motor: ['speed'], + roller: ['rotate'], + valve: ['openClose', 'flow'], + pump: ['running', 'flow'], + light: ['brightness'], + display: ['valueDisplay'], +} + +const VALID_DYNAMIC_TYPES = new Set([ + ...COMMON_DYNAMIC_TYPES, + ...Object.values(SEMANTIC_DYNAMIC_TYPES).flat(), +]) + +const SEMANTIC_ONLY_DYNAMIC_TYPES = new Set(['fill', 'level', 'conveyorFlow']) + +const NATIVE_NODE_DYNAMIC_TYPES: Partial> = { + tank: [...COMMON_DYNAMIC_TYPES, 'fill', 'level'], +} + +function semanticAllowsDynamicType(semanticType: string, type: DynamicType) { + if (!SEMANTIC_ONLY_DYNAMIC_TYPES.has(type)) return true + return (SEMANTIC_DYNAMIC_TYPES[semanticType] ?? []).includes(type) +} + +function uniqueDynamicTypes(values: readonly unknown[]): DynamicType[] { + const result: DynamicType[] = [] + for (const value of values) { + if (typeof value !== 'string') continue + if (!VALID_DYNAMIC_TYPES.has(value as DynamicType)) continue + if (!result.includes(value as DynamicType)) result.push(value as DynamicType) + } + return result +} + +function readDynamicCapabilities( + node: AnyNode | null | undefined, +): DynamicCapabilityMetadata | null { + const metadata = readMetadata(node) + const raw = readRecord(metadata.dynamicCapabilities) + const semanticType = + typeof raw.semanticType === 'string' && raw.semanticType.trim() + ? raw.semanticType.trim() + : undefined + const supportedTypes = Array.isArray(raw.supportedTypes) + ? uniqueDynamicTypes(raw.supportedTypes) + : undefined + const recommendedTypes = Array.isArray(raw.recommendedTypes) + ? uniqueDynamicTypes(raw.recommendedTypes) + : undefined + const source = typeof raw.source === 'string' && raw.source.trim() ? raw.source.trim() : undefined + if (!(semanticType || supportedTypes?.length || recommendedTypes?.length || source)) return null + return { semanticType, supportedTypes, recommendedTypes, source } +} + +export function buildDynamicCapabilityMetadata( + semanticType: string, + source = 'generated-geometry', +): DynamicCapabilityMetadata { + const supportedTypes = getDynamicTypesForSemanticType(semanticType) + const recommendedTypes = SEMANTIC_DYNAMIC_TYPES[semanticType] + ? [...SEMANTIC_DYNAMIC_TYPES[semanticType]] + : [getRecommendedDynamicTypeForSemanticType(semanticType)] + return { semanticType, supportedTypes, recommendedTypes, source } +} + +export const NODE_TYPE_SEMANTIC_DEFAULTS: Partial> = { + pipe: 'pipe', + tank: 'tank', + 'conveyor-belt': 'conveyor', + 'data-widget': 'display', +} + +const SEMANTIC_INFERENCE_RULES: Array<{ semanticType: string; pattern: RegExp }> = [ + { + semanticType: 'conveyor', + pattern: + /conveyor|conveyer|belt|belt_surface|roller_table|roller_array|cargo_platform|输送|传送|皮带线/, + }, + { semanticType: 'pipe', pattern: /pipe|duct|hose|tube|manifold|nozzle|inlet|outlet|管|风管/ }, + { semanticType: 'silo', pattern: /silo|hopper|(^|[_\s-])bin($|[_\s-])|料仓|料斗|仓/ }, + { semanticType: 'cabinet', pattern: /cabinet|locker|enclosure|rack|柜|机柜|箱柜/ }, + { semanticType: 'container', pattern: /container|crate|case|storage|箱|盒|容器/ }, + { semanticType: 'battery', pattern: /battery|cell|电池|蓄电/ }, + { semanticType: 'tank', pattern: /tank|vessel|reactor|罐|釜|水箱|储罐/ }, + { semanticType: 'fan', pattern: /fan|blower|impeller|vent|风机|风扇|叶轮/ }, + { semanticType: 'motor', pattern: /motor|gearbox|drive_motor|电机|马达/ }, + { semanticType: 'roller', pattern: /roller|drum|wheel|滚筒|托辊/ }, + { semanticType: 'valve', pattern: /valve|damper|gate|ball_valve|阀|闸/ }, + { semanticType: 'pump', pattern: /pump|compressor|volute|泵|压缩机/ }, + { semanticType: 'light', pattern: /light|lamp|beacon|headlight|status_light|灯|指示灯/ }, + { + semanticType: 'display', + pattern: /display|gauge|meter|screen|indicator|instrument|panel|数显|仪表|屏|表/, + }, +] + +function readRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function readMetadata(node: AnyNode | null | undefined): Record { + return readRecord(node?.metadata) +} + +function normalizeSemanticToken(value: unknown) { + return typeof value === 'string' ? value.trim().toLowerCase() : '' +} + +function hasSemanticToken(tokens: string[], values: readonly string[]) { + return tokens.some((token) => values.includes(token)) +} + +function inferStructuredPartSemanticType(node: AnyNode): string | undefined { + const metadata = readMetadata(node) + const asset = readRecord((node as unknown as Record).asset) + const sourceArgs = readRecord(metadata.sourceArgs) + const equipmentContract = readRecord(metadata.equipmentContract) + const roleTokens = [metadata.semanticRole, metadata.primarySemanticRole].map( + normalizeSemanticToken, + ) + const partTokens = [metadata.sourcePartKind, metadata.sourcePartId].map(normalizeSemanticToken) + const groupTokens = [metadata.semanticGroup].map(normalizeSemanticToken) + const equipmentTokens = [ + node.type, + metadata.semanticType, + metadata.family, + metadata.archetypeFamily, + metadata.layoutFamily, + metadata.category, + metadata.catalogItemId, + sourceArgs.family, + sourceArgs.object, + equipmentContract.equipmentFamily, + equipmentContract.profileId, + equipmentContract.primarySemanticRole, + asset.id, + asset.category, + asset.name, + ].map(normalizeSemanticToken) + + const tankTokens = [ + 'tank', + 'storage_tank', + 'vertical_tank', + 'horizontal_tank', + 'pressure_vessel', + 'vessel', + 'reactor', + 'reactor_vessel', + 'factory-barrel', + 'factory barrel', + ] + + if (hasSemanticToken(equipmentTokens, tankTokens)) return 'tank' + + const conveyorTokens = [ + 'conveyor', + 'conveyor_frame', + 'belt_surface', + 'conveyor_belt', + 'rubber_belt', + 'moving_belt_surface', + 'covered_conveyor_belt', + 'chip_belt', + 'packaging_conveyor', + 'chip_conveyor_frame', + 'casting_conveyor_frame', + 'infeed_conveyor', + 'cargo_platform', + ] + const rollerTokens = [ + 'roller', + 'roller_array', + 'support_rollers', + 'drive_roller', + 'idler_roller', + 'drum', + ] + const motorTokens = [ + 'motor', + 'drive_motor', + 'conveyor_drive_motor', + 'conveyor_drive', + 'conveyor_drive_unit', + 'ribbed_motor_body', + ] + + if (hasSemanticToken(roleTokens, conveyorTokens)) return 'conveyor' + if (hasSemanticToken(roleTokens, rollerTokens)) return 'roller' + if (hasSemanticToken(roleTokens, motorTokens)) return 'motor' + if (hasSemanticToken(partTokens, conveyorTokens)) return 'conveyor' + if (hasSemanticToken(partTokens, rollerTokens)) return 'roller' + if (hasSemanticToken(partTokens, motorTokens)) return 'motor' + if (hasSemanticToken(groupTokens, conveyorTokens)) { + return 'conveyor' + } + + return undefined +} + +function collectText(value: unknown, depth = 0): string[] { + if (depth > 2) return [] + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return [String(value)] + } + if (Array.isArray(value)) return value.flatMap((item) => collectText(item, depth + 1)) + if (value && typeof value === 'object') { + return Object.values(value as Record).flatMap((item) => + collectText(item, depth + 1), + ) + } + return [] +} + +function nodeInferenceText(node: AnyNode): string { + const metadata = readMetadata(node) + const asset = readRecord((node as unknown as Record).asset) + const prioritizedMetadata = [ + metadata.semanticRole, + metadata.semanticGroup, + metadata.sourcePartKind, + metadata.sourcePartId, + metadata.stationRole, + metadata.factoryStationRole, + metadata.equipmentRole, + metadata.primarySemanticRole, + metadata.family, + metadata.archetypeFamily, + metadata.layoutFamily, + metadata.deviceProfile, + metadata.profileId, + metadata.category, + metadata.semanticSummary, + ] + return [ + node.type, + (node as unknown as Record).name, + ...prioritizedMetadata, + asset.id, + asset.name, + asset.category, + ...collectText(metadata.equipmentContract), + ...collectText(metadata.sourceArgs), + ...collectText(metadata.geometryBrief), + ] + .filter((value): value is string | number | boolean => value != null) + .join(' ') + .toLowerCase() +} + +export function inferNodeSemanticType(node: AnyNode | null | undefined): string { + if (!node) return 'generic' + const structuredPartSemanticType = inferStructuredPartSemanticType(node) + if (structuredPartSemanticType) return structuredPartSemanticType + const text = nodeInferenceText(node) + for (const rule of SEMANTIC_INFERENCE_RULES) { + if (rule.pattern.test(text)) return rule.semanticType + } + return NODE_TYPE_SEMANTIC_DEFAULTS[node.type] ?? 'generic' +} + +export function getNodeSemanticType(node: AnyNode | null | undefined): string { + if (!node) return 'generic' + const metadata = readMetadata(node) + const semanticType = metadata.semanticType + if (typeof semanticType === 'string' && semanticType.trim()) return semanticType.trim() + const structuredPartSemanticType = inferStructuredPartSemanticType(node) + if (structuredPartSemanticType) return structuredPartSemanticType + const declared = readDynamicCapabilities(node)?.semanticType + return declared ?? inferNodeSemanticType(node) +} + +export function getDynamicTypesForSemanticType(semanticType: string): DynamicType[] { + const merged = [...COMMON_DYNAMIC_TYPES, ...(SEMANTIC_DYNAMIC_TYPES[semanticType] ?? [])] + return Array.from(new Set(merged)) +} + +export function getRecommendedDynamicTypeForSemanticType(semanticType: string): DynamicType { + return SEMANTIC_DYNAMIC_TYPES[semanticType]?.[0] ?? 'visible' +} + +export function getDynamicTypesForNode(node: AnyNode | null | undefined): DynamicType[] { + const declared = readDynamicCapabilities(node) + const semanticType = getNodeSemanticType(node) + const semanticTypes = getDynamicTypesForSemanticType(semanticType) + const declaredTypes = (declared?.supportedTypes ?? []).filter((type) => + semanticAllowsDynamicType(semanticType, type), + ) + const merged = Array.from(new Set([...semanticTypes, ...declaredTypes])) + const nativeTypes = node ? NATIVE_NODE_DYNAMIC_TYPES[node.type] : undefined + if (!nativeTypes) return merged + return merged.filter((type) => nativeTypes.includes(type)) +} + +export function getRecommendedDynamicTypeForNode(node: AnyNode | null | undefined): DynamicType { + const declared = readDynamicCapabilities(node) + const allowedTypes = getDynamicTypesForNode(node) + const declaredType = declared?.recommendedTypes?.find((type) => allowedTypes.includes(type)) + const semanticType = getRecommendedDynamicTypeForSemanticType(getNodeSemanticType(node)) + if (declaredType) return declaredType + if (allowedTypes.includes(semanticType)) return semanticType + return allowedTypes[0] ?? 'visible' +} + +export function isDynamicTypeSupportedByNode( + node: AnyNode | null | undefined, + type: DynamicType, +): boolean { + return getDynamicTypesForNode(node).includes(type) +} diff --git a/packages/core/src/dynamics/metadata.ts b/packages/core/src/dynamics/metadata.ts new file mode 100644 index 000000000..767f08912 --- /dev/null +++ b/packages/core/src/dynamics/metadata.ts @@ -0,0 +1,112 @@ +import type { AnyNode } from '../schema/types' +import type { + DynamicAxis, + DynamicBinding, + DynamicJointBinding, + DynamicJointChannel, + DynamicMetadata, +} from './types' + +const AXES = new Set(['x', 'y', 'z']) + +export function readDynamicMetadata(node: AnyNode | null | undefined): DynamicMetadata { + const metadata = + node?.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + + const dynamicBindings = Array.isArray(metadata.dynamicBindings) + ? metadata.dynamicBindings.filter(isDynamicBinding) + : [] + const jointChannels = Array.isArray(metadata.jointChannels) + ? metadata.jointChannels.filter(isDynamicJointChannel) + : [] + const jointBindings = Array.isArray(metadata.jointBindings) + ? metadata.jointBindings.filter(isDynamicJointBinding) + : [] + const semanticType = + typeof metadata.semanticType === 'string' ? metadata.semanticType.trim() : undefined + + return { + semanticType: semanticType || undefined, + dynamicBindings, + jointChannels, + jointBindings, + } +} + +export function isDynamicBinding(value: unknown): value is DynamicBinding { + if (!(value && typeof value === 'object')) return false + const record = value as Record + return ( + typeof record.id === 'string' && + typeof record.type === 'string' && + typeof record.path === 'string' + ) +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function isFinitePair(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length === 2 && + isFiniteNumber(value[0]) && + isFiniteNumber(value[1]) + ) +} + +function isFiniteVec3(value: unknown): value is [number, number, number] { + return ( + Array.isArray(value) && + value.length === 3 && + isFiniteNumber(value[0]) && + isFiniteNumber(value[1]) && + isFiniteNumber(value[2]) + ) +} + +export function isDynamicJointChannel(value: unknown): value is DynamicJointChannel { + if (!(value && typeof value === 'object')) return false + const record = value as Record + if (typeof record.id !== 'string' || !record.id.trim()) return false + if (typeof record.label !== 'string' || !record.label.trim()) return false + if (typeof record.targetNodeId !== 'string' || !record.targetNodeId.trim()) return false + if (typeof record.axis !== 'string' || !AXES.has(record.axis as DynamicAxis)) return false + if (record.motion !== 'rotation' && record.motion !== 'translation') return false + if (record.pivot != null && !isFiniteVec3(record.pivot)) return false + if (record.inputRange != null && !isFinitePair(record.inputRange)) return false + if (record.outputRange != null && !isFinitePair(record.outputRange)) return false + return true +} + +export function isDynamicJointBinding(value: unknown): value is DynamicJointBinding { + if (!(value && typeof value === 'object')) return false + const record = value as Record + if (typeof record.id !== 'string' || !record.id.trim()) return false + if (typeof record.channelId !== 'string' || !record.channelId.trim()) return false + if (typeof record.path !== 'string') return false + if (record.inputRange != null && !isFinitePair(record.inputRange)) return false + if (record.outputRange != null && !isFinitePair(record.outputRange)) return false + if (record.enabled != null && typeof record.enabled !== 'boolean') return false + return true +} + +export function writeDynamicMetadataPatch( + node: AnyNode, + patch: DynamicMetadata, +): Pick { + const metadata = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + + return { + metadata: { + ...metadata, + ...patch, + }, + } as Pick +} diff --git a/packages/core/src/dynamics/types.ts b/packages/core/src/dynamics/types.ts new file mode 100644 index 000000000..81b614b58 --- /dev/null +++ b/packages/core/src/dynamics/types.ts @@ -0,0 +1,127 @@ +export type DynamicType = + | 'visible' + | 'move' + | 'blink' + | 'fill' + | 'scale' + | 'color' + | 'rotate' + | 'flow' + | 'conveyorFlow' + | 'level' + | 'speed' + | 'openClose' + | 'running' + | 'brightness' + | 'valueDisplay' + +export type DynamicAxis = 'x' | 'y' | 'z' +export type DynamicMotionMode = 'follow' | 'smooth' +export type DynamicMoveStyle = 'translate' | 'roll' +export type DynamicColorMode = 'condition' | 'gradient' +export type DynamicScaleEffect = 'fixed' | 'pulse' | 'alarmPulse' +export type DynamicFlowMedium = 'steam' | 'liquid' +export type DynamicConveyorEndpointBehavior = 'loop' | 'disappear' | 'continue' | 'accumulate' +export type DynamicJointMotionKind = 'rotation' | 'translation' + +export type DynamicJointChannel = { + id: string + label: string + targetNodeId: string + axis: DynamicAxis + motion: DynamicJointMotionKind + pivot?: [number, number, number] + inputRange?: [number, number] + outputRange?: [number, number] + unit?: string + source?: string +} + +export type DynamicJointBinding = { + id: string + channelId: string + path: string + inputRange?: [number, number] + outputRange?: [number, number] + enabled?: boolean +} + +export type DynamicBinding = { + id: string + type: DynamicType + path: string + axis?: DynamicAxis + motionMode?: DynamicMotionMode + moveStyle?: DynamicMoveStyle + color?: string + arrowColor?: string + endColor?: string + colorMode?: DynamicColorMode + scaleEffect?: DynamicScaleEffect + flowMedium?: DynamicFlowMedium + condition?: 'truthy' | 'equals' | 'greaterThan' | 'lessThan' + value?: string | number | boolean + inputRange?: [number, number] + outputRange?: [number, number] + speedRange?: [number, number] + distance?: number + spacing?: number + cadenceSeconds?: number + maxItems?: number + endpointBehavior?: DynamicConveyorEndpointBehavior + itemTemplateNodeId?: string + direction?: DynamicAxis | 'forward' | 'backward' + loop?: boolean +} + +export type DynamicCapabilityMetadata = { + semanticType?: string + supportedTypes?: DynamicType[] + recommendedTypes?: DynamicType[] + source?: string +} + +export type DynamicMetadata = { + semanticType?: string + dynamicBindings?: DynamicBinding[] + dynamicCapabilities?: DynamicCapabilityMetadata + jointChannels?: DynamicJointChannel[] + jointBindings?: DynamicJointBinding[] +} + +export const DYNAMIC_TYPE_LABELS: Record = { + visible: '可见', + move: '移动', + blink: '闪烁', + fill: '装载量', + scale: '缩放', + color: '颜色', + rotate: '转动', + flow: '流量', + conveyorFlow: '输送流动', + level: '液位', + speed: '速度', + openClose: '开关', + running: '运行', + brightness: '亮度', + valueDisplay: '数值反馈', +} + + +export const SEMANTIC_TYPE_LABELS: Record = { + generic: '普通物体', + pipe: '管道', + conveyor: '输送带', + tank: '储罐', + container: '容器/箱体', + cabinet: '柜体', + silo: '料仓', + battery: '电池', + fan: '风机', + motor: '电机', + roller: '滚筒', + valve: '阀门', + pump: '泵', + light: '灯', + display: '仪表/数显', +} diff --git a/packages/core/src/equipment/equipment-contracts.test.ts b/packages/core/src/equipment/equipment-contracts.test.ts new file mode 100644 index 000000000..a690d54fb --- /dev/null +++ b/packages/core/src/equipment/equipment-contracts.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'bun:test' +import { + createEquipmentSpecFromBinding, + equipmentPortKey, + type EquipmentContract, + normalizeEquipmentContract, + normalizeEquipmentEnvelope, + normalizeEquipmentNodeBinding, + normalizeEquipmentPort, + normalizeIndustryPluginPackManifest, +} from './equipment-contracts' + +describe('equipment contracts', () => { + const pumpContract = { + profileId: 'chemical.centrifugal_pump', + equipmentFamily: 'pump', + scaleClass: 'skid', + envelope: { length: 2.4, width: 1.1, height: 1.35, origin: 'profile', tolerance: 0.05 }, + ports: [ + { id: 'inlet', medium: 'water', side: 'left', height: 0.55, diameter: 0.15 }, + { id: 'outlet', medium: 'water', side: 'right', height: 0.72, diameter: 0.1 }, + ], + requiredRoles: ['transfer'], + primarySemanticRole: 'pump', + } satisfies EquipmentContract + + test('normalizes equipment envelope and ports', () => { + const inletPort = pumpContract.ports[0]! + + expect(normalizeEquipmentEnvelope(pumpContract.envelope)).toEqual(pumpContract.envelope) + expect(normalizeEquipmentPort(inletPort)).toEqual(inletPort) + expect(normalizeEquipmentContract(pumpContract)).toEqual(pumpContract) + expect(equipmentPortKey('chemical.centrifugal_pump', 'inlet')).toBe( + 'chemical.centrifugal_pump:inlet', + ) + }) + + test('rejects invalid dimensions, ports, and contracts', () => { + expect(normalizeEquipmentEnvelope({ length: 0, width: 1, height: 1, origin: 'profile' })).toBe( + null, + ) + expect(normalizeEquipmentPort({ id: 'inlet', medium: 'water', side: 'left' })).toBe(null) + expect(normalizeEquipmentContract({ ...pumpContract, ports: [{ id: 'broken' }] })).toBe(null) + }) + + test('normalizes bindings and resolves equipment specs from contract paths', () => { + const binding = normalizeEquipmentNodeBinding({ + profileId: 'chemical.centrifugal_pump', + nodeKind: 'factory:pump', + requiredPluginId: '@pascal/plugin-factory-equipment', + paramMap: { + pumpType: { source: 'literal', value: 'centrifugal' }, + length: 'envelope.length', + flowMedium: 'ports.inlet.medium', + inletDiameter: 'ports.inlet.diameter', + outletDiameter: 'ports.outlet.diameter', + motorPower: { source: 'contract', path: 'metadata.motorPower', fallback: 15 }, + }, + portMap: { inlet: 'inlet', outlet: 'outlet' }, + }) + const contract = normalizeEquipmentContract({ ...pumpContract, nodeBinding: binding }) + + expect(binding).toMatchObject({ + profileId: 'chemical.centrifugal_pump', + nodeKind: 'factory:pump', + portMap: { inlet: 'inlet', outlet: 'outlet' }, + }) + expect(contract?.nodeBinding?.nodeKind).toBe('factory:pump') + expect( + createEquipmentSpecFromBinding({ + contract: contract!, + position: [1, 0, 2], + rotation: [0, Math.PI / 2, 0], + }), + ).toEqual({ + nodeKind: 'factory:pump', + profileId: 'chemical.centrifugal_pump', + params: { + pumpType: 'centrifugal', + length: 2.4, + flowMedium: 'water', + inletDiameter: 0.15, + outletDiameter: 0.1, + motorPower: 15, + }, + position: [1, 0, 2], + rotation: [0, Math.PI / 2, 0], + }) + }) + + test('normalizes industry plugin manifests with equipment bindings', () => { + expect( + normalizeIndustryPluginPackManifest({ + id: 'chemical-process-pack', + name: 'Chemical Process Pack', + industry: 'chemical', + version: '0.1.0', + schemaVersion: 1, + pluginApiVersion: 1, + dependsOnPlugins: ['@pascal/plugin-factory-equipment'], + equipmentBindings: [ + { + profileId: 'chemical.centrifugal_pump', + nodeKind: 'factory:pump', + paramMap: { length: 'envelope.length' }, + }, + ], + }), + ).toMatchObject({ + id: 'chemical-process-pack', + dependsOnPlugins: ['@pascal/plugin-factory-equipment'], + equipmentBindings: [{ profileId: 'chemical.centrifugal_pump', nodeKind: 'factory:pump' }], + }) + }) +}) diff --git a/packages/core/src/equipment/equipment-contracts.ts b/packages/core/src/equipment/equipment-contracts.ts new file mode 100644 index 000000000..d82789bfb --- /dev/null +++ b/packages/core/src/equipment/equipment-contracts.ts @@ -0,0 +1,414 @@ +export type EquipmentPortSide = 'left' | 'right' | 'front' | 'back' | 'top' | 'bottom' + +export type EquipmentConnectionMedium = + | 'water' + | 'hydrogen' + | 'oxygen' + | 'power' + | 'cooling' + | 'material' + | 'gas' + | 'molten_metal' + | 'utility' + | (string & {}) + +export type EquipmentEnvelopeOrigin = + | 'profile' + | 'station_profile' + | 'user' + | 'vendor' + | 'vendor_profile' + | 'generated' + +export type EquipmentEnvelope = { + length: number + width: number + height: number + origin: EquipmentEnvelopeOrigin + tolerance?: number +} + +export type EquipmentPort = { + id: string + medium: EquipmentConnectionMedium + side: EquipmentPortSide + height: number + offset?: number + diameter?: number + direction?: readonly [number, number, number] +} + +export type EquipmentSourcePackRef = { + id: string + version?: string + industry?: string +} + +export type EquipmentContract = { + profileId: string + equipmentFamily: string + scaleClass: string + envelope: EquipmentEnvelope + ports: EquipmentPort[] + requiredRoles?: string[] + primarySemanticRole?: string + sourcePack?: EquipmentSourcePackRef + nodeBinding?: EquipmentNodeBinding +} + +export type EquipmentParamValue = + | string + | number + | boolean + | null + | EquipmentParamValue[] + | { readonly [key: string]: EquipmentParamValue } + +export type EquipmentParamMapping = + | { source: 'contract'; path: string; fallback?: EquipmentParamValue } + | { source: 'literal'; value: EquipmentParamValue } + +export type EquipmentNodeBinding = { + profileId: string + nodeKind: string + paramMap: Record + portMap?: Record + fallbackNodeKind?: string + requiredPluginId?: string +} + +export type EquipmentSpec = { + nodeKind: string + profileId: string + params: Record + position?: readonly [number, number, number] + rotation?: readonly [number, number, number] + metadata?: Record +} + +export type EquipmentNodeDescriptor = { + family: string + label?: string + acceptsProfiles?: readonly string[] + defaultPorts?: readonly EquipmentPort[] +} + +export type IndustryPluginPackManifest = { + id: string + name: string + industry: string + version: string + schemaVersion: 1 + pluginApiVersion?: 1 + dependsOnPlugins?: string[] + equipmentBindings?: EquipmentNodeBinding[] +} + +type UnknownRecord = Record + +const EQUIPMENT_ENVELOPE_ORIGINS: readonly EquipmentEnvelopeOrigin[] = [ + 'profile', + 'station_profile', + 'user', + 'vendor', + 'vendor_profile', + 'generated', +] + +const EQUIPMENT_PORT_SIDES: readonly EquipmentPortSide[] = [ + 'left', + 'right', + 'front', + 'back', + 'top', + 'bottom', +] + +export function isRecord(input: unknown): input is UnknownRecord { + return typeof input === 'object' && input !== null && !Array.isArray(input) +} + +function nonEmptyString(input: unknown): string | null { + return typeof input === 'string' && input.trim().length > 0 ? input.trim() : null +} + +function finiteNumber(input: unknown): number | null { + return typeof input === 'number' && Number.isFinite(input) ? input : null +} + +function isEquipmentEnvelopeOrigin(input: string): input is EquipmentEnvelopeOrigin { + return EQUIPMENT_ENVELOPE_ORIGINS.includes(input as EquipmentEnvelopeOrigin) +} + +function isEquipmentPortSide(input: string): input is EquipmentPortSide { + return EQUIPMENT_PORT_SIDES.includes(input as EquipmentPortSide) +} + +function positiveNumber(input: unknown): number | null { + const value = finiteNumber(input) + return value !== null && value > 0 ? value : null +} + +function normalizeStringArray(input: unknown): string[] | undefined { + if (!Array.isArray(input)) return undefined + const values = input.map(nonEmptyString) + return values.every((value): value is string => value !== null) ? values : undefined +} + +function normalizeStringRecord(input: unknown): Record | undefined { + if (!isRecord(input)) return undefined + const entries = Object.entries(input).map(([key, value]) => [key, nonEmptyString(value)] as const) + if (entries.some(([, value]) => value === null)) return undefined + return Object.fromEntries(entries) as Record +} + +function isEquipmentParamValue(input: unknown): input is EquipmentParamValue { + if (input === null) return true + if (typeof input === 'string' || typeof input === 'boolean') return true + if (typeof input === 'number') return Number.isFinite(input) + if (Array.isArray(input)) return input.every(isEquipmentParamValue) + if (!isRecord(input)) return false + return Object.values(input).every(isEquipmentParamValue) +} + +function normalizeParamMapping(input: unknown): EquipmentParamMapping | null { + if (typeof input === 'string') { + const path = nonEmptyString(input) + return path ? { source: 'contract', path } : null + } + if (!isRecord(input)) return null + if (input.source === 'literal') { + return isEquipmentParamValue(input.value) ? { source: 'literal', value: input.value } : null + } + if (input.source === 'contract') { + const path = nonEmptyString(input.path) + if (!path) return null + const mapping: EquipmentParamMapping = { source: 'contract', path } + if ('fallback' in input) { + if (!isEquipmentParamValue(input.fallback)) return null + mapping.fallback = input.fallback + } + return mapping + } + return null +} + +function normalizeParamMap(input: unknown): Record | null { + if (!isRecord(input)) return null + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + const paramKey = nonEmptyString(key) + const mapping = normalizeParamMapping(value) + if (!paramKey || !mapping) return null + result[paramKey] = mapping + } + return result +} + +function normalizeVec3(input: unknown): readonly [number, number, number] | undefined { + if (!Array.isArray(input) || input.length !== 3) return undefined + const x = finiteNumber(input[0]) + const y = finiteNumber(input[1]) + const z = finiteNumber(input[2]) + return x !== null && y !== null && z !== null ? [x, y, z] : undefined +} + +export function normalizeEquipmentEnvelope(input: unknown): EquipmentEnvelope | null { + if (!isRecord(input)) return null + const length = positiveNumber(input.length) + const width = positiveNumber(input.width) + const height = positiveNumber(input.height) + const origin = nonEmptyString(input.origin) + if ( + length === null || + width === null || + height === null || + origin === null || + !isEquipmentEnvelopeOrigin(origin) + ) { + return null + } + + const envelope: EquipmentEnvelope = { length, width, height, origin } + if ('tolerance' in input) { + const tolerance = finiteNumber(input.tolerance) + if (tolerance === null || tolerance < 0) return null + envelope.tolerance = tolerance + } + return envelope +} + +export function normalizeEquipmentPort(input: unknown): EquipmentPort | null { + if (!isRecord(input)) return null + const id = nonEmptyString(input.id) + const medium = nonEmptyString(input.medium) + const side = nonEmptyString(input.side) + const height = finiteNumber(input.height) + if (!id || !medium || !side || !isEquipmentPortSide(side) || height === null) return null + + const port: EquipmentPort = { id, medium, side, height } + if ('offset' in input) { + const offset = finiteNumber(input.offset) + if (offset === null) return null + port.offset = offset + } + if ('diameter' in input) { + const diameter = positiveNumber(input.diameter) + if (diameter === null) return null + port.diameter = diameter + } + if ('direction' in input) { + const direction = normalizeVec3(input.direction) + if (!direction) return null + port.direction = direction + } + return port +} + +export function normalizeEquipmentNodeBinding(input: unknown): EquipmentNodeBinding | null { + if (!isRecord(input)) return null + const profileId = nonEmptyString(input.profileId) + const nodeKind = nonEmptyString(input.nodeKind) + const paramMap = normalizeParamMap(input.paramMap) + if (!profileId || !nodeKind || !paramMap) return null + + const binding: EquipmentNodeBinding = { profileId, nodeKind, paramMap } + const portMap = normalizeStringRecord(input.portMap) + if ('portMap' in input && !portMap) return null + if (portMap) binding.portMap = portMap + + const fallbackNodeKind = nonEmptyString(input.fallbackNodeKind) + if (fallbackNodeKind) binding.fallbackNodeKind = fallbackNodeKind + + const requiredPluginId = nonEmptyString(input.requiredPluginId) + if (requiredPluginId) binding.requiredPluginId = requiredPluginId + + return binding +} + +export function normalizeEquipmentContract(input: unknown): EquipmentContract | null { + if (!isRecord(input)) return null + const profileId = nonEmptyString(input.profileId) + const equipmentFamily = nonEmptyString(input.equipmentFamily) + const scaleClass = nonEmptyString(input.scaleClass) + const envelope = normalizeEquipmentEnvelope(input.envelope) + if (!profileId || !equipmentFamily || !scaleClass || !envelope || !Array.isArray(input.ports)) { + return null + } + + const ports = input.ports.map(normalizeEquipmentPort) + if (!ports.every((port): port is EquipmentPort => port !== null)) return null + + const contract: EquipmentContract = { profileId, equipmentFamily, scaleClass, envelope, ports } + const requiredRoles = normalizeStringArray(input.requiredRoles) + if ('requiredRoles' in input && !requiredRoles) return null + if (requiredRoles) contract.requiredRoles = requiredRoles + + const primarySemanticRole = nonEmptyString(input.primarySemanticRole) + if (primarySemanticRole) contract.primarySemanticRole = primarySemanticRole + + if ('sourcePack' in input) { + if (!isRecord(input.sourcePack)) return null + const id = nonEmptyString(input.sourcePack.id) + if (!id) return null + contract.sourcePack = { id } + const version = nonEmptyString(input.sourcePack.version) + const industry = nonEmptyString(input.sourcePack.industry) + if (version) contract.sourcePack.version = version + if (industry) contract.sourcePack.industry = industry + } + + if ('nodeBinding' in input) { + const nodeBinding = normalizeEquipmentNodeBinding(input.nodeBinding) + if (!nodeBinding) return null + contract.nodeBinding = nodeBinding + } + + return contract +} + +export function normalizeIndustryPluginPackManifest( + input: unknown, +): IndustryPluginPackManifest | null { + if (!isRecord(input)) return null + const id = nonEmptyString(input.id) + const name = nonEmptyString(input.name) + const industry = nonEmptyString(input.industry) + const version = nonEmptyString(input.version) + if (!id || !name || !industry || !version || input.schemaVersion !== 1) return null + + const manifest: IndustryPluginPackManifest = { id, name, industry, version, schemaVersion: 1 } + if ('pluginApiVersion' in input) { + if (input.pluginApiVersion !== 1) return null + manifest.pluginApiVersion = 1 + } + + const dependsOnPlugins = normalizeStringArray(input.dependsOnPlugins) + if ('dependsOnPlugins' in input && !dependsOnPlugins) return null + if (dependsOnPlugins) manifest.dependsOnPlugins = dependsOnPlugins + + if ('equipmentBindings' in input) { + if (!Array.isArray(input.equipmentBindings)) return null + const equipmentBindings = input.equipmentBindings.map(normalizeEquipmentNodeBinding) + if (!equipmentBindings.every((binding): binding is EquipmentNodeBinding => binding !== null)) { + return null + } + manifest.equipmentBindings = equipmentBindings + } + + return manifest +} + +export function equipmentPortKey(profileId: string, portId: string): string { + return `${profileId}:${portId}` +} + +function readPath(input: unknown, path: string): unknown { + return path.split('.').reduce((current, segment) => { + if (current === undefined || current === null) return undefined + if (Array.isArray(current)) { + const index = Number(segment) + if (Number.isInteger(index)) return current[index] + return current.find((item) => isRecord(item) && item.id === segment) + } + return isRecord(current) ? current[segment] : undefined + }, input) +} + +export function createEquipmentSpecFromBinding(input: { + contract: EquipmentContract + binding?: EquipmentNodeBinding + defaults?: Record + position?: readonly [number, number, number] + rotation?: readonly [number, number, number] + metadata?: Record +}): EquipmentSpec | null { + const binding = input.binding ?? input.contract.nodeBinding + if (!binding || binding.profileId !== input.contract.profileId) return null + + const params: Record = { ...(input.defaults ?? {}) } + for (const [key, mapping] of Object.entries(binding.paramMap)) { + if (mapping.source === 'literal') { + params[key] = mapping.value + continue + } + const value = readPath(input.contract, mapping.path) + if (isEquipmentParamValue(value)) { + params[key] = value + } else if ('fallback' in mapping) { + params[key] = mapping.fallback ?? null + } else { + return null + } + } + + const spec: EquipmentSpec = { + nodeKind: binding.nodeKind, + profileId: input.contract.profileId, + params, + } + if (input.position) spec.position = input.position + if (input.rotation) spec.rotation = input.rotation + if (input.metadata) spec.metadata = input.metadata + return spec +} diff --git a/packages/core/src/equipment/index.ts b/packages/core/src/equipment/index.ts new file mode 100644 index 000000000..733416e23 --- /dev/null +++ b/packages/core/src/equipment/index.ts @@ -0,0 +1 @@ +export * from './equipment-contracts' diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index f67291e6e..a2af94e7b 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,30 +1,58 @@ -import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' -import type { Object3D } from 'three' import type { + AssemblyNode, + BoxNode, BuildingNode, + CableTrayNode, + CapsuleNode, CeilingNode, ColumnNode, + ConeNode, + ConformalStripNode, + ConveyorBeltNode, + CylinderNode, + DataChartNode, + DataTableNode, + DataWidgetNode, DoorNode, ElevatorNode, + ExtrudeNode, FenceNode, + FrustumNode, GuideNode, + HalfCylinderNode, + HemisphereNode, ItemNode, + LadderNode, + LatheNode, LevelNode, + PipeFittingNode, + PipeNode, + RoadNode, RoofNode, RoofSegmentNode, + RoundedPanelNode, ScanNode, ShelfNode, SiteNode, SlabNode, SpawnNode, + SphereNode, StairNode, StairSegmentNode, + SteelBeamNode, + SteelFrameNode, + SweepNode, + TankNode, + TorusNode, + TrapezoidPrismNode, WallNode, + WedgeNode, WindowNode, ZoneNode, } from '../schema' import type { AnyNode } from '../schema/types' +import type { SceneObjectRef, ScenePointerEvent } from '../types/scene-object' // Base event interfaces export interface GridEvent { @@ -44,8 +72,8 @@ export interface GridEvent { * `use-grid-events.ts`, where there is no specific mesh to attribute * the intersection to. */ - object?: Object3D - nativeEvent: ThreeEvent + object?: SceneObjectRef + nativeEvent: ScenePointerEvent } export interface NodeEvent { @@ -54,13 +82,39 @@ export interface NodeEvent { localPosition: [number, number, number] normal?: [number, number, number] faceIndex?: number - object: Object3D + object: SceneObjectRef stopPropagation: () => void - nativeEvent: ThreeEvent + nativeEvent: ScenePointerEvent } +export type BoxEvent = NodeEvent +export type AssemblyEvent = NodeEvent +export type CableTrayEvent = NodeEvent +export type CylinderEvent = NodeEvent +export type DataChartEvent = NodeEvent +export type DataTableEvent = NodeEvent +export type DataWidgetEvent = NodeEvent +export type ConeEvent = NodeEvent +export type ConformalStripEvent = NodeEvent +export type ConveyorBeltEvent = NodeEvent +export type FrustumEvent = NodeEvent +export type SphereEvent = NodeEvent +export type HemisphereEvent = NodeEvent +export type TorusEvent = NodeEvent +export type WedgeEvent = NodeEvent +export type TrapezoidPrismEvent = NodeEvent +export type LatheEvent = NodeEvent +export type CapsuleEvent = NodeEvent +export type HalfCylinderEvent = NodeEvent +export type RoundedPanelEvent = NodeEvent +export type ExtrudeEvent = NodeEvent +export type SweepEvent = NodeEvent +export type TankEvent = NodeEvent export type WallEvent = NodeEvent export type FenceEvent = NodeEvent +export type PipeFittingEvent = NodeEvent +export type PipeEvent = NodeEvent +export type RoadEvent = NodeEvent export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent @@ -78,8 +132,11 @@ export type StairSegmentEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent export type ElevatorEvent = NodeEvent +export type LadderEvent = NodeEvent export type ScanEvent = NodeEvent export type GuideEvent = NodeEvent +export type SteelBeamEvent = NodeEvent +export type SteelFrameEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -133,6 +190,7 @@ export interface CameraControlFitSceneEvent { center: [number, number] size: [number, number] } + reason?: string } type CameraControlEvents = { @@ -192,13 +250,40 @@ type AIChatEvents = { } type EditorEvents = GridEvents & + NodeEvents<'assembly', AssemblyEvent> & + NodeEvents<'box', BoxEvent> & + NodeEvents<'cable-tray', CableTrayEvent> & + NodeEvents<'cylinder', CylinderEvent> & + NodeEvents<'data-chart', DataChartEvent> & + NodeEvents<'data-table', DataTableEvent> & + NodeEvents<'data-widget', DataWidgetEvent> & + NodeEvents<'cone', ConeEvent> & + NodeEvents<'conformal-strip', ConformalStripEvent> & + NodeEvents<'conveyor-belt', ConveyorBeltEvent> & + NodeEvents<'frustum', FrustumEvent> & + NodeEvents<'hemisphere', HemisphereEvent> & + NodeEvents<'torus', TorusEvent> & + NodeEvents<'wedge', WedgeEvent> & + NodeEvents<'trapezoid-prism', TrapezoidPrismEvent> & + NodeEvents<'sphere', SphereEvent> & + NodeEvents<'lathe', LatheEvent> & + NodeEvents<'capsule', CapsuleEvent> & + NodeEvents<'half-cylinder', HalfCylinderEvent> & + NodeEvents<'rounded-panel', RoundedPanelEvent> & + NodeEvents<'extrude', ExtrudeEvent> & + NodeEvents<'sweep', SweepEvent> & + NodeEvents<'tank', TankEvent> & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & + NodeEvents<'pipe-fitting', PipeFittingEvent> & + NodeEvents<'pipe', PipeEvent> & + NodeEvents<'road', RoadEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & NodeEvents<'elevator', ElevatorEvent> & NodeEvents<'level', LevelEvent> & + NodeEvents<'ladder', LadderEvent> & NodeEvents<'zone', ZoneEvent> & NodeEvents<'slab', SlabEvent> & NodeEvents<'shelf', ShelfEvent> & @@ -209,6 +294,8 @@ type EditorEvents = GridEvents & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & NodeEvents<'stair-segment', StairSegmentEvent> & + NodeEvents<'steel-beam', SteelBeamEvent> & + NodeEvents<'steel-frame', SteelFrameEvent> & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & NodeEvents<'scan', ScanEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 39a84ec32..a720ee590 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -1,7 +1,7 @@ 'use client' import { useLayoutEffect } from 'react' -import type * as THREE from 'three' +import type { SceneObjectRef } from '../../types/scene-object' // `byType` is a Proxy-backed Map keyed by kind. Sets are created lazily on // first access, so any kind (built-in or plugin-contributed) participates @@ -41,8 +41,8 @@ const byTypeProxy = new Proxy({} as ByTypeMap, { }) export const sceneRegistry = { - // Master lookup: ID -> Object3D - nodes: new Map(), + // Master lookup: ID -> live scene object. + nodes: new Map(), // Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind // gets a Set on first touch — no hardcoded list. @@ -57,7 +57,7 @@ export const sceneRegistry = { }, } -export function useRegistry(id: string, type: string, ref: React.RefObject) { +export function useRegistry(id: string, type: string, ref: React.RefObject) { useLayoutEffect(() => { const obj = ref.current if (!obj) return diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts new file mode 100644 index 000000000..588c9e7bf --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -0,0 +1,93 @@ +import { nodeRegistry } from '../../registry' +import type { + FloorPlacedConfig, + FloorPlacedFootprint, + FloorPlacedFootprintContext, + FloorPlacedFootprintsResolver, +} from '../../registry/types' +import type { AnyNode, AnyNodeId } from '../../schema' +import { spatialGridManager } from './spatial-grid-manager' + +export type FloorPlacedElevationArgs = { + node: AnyNode + nodes: Record + position: [number, number, number] + rotation?: unknown + levelId?: string | null +} + +function finiteSlabElevation(elevation: number): number { + return Number.isFinite(elevation) ? elevation : 0 +} + +function withPositionAndRotation({ + node, + position, + rotation, +}: Pick): AnyNode { + return { + ...(node as Record), + position, + ...(rotation !== undefined ? { rotation } : {}), + } as AnyNode +} + +export function getFloorPlacedFootprints( + floorPlaced: FloorPlacedConfig, + node: AnyNode, + ctx?: FloorPlacedFootprintContext, +): FloorPlacedFootprint[] { + const rawFootprints = floorPlaced.footprints?.(node, ctx) + if (rawFootprints) return [...rawFootprints] + + const footprint = floorPlaced.footprint?.(node, ctx) + return footprint ? [footprint] : [] +} + +export function getFloorPlacedElevation({ + node, + nodes, + position, + rotation, + levelId, +}: FloorPlacedElevationArgs): number { + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced) return 0 + + const effectiveNode = withPositionAndRotation({ node, position, rotation }) + if (floorPlaced.applies && !floorPlaced.applies(effectiveNode)) return 0 + + const parentId = (effectiveNode as { parentId?: AnyNodeId | null }).parentId ?? null + const parent = parentId ? nodes[parentId] : null + if (parentId && !parent) return 0 + if (parent && parent.type !== 'level') return 0 + if (!parent && !levelId) return 0 + + const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId + if (!resolvedLevelId) return 0 + + let maxElevation = Number.NEGATIVE_INFINITY + for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) { + const footprintPosition = footprint.position ?? position + const elevation = finiteSlabElevation( + spatialGridManager.getSlabElevationForItem( + resolvedLevelId, + footprintPosition, + footprint.dimensions, + footprint.rotation, + ), + ) + if (elevation > maxElevation) { + maxElevation = elevation + } + } + + return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation +} + +export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] { + const [x, y, z] = args.position + return [x, y + getFloorPlacedElevation(args), z] +} + +export type { FloorPlacedFootprint, FloorPlacedFootprintContext, FloorPlacedFootprintsResolver } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.test.ts new file mode 100644 index 000000000..95f5f82d3 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from 'bun:test' +import type { CeilingNode, ItemNode, WallNode } from '../../schema' +import { SpatialGridManager } from './spatial-grid-manager' + +function item( + id: string, + overrides: Omit, 'asset'> & { + asset?: Partial + } = {}, +): ItemNode { + const { asset: assetOverrides, ...nodeOverrides } = overrides + return { + id, + object: 'node', + type: 'item', + parentId: 'level-1', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + children: [], + asset: { + id: `asset-${id}`, + category: 'test', + name: id, + thumbnail: '', + source: 'library', + src: '', + dimensions: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + ...assetOverrides, + }, + ...nodeOverrides, + } as unknown as ItemNode +} + +function wall(id: string, start: [number, number], end: [number, number]): WallNode { + return { + id, + object: 'node', + type: 'wall', + parentId: 'level-1', + visible: true, + metadata: {}, + children: [], + start, + end, + height: 3, + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as WallNode +} + +function ceiling(id: string): CeilingNode { + return { + id, + object: 'node', + type: 'ceiling', + parentId: 'level-1', + visible: true, + metadata: {}, + children: [], + polygon: [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ], + holes: [], + holeMetadata: [], + height: 3, + autoFromWalls: false, + } as unknown as CeilingNode +} + +describe('SpatialGridManager placement indices', () => { + test('floor validation uses grid candidates and ignores low-profile surfaces', () => { + const manager = new SpatialGridManager() + manager.handleNodeCreated( + item('rug', { + asset: { + dimensions: [2, 0.05, 2], + surface: { height: 0.05 }, + }, + }), + 'level-1', + ) + + expect(manager.canPlaceOnFloor('level-1', [0, 0, 0], [1, 1, 1], [0, 0, 0])).toMatchObject({ + valid: true, + conflictIds: [], + }) + + manager.handleNodeCreated(item('table'), 'level-1') + + expect(manager.canPlaceOnFloor('level-1', [0, 0, 0], [1, 1, 1], [0, 0, 0])).toMatchObject({ + valid: false, + conflictIds: ['table'], + }) + }) + + test('item updates remove stale floor placement before reindexing to ceiling', () => { + const manager = new SpatialGridManager() + const ceilingNode = ceiling('ceiling-1') + const floorItem = item('light') + const ceilingItem = item('light', { + parentId: 'ceiling-1', + asset: { attachTo: 'ceiling' }, + }) + + manager.handleNodeCreated(ceilingNode, 'level-1') + manager.handleNodeCreated(floorItem, 'level-1') + expect(manager.canPlaceOnFloor('level-1', [0, 0, 0], [1, 1, 1], [0, 0, 0]).valid).toBe(false) + + manager.handleNodeUpdated(ceilingItem, 'level-1') + + expect(manager.canPlaceOnFloor('level-1', [0, 0, 0], [1, 1, 1], [0, 0, 0])).toMatchObject({ + valid: true, + conflictIds: [], + }) + expect(manager.canPlaceOnCeiling('ceiling-1', [0, 0, 0], [1, 1, 1], [0, 0, 0])).toMatchObject({ + valid: false, + conflictIds: ['light'], + }) + }) + + test('wall supplemental validation is scoped to items indexed on the same wall', () => { + const manager = new SpatialGridManager() + manager.handleNodeCreated(wall('wall-1', [0, 0], [4, 0]), 'level-1') + manager.handleNodeCreated(wall('wall-2', [0, 2], [4, 2]), 'level-1') + manager.handleNodeCreated( + item('same-wall', { + parentId: 'wall-1', + position: [2, 0, 0], + asset: { attachTo: 'wall' }, + }), + 'level-1', + ) + manager.handleNodeCreated( + item('other-wall', { + parentId: 'wall-2', + position: [2, 0, 0], + asset: { attachTo: 'wall' }, + }), + 'level-1', + ) + + expect(manager.canPlaceOnWall('level-1', 'wall-1', 2, 0, [1, 1, 1], 'wall')).toMatchObject({ + valid: false, + conflictIds: ['same-wall'], + }) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index b9708753f..b0e446c80 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,6 +1,11 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' -import useScene from '../../store/use-scene' +import { + getWallCurveFrameAt, + isCurvedWall, + sampleWallCenterline, +} from '../../systems/wall/wall-curve' +import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -52,6 +57,11 @@ function getItemFootprint( ] } +type ItemPlacementIndex = + | { surface: 'floor'; levelId: string } + | { surface: 'wall'; levelId: string; wallId: string } + | { surface: 'ceiling'; ceilingId: string } + type ItemLocalBounds = { min: [number, number, number] max: [number, number, number] @@ -120,18 +130,6 @@ function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number return minA < maxB - epsilon && maxA > minB + epsilon } -function resolveNodeLevelId(node: AnyNode, nodes: Record): string { - if (node.type === 'level') return node.id - - let current: AnyNode | undefined = node - while (current) { - if (current.type === 'level') return current.id - current = current.parentId ? nodes[current.parentId] : undefined - } - - return 'default' -} - /** * Test if two line segments (a1->a2) and (b1->b2) intersect. */ @@ -261,27 +259,17 @@ function segmentsCollinearAndOverlap( bz2: number, ): boolean { const EPSILON = 1e-6 - - // Cross product to check collinearity const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1) const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1) + if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) return false - if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) { - return false // Not collinear - } - - // Check if a point is on segment b const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON && Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON - // BOTH endpoints of wall (a) must be on edge (b) for substantial overlap - const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1) - const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2) - - return a1OnB && a2OnB + return onSegment(bx1, bz1, bx2, bz2, ax1, az1) && onSegment(bx1, bz1, bx2, bz2, ax2, az2) } /** @@ -294,7 +282,7 @@ function segmentsCollinearAndOverlap( * Note: A wall with just one endpoint touching the edge but the rest outside * is NOT considered overlapping (adjacent only). */ -export function wallOverlapsPolygon( +function legacyWallOverlapsPolygon( start: [number, number], end: [number, number], polygon: Array<[number, number]>, @@ -353,6 +341,185 @@ export function wallOverlapsPolygon( return false } +function pointSegmentDistance( + px: number, + pz: number, + ax: number, + az: number, + bx: number, + bz: number, +): number { + const dx = bx - ax + const dz = bz - az + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) + return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) +} + +const ON_BOUNDARY_EPSILON = 1e-4 +const WALL_SLAB_MIN_OVERLAP = 0.05 + +function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean { + const n = polygon.length + for (let i = 0; i < n; i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[(i + 1) % n]! + if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true + } + return false +} + +function segmentInsideLength( + ax: number, + az: number, + bx: number, + bz: number, + polygon: Array<[number, number]>, +): number { + const dx = bx - ax + const dz = bz - az + const length = Math.hypot(dx, dz) + if (length < 1e-9) return 0 + + const ts = [0, 1] + const n = polygon.length + for (let i = 0; i < n; i++) { + const [px, pz] = polygon[i]! + const [qx, qz] = polygon[(i + 1) % n]! + const ex = qx - px + const ez = qz - pz + const denom = dx * ez - dz * ex + if (Math.abs(denom) < 1e-12) continue + const t = ((px - ax) * ez - (pz - az) * ex) / denom + const s = ((px - ax) * dz - (pz - az) * dx) / denom + if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t) + } + ts.sort((a, b) => a - b) + + let inside = 0 + for (let i = 1; i < ts.length; i++) { + const t0 = ts[i - 1]! + const t1 = ts[i]! + if (t1 - t0 < 1e-9) continue + const tm = (t0 + t1) / 2 + const mx = ax + dx * tm + const mz = az + dz * tm + if (pointOnPolygonBoundary(mx, mz, polygon) || pointInPolygon(mx, mz, polygon)) { + inside += (t1 - t0) * length + } + } + return inside +} + +function polylineInsideLength( + points: Array<{ x: number; y: number }>, + polygon: Array<[number, number]>, +): number { + let total = 0 + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]! + const b = points[i]! + total += segmentInsideLength(a.x, a.y, b.x, b.y, polygon) + } + return total +} + +type WallOverlapInput = { + start: [number, number] + end: [number, number] + curveOffset?: number + thickness?: number +} + +function wallTestPolylines( + start: [number, number], + end: [number, number], + curveOffset: number, + halfThickness: number, +): Array> { + const wallLike = { start, end, curveOffset } + if (curveOffset !== 0 && isCurvedWall(wallLike)) { + const count = 16 + const center: Array<{ x: number; y: number }> = [] + const left: Array<{ x: number; y: number }> = [] + const right: Array<{ x: number; y: number }> = [] + for (let i = 0; i <= count; i++) { + const frame = getWallCurveFrameAt(wallLike, i / count) + center.push(frame.point) + left.push({ + x: frame.point.x + frame.normal.x * halfThickness, + y: frame.point.y + frame.normal.y * halfThickness, + }) + right.push({ + x: frame.point.x - frame.normal.x * halfThickness, + y: frame.point.y - frame.normal.y * halfThickness, + }) + } + return halfThickness > 0 ? [center, left, right] : [center] + } + + const center = [ + { x: start[0], y: start[1] }, + { x: end[0], y: end[1] }, + ] + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.hypot(dx, dz) + if (len < 1e-10 || halfThickness <= 0) return [center] + const nx = (-dz / len) * halfThickness + const nz = (dx / len) * halfThickness + return [ + center, + [ + { x: start[0] + nx, y: start[1] + nz }, + { x: end[0] + nx, y: end[1] + nz }, + ], + [ + { x: start[0] - nx, y: start[1] - nz }, + { x: end[0] - nx, y: end[1] - nz }, + ], + ] +} + +export function wallOverlapsPolygon( + startOrWall: [number, number] | WallOverlapInput, + endOrPolygon: [number, number] | Array<[number, number]>, + polygonArg?: Array<[number, number]>, +): boolean { + let start: [number, number] + let end: [number, number] + let polygon: Array<[number, number]> + let curveOffset = 0 + let thickness = DEFAULT_WALL_THICKNESS + if (Array.isArray(startOrWall)) { + start = startOrWall as [number, number] + end = endOrPolygon as [number, number] + polygon = polygonArg as Array<[number, number]> + } else { + start = startOrWall.start + end = startOrWall.end + curveOffset = startOrWall.curveOffset ?? 0 + thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS + polygon = endOrPolygon as Array<[number, number]> + } + + const polylines = wallTestPolylines(start, end, curveOffset, Math.max(thickness / 2, 0)) + const center = polylines[0]! + let centerLength = 0 + for (let i = 1; i < center.length; i++) { + centerLength += Math.hypot(center[i]!.x - center[i - 1]!.x, center[i]!.y - center[i - 1]!.y) + } + if (centerLength < 1e-9) return false + + let overlap = 0 + for (const line of polylines) { + overlap = Math.max(overlap, polylineInsideLength(line, polygon)) + } + const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) + return overlap >= threshold +} + export class SpatialGridManager { private readonly floorGrids = new Map() // levelId -> grid private readonly wallGrids = new Map() // levelId -> wall grid @@ -361,6 +528,10 @@ export class SpatialGridManager { private readonly ceilingGrids = new Map() // ceilingId -> grid private readonly ceilings = new Map() // ceilingId -> ceiling data private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) + private readonly floorItemsByLevel = new Map>() + private readonly wallItemsByWall = new Map>() + private readonly ceilingItemsByCeiling = new Map>() + private readonly itemPlacementIndex = new Map() private readonly cellSize: number @@ -409,6 +580,108 @@ export class SpatialGridManager { return this.slabsByLevel.get(levelId)! } + private getFloorItemMap(levelId: string): Map { + if (!this.floorItemsByLevel.has(levelId)) { + this.floorItemsByLevel.set(levelId, new Map()) + } + return this.floorItemsByLevel.get(levelId)! + } + + private getWallItemMap(wallId: string): Map { + if (!this.wallItemsByWall.has(wallId)) { + this.wallItemsByWall.set(wallId, new Map()) + } + return this.wallItemsByWall.get(wallId)! + } + + private getCeilingItemMap(ceilingId: string): Map { + if (!this.ceilingItemsByCeiling.has(ceilingId)) { + this.ceilingItemsByCeiling.set(ceilingId, new Map()) + } + return this.ceilingItemsByCeiling.get(ceilingId)! + } + + private removeIndexedItem(itemId: string) { + const placement = this.itemPlacementIndex.get(itemId) + if (!placement) return + + if (placement.surface === 'floor') { + this.getFloorGrid(placement.levelId).remove(itemId) + const items = this.floorItemsByLevel.get(placement.levelId) + items?.delete(itemId) + if (items?.size === 0) this.floorItemsByLevel.delete(placement.levelId) + } else if (placement.surface === 'wall') { + this.getWallGrid(placement.levelId).removeByItemId(itemId) + const items = this.wallItemsByWall.get(placement.wallId) + items?.delete(itemId) + if (items?.size === 0) this.wallItemsByWall.delete(placement.wallId) + } else { + this.getCeilingGrid(placement.ceilingId).remove(itemId) + this.itemCeilingMap.delete(itemId) + const items = this.ceilingItemsByCeiling.get(placement.ceilingId) + items?.delete(itemId) + if (items?.size === 0) this.ceilingItemsByCeiling.delete(placement.ceilingId) + } + + this.itemPlacementIndex.delete(itemId) + } + + private indexItem(item: ItemNode, levelId: string) { + this.removeIndexedItem(item.id) + + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { + const wallId = item.parentId + if (!(wallId && this.walls.has(wallId))) return + + const wallLength = this.getWallLength(wallId) + if (wallLength <= 0) return + + const [width, height] = getScaledDimensions(item) + const halfW = width / wallLength / 2 + const t = item.position[0] / wallLength + this.getWallGrid(levelId).insert({ + itemId: item.id, + wallId, + tStart: t - halfW, + tEnd: t + halfW, + yStart: item.position[1], + yEnd: item.position[1] + height, + attachType: item.asset.attachTo as 'wall' | 'wall-side', + side: item.side, + }) + this.getWallItemMap(wallId).set(item.id, item) + this.itemPlacementIndex.set(item.id, { surface: 'wall', levelId, wallId }) + return + } + + if (item.asset.attachTo === 'ceiling') { + const ceilingId = item.parentId + if (!(ceilingId && this.ceilings.has(ceilingId))) return + + this.getCeilingGrid(ceilingId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + this.itemCeilingMap.set(item.id, ceilingId) + this.getCeilingItemMap(ceilingId).set(item.id, item) + this.itemPlacementIndex.set(item.id, { surface: 'ceiling', ceilingId }) + return + } + + if (!item.asset.attachTo) { + this.getFloorGrid(levelId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + this.getFloorItemMap(levelId).set(item.id, item) + this.itemPlacementIndex.set(item.id, { surface: 'floor', levelId }) + } + } + // Called when nodes change handleNodeCreated(node: AnyNode, levelId: string) { if (node.type === 'slab') { @@ -420,50 +693,7 @@ export class SpatialGridManager { this.walls.set(wall.id, wall) } else if (node.type === 'item') { const item = node as ItemNode - if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { - // Wall-attached item - use parentId as the wall ID - const wallId = item.parentId - if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId) - if (wallLength > 0) { - const [width, height] = getScaledDimensions(item) - const halfW = width / wallLength / 2 - // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength - // position[1] is the bottom of the item - this.getWallGrid(levelId).insert({ - itemId: item.id, - wallId, - tStart: t - halfW, - tEnd: t + halfW, - yStart: item.position[1], - yEnd: item.position[1] + height, - attachType: item.asset.attachTo as 'wall' | 'wall-side', - side: item.side, - }) - } - } - } else if (item.asset.attachTo === 'ceiling') { - // Ceiling item - use parentId as the ceiling ID - const ceilingId = item.parentId - if (ceilingId && this.ceilings.has(ceilingId)) { - this.getCeilingGrid(ceilingId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - this.itemCeilingMap.set(item.id, ceilingId) - } - } else if (!item.asset.attachTo) { - // Floor item - this.getFloorGrid(levelId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - } + this.indexItem(item, levelId) } } @@ -477,56 +707,7 @@ export class SpatialGridManager { this.walls.set(wall.id, wall) } else if (node.type === 'item') { const item = node as ItemNode - if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { - // Remove old placement and re-insert - this.getWallGrid(levelId).removeByItemId(item.id) - const wallId = item.parentId - if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId) - if (wallLength > 0) { - const [width, height] = getScaledDimensions(item) - const halfW = width / wallLength / 2 - // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength - // position[1] is the bottom of the item - this.getWallGrid(levelId).insert({ - itemId: item.id, - wallId, - tStart: t - halfW, - tEnd: t + halfW, - yStart: item.position[1], - yEnd: item.position[1] + height, - attachType: item.asset.attachTo as 'wall' | 'wall-side', - side: item.side, - }) - } - } - } else if (item.asset.attachTo === 'ceiling') { - // Remove from old ceiling grid - const oldCeilingId = this.itemCeilingMap.get(item.id) - if (oldCeilingId) { - this.getCeilingGrid(oldCeilingId).remove(item.id) - this.itemCeilingMap.delete(item.id) - } - // Insert into new ceiling grid - const ceilingId = item.parentId - if (ceilingId && this.ceilings.has(ceilingId)) { - this.getCeilingGrid(ceilingId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - this.itemCeilingMap.set(item.id, ceilingId) - } - } else if (!item.asset.attachTo) { - this.getFloorGrid(levelId).update( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - } + this.indexItem(item, levelId) } } @@ -536,20 +717,25 @@ export class SpatialGridManager { } else if (nodeType === 'ceiling') { this.ceilings.delete(nodeId) this.ceilingGrids.delete(nodeId) + const removedItems = this.ceilingItemsByCeiling.get(nodeId) + if (removedItems) { + for (const itemId of removedItems.keys()) { + this.itemCeilingMap.delete(itemId) + this.itemPlacementIndex.delete(itemId) + } + this.ceilingItemsByCeiling.delete(nodeId) + } } else if (nodeType === 'wall') { this.walls.delete(nodeId) // Remove all items attached to this wall from the spatial grid const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) + this.wallItemsByWall.delete(nodeId) + for (const itemId of removedItemIds) { + this.itemPlacementIndex.delete(itemId) + } return removedItemIds // Caller can use this to delete the items from scene } else if (nodeType === 'item') { - this.getFloorGrid(levelId).remove(nodeId) - this.getWallGrid(levelId).removeByItemId(nodeId) - // Also clean up ceiling grid - const oldCeilingId = this.itemCeilingMap.get(nodeId) - if (oldCeilingId) { - this.getCeilingGrid(oldCeilingId).remove(nodeId) - this.itemCeilingMap.delete(nodeId) - } + this.removeIndexedItem(nodeId) } return [] } @@ -562,37 +748,22 @@ export class SpatialGridManager { rotation: [number, number, number], ignoreIds?: string[], ) { - const nodes = useScene.getState().nodes const ignoreSet = new Set(ignoreIds ?? []) - const [width, , depth] = dimensions - const yRot = rotation[1] - const cos = Math.abs(Math.cos(yRot)) - const sin = Math.abs(Math.sin(yRot)) - const rotatedW = width * cos + depth * sin - const rotatedD = width * sin + depth * cos - const draftBounds = { - minX: position[0] - rotatedW / 2, - maxX: position[0] + rotatedW / 2, - minZ: position[2] - rotatedD / 2, - maxZ: position[2] + rotatedD / 2, - } + const candidateIds = this.getFloorGrid(levelId).queryOverlaps( + position, + dimensions, + rotation, + ignoreIds, + ) + const indexedItems = this.floorItemsByLevel.get(levelId) const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'item') continue - const item = node as ItemNode - if (item.asset.attachTo) continue + for (const itemId of candidateIds) { + if (ignoreSet.has(itemId)) continue + const item = indexedItems?.get(itemId) + if (!item) continue if (isLowProfileItemSurface(item)) continue - if (ignoreSet.has(item.id)) continue - if (resolveNodeLevelId(item, nodes) !== levelId) continue - - const bounds = getItemParentAabb(item) - if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) - ) { - conflicts.push(item.id) - } + conflicts.push(item.id) } return { valid: conflicts.length === 0, conflictIds: conflicts } @@ -642,7 +813,6 @@ export class SpatialGridManager { if (!baseResult.valid) return baseResult - const nodes = useScene.getState().nodes const ignoreSet = new Set(ignoreIds ?? []) const draftBounds = { minX: localX - itemWidth / 2, @@ -652,12 +822,9 @@ export class SpatialGridManager { } const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'item') continue - const item = node as ItemNode - if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue + const indexedItems = this.wallItemsByWall.get(wallId) + for (const item of indexedItems?.values() ?? []) { if (ignoreSet.has(item.id)) continue - if (item.parentId !== wallId) continue if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) { if (side !== item.side) continue @@ -763,14 +930,29 @@ export class SpatialGridManager { * Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments). * Returns the highest slab elevation found, or 0 if none. */ - getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number { + getSlabElevationForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + ): number { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) return 0 + const wallLike: WallOverlapInput = { start, end, curveOffset, thickness } + const isCurved = curveOffset !== 0 && isCurvedWall(wallLike) + const holeSamplePoints: Array<{ x: number; y: number }> = isCurved + ? sampleWallCenterline(wallLike, 8) + : [0, 0.25, 0.5, 0.75, 1].map((t) => ({ + x: start[0] + (end[0] - start[0]) * t, + y: start[1] + (end[1] - start[1]) * t, + })) + let maxElevation = Number.NEGATIVE_INFINITY for (const slab of slabMap.values()) { if (slab.polygon.length < 3) continue - if (!wallOverlapsPolygon(start, end, slab.polygon)) continue + if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue const holes = slab.holes || [] if (holes.length === 0) { @@ -783,15 +965,11 @@ export class SpatialGridManager { // Sample multiple points along the wall to check whether any portion lies on // solid slab (not inside any hole). Checking only the midpoint fails when the // midpoint falls in a staircase hole but the wall's endpoints are on solid slab. - const dx = end[0] - start[0] - const dz = end[1] - start[1] let hasValidPoint = false - for (const t of [0, 0.25, 0.5, 0.75, 1]) { - const px = start[0] + dx * t - const pz = start[1] + dz * t + for (const sample of holeSamplePoints) { let inHole = false for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(px, pz, hole)) { + if (hole.length >= 3 && pointInPolygon(sample.x, sample.y, hole)) { inHole = true break } @@ -843,36 +1021,21 @@ export class SpatialGridManager { } } - const nodes = useScene.getState().nodes const ignoreSet = new Set(ignoreIds ?? []) - const [width, , depth] = dimensions - const yRot = rotation[1] - const cos = Math.abs(Math.cos(yRot)) - const sin = Math.abs(Math.sin(yRot)) - const rotatedW = width * cos + depth * sin - const rotatedD = width * sin + depth * cos - const draftBounds = { - minX: position[0] - rotatedW / 2, - maxX: position[0] + rotatedW / 2, - minZ: position[2] - rotatedD / 2, - maxZ: position[2] + rotatedD / 2, - } + const candidateIds = this.getCeilingGrid(ceilingId).queryOverlaps( + position, + dimensions, + rotation, + ignoreIds, + ) + const indexedItems = this.ceilingItemsByCeiling.get(ceilingId) const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'item') continue - const item = node as ItemNode - if (item.asset.attachTo !== 'ceiling') continue - if (ignoreSet.has(item.id)) continue - if (item.parentId !== ceilingId) continue - - const bounds = getItemParentAabb(item) - if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) - ) { - conflicts.push(item.id) - } + for (const itemId of candidateIds) { + if (ignoreSet.has(itemId)) continue + const item = indexedItems?.get(itemId) + if (!item) continue + conflicts.push(item.id) } return { valid: conflicts.length === 0, conflictIds: conflicts } @@ -882,6 +1045,17 @@ export class SpatialGridManager { this.floorGrids.delete(levelId) this.wallGrids.delete(levelId) this.slabsByLevel.delete(levelId) + this.floorItemsByLevel.delete(levelId) + for (const [itemId, placement] of [...this.itemPlacementIndex]) { + if (placement.surface === 'floor' && placement.levelId === levelId) { + this.itemPlacementIndex.delete(itemId) + } else if (placement.surface === 'wall' && placement.levelId === levelId) { + this.itemPlacementIndex.delete(itemId) + const items = this.wallItemsByWall.get(placement.wallId) + items?.delete(itemId) + if (items?.size === 0) this.wallItemsByWall.delete(placement.wallId) + } + } } clear() { @@ -892,6 +1066,10 @@ export class SpatialGridManager { this.ceilingGrids.clear() this.ceilings.clear() this.itemCeilingMap.clear() + this.floorItemsByLevel.clear() + this.wallItemsByWall.clear() + this.ceilingItemsByCeiling.clear() + this.itemPlacementIndex.clear() } } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 3829c4b98..71c23e7f6 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,5 +1,7 @@ import { nodeRegistry } from '../../registry' +import { getFloorPlacedFootprints } from './floor-placed-elevation' import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' +import { getSceneHistoryPauseDepth } from '../../store/history-control' import useScene from '../../store/use-scene' import { itemOverlapsPolygon, @@ -28,6 +30,27 @@ export function resolveLevelId(node: AnyNode, nodes: Record): s return 'default' // fallback for orphaned items } +export function resolveBuildingForLevel( + levelId: AnyNodeId, + nodes: Record, +): AnyNodeId | null { + const level = nodes[levelId] as AnyNode | undefined + if (!level) return null + const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null + if (directParent) { + const candidate = nodes[directParent] + if (candidate?.type === 'building') return candidate.id as AnyNodeId + } + for (const candidate of Object.values(nodes)) { + if (candidate?.type !== 'building') continue + const children = (candidate as { children?: AnyNodeId[] }).children + if (Array.isArray(children) && children.includes(levelId)) { + return candidate.id as AnyNodeId + } + } + return null +} + // Call this once at app initialization. Returns an unsubscribe function that // detaches the scene-store listener (useful when the editor is unmounted so // the spatial grid singleton does not hold stale references to old scenes). @@ -43,74 +66,106 @@ export function initSpatialGridSync(): () => void { // 2. Then subscribe to future changes const markDirty = (id: AnyNodeId) => store.getState().markDirty(id) - // Subscribe to all changes + let pendingState: typeof state | null = null + let pendingPrevState: typeof state | null = null + let rafId = 0 + + const flushSpatialGridSync = () => { + rafId = 0 + if (!pendingState || !pendingPrevState) return + + const currentState = pendingState + const previousState = pendingPrevState + pendingState = null + pendingPrevState = null + + syncSpatialGridDiff(currentState, previousState, markDirty) + } + const unsubscribe = store.subscribe((state, prevState) => { - // Detect added nodes - for (const [id, node] of Object.entries(state.nodes)) { - if (!prevState.nodes[id as AnyNode['id']]) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeCreated(node, levelId) + if (getSceneHistoryPauseDepth() > 0) return - // When a slab is added, mark overlapping items/walls dirty - if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) - } + if (!pendingPrevState) pendingPrevState = prevState + pendingState = state + + if (rafId) return + rafId = requestAnimationFrame(flushSpatialGridSync) + }) + + return () => { + if (rafId) cancelAnimationFrame(rafId) + unsubscribe() + } +} + +function syncSpatialGridDiff( + state: ReturnType, + prevState: ReturnType, + markDirty: (id: AnyNodeId) => void, +) { + // Detect added nodes + for (const [id, node] of Object.entries(state.nodes)) { + if (!prevState.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeCreated(node, levelId) + + // When a slab is added, mark overlapping items/walls dirty + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) } } + } - // Detect removed nodes - for (const [id, node] of Object.entries(prevState.nodes)) { - if (!state.nodes[id as AnyNode['id']]) { - const levelId = resolveLevelId(node, prevState.nodes) - spatialGridManager.handleNodeDeleted(id, node.type, levelId) + // Detect removed nodes + for (const [id, node] of Object.entries(prevState.nodes)) { + if (!state.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, prevState.nodes) + spatialGridManager.handleNodeDeleted(id, node.type, levelId) - // When a slab is removed, mark items/walls that were on it dirty (using current state) - if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) - } + // When a slab is removed, mark items/walls that were on it dirty (using current state) + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) } } + } - // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes) - for (const [id, node] of Object.entries(state.nodes)) { - const prev = prevState.nodes[id as AnyNode['id']] - if (!prev) continue - - if (node.type === 'item' && prev.type === 'item') { - if ( - !( - arraysEqual(node.position, prev.position) && - arraysEqual(node.rotation, prev.rotation) && - arraysEqual(node.scale, prev.scale) - ) || - node.parentId !== prev.parentId || - node.side !== prev.side - ) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeUpdated(node, levelId) - // Scale changes affect footprint size — mark dirty so slab elevation recalculates - if (!arraysEqual(node.scale, prev.scale)) { - markDirty(node.id) - } - } - } else if (node.type === 'slab' && prev.type === 'slab') { - if ( - node.polygon !== prev.polygon || - node.elevation !== prev.elevation || - node.holes !== prev.holes - ) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeUpdated(node, levelId) - - // Mark nodes overlapping old polygon and new polygon as dirty - markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes) + for (const [id, node] of Object.entries(state.nodes)) { + const prev = prevState.nodes[id as AnyNode['id']] + if (!prev) continue + + if (node.type === 'item' && prev.type === 'item') { + if ( + !( + arraysEqual(node.position, prev.position) && + arraysEqual(node.rotation, prev.rotation) && + arraysEqual(node.scale, prev.scale) + ) || + node.parentId !== prev.parentId || + node.side !== prev.side + ) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) + // Scale changes affect footprint size — mark dirty so slab elevation recalculates + if (!arraysEqual(node.scale, prev.scale)) { + markDirty(node.id) } } - } - }) + } else if (node.type === 'slab' && prev.type === 'slab') { + if ( + node.polygon !== prev.polygon || + node.elevation !== prev.elevation || + node.holes !== prev.holes + ) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) - return unsubscribe + // Mark nodes overlapping old polygon and new polygon as dirty + markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + } + } + } } function arraysEqual(a: number[], b: number[]): boolean { @@ -155,8 +210,16 @@ function markNodesOverlappingSlab( if (resolveLevelId(node, nodes) !== slabLevelId) continue const position = (node as { position?: [number, number, number] }).position if (!position) continue - const { dimensions, rotation } = floorPlaced.footprint(node) - if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) { + const overlaps = getFloorPlacedFootprints(floorPlaced, node, { nodes }).some((footprint) => + itemOverlapsPolygon( + footprint.position ?? position, + footprint.dimensions, + footprint.rotation, + slab.polygon, + 0.01, + ), + ) + if (overlaps) { markDirty(node.id) } } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid.ts b/packages/core/src/hooks/spatial-grid/spatial-grid.ts index 97711652f..f5051349f 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid.ts @@ -126,12 +126,12 @@ export class SpatialGrid { // Query: is this placement valid? // Uses cells as broad-phase, then checks actual AABB overlap (narrow-phase) // to avoid false positives when adjacent items share a cell but don't overlap. - canPlace( + queryOverlaps( position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number], ignoreIds: string[] = [], - ): { valid: boolean; conflictIds: string[] } { + ): string[] { const bounds = this.getAABB(position, dimensions, rotation) const cellKeys = this.getItemCells(bounds) const ignoreSet = new Set(ignoreIds) @@ -166,6 +166,16 @@ export class SpatialGrid { } } + return conflicts + } + + canPlace( + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds: string[] = [], + ): { valid: boolean; conflictIds: string[] } { + const conflicts = this.queryOverlaps(position, dimensions, rotation, ignoreIds) return { valid: conflicts.length === 0, conflictIds: conflicts, diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts new file mode 100644 index 000000000..4f62dbbe8 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test' +import { wallOverlapsPolygon } from './spatial-grid-manager' + +const SLAB: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +const wall = (start: [number, number], end: [number, number], curveOffset = 0) => ({ + start, + end, + curveOffset, + thickness: 0.1, +}) + +describe('wallOverlapsPolygon', () => { + test('excludes perpendicular walls butting into the slab on every side', () => { + expect(wallOverlapsPolygon(wall([2, 4], [2, 7]), SLAB)).toBe(false) + expect(wallOverlapsPolygon(wall([2, 0], [2, -3]), SLAB)).toBe(false) + expect(wallOverlapsPolygon(wall([0, 2], [-3, 2]), SLAB)).toBe(false) + expect(wallOverlapsPolygon(wall([4, 2], [7, 2]), SLAB)).toBe(false) + expect(wallOverlapsPolygon(wall([2, 7], [2, 4]), SLAB)).toBe(false) + expect(wallOverlapsPolygon(wall([-3, 2], [0, 2]), SLAB)).toBe(false) + }) + + test('includes walls lying on the slab boundary', () => { + expect(wallOverlapsPolygon(wall([0, 0], [4, 0]), SLAB)).toBe(true) + expect(wallOverlapsPolygon(wall([4, 0], [4, 4]), SLAB)).toBe(true) + expect(wallOverlapsPolygon(wall([4, 4], [0, 4]), SLAB)).toBe(true) + expect(wallOverlapsPolygon(wall([0, 4], [0, 0]), SLAB)).toBe(true) + expect(wallOverlapsPolygon(wall([1, 0], [3, 0]), SLAB)).toBe(true) + }) + + test('excludes corner-only contact', () => { + expect(wallOverlapsPolygon(wall([4, 4], [6, 6]), SLAB)).toBe(false) + expect(wallOverlapsPolygon(wall([0, 0], [-2, -2]), SLAB)).toBe(false) + }) + + test('includes walls inside or crossing through the slab', () => { + expect(wallOverlapsPolygon(wall([1, 1], [3, 3]), SLAB)).toBe(true) + expect(wallOverlapsPolygon(wall([-1, 2], [5, 2]), SLAB)).toBe(true) + }) + + test('uses wall thickness for grazing overlap', () => { + expect(wallOverlapsPolygon(wall([0, -0.04], [4, -0.04]), SLAB)).toBe(true) + expect(wallOverlapsPolygon(wall([0, -1], [4, -1]), SLAB)).toBe(false) + }) + + test('handles curved walls by their sampled body', () => { + expect(wallOverlapsPolygon(wall([0, -0.5], [4, -0.5], -1), SLAB)).toBe(true) + expect(wallOverlapsPolygon(wall([0, -0.5], [4, -0.5], 1), SLAB)).toBe(false) + }) + + test('supports the legacy start/end call shape', () => { + expect(wallOverlapsPolygon([1, 1], [3, 3], SLAB)).toBe(true) + expect(wallOverlapsPolygon([2, 4], [2, 7], SLAB)).toBe(false) + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3cad9ae8..d2c3e72a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,43 +1,179 @@ export type { + BoxEvent, BuildingEvent, + CableTrayEvent, CameraControlEvent, CameraControlFitSceneEvent, + CapsuleEvent, CeilingEvent, ColumnEvent, + CylinderEvent, + DataWidgetEvent, DoorEvent, ElevatorEvent, EventSuffix, + ExtrudeEvent, FenceEvent, GridEvent, GuideEvent, + HalfCylinderEvent, ItemEvent, + LadderEvent, + LatheEvent, LevelEvent, NodeEvent, + PipeFittingEvent, + RoadEvent, RoofEvent, RoofSegmentEvent, + RoundedPanelEvent, ScanEvent, ShelfEvent, SiteEvent, SlabEvent, SpawnEvent, + SphereEvent, StairEvent, StairSegmentEvent, + SteelBeamEvent, + SteelFrameEvent, + SweepEvent, + TankEvent, WallEvent, WindowEvent, ZoneEvent, } from './events/bus' export { emitter, eventSuffixes } from './events/bus' +export { + buildDynamicCapabilityMetadata, + COMMON_DYNAMIC_TYPES, + getDynamicTypesForNode, + getDynamicTypesForSemanticType, + getNodeSemanticType, + getRecommendedDynamicTypeForNode, + getRecommendedDynamicTypeForSemanticType, + inferNodeSemanticType, + NODE_TYPE_SEMANTIC_DEFAULTS, + SEMANTIC_DYNAMIC_TYPES, +} from './dynamics/capabilities' +export { + isDynamicBinding, + isDynamicJointBinding, + isDynamicJointChannel, + readDynamicMetadata, + writeDynamicMetadataPatch, +} from './dynamics/metadata' +export { + DYNAMIC_TYPE_LABELS, + type DynamicAxis, + type DynamicBinding, + type DynamicCapabilityMetadata, + type DynamicConveyorEndpointBehavior, + type DynamicJointBinding, + type DynamicJointChannel, + type DynamicJointMotionKind, + type DynamicMetadata, + type DynamicType, + SEMANTIC_TYPE_LABELS, +} from './dynamics/types' +export * from './equipment' +export { + registerSemanticRecipe, + semanticRecipeRegistry, + assertSemanticRecipeComposeResult, + assertSemanticRecipeDefinition, + type SemanticRecipeComposeInput, + type SemanticRecipeComposeResult, + type SemanticRecipeDefinition, + type SemanticRecipeEditableParam, + type SemanticRecipeEditableParamEffect, + type SemanticRecipeEditableParamKind, + type SemanticRecipeEnvelope, + type SemanticRecipeId, + type SemanticRecipePart, + type SemanticRecipePort, + type SemanticRecipePortSide, + type SemanticRecipeRegistry, + type SemanticRecipeValidationIssue, + validateSemanticRecipeComposeResult, + validateSemanticRecipeDefinition, +} from './registry/semantic-recipes' export { sceneRegistry, useRegistry, } from './hooks/scene-registry/scene-registry' +export { + type FloorPlacedElevationArgs, + type FloorPlacedFootprint, + type FloorPlacedFootprintContext, + type FloorPlacedFootprintsResolver, + getFloorPlacedElevation, + getFloorPlacedFootprints, + getFloorStackedPosition, +} from './hooks/spatial-grid/floor-placed-elevation' export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager' export { initSpatialGridSync, + resolveBuildingForLevel, resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' +export { + type AssemblyComposeInput, + type AssemblyPartPlanItem, + composeAssemblyPrimitives, + getAssemblyGeometryBrief, + planAssemblyParts, +} from './lib/assembly-compose' +export { + type AssemblyConstraintValidation, + type AssemblyObjectFamily, + extractUserGeometryConstraints, + type HardColorConstraint, + type HardGeometryConstraint, + type HardNumberConstraint, + inferAssemblyFamily, + materialFromColor, + type UserGeometryConstraints, + validateAssemblyConstraints, +} from './lib/assembly-constraints' export { loadAssetUrl, saveAsset } from './lib/asset-storage' +export { + applyDeviceProfileToPartInput, + buildDraftDeviceProfile, + createDeviceProfileResolver, + DEVICE_PROFILE_DEFINITIONS, + type DeviceArchetypeFamily, + type DeviceProfileDefinition, + type DeviceProfileExecutionValidation, + type DeviceProfileId, + type DeviceProfileMergeResult, + type DeviceProfileQualityInputShape, + type DeviceProfileQualityScore, + type DeviceProfileResolver, + type DeviceProfileSource, + type DeviceProfileStatus, + type DeviceProfileValidation, + type DimensionDefaults, + type DimensionRule, + type DraftDeviceProfileResult, + deviceProfileCapabilitySummary, + evaluateDeviceProfileQuality, + getDeviceProfileDefinition, + inferDeviceProfileDefinition, + mergeDeviceProfiles, + normalizeDeviceProfileInput, + type ProfilePartSpec, + validateDeviceProfileDefinition, + validateDeviceProfileForExecution, + validateDeviceProfileSchema, + validateDeviceProfiles, +} from './lib/device-profile-registry' +export { + applyDimensionSemanticsToObjectInput, + type DimensionSemantics, + parseDimensionSemantics, +} from './lib/dimension-semantics' export { clampDoorOperationState, getDoorRenderOpenAmount, @@ -45,19 +181,256 @@ export { isOperationDoorType, SECTIONAL_GARAGE_RENDER_OPEN_SCALE, } from './lib/door-operation' +export { + executableFamilyForLayoutFamily, + FAMILY_DEFINITIONS, + type FamilyDefinition, + type FamilyId, + familyCapabilitySummary, + familyPartDefinitions, + getFamilyDefinition, + getLayoutFamilyDefinition, + inferFamilyDefinition, + isFamilyId, + LAYOUT_FAMILY_DEFINITIONS, + type LayoutFamilyDefinition, + type LayoutFamilyGroup, + type LayoutFamilyId, + layoutFamilyCapabilitySummary, + normalizeFamilyId, + normalizeLayoutFamilyId, +} from './lib/family-registry' +export { + createGeometryGoldenSnapshot, + type GeometryGoldenShapeSnapshot, + type GeometryGoldenSnapshot, + type GeometryGoldenSnapshotOptions, + stringifyGeometryGoldenSnapshot, +} from './lib/geometry-golden-snapshot' +export { + composeIndustrialArchetype, + type IndustrialArchetypeComposeInput, + industrialArchetypeBrief, + industrialComposeParams, + resolveIndustrialArchetypeEntry, +} from './lib/industrial-archetype-compose' +export { + findIndustrialArchetype, + findIndustrialArchetypeByRecipeId, + INDUSTRIAL_ARCHETYPE_ENTRIES, + type IndustrialArchetypeEntry, + type IndustrialArchetypeId, + type IndustrialArchetypeRecipeId, + type IndustrialVariantId, + industrialAliasesForRecipe, +} from './lib/industrial-archetype-registry' +export { getDefaultLevelName, getLevelDisplayName } from './lib/level-name' +export { + composeObjectPrimitives, + type ObjectComposeCategory, + type ObjectComposeDetail, + type ObjectComposeInput, +} from './lib/object-compose' +export { + angularStep, + normalizedRadialDirection, + radialExtrudeRotationInHorizontalPlane, + radialExtrudeRotationInLocalPlane, + transformedLocalAxis, +} from './lib/orientation-utils' +export { + assessPartBlueprint, + assessPartVisualDetails, + type BoundingBox, + composePartPrimitives, + type LayoutAnchor, + type LayoutDimensions, + type LayoutPlan, + type LayoutProfileInput, + type PartBlueprintAssessment, + type PartComposeDetail, + type PartComposeInput, + type PartComposeKind, + type PartComposePartInput, + type PartPlacement, + type PartSpec, + type PartVisualAssessment, + resolveLayout, +} from './lib/part-compose' +export { + AIRCRAFT_PART_DEFINITIONS, + CONVEYOR_PART_DEFINITIONS, + DESK_PART_DEFINITIONS, + ELECTRICAL_PART_DEFINITIONS, + GENERIC_PART_DEFINITIONS, + getPartDefinitions, + KIOSK_PART_DEFINITIONS, + type NormalizedPartPlan, + normalizeAircraftPartPlan, + normalizeConveyorPartPlan, + normalizeDeskPartPlan, + normalizeElectricalPartPlan, + normalizeGenericPartPlan, + normalizeKioskPartPlan, + normalizePartPlanForFamily, + normalizePipeSystemPartPlan, + normalizePumpPartPlan, + normalizeVehiclePartPlan, + type PartDefinition, + type PartParameterDefinition, + type PartParameterType, + PIPE_SYSTEM_PART_DEFINITIONS, + PUMP_PART_DEFINITIONS, + partCapabilitySummary, + VEHICLE_PART_DEFINITIONS, +} from './lib/part-registry' +export { + CORE_COMPONENT_PART_CAPABILITIES, + type CoreComponentPartCapability, + coreComponentPartKinds, + GENERIC_PART_CAPABILITIES, + type GenericPartCapability, + type PartCapabilityCategory, + partCapabilitiesPrompt, +} from './lib/part-taxonomy' +export { + type Point2D as PolygonPoint2D, + pointInPolygon as pointInPolygon2D, + pointOnSegment, + polygonContainsPolygon, + polygonsIntersect, + polygonsOverlap, + segmentsIntersect, +} from './lib/polygon-relations' +export { + type PrimitiveAnchor, + type PrimitiveAxis, + type PrimitiveBevelContract, + type PrimitiveCutoutInput, + type PrimitiveCutoutKind, + type PrimitiveDuctCrossSection, + type PrimitiveDuctInput, + type PrimitiveEditableDimension, + type PrimitiveEditableHints, + type PrimitiveGeometryBrief, + type PrimitiveMaterialInput, + type PrimitivePatternInput, + type PrimitivePatternKind, + type PrimitivePortKind, + type PrimitivePortMarkerInput, + type PrimitiveShapeInput, + type PrimitiveShapeContract, + type PrimitiveShapeKind, + type ResolvedPrimitiveTransform, + type ResolveTransformsOptions, + extractPrimitiveShapeContract, + resolvePrimitiveWorldTransforms, + type Vec3, +} from './lib/primitive-compose' +export { + buildPrimitiveGeometryFacts, + getPrimitiveShapeHalfExtents, + type PrimitiveGeometryFacts, + type PrimitiveShapeFact, +} from './lib/primitive-facts' +export { + type ComposeRecipeInput, + composeRecipePrimitives, + findPrimitiveRecipe, + getPrimitiveRecipeGeometryBrief, + listPrimitiveRecipes, + type PrimitiveRecipeDefinition, + type PrimitiveRecipeId, + type PrimitiveRecipeParams, +} from './lib/primitive-recipes' +export { + getPrimitiveDefinition, + lowerDerivedPrimitiveShape, + normalizePrimitiveKindFromRegistry, + PRIMITIVE_DEFINITIONS, + type PrimitiveDefinition, + type PrimitiveParameterDefinition, + type PrimitiveParameterType, + primitiveCapabilitySummary, +} from './lib/primitive-registry' +export { + applyPrimitiveRevision, + type PrimitiveRevisionEdge, + type PrimitiveRevisionInput, + type PrimitiveRevisionOperation, + type PrimitiveRevisionResult, + type PrimitiveShapeSelector, + selectPrimitiveShapeIndexes, +} from './lib/primitive-revision' +export { + type PrimitiveSemanticValidationOptions, + type PrimitiveSemanticValidationResult, + validatePrimitiveSemantics, +} from './lib/primitive-semantic-validation' +export { + assessPrimitiveVisualQuality, + type PrimitiveVisualQualityFamily, + type PrimitiveVisualQualityOptions, + type PrimitiveVisualQualityResult, +} from './lib/primitive-visual-quality' +export { + INDUSTRIAL_RECIPE_DIMENSIONS, + type RecipeDimensionSize, + type RecipeDimensions, + resolveRecipeDimensions, + resolveRecipeSizeKey, +} from './lib/recipe-dimensions' +export { + composeRobotArmPrimitives, + type RobotArmComposeInput, + type RobotArmPose, + type RobotArmStyle, +} from './lib/robot-arm-compose' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { type AutoSlabSyncPlan, detectSpacesForLevel, initSpaceDetectionSync, + isSpaceDetectionPaused, + pauseSpaceDetection, planAutoSlabsForLevel, + resumeSpaceDetection, type Space, wallTouchesOthers, } from './lib/space-detection' +export { + formatLiveDataValue, + getLiveDataPathLabel, + formatStaticLiveDataValue, + getStaticLiveDataValue, + isLiveDataBindingConfig, + type LiveDataBindingConfig, + type LiveDataBindingEffect, + type LiveDataPath, + type LiveDataSnapshot, + type LiveDataValue, + renderLiveDataTemplate, + resolveBindingColor, + resolveBindingPositionYOffset, + resolveBindingPreview, + resolveBindingRotationYOffset, + STATIC_LIVE_DATA, + STATIC_LIVE_DATA_OPTIONS, + STATIC_LIVE_DATA_PATHS, + type StaticLiveDataEntry, + type StaticLiveDataKey, + type StaticLiveDataValue, +} from './live-data/static-live-data' +export { + getLiveDataValue, + type LiveDataConnectionStatus, + useLiveData, +} from './live-data/live-data-store' export { getCatalogMaterialById, getLibraryMaterialIdFromRef, getMaterialPresetByRef, + getMaterialSolidColorByRef, getMaterialsForCategory, LIBRARY_MATERIAL_REF_PREFIX, MATERIAL_CATALOG, @@ -75,6 +448,7 @@ export { resetSceneHistoryPauseDepth, resumeSceneHistory, } from './store/history-control' +export { default as useAlignmentGuides } from './store/use-alignment-guides' export { type ControlValue, type DoorAnimationState, @@ -88,6 +462,7 @@ export { } from './store/use-interactive' export { default as useLiveNodeOverrides, + getEffectiveNode, type LiveNodeOverrides, } from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' @@ -119,7 +494,6 @@ export { stepElevatorRuntimeState, stepElevatorRuntimes, } from './systems/elevator/elevator-runtime' -export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' export { DEFAULT_ELEVATOR_LEVEL_HEIGHT, type ElevatorLevelEntry, @@ -129,6 +503,18 @@ export { resolveElevatorServiceLevelIds, resolveElevatorServiceLevels, } from './systems/elevator/elevator-service' +export { + clampPipeRotateDegrees, + getPipeEndpoint3D, + getPipeMidpoint3D, + getPipeRotateRadians, + isPipeNearlyVertical, + type PipeCenterlineLike, + type PipeCenterlinePoint3D, + samplePipeCenterline3D, +} from './systems/pipe/pipe-centerline' +export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' +export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { StairOpeningSystem } from './systems/stair/stair-opening-system' export { @@ -170,6 +556,32 @@ export { } from './systems/wall/wall-move' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' +export { + createTransferEndpointConnection, + getTransferPortPoint, + isTransferEndpointNode, + resolveTransferEndpointSnap, + TRANSFER_ENDPOINT_SNAP_DISTANCE, + type TransferEndpointNode, + type TransferEndpointSnap, +} from './transfer-network/endpoints' +export { + addTransferConnectionToMetadata, + areConveyorPortsTouching, + createConveyorEndpointConnection, + distance3D, + getConveyorPortPoint, + getTransferConnections, + isConveyorBeltRouteNode, + removeTransferConnectionsFromMetadata, + removeTransferConnectionsReferencingNodesFromMetadata, + resolveConveyorEndpointSnap, + TRANSFER_ENDPOINT_SNAP_THRESHOLD, + type ConveyorBeltRouteNode, + type ConveyorEndpointSnap, + type TransferConnection, + type TransferPort, +} from './transfer-network/conveyor' export { isObject } from './utils/types' export { type BuildStats, @@ -180,3 +592,4 @@ export { type ValidationSeverity, validateBuildJson, } from './validation/validate-build-json' +export type { SceneObjectRef, ScenePointerEvent } from './types/scene-object' diff --git a/packages/core/src/lib/assembly-compose.test.ts b/packages/core/src/lib/assembly-compose.test.ts new file mode 100644 index 000000000..5ded818b7 --- /dev/null +++ b/packages/core/src/lib/assembly-compose.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { composeAssemblyPrimitives } from './assembly-compose' +import { FAMILY_DEFINITIONS } from './family-registry' + +describe('assembly composition routing', () => { + test('composes every canonical registry family through assembly routing', () => { + for (const family of FAMILY_DEFINITIONS) { + const shapes = composeAssemblyPrimitives({ family: family.id }) + + expect( + shapes.length, + `family ${family.id} should compose at least one shape`, + ).toBeGreaterThan(0) + } + }) + + test('does not silently return empty arrays for registry-only assembly families', () => { + const registryOnlyFamilies = [ + 'aircraft', + 'bicycle', + 'desk', + 'kiosk', + 'valve', + 'mixer', + 'pipe_system', + 'heat_exchanger', + 'fluid_machine', + 'forming_machine', + 'material_handling', + 'process_equipment', + ] as const + + for (const family of registryOnlyFamilies) { + const shapes = composeAssemblyPrimitives({ family }) + + expect( + shapes.length, + `family ${family} should not route to an empty fallback`, + ).toBeGreaterThan(0) + } + }) +}) diff --git a/packages/core/src/lib/assembly-compose.ts b/packages/core/src/lib/assembly-compose.ts new file mode 100644 index 000000000..b4013bcc6 --- /dev/null +++ b/packages/core/src/lib/assembly-compose.ts @@ -0,0 +1,2239 @@ +import { + type AssemblyObjectFamily, + extractUserGeometryConstraints, + materialFromColor, + type UserGeometryConstraints, +} from './assembly-constraints' +import { getFamilyDefinition, normalizeFamilyId } from './family-registry' +import { + composePartPrimitives, + type PartComposeInput, + type PartComposePartInput, +} from './part-compose' +import type { PrimitiveGeometryBrief, PrimitiveShapeInput, Vec3 } from './primitive-compose' +import { composeRobotArmPrimitives } from './robot-arm-compose' + +export type AssemblyPartPlanItem = { + role: string + capability: string + partKind: string + count?: number +} + +export type AssemblyComposeInput = { + name?: string + prompt?: string + family?: AssemblyObjectFamily | string + object?: string + style?: string + constraints?: Partial + parts?: AssemblyPartPlanItem[] + position?: Vec3 + primaryColor?: string + secondaryColor?: string + darkColor?: string + metalColor?: string + color?: string + length?: number + width?: number + diameter?: number + height?: number + variant?: string + axisCount?: number + endEffector?: string + params?: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function textOf(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function numberValue(...values: unknown[]): number | undefined { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value + } + return undefined +} + +function colorValue( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, + fallback: string, +) { + return ( + constraints.primaryColor?.value ?? + input.primaryColor ?? + input.color ?? + stringValue(input.params?.primaryColor, input.params?.color) ?? + fallback + ) +} + +function stringValue(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) return value + } + return undefined +} + +function normalizeAssemblyFamily(value: unknown): AssemblyObjectFamily | undefined { + return normalizeFamilyId(value) +} + +function familyFor( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): AssemblyObjectFamily { + const explicitFamily = normalizeAssemblyFamily(input.family) + if (explicitFamily) return explicitFamily + const text = `${input.object ?? ''} ${input.name ?? ''} ${input.prompt ?? ''}` + return ( + extractUserGeometryConstraints(text, input as Record).family || + constraints.family + ) +} + +function withInputConstraints(input: AssemblyComposeInput): UserGeometryConstraints { + const prompt = input.prompt ?? textOf([input.name, input.object, input.style]) + const raw: Record = { ...input, ...(isRecord(input.params) ? input.params : {}) } + const extracted = extractUserGeometryConstraints(prompt, raw) + return { ...extracted, ...input.constraints } +} + +function assemblyName(input: AssemblyComposeInput, fallback: string) { + return input.name ?? input.object ?? input.prompt ?? fallback +} + +function partInput( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, + parts: PartComposePartInput[], +): PartComposeInput { + return { + name: assemblyName(input, 'assembly'), + position: input.position, + detail: 'high', + primaryColor: colorValue(input, constraints, '#64748b'), + darkColor: '#111827', + metalColor: '#cbd5e1', + accentColor: '#2563eb', + enhanceVisualDetails: true, + parts, + } +} + +function dimensionedRegistryPart( + part: PartComposePartInput, + index: number, + constraints: UserGeometryConstraints, + fallback: { length?: number; width?: number; height?: number }, +): PartComposePartInput { + if (index !== 0) return part + return { + ...part, + length: part.length ?? constraints.length?.value ?? fallback.length, + width: part.width ?? constraints.width?.value ?? fallback.width, + height: part.height ?? constraints.height?.value ?? fallback.height, + } +} + +function composeRegistryFamilyAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, + family: AssemblyObjectFamily, +): PrimitiveShapeInput[] { + const definition = getFamilyDefinition(family) + if (!definition) return [] + const kinds = Array.from(new Set([...definition.requiredParts, ...definition.optionalParts])) + if (kinds.length === 0) return [] + const parts = kinds.map((kind, index) => + dimensionedRegistryPart( + { kind, ...(index === 0 ? { semanticRole: definition.primarySemanticRoles[0] } : {}) }, + index, + constraints, + definition.defaultDimensions, + ), + ) + return composePartPrimitives({ + ...partInput(input, constraints, parts), + family: definition.id, + registryPartPlan: true, + autoComplete: false, + }) +} + +function styleText(input: AssemblyComposeInput, constraints: UserGeometryConstraints) { + return `${input.style ?? ''} ${constraints.style ?? ''} ${input.prompt ?? ''}`.toLowerCase() +} + +function vehicleStyle(input: AssemblyComposeInput, constraints: UserGeometryConstraints) { + const text = styleText(input, constraints) + if (/suv|offroad|越野/.test(text)) return 'suv' + if (/truck|pickup|卡车|貨車|货车|皮卡/.test(text)) return 'truck' + if (/van|mpv|bus|面包车|廂式|商务/.test(text)) return 'van' + if (/sport|race|跑车|赛车/.test(text)) return 'sports' + return 'sedan' +} + +function sizeScale(input: AssemblyComposeInput): number { + const raw = String( + input.params?.size ?? input.style ?? input.name ?? input.prompt ?? '', + ).toLowerCase() + if (/compact|small|mini|tiny|小|迷你/.test(raw)) return 0.8 + if (/large|big|long|大型|大号|加长/.test(raw)) return 1.18 + return 1 +} + +function composeVehicleAssembly(input: AssemblyComposeInput, constraints: UserGeometryConstraints) { + const style = vehicleStyle(input, constraints) + const defaultLength = (style === 'truck' ? 5.2 : 4.4) * sizeScale(input) + const length = + constraints.length?.value ?? + numberValue(input.length, input.params?.length) ?? + Number(defaultLength.toFixed(3)) + const width = + constraints.width?.value ?? + numberValue(input.width, input.params?.width) ?? + Number((length * (style === 'suv' || style === 'truck' ? 0.43 : 0.42)).toFixed(3)) + const height = + constraints.height?.value ?? + numberValue(input.height, input.params?.height) ?? + Number((length * (style === 'sports' ? 0.26 : style === 'suv' ? 0.38 : 0.32)).toFixed(3)) + const color = colorValue(input, constraints, '#64748b') + return compactVehicleAssemblyShapes(input, style, length, width, height, color) +} + +function compactVehicleAssemblyShapes( + input: AssemblyComposeInput, + style: string, + length: number, + width: number, + height: number, + color: string, +): PrimitiveShapeInput[] { + const name = assemblyName(input, 'vehicle') + const bodyMat = materialFromColor(color) + const glassMat = materialFromColor('#60a5fa') + const darkMat = materialFromColor('#111827') + const lightMat = materialFromColor('#fde68a') + const tailLightMat = materialFromColor('#ef4444') + const hubMat = materialFromColor('#cbd5e1') + const bodyHeight = height * 0.38 + const cabinHeight = height * (style === 'sports' ? 0.34 : 0.4) + const wheelRadius = Math.max(0.16, Math.min(length * 0.085, height * 0.23)) + const wheelY = wheelRadius + const bodyY = wheelY + bodyHeight * 0.58 + const deckY = bodyY + bodyHeight * 0.52 + const cabinLength = length * (style === 'truck' ? 0.32 : style === 'sports' ? 0.34 : 0.36) + const cabinWidth = width * 0.66 + const cabinX = style === 'truck' ? length * 0.11 : -length * 0.03 + const cabinY = deckY + cabinHeight * 0.38 + const axleX = length * 0.34 + const wheelZ = width * 0.5 + const bumperY = wheelY + bodyHeight * 0.16 + const seamY = deckY + bodyHeight * 0.07 + + return [ + { + kind: 'trapezoid-prism', + name: `${name} vehicle body shell`, + semanticRole: 'vehicle_body', + sourcePartKind: 'body_shell', + position: [0, bodyY, 0], + length, + width, + height: bodyHeight, + topLengthScale: style === 'van' ? 0.98 : 0.94, + topWidthScale: style === 'truck' || style === 'suv' ? 0.93 : 0.88, + cornerRadius: Math.min(length, width, bodyHeight) * 0.08, + cornerSegments: 5, + material: bodyMat, + }, + { + kind: 'wedge', + name: `${name} vehicle front deck hood`, + semanticRole: 'vehicle_deck', + sourcePartKind: 'body_shell', + position: [length * 0.24, deckY, 0], + length: length * 0.32, + width: width * 0.78, + height: bodyHeight * 0.08, + slopeAxis: 'x', + slopeDirection: 'negative', + material: bodyMat, + }, + { + kind: 'wedge', + name: `${name} vehicle rear deck trunk`, + semanticRole: 'vehicle_deck', + sourcePartKind: 'body_shell', + position: [-length * 0.32, deckY - bodyHeight * 0.03, 0], + length: length * 0.24, + width: width * 0.78, + height: bodyHeight * 0.075, + slopeAxis: 'x', + slopeDirection: 'positive', + material: bodyMat, + }, + { + kind: 'trapezoid-prism', + name: `${name} vehicle cabin frame`, + semanticRole: 'vehicle_cabin', + sourcePartKind: 'body_shell', + position: [cabinX, cabinY, 0], + length: cabinLength, + width: cabinWidth, + height: cabinHeight, + topLengthScale: style === 'sports' ? 0.58 : 0.68, + topWidthScale: 0.72, + cornerRadius: Math.min(width, cabinHeight) * 0.04, + cornerSegments: 4, + material: bodyMat, + }, + { + kind: 'rounded-panel', + name: `${name} vehicle roof panel`, + semanticRole: 'vehicle_roof', + sourcePartKind: 'body_shell', + position: [cabinX, cabinY + cabinHeight * 0.52, 0], + rotation: [0, 0, 0], + length: cabinLength * (style === 'sports' ? 0.6 : 0.72), + width: cabinWidth * 0.74, + thickness: Math.max(0.012, bodyHeight * 0.035), + cornerRadius: Math.min(cabinLength, cabinWidth) * 0.035, + material: bodyMat, + }, + { + kind: 'rounded-panel', + name: `${name} windshield`, + semanticRole: 'vehicle_window', + sourcePartKind: 'window_strip', + position: [cabinX + cabinLength * 0.33, cabinY + cabinHeight * 0.02, 0], + rotation: [0, Math.PI / 2, 0], + length: cabinWidth * 0.62, + width: cabinHeight * 0.44, + thickness: 0.018, + cornerRadius: cabinHeight * 0.05, + material: glassMat, + }, + { + kind: 'rounded-panel', + name: `${name} rear window`, + semanticRole: 'vehicle_window', + sourcePartKind: 'window_strip', + position: [cabinX - cabinLength * 0.33, cabinY + cabinHeight * 0.02, 0], + rotation: [0, Math.PI / 2, 0], + length: cabinWidth * 0.58, + width: cabinHeight * 0.38, + thickness: 0.018, + cornerRadius: cabinHeight * 0.05, + material: glassMat, + }, + { + kind: 'rounded-panel', + name: `${name} side window left`, + semanticRole: 'vehicle_window', + sourcePartKind: 'window_strip', + position: [cabinX, cabinY + cabinHeight * 0.02, -cabinWidth * 0.51], + rotation: [Math.PI / 2, 0, 0], + length: cabinLength * 0.72, + width: cabinHeight * 0.36, + thickness: 0.018, + cornerRadius: cabinHeight * 0.05, + material: glassMat, + }, + { + kind: 'rounded-panel', + name: `${name} side window right`, + semanticRole: 'vehicle_window', + sourcePartKind: 'window_strip', + position: [cabinX, cabinY + cabinHeight * 0.02, cabinWidth * 0.51], + rotation: [Math.PI / 2, 0, 0], + length: cabinLength * 0.72, + width: cabinHeight * 0.36, + thickness: 0.018, + cornerRadius: cabinHeight * 0.05, + material: glassMat, + }, + ...[-axleX, axleX].flatMap((x) => + [-wheelZ, wheelZ].flatMap((z): PrimitiveShapeInput[] => [ + { + kind: 'torus', + name: `${name} vehicle tire`, + semanticRole: 'vehicle_tire', + sourcePartKind: 'wheel_set', + position: [x, wheelY, z], + axis: 'z', + majorRadius: wheelRadius, + tubeRadius: wheelRadius * 0.24, + radialSegments: 12, + tubularSegments: 24, + material: darkMat, + }, + { + kind: 'cylinder', + name: `${name} vehicle wheel hub`, + semanticRole: 'wheel_hub', + sourcePartKind: 'wheel_set', + position: [x, wheelY, z + (z > 0 ? 0.012 : -0.012)], + axis: 'z', + radius: wheelRadius * 0.38, + height: wheelRadius * 0.14, + radialSegments: 16, + material: hubMat, + }, + { + kind: 'torus', + name: `${name} vehicle wheel arch`, + semanticRole: 'vehicle_body_detail', + sourcePartKind: 'body_shell', + position: [x, wheelY + wheelRadius * 0.24, z * 0.98], + axis: 'z', + majorRadius: wheelRadius * 1.1, + tubeRadius: wheelRadius * 0.07, + arc: Math.PI, + radialSegments: 8, + tubularSegments: 18, + material: bodyMat, + }, + ]), + ), + { + kind: 'sphere', + name: `${name} left headlight`, + semanticRole: 'headlight', + sourcePartKind: 'light_pair', + position: [length * 0.49, bumperY + bodyHeight * 0.26, -width * 0.28], + radius: Math.max(0.035, width * 0.035), + material: lightMat, + }, + { + kind: 'sphere', + name: `${name} left tail light`, + semanticRole: 'taillight', + sourcePartKind: 'light_pair', + position: [-length * 0.49, bumperY + bodyHeight * 0.23, -width * 0.3], + radius: Math.max(0.03, width * 0.03), + material: tailLightMat, + }, + { + kind: 'sphere', + name: `${name} right tail light`, + semanticRole: 'taillight', + sourcePartKind: 'light_pair', + position: [-length * 0.49, bumperY + bodyHeight * 0.23, width * 0.3], + radius: Math.max(0.03, width * 0.03), + material: tailLightMat, + }, + { + kind: 'sphere', + name: `${name} right headlight`, + semanticRole: 'headlight', + sourcePartKind: 'light_pair', + position: [length * 0.49, bumperY + bodyHeight * 0.26, width * 0.28], + radius: Math.max(0.035, width * 0.035), + material: lightMat, + }, + { + kind: 'box', + name: `${name} front bumper bar`, + semanticRole: 'front_bumper', + sourcePartKind: 'bar_pair', + position: [length * 0.51, bumperY, 0], + length: length * 0.035, + width: width * 0.82, + height: bodyHeight * 0.14, + material: darkMat, + }, + { + kind: 'box', + name: `${name} rear bumper bar`, + semanticRole: 'rear_bumper', + sourcePartKind: 'bar_pair', + position: [-length * 0.51, bumperY, 0], + length: length * 0.035, + width: width * 0.82, + height: bodyHeight * 0.14, + material: darkMat, + }, + { + kind: 'rounded-panel', + name: `${name} left rocker sill`, + semanticRole: 'vehicle_body_detail', + sourcePartKind: 'body_shell', + position: [0, bodyY - bodyHeight * 0.25, -width * 0.515], + rotation: [Math.PI / 2, 0, 0], + length: length * 0.72, + width: bodyHeight * 0.12, + thickness: 0.018, + material: darkMat, + }, + { + kind: 'rounded-panel', + name: `${name} right rocker sill`, + semanticRole: 'vehicle_body_detail', + sourcePartKind: 'body_shell', + position: [0, bodyY - bodyHeight * 0.25, width * 0.515], + rotation: [Math.PI / 2, 0, 0], + length: length * 0.72, + width: bodyHeight * 0.12, + thickness: 0.018, + material: darkMat, + }, + { + kind: 'rounded-panel', + name: `${name} hood seam`, + semanticRole: 'vehicle_body_detail', + sourcePartKind: 'body_shell', + position: [length * 0.25, seamY, 0], + rotation: [0, 0, 0], + length: length * 0.28, + width: width * 0.62, + thickness: 0.01, + cornerRadius: Math.min(width, length) * 0.01, + material: darkMat, + }, + { + kind: 'rounded-panel', + name: `${name} trunk seam`, + semanticRole: 'vehicle_body_detail', + sourcePartKind: 'body_shell', + position: [-length * 0.32, seamY - bodyHeight * 0.025, 0], + rotation: [0, 0, 0], + length: length * 0.2, + width: width * 0.62, + thickness: 0.01, + cornerRadius: Math.min(width, length) * 0.01, + material: darkMat, + }, + { + kind: 'box', + name: `${name} left mirror`, + semanticRole: 'side_mirror', + sourcePartKind: 'body_shell', + position: [cabinX + cabinLength * 0.28, cabinY + cabinHeight * 0.03, -width * 0.46], + length: length * 0.035, + width: width * 0.08, + height: cabinHeight * 0.08, + material: darkMat, + }, + { + kind: 'box', + name: `${name} right mirror`, + semanticRole: 'side_mirror', + sourcePartKind: 'body_shell', + position: [cabinX + cabinLength * 0.28, cabinY + cabinHeight * 0.03, width * 0.46], + length: length * 0.035, + width: width * 0.08, + height: cabinHeight * 0.08, + material: darkMat, + }, + ] +} + +function composeFanAssembly(input: AssemblyComposeInput, constraints: UserGeometryConstraints) { + const height = + constraints.height?.value ?? numberValue(input.height, input.params?.height) ?? 1.35 + const radius = constraints.width?.value ? constraints.width.value / 2 : 0.36 + const baseHeight = height * 0.04 + const poleHeight = height * 0.66 + const poleTopY = baseHeight + poleHeight + const motorRadius = radius * 0.18 + const headCenterY = poleTopY + motorRadius + return composePartPrimitives( + partInput(input, constraints, [ + { + id: 'fan_base', + kind: 'circular_base', + radius: radius * 0.55, + height: baseHeight, + position: [0, baseHeight / 2, 0], + }, + { + id: 'fan_pole', + kind: 'vertical_pole', + height: poleHeight, + radius: radius * 0.045, + position: [0, baseHeight + poleHeight / 2, 0], + }, + { + id: 'fan_motor', + kind: 'motor_housing', + radius: motorRadius, + depth: radius * 0.32, + position: [0, headCenterY, -radius * 0.06], + }, + { + id: 'fan_blades', + kind: 'fan_blade', + count: 3, + length: radius * 0.58, + bladeWidth: radius * 0.18, + position: [0, headCenterY, 0], + }, + { + id: 'fan_grill', + kind: 'protective_grill', + radius: radius, + ringCount: 5, + spokeCount: 24, + position: [0, headCenterY, 0], + }, + ]), + ) +} + +function composePumpAssembly(input: AssemblyComposeInput, constraints: UserGeometryConstraints) { + const length = constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? 1.4 + const width = constraints.width?.value ?? length * 0.42 + const height = constraints.height?.value ?? length * 0.45 + return composePartPrimitives( + partInput(input, constraints, [ + { kind: 'skid_base', id: 'base', length, width, height: height * 0.12 }, + { + kind: 'ribbed_motor_body', + id: 'motor', + length: length * 0.42, + width: width * 0.58, + height: height * 0.48, + alignAbove: 'base', + side: 'left', + }, + { + kind: 'volute_casing', + id: 'volute', + radius: height * 0.23, + alignAbove: 'base', + side: 'right', + }, + { + kind: 'inlet_port', + id: 'inlet', + connectTo: 'volute', + connectPoint: 'inlet', + childPoint: 'back', + }, + { + kind: 'outlet_port', + id: 'outlet', + connectTo: 'volute', + connectPoint: 'outlet', + childPoint: 'back', + }, + { kind: 'flange_ring', connectTo: 'inlet', connectPoint: 'open', childPoint: 'back' }, + { kind: 'flange_ring', connectTo: 'outlet', connectPoint: 'open', childPoint: 'back' }, + { kind: 'control_box', alignAbove: 'motor' }, + { kind: 'nameplate' }, + ]), + ) +} + +function composeOutdoorAcAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const length = constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? 0.9 + const width = + constraints.width?.value ?? numberValue(input.width, input.params?.width) ?? length * 0.42 + const height = + constraints.height?.value ?? numberValue(input.height, input.params?.height) ?? length * 0.75 + const color = colorValue(input, constraints, '#e5e7eb') + const mat = materialFromColor(color) + const fanCenter: Vec3 = [length * 0.18, height * 0.55, width * 0.54] + const bladeLength = Math.min(length, height) * 0.28 + const bladeWidth = Math.min(length, height) * 0.055 + const bladeMat = materialFromColor('#475569') + return [ + { + kind: 'box', + name: `${assemblyName(input, 'outdoor AC')} enclosure`, + semanticRole: 'rounded_machine_body', + sourcePartKind: 'structure.enclosure', + position: [0, height / 2, 0], + length, + width, + height, + cornerRadius: Math.min(length, width, height) * 0.04, + cornerSegments: 8, + material: mat, + }, + { + kind: 'torus', + name: 'front circular grille', + semanticRole: 'vent_grille', + sourcePartKind: 'visual.glass_label_vent', + position: [length * 0.18, height * 0.55, width * 0.51], + axis: 'z', + majorRadius: Math.min(length, height) * 0.18, + tubeRadius: Math.min(length, height) * 0.012, + material: materialFromColor('#111827'), + }, + { + kind: 'cylinder', + name: 'fan hub', + semanticRole: 'fan_hub', + sourcePartKind: 'mechanical.wheel_rotor', + position: fanCenter, + axis: 'z', + radius: Math.min(length, height) * 0.045, + height: width * 0.04, + material: materialFromColor('#334155'), + }, + ...[0, (Math.PI * 2) / 3, (Math.PI * 4) / 3].map( + (angle): PrimitiveShapeInput => ({ + kind: 'box', + name: 'front fan blade', + semanticRole: 'fan_blade', + sourcePartKind: 'mechanical.wheel_rotor', + position: [ + fanCenter[0] + Math.cos(angle) * bladeLength * 0.28, + fanCenter[1] + Math.sin(angle) * bladeLength * 0.28, + fanCenter[2] + width * 0.012, + ], + rotation: [0, 0, angle], + length: bladeLength, + width: width * 0.035, + height: bladeWidth, + material: bladeMat, + }), + ), + { + kind: 'wedge', + name: 'side vent slats', + semanticRole: 'vent_slats', + sourcePartKind: 'visual.glass_label_vent', + position: [-length * 0.2, height * 0.48, -width * 0.52], + length: length * 0.44, + width: width * 0.04, + height: height * 0.38, + material: materialFromColor('#94a3b8'), + }, + { + kind: 'cylinder', + name: 'copper pipe port', + semanticRole: 'pipe_port', + sourcePartKind: 'connection.pipe_port', + position: [-length * 0.45, height * 0.35, -width * 0.55], + axis: 'z', + radius: height * 0.035, + height: width * 0.18, + material: materialFromColor('#b45309'), + }, + ] +} + +function composeConveyorAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const length = constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? 2.4 + const width = constraints.width?.value ?? length * 0.28 + const height = + constraints.height?.value ?? + numberValue(input.height, input.params?.height) ?? + Math.max(0.45, length * 0.18) + const frameMat = materialFromColor(colorValue(input, constraints, '#334155')) + const beltMat = materialFromColor(colorValue(input, constraints, '#111827')) + const metalMat = materialFromColor('#cbd5e1') + const rollerCount = Math.max(6, Math.min(12, Math.round(length * 3.2))) + return [ + { + kind: 'box', + name: 'belt conveyor left rail', + semanticRole: 'conveyor_frame', + sourcePartKind: 'material_handling.conveyor', + position: [0, height * 0.7, -width * 0.52], + length, + width: width * 0.04, + height: height * 0.08, + material: frameMat, + }, + { + kind: 'box', + name: 'belt conveyor right rail', + semanticRole: 'conveyor_frame', + sourcePartKind: 'material_handling.conveyor', + position: [0, height * 0.7, width * 0.52], + length, + width: width * 0.04, + height: height * 0.08, + material: frameMat, + }, + { + kind: 'box', + name: 'moving belt surface', + semanticRole: 'belt_surface', + sourcePartKind: 'material_handling.conveyor_belt', + position: [0, height * 0.76, 0], + length: length * 0.96, + width: width * 0.86, + height: height * 0.045, + material: beltMat, + }, + ...Array.from( + { length: rollerCount }, + (_, index): PrimitiveShapeInput => ({ + kind: 'cylinder', + name: 'conveyor roller', + semanticRole: 'roller_array', + sourcePartKind: 'material_handling.roller', + position: [ + -length * 0.42 + (length * 0.84 * index) / Math.max(1, rollerCount - 1), + height * 0.66, + 0, + ], + axis: 'z', + radius: height * 0.045, + height: width * 0.82, + material: metalMat, + }), + ), + ...[-0.42, -0.14, 0.14, 0.42].flatMap((x): PrimitiveShapeInput[] => [ + { + kind: 'cylinder', + name: 'conveyor support leg', + semanticRole: 'support_leg', + sourcePartKind: 'structure.base_frame', + position: [length * x, height * 0.34, -width * 0.38], + axis: 'y', + radius: width * 0.025, + height: height * 0.68, + material: frameMat, + }, + { + kind: 'cylinder', + name: 'conveyor support leg', + semanticRole: 'support_leg', + sourcePartKind: 'structure.base_frame', + position: [length * x, height * 0.34, width * 0.38], + axis: 'y', + radius: width * 0.025, + height: height * 0.68, + material: frameMat, + }, + ]), + { + kind: 'cylinder', + name: 'belt drive motor', + semanticRole: 'drive_motor', + sourcePartKind: 'mechanical.motor', + position: [length * 0.5, height * 0.72, width * 0.62], + axis: 'x', + radius: height * 0.12, + height: length * 0.16, + material: frameMat, + }, + ] +} + +type MachineToolVariant = 'cnc' | 'lathe' | 'milling' | 'grinder' | 'planer' | 'drill' + +function machineToolVariant( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): MachineToolVariant { + const text = + `${input.family ?? ''} ${input.object ?? ''} ${input.name ?? ''} ${input.variant ?? ''} ${input.params?.variant ?? ''} ${styleText(input, constraints)}`.toLowerCase() + if (/cnc|machining[_\s-]?center|\u6570\u63a7|\u52a0\u5de5\u4e2d\u5fc3/.test(text)) return 'cnc' + if (/lathe|turning|\u8f66\u5e8a|\u8eca\u5e8a/.test(text)) return 'lathe' + if (/milling|mill|\u94e3\u5e8a|\u92d1\u5e8a/.test(text)) return 'milling' + if (/grinder|grinding|\u78e8\u5e8a/.test(text)) return 'grinder' + if (/planer|planing|\u5228\u5e8a/.test(text)) return 'planer' + if (/drill|drilling|\u94bb\u5e8a|\u947d\u5e8a/.test(text)) return 'drill' + return 'cnc' +} + +function composeMachineToolAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const length = constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? 2.2 + const width = constraints.width?.value ?? length * 0.55 + const height = constraints.height?.value ?? length * 0.72 + const color = colorValue(input, constraints, '#64748b') + const hasExplicitBodyColor = color !== '#64748b' + const bodyMaterial = materialFromColor(color) + const baseMaterial = hasExplicitBodyColor ? bodyMaterial : materialFromColor('#334155') + const metalMaterial = materialFromColor('#cbd5e1') + const darkMaterial = materialFromColor('#111827') + const variant = machineToolVariant(input, constraints) + if (variant === 'lathe') { + return [ + { + kind: 'box', + name: 'lathe long base', + semanticRole: 'machine_base', + sourcePartKind: 'machine_tool.lathe_bed', + position: [0, height * 0.11, 0], + length, + width: width * 0.46, + height: height * 0.16, + material: baseMaterial, + }, + { + kind: 'box', + name: 'lathe precision bed ways', + semanticRole: 'machine_bed', + sourcePartKind: 'machine_tool.lathe_bed', + position: [0, height * 0.23, 0], + length: length * 0.9, + width: width * 0.32, + height: height * 0.06, + material: metalMaterial, + }, + { + kind: 'box', + name: 'headstock block', + semanticRole: 'headstock', + sourcePartKind: 'mechanical.motion_axis', + position: [-length * 0.36, height * 0.34, 0], + length: length * 0.18, + width: width * 0.52, + height: height * 0.28, + material: bodyMaterial, + }, + { + kind: 'cylinder', + name: 'spindle chuck', + semanticRole: 'spindle_chuck', + sourcePartKind: 'mechanical.motion_axis', + position: [-length * 0.24, height * 0.37, 0], + axis: 'x', + radius: width * 0.14, + height: length * 0.08, + material: darkMaterial, + }, + { + kind: 'box', + name: 'tailstock', + semanticRole: 'tailstock', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.34, height * 0.32, 0], + length: length * 0.14, + width: width * 0.38, + height: height * 0.22, + material: bodyMaterial, + }, + { + kind: 'box', + name: 'cross slide tool post', + semanticRole: 'tool_post', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.05, height * 0.34, 0], + length: length * 0.16, + width: width * 0.5, + height: height * 0.12, + material: metalMaterial, + }, + { + kind: 'cylinder', + name: 'workpiece centerline', + semanticRole: 'spindle_axis', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.37, 0], + axis: 'x', + radius: width * 0.035, + height: length * 0.52, + material: metalMaterial, + }, + { + kind: 'box', + name: 'lathe control panel', + semanticRole: 'control_panel', + sourcePartKind: 'electrical.controls', + position: [-length * 0.47, height * 0.52, width * 0.3], + length: length * 0.08, + width: width * 0.04, + height: height * 0.24, + material: darkMaterial, + }, + ] + } + if (variant === 'planer') { + return [ + { + kind: 'box', + name: 'planer long machine base', + semanticRole: 'machine_base', + sourcePartKind: 'machine_tool.planer_bed', + position: [0, height * 0.12, 0], + length, + width, + height: height * 0.18, + material: baseMaterial, + }, + { + kind: 'box', + name: 'traveling work table', + semanticRole: 'work_table', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.28, 0], + length: length * 0.76, + width: width * 0.72, + height: height * 0.08, + material: metalMaterial, + }, + { + kind: 'box', + name: 'gantry uprights', + semanticRole: 'gantry_frame', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.55, 0], + length: length * 0.16, + width: width * 0.95, + height: height * 0.6, + material: bodyMaterial, + }, + { + kind: 'box', + name: 'cross rail', + semanticRole: 'cross_rail', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.76, 0], + length: length * 0.86, + width: width * 0.08, + height: height * 0.08, + material: metalMaterial, + }, + { + kind: 'box', + name: 'reciprocating ram', + semanticRole: 'reciprocating_ram', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.08, height * 0.68, 0], + length: length * 0.34, + width: width * 0.14, + height: height * 0.12, + material: darkMaterial, + }, + { + kind: 'cylinder', + name: 'single point tool head', + semanticRole: 'tool_head', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.24, height * 0.55, 0], + axis: 'y', + radius: width * 0.045, + height: height * 0.18, + material: darkMaterial, + }, + { + kind: 'box', + name: 'planer control panel', + semanticRole: 'control_panel', + sourcePartKind: 'electrical.controls', + position: [length * 0.48, height * 0.46, width * 0.54], + length: length * 0.1, + width: width * 0.04, + height: height * 0.26, + material: darkMaterial, + }, + ] + } + if (variant === 'drill') { + return [ + { + kind: 'box', + name: 'drill press base', + semanticRole: 'machine_base', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.05, 0], + length: length * 0.62, + width: width * 0.72, + height: height * 0.1, + material: baseMaterial, + }, + { + kind: 'cylinder', + name: 'round drill column', + semanticRole: 'machine_column', + sourcePartKind: 'structure.base_frame', + position: [-length * 0.18, height * 0.46, 0], + axis: 'y', + radius: width * 0.06, + height: height * 0.82, + material: metalMaterial, + }, + { + kind: 'box', + name: 'lifting drill table', + semanticRole: 'work_table', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.06, height * 0.36, 0], + length: length * 0.42, + width: width * 0.48, + height: height * 0.06, + material: metalMaterial, + }, + { + kind: 'box', + name: 'radial drill head', + semanticRole: 'spindle_head', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.12, height * 0.78, 0], + length: length * 0.38, + width: width * 0.3, + height: height * 0.16, + material: bodyMaterial, + }, + { + kind: 'cylinder', + name: 'vertical drill bit', + semanticRole: 'drill_bit', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.22, height * 0.62, 0], + axis: 'y', + radius: width * 0.025, + height: height * 0.24, + material: darkMaterial, + }, + { + kind: 'box', + name: 'drill control panel', + semanticRole: 'control_panel', + sourcePartKind: 'electrical.controls', + position: [length * 0.32, height * 0.78, width * 0.2], + length: length * 0.08, + width: width * 0.04, + height: height * 0.16, + material: darkMaterial, + }, + ] + } + if (variant === 'grinder') { + return [ + { + kind: 'box', + name: 'grinder base', + semanticRole: 'machine_base', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.1, 0], + length, + width: width * 0.78, + height: height * 0.2, + material: baseMaterial, + }, + { + kind: 'box', + name: 'magnetic chuck table', + semanticRole: 'work_table', + sourcePartKind: 'mechanical.motion_axis', + position: [-length * 0.08, height * 0.3, 0], + length: length * 0.64, + width: width * 0.52, + height: height * 0.06, + material: metalMaterial, + }, + { + kind: 'box', + name: 'grinder column', + semanticRole: 'machine_column', + sourcePartKind: 'structure.base_frame', + position: [length * 0.24, height * 0.52, 0], + length: length * 0.18, + width: width * 0.45, + height: height * 0.64, + material: bodyMaterial, + }, + { + kind: 'cylinder', + name: 'grinding wheel', + semanticRole: 'grinding_wheel', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.08, height * 0.58, 0], + axis: 'z', + radius: width * 0.16, + height: width * 0.08, + material: darkMaterial, + }, + { + kind: 'box', + name: 'wheel guard', + semanticRole: 'wheel_guard', + sourcePartKind: 'structure.enclosure', + position: [length * 0.08, height * 0.62, 0], + length: width * 0.38, + width: width * 0.12, + height: width * 0.24, + material: bodyMaterial, + }, + { + kind: 'box', + name: 'grinder control panel', + semanticRole: 'control_panel', + sourcePartKind: 'electrical.controls', + position: [length * 0.44, height * 0.5, width * 0.42], + length: length * 0.1, + width: width * 0.04, + height: height * 0.26, + material: darkMaterial, + }, + ] + } + if (variant === 'milling') { + return [ + { + kind: 'box', + name: 'milling machine base', + semanticRole: 'machine_base', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.1, 0], + length: length * 0.72, + width: width * 0.72, + height: height * 0.2, + material: baseMaterial, + }, + { + kind: 'box', + name: 'vertical column', + semanticRole: 'machine_column', + sourcePartKind: 'structure.base_frame', + position: [-length * 0.22, height * 0.48, 0], + length: length * 0.2, + width: width * 0.55, + height: height * 0.72, + material: bodyMaterial, + }, + { + kind: 'box', + name: 'T slot work table', + semanticRole: 'work_table', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.12, height * 0.34, 0], + length: length * 0.58, + width: width * 0.48, + height: height * 0.07, + material: metalMaterial, + }, + { + kind: 'box', + name: 'spindle head', + semanticRole: 'spindle_head', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.05, height * 0.68, 0], + length: length * 0.32, + width: width * 0.28, + height: height * 0.16, + material: bodyMaterial, + }, + { + kind: 'cylinder', + name: 'milling cutter', + semanticRole: 'milling_cutter', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.16, height * 0.53, 0], + axis: 'y', + radius: width * 0.055, + height: height * 0.16, + material: darkMaterial, + }, + { + kind: 'box', + name: 'milling control panel', + semanticRole: 'control_panel', + sourcePartKind: 'electrical.controls', + position: [length * 0.38, height * 0.52, width * 0.42], + length: length * 0.1, + width: width * 0.04, + height: height * 0.24, + material: darkMaterial, + }, + ] + } + return [ + { + kind: 'box', + name: 'machine base', + semanticRole: 'machine_base', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.08, 0], + length, + width, + height: height * 0.16, + material: baseMaterial, + }, + { + kind: 'trapezoid-prism', + name: `${assemblyName(input, 'machine tool')} enclosure`, + semanticRole: 'machine_enclosure', + sourcePartKind: 'structure.enclosure', + position: [0, height * 0.5, 0], + length, + width, + height: height * 0.72, + topLengthScale: 0.92, + topWidthScale: 0.9, + material: bodyMaterial, + }, + { + kind: 'box', + name: 'work bed', + semanticRole: 'machine_bed', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.34, 0], + length: length * 0.68, + width: width * 0.62, + height: height * 0.06, + material: materialFromColor('#475569'), + }, + { + kind: 'box', + name: 'linear rail', + semanticRole: 'linear_rail', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.46, 0], + length: length * 0.66, + width: width * 0.05, + height: height * 0.04, + material: materialFromColor('#cbd5e1'), + }, + { + kind: 'cylinder', + name: 'spindle head', + semanticRole: 'spindle_head', + sourcePartKind: 'mechanical.motion_axis', + position: [length * 0.14, height * 0.58, 0], + axis: 'y', + radius: width * 0.08, + height: height * 0.18, + material: materialFromColor('#111827'), + }, + { + kind: 'rounded-panel', + name: 'front access glass', + semanticRole: 'glass_panel', + sourcePartKind: 'visual.glass_label_vent', + position: [-length * 0.18, height * 0.56, width * 0.51], + length: length * 0.35, + width: height * 0.28, + thickness: width * 0.02, + rotation: [Math.PI / 2, 0, 0], + material: { properties: { color: '#38bdf8', opacity: 0.42, transparent: true } }, + }, + { + kind: 'box', + name: 'control panel', + semanticRole: 'control_panel', + sourcePartKind: 'electrical.controls', + position: [length * 0.46, height * 0.56, width * 0.54], + length: length * 0.12, + width: width * 0.04, + height: height * 0.28, + material: materialFromColor('#111827'), + }, + ] +} + +function composeTankAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const text = styleText(input, constraints) + const height = constraints.height?.value ?? numberValue(input.height, input.params?.height) + const diameter = + constraints.width?.value ?? + numberValue(input.diameter, input.params?.diameter, input.width, input.params?.width) + const length = + constraints.length?.value ?? + numberValue(input.length, input.params?.length) ?? + (height ? height * 0.45 : 1.4) + const vertical = + /vertical|storage|\u7acb\u5f0f|\u7acb\u7f50|\u50a8\u7f50|\u5132\u7f50/.test(text) || + (height != null && height >= length) + const spherical = /spherical|sphere|ball|globular|\u7403\u7f50|\u7403\u5f62/.test(text) + if (spherical) { + const radius = (diameter ?? height ?? Math.max(1.2, length * 0.55)) / 2 + const legHeight = Math.max(0.25, radius * 0.55) + const centerY = legHeight + radius * 0.85 + const shellMat = materialFromColor(colorValue(input, constraints, '#94a3b8')) + const metalMat = materialFromColor('#64748b') + const nozzleRadius = Math.max(0.04, radius * 0.09) + const legRadius = Math.max(0.025, radius * 0.035) + const legSpread = radius * 0.62 + return [ + { + kind: 'sphere', + name: `${assemblyName(input, 'spherical tank')} vessel shell`, + semanticRole: 'vessel_shell', + sourcePartKind: 'process.spherical_vessel', + position: [0, centerY, 0], + radius, + widthSegments: 64, + heightSegments: 32, + material: shellMat, + }, + { + kind: 'torus', + name: 'spherical tank equator seam', + semanticRole: 'seam_ring', + sourcePartKind: 'connection.seam_ring', + position: [0, centerY, 0], + axis: 'y', + majorRadius: radius * 1.01, + tubeRadius: Math.max(0.012, radius * 0.018), + material: metalMat, + }, + { + kind: 'cylinder', + name: 'top inlet nozzle', + semanticRole: 'inlet_port', + sourcePartKind: 'connection.pipe_port', + position: [0, centerY + radius + nozzleRadius * 0.6, 0], + axis: 'y', + radius: nozzleRadius, + height: nozzleRadius * 1.2, + material: metalMat, + }, + ...[-1, 1].flatMap((xSign) => + [-1, 1].map((zSign) => ({ + kind: 'cylinder' as const, + name: 'spherical tank support leg', + semanticRole: 'support_leg', + sourcePartKind: 'structure.support_leg', + position: [xSign * legSpread, legHeight / 2, zSign * legSpread] as [ + number, + number, + number, + ], + axis: 'y' as const, + radius: legRadius, + height: legHeight, + material: metalMat, + })), + ), + ] + } + if (vertical) { + const h = height ?? Math.max(1.4, length * 1.8) + const radius = (diameter ?? Math.max(0.5, h * 0.28)) / 2 + const shellMat = materialFromColor(colorValue(input, constraints, '#94a3b8')) + const metalMat = materialFromColor('#cbd5e1') + return [ + { + kind: 'cylinder', + name: `${assemblyName(input, 'storage tank')} vertical vessel shell`, + semanticRole: 'vessel_shell', + sourcePartKind: 'process.vertical_vessel', + position: [0, h / 2, 0], + axis: 'y', + radius, + height: h, + radialSegments: 48, + material: shellMat, + }, + { + kind: 'cylinder', + name: 'tank support base', + semanticRole: 'support_base', + sourcePartKind: 'structure.base_frame', + position: [0, h * 0.04, 0], + axis: 'y', + radius: radius * 1.05, + height: h * 0.08, + material: materialFromColor('#334155'), + }, + { + kind: 'cylinder', + name: 'top inlet nozzle', + semanticRole: 'inlet_port', + sourcePartKind: 'connection.pipe_port', + position: [0, h + radius * 0.18, 0], + axis: 'y', + radius: radius * 0.12, + height: radius * 0.36, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'side outlet nozzle', + semanticRole: 'outlet_port', + sourcePartKind: 'connection.pipe_port', + position: [radius * 1.25, h * 0.28, 0], + axis: 'x', + radius: radius * 0.1, + height: radius * 0.5, + material: metalMat, + }, + { + kind: 'torus', + name: 'manway flange', + semanticRole: 'manway', + sourcePartKind: 'connection.mounting_flange', + position: [radius * 1.02, h * 0.62, 0], + axis: 'x', + majorRadius: radius * 0.22, + tubeRadius: radius * 0.025, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'level gauge', + semanticRole: 'level_gauge', + sourcePartKind: 'visual.gauge', + position: [-radius * 1.05, h * 0.5, 0], + axis: 'y', + radius: radius * 0.025, + height: h * 0.48, + material: materialFromColor('#38bdf8'), + }, + ] + } + return composePartPrimitives( + partInput(input, constraints, [ + { kind: 'cylindrical_tank', length, radius: diameter ? diameter / 2 : length * 0.18 }, + { kind: 'pipe_port', id: 'nozzle', position: [length * 0.45, length * 0.2, 0] }, + { kind: 'flange_ring', connectTo: 'nozzle', connectPoint: 'open', childPoint: 'back' }, + { kind: 'leg_set', length, width: length * 0.3, height: length * 0.18 }, + ]), + ) +} + +function composeDistillationTowerAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const height = constraints.height?.value ?? numberValue(input.height, input.params?.height) ?? 8 + const diameter = + constraints.width?.value ?? + numberValue(input.diameter, input.params?.diameter, input.width, input.params?.width) ?? + 1 + const radius = diameter / 2 + const shellMat = materialFromColor(colorValue(input, constraints, '#b0c4de')) + const darkMat = materialFromColor('#334155') + const metalMat = materialFromColor('#cbd5e1') + const nozzleRadius = Math.max(radius * 0.08, 0.04) + const nozzleLength = Math.max(radius * 0.7, 0.28) + const trayCount = height >= 6 ? 7 : 5 + const trayStart = height * 0.16 + const trayStep = (height * 0.68) / Math.max(1, trayCount - 1) + const platformYs = [height * 0.36, height * 0.68] + return [ + { + kind: 'cylinder', + name: `${assemblyName(input, 'distillation tower')} vertical column shell`, + semanticRole: 'distillation_column_shell', + sourcePartKind: 'process.distillation_column', + position: [0, height / 2, 0], + axis: 'y', + radius, + height, + radialSegments: 48, + material: shellMat, + }, + { + kind: 'cylinder', + name: 'bottom support skirt', + semanticRole: 'support_base', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.04, 0], + axis: 'y', + radius: radius * 1.04, + height: height * 0.08, + material: darkMat, + }, + { + kind: 'torus', + name: 'top flange ring', + semanticRole: 'top_flange', + sourcePartKind: 'connection.mounting_flange', + position: [0, height * 0.98, 0], + axis: 'y', + majorRadius: radius * 1.02, + tubeRadius: radius * 0.035, + material: metalMat, + }, + { + kind: 'torus', + name: 'bottom flange ring', + semanticRole: 'bottom_flange', + sourcePartKind: 'connection.mounting_flange', + position: [0, height * 0.12, 0], + axis: 'y', + majorRadius: radius * 1.02, + tubeRadius: radius * 0.035, + material: metalMat, + }, + ...Array.from( + { length: trayCount }, + (_, index): PrimitiveShapeInput => ({ + kind: 'torus', + name: 'internal tray level marker', + semanticRole: 'tray_level', + sourcePartKind: 'process.tray_pack', + position: [0, trayStart + trayStep * index, 0], + axis: 'y', + majorRadius: radius * 0.9, + tubeRadius: Math.max(radius * 0.012, 0.006), + material: materialFromColor('#64748b'), + }), + ), + { + kind: 'cylinder', + name: 'feed inlet nozzle', + semanticRole: 'inlet_port', + sourcePartKind: 'connection.pipe_port', + position: [radius + nozzleLength / 2, height * 0.42, 0], + axis: 'x', + radius: nozzleRadius, + height: nozzleLength, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'side product outlet nozzle', + semanticRole: 'outlet_port', + sourcePartKind: 'connection.pipe_port', + position: [-(radius + nozzleLength / 2), height * 0.72, 0], + axis: 'x', + radius: nozzleRadius, + height: nozzleLength, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'overhead vapor outlet', + semanticRole: 'overhead_vapor_outlet', + sourcePartKind: 'connection.pipe_port', + position: [0, height + nozzleLength * 0.35, 0], + axis: 'y', + radius: nozzleRadius, + height: nozzleLength * 0.7, + material: metalMat, + }, + ...platformYs.map( + (y): PrimitiveShapeInput => ({ + kind: 'torus', + name: 'circular access platform', + semanticRole: 'access_platform', + sourcePartKind: 'structure.platform', + position: [0, y, 0], + axis: 'y', + majorRadius: radius * 1.34, + tubeRadius: Math.max(radius * 0.025, 0.012), + material: darkMat, + }), + ), + { + kind: 'cylinder', + name: 'vertical cage ladder', + semanticRole: 'ladder', + sourcePartKind: 'structure.ladder', + position: [radius * 1.52, height * 0.5, 0], + axis: 'y', + radius: Math.max(radius * 0.025, 0.015), + height: height * 0.86, + material: darkMat, + }, + ] +} + +function composeReactorAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const height = constraints.height?.value ?? numberValue(input.height, input.params?.height) ?? 2.8 + const diameter = + constraints.width?.value ?? + numberValue(input.diameter, input.params?.diameter, input.width, input.params?.width) ?? + height * 0.42 + const radius = diameter / 2 + const shellMat = materialFromColor(colorValue(input, constraints, '#94a3b8')) + const metalMat = materialFromColor('#cbd5e1') + const darkMat = materialFromColor('#111827') + return [ + { + kind: 'cylinder', + name: `${assemblyName(input, 'reactor')} stirred vessel shell`, + semanticRole: 'reactor_vessel_shell', + sourcePartKind: 'process.reactor_vessel', + position: [0, height * 0.48, 0], + axis: 'y', + radius, + height: height * 0.78, + radialSegments: 48, + material: shellMat, + }, + { + kind: 'sphere', + name: 'reactor top dished head', + semanticRole: 'top_head', + sourcePartKind: 'process.vessel_head', + position: [0, height * 0.89, 0], + radius: 1, + scale: [radius, radius * 0.26, radius], + material: shellMat, + }, + { + kind: 'sphere', + name: 'reactor bottom dished head', + semanticRole: 'bottom_head', + sourcePartKind: 'process.vessel_head', + position: [0, height * 0.08, 0], + radius: 1, + scale: [radius, radius * 0.26, radius], + material: shellMat, + }, + { + kind: 'cylinder', + name: 'top agitator motor', + semanticRole: 'agitator_motor', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 1.05, 0], + axis: 'y', + radius: radius * 0.22, + height: height * 0.16, + material: darkMat, + }, + { + kind: 'cylinder', + name: 'agitator shaft', + semanticRole: 'agitator_shaft', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.48, 0], + axis: 'y', + radius: radius * 0.035, + height: height * 0.68, + material: metalMat, + }, + { + kind: 'box', + name: 'lower reactor impeller blade', + semanticRole: 'reactor_impeller', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.28, 0], + length: radius * 1.35, + width: radius * 0.08, + height: radius * 0.08, + rotation: [0, 0, Math.PI / 10], + material: metalMat, + }, + { + kind: 'box', + name: 'upper reactor impeller blade', + semanticRole: 'reactor_impeller', + sourcePartKind: 'mechanical.motion_axis', + position: [0, height * 0.52, 0], + length: radius * 1.2, + width: radius * 0.08, + height: radius * 0.08, + rotation: [0, Math.PI / 2, -Math.PI / 10], + material: metalMat, + }, + { + kind: 'cylinder', + name: 'feed inlet nozzle', + semanticRole: 'inlet_port', + sourcePartKind: 'connection.pipe_port', + position: [radius * 1.2, height * 0.65, 0], + axis: 'x', + radius: radius * 0.09, + height: radius * 0.5, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'bottom outlet nozzle', + semanticRole: 'outlet_port', + sourcePartKind: 'connection.pipe_port', + position: [0, -radius * 0.12, 0], + axis: 'y', + radius: radius * 0.09, + height: radius * 0.35, + material: metalMat, + }, + { + kind: 'torus', + name: 'reactor manway flange', + semanticRole: 'manway', + sourcePartKind: 'connection.mounting_flange', + position: [-radius * 1.04, height * 0.62, 0], + axis: 'x', + majorRadius: radius * 0.22, + tubeRadius: radius * 0.025, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'support skirt', + semanticRole: 'support_base', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.04, 0], + axis: 'y', + radius: radius * 1.05, + height: height * 0.08, + material: darkMat, + }, + ] +} + +function composeCompressorAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const length = constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? 1.8 + const width = + constraints.width?.value ?? numberValue(input.width, input.params?.width) ?? length * 0.42 + const height = + constraints.height?.value ?? numberValue(input.height, input.params?.height) ?? length * 0.45 + const bodyMat = materialFromColor(colorValue(input, constraints, '#64748b')) + const metalMat = materialFromColor('#cbd5e1') + return [ + { + kind: 'box', + name: 'compressor skid base', + semanticRole: 'machine_base', + sourcePartKind: 'structure.base_frame', + position: [0, height * 0.08, 0], + length, + width, + height: height * 0.16, + material: materialFromColor('#334155'), + }, + { + kind: 'cylinder', + name: 'ribbed drive motor', + semanticRole: 'motor_body', + sourcePartKind: 'mechanical.motor', + position: [-length * 0.22, height * 0.38, 0], + axis: 'x', + radius: height * 0.18, + height: length * 0.34, + radialSegments: 32, + material: bodyMat, + }, + { + kind: 'cylinder', + name: 'compressor casing', + semanticRole: 'compressor_casing', + sourcePartKind: 'fluid.rotating_machine', + position: [length * 0.22, height * 0.38, 0], + axis: 'x', + radius: height * 0.2, + height: length * 0.28, + radialSegments: 32, + material: bodyMat, + }, + { + kind: 'cylinder', + name: 'coupling guard', + semanticRole: 'coupling_guard', + sourcePartKind: 'mechanical.guard', + position: [0, height * 0.38, 0], + axis: 'x', + radius: height * 0.13, + height: length * 0.14, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'suction inlet port', + semanticRole: 'inlet_port', + sourcePartKind: 'connection.pipe_port', + position: [length * 0.22, height * 0.38, width * 0.38], + axis: 'z', + radius: height * 0.07, + height: width * 0.34, + material: metalMat, + }, + { + kind: 'cylinder', + name: 'discharge outlet port', + semanticRole: 'outlet_port', + sourcePartKind: 'connection.pipe_port', + position: [length * 0.36, height * 0.62, 0], + axis: 'y', + radius: height * 0.06, + height: height * 0.28, + material: metalMat, + }, + { + kind: 'box', + name: 'compressor control panel', + semanticRole: 'control_panel', + sourcePartKind: 'electrical.controls', + position: [-length * 0.46, height * 0.42, width * 0.34], + length: length * 0.1, + width: width * 0.04, + height: height * 0.32, + material: materialFromColor('#111827'), + }, + ] +} + +function composeGrateCoolerAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): PrimitiveShapeInput[] { + const length = constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? 4.8 + const width = + constraints.width?.value ?? numberValue(input.width, input.params?.width) ?? length * 0.34 + const height = + constraints.height?.value ?? numberValue(input.height, input.params?.height) ?? length * 0.22 + const bodyMat = materialFromColor(colorValue(input, constraints, '#64748b')) + const darkMat = materialFromColor('#111827') + const metalMat = materialFromColor('#cbd5e1') + return [ + { + kind: 'box', + name: 'grate cooler housing', + semanticRole: 'cooler_housing', + sourcePartKind: 'structure.enclosure', + position: [0, height * 0.52, 0], + length, + width, + height: height * 0.72, + cornerRadius: Math.min(length, width, height) * 0.03, + material: bodyMat, + }, + { + kind: 'box', + name: 'inclined grate bed', + semanticRole: 'cooler_grate_bed', + sourcePartKind: 'material_handling.grate_bed', + position: [0, height * 0.48, 0], + length: length * 0.82, + width: width * 0.72, + height: height * 0.06, + rotation: [0, 0, -0.08], + material: metalMat, + }, + { + kind: 'box', + name: 'hot clinker inlet hood', + semanticRole: 'inlet_chute', + sourcePartKind: 'connection.chute', + position: [-length * 0.44, height * 0.78, 0], + length: length * 0.12, + width: width * 0.62, + height: height * 0.32, + material: darkMat, + }, + { + kind: 'box', + name: 'cooled clinker outlet chute', + semanticRole: 'outlet_chute', + sourcePartKind: 'connection.chute', + position: [length * 0.46, height * 0.28, 0], + length: length * 0.12, + width: width * 0.62, + height: height * 0.2, + material: darkMat, + }, + ...[-0.28, 0, 0.28].map( + (x): PrimitiveShapeInput => ({ + kind: 'box', + name: 'under grate cooling air box', + semanticRole: 'cooling_air_box', + sourcePartKind: 'process.cooling_air', + position: [length * x, height * 0.14, -width * 0.48], + length: width * 0.18, + width: width * 0.16, + height: height * 0.18, + material: darkMat, + }), + ), + ...[-0.3, 0, 0.3].map( + (x): PrimitiveShapeInput => ({ + kind: 'box', + name: 'grate segment line', + semanticRole: 'grate_segment', + sourcePartKind: 'material_handling.grate_bed', + position: [length * x, height * 0.54, 0], + length: length * 0.02, + width: width * 0.72, + height: height * 0.03, + material: darkMat, + }), + ), + { + kind: 'box', + name: 'grate cooler drive unit', + semanticRole: 'drive_motor', + sourcePartKind: 'mechanical.motor', + position: [length * 0.35, height * 0.32, width * 0.55], + length: length * 0.14, + width: width * 0.16, + height: height * 0.18, + material: darkMat, + }, + ] +} + +function composeElectricalAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +) { + const height = constraints.height?.value ?? numberValue(input.height, input.params?.height) ?? 1.6 + const length = + constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? height * 0.62 + const width = constraints.width?.value ?? height * 0.28 + return composePartPrimitives( + partInput(input, constraints, [ + { kind: 'electrical_cabinet', length, width, height }, + { kind: 'control_box', position: [length * 0.2, height * 0.62, width * 0.53] }, + { kind: 'cable_tray', alignBeside: 0, side: 'right', height: height * 0.62 }, + { kind: 'nameplate' }, + { kind: 'warning_label' }, + ]), + ) +} + +function composeRobotArmAssembly( + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +) { + const text = styleText(input, constraints) + const isFanuc = /fanuc|m-710/i.test(`${text} ${input.object ?? ''} ${input.name ?? ''}`) + const reach = constraints.length?.value ?? numberValue(input.length, input.params?.length) ?? 2.05 + const primaryColor = + constraints.primaryColor?.value ?? + stringValue(input.primaryColor, input.color, input.params?.primaryColor, input.params?.color) ?? + (isFanuc || /white|白|白色/.test(text) ? '#f8fafc' : '#64748b') + const secondaryColor = + stringValue(input.secondaryColor, input.params?.secondaryColor, input.params?.accentColor) ?? + (isFanuc || /yellow|黄|黃色|黄色/.test(text) ? '#facc15' : primaryColor) + const axisCount = + numberValue(input.axisCount, input.params?.axisCount) ?? + (/six|6.?axis|六轴|六軸/.test(text) ? 6 : 3) + return composeRobotArmPrimitives({ + name: assemblyName(input, isFanuc ? 'FANUC robot arm' : 'industrial robot arm'), + style: isFanuc ? 'fanuc' : 'industrial', + pose: 'work-ready', + axisCount, + baseShape: 'round', + endEffector: stringValue(input.endEffector, input.params?.endEffector) ?? 'tool-flange', + reach, + detail: 'high', + primaryColor, + secondaryColor, + darkColor: stringValue(input.darkColor, input.params?.darkColor) ?? '#111827', + metalColor: stringValue(input.metalColor, input.params?.metalColor) ?? '#cbd5e1', + position: input.position, + }) +} + +export function planAssemblyParts(input: AssemblyComposeInput = {}): AssemblyPartPlanItem[] { + const constraints = withInputConstraints(input) + switch (familyFor(input, constraints)) { + case 'vehicle': + return [ + { role: 'body', capability: 'structure.enclosure', partKind: 'body_shell' }, + { role: 'wheels', capability: 'mechanical.wheel_rotor', partKind: 'wheel_set', count: 4 }, + { role: 'glass', capability: 'visual.glass_label_vent', partKind: 'window_strip' }, + { role: 'lights', capability: 'electrical.controls', partKind: 'headlights', count: 4 }, + { role: 'bumpers', capability: 'structure.base_frame', partKind: 'bumper', count: 2 }, + ] + case 'outdoor_ac': + return [ + { role: 'enclosure', capability: 'structure.enclosure', partKind: 'rounded_machine_body' }, + { role: 'grille', capability: 'visual.glass_label_vent', partKind: 'vent_grill' }, + { role: 'fan', capability: 'mechanical.wheel_rotor', partKind: 'fan_blade' }, + { role: 'ports', capability: 'connection.pipe_port', partKind: 'pipe_port' }, + ] + case 'machine_tool': + return [ + { role: 'enclosure', capability: 'structure.enclosure', partKind: 'rounded_machine_body' }, + { role: 'linear_axis', capability: 'mechanical.motion_axis', partKind: 'pipe_rack' }, + { role: 'spindle', capability: 'mechanical.motion_axis', partKind: 'ribbed_motor_body' }, + { role: 'controls', capability: 'electrical.controls', partKind: 'control_box' }, + ] + case 'distillation_tower': + return [ + { + role: 'vertical_column_shell', + capability: 'process.vertical_vessel', + partKind: 'distillation_column_shell', + }, + { role: 'tray_pack', capability: 'process.internals', partKind: 'tray_level', count: 7 }, + { role: 'ports', capability: 'connection.pipe_port', partKind: 'pipe_port', count: 3 }, + { + role: 'platforms', + capability: 'structure.platform', + partKind: 'access_platform', + count: 2, + }, + { role: 'ladder', capability: 'structure.ladder', partKind: 'ladder' }, + ] + case 'reactor': + return [ + { + role: 'reactor_vessel', + capability: 'process.vertical_vessel', + partKind: 'reactor_vessel_shell', + }, + { role: 'agitator', capability: 'mechanical.motion_axis', partKind: 'agitator_shaft' }, + { role: 'ports', capability: 'connection.pipe_port', partKind: 'pipe_port', count: 2 }, + ] + case 'compressor': + return [ + { role: 'skid', capability: 'structure.base_frame', partKind: 'skid_base' }, + { role: 'motor', capability: 'mechanical.motion_axis', partKind: 'ribbed_motor_body' }, + { role: 'compressor_casing', capability: 'fluid.flow_body', partKind: 'compressor_casing' }, + { role: 'ports', capability: 'connection.pipe_port', partKind: 'pipe_port', count: 2 }, + ] + case 'grate_cooler': + return [ + { role: 'housing', capability: 'structure.enclosure', partKind: 'cooler_housing' }, + { + role: 'grate_bed', + capability: 'material_handling.conveyor', + partKind: 'cooler_grate_bed', + }, + { + role: 'cooling_fans', + capability: 'mechanical.wheel_rotor', + partKind: 'cooling_fan', + count: 3, + }, + ] + case 'robot_arm': + return [ + { role: 'base', capability: 'structure.base_frame', partKind: 'robot_base' }, + { + role: 'joints', + capability: 'mechanical.motion_axis', + partKind: 'robot_joints', + count: 6, + }, + { role: 'links', capability: 'structure.enclosure', partKind: 'robot_links' }, + { role: 'tool_flange', capability: 'connection.mounting_flange', partKind: 'tool_flange' }, + ] + default: + return [] + } +} + +export function getAssemblyGeometryBrief( + input: AssemblyComposeInput = {}, +): PrimitiveGeometryBrief | undefined { + const constraints = withInputConstraints(input) + const family = familyFor(input, constraints) + if (family === 'unknown') return undefined + const roles = assemblyRequiredRoles(family) + return { + category: family, + units: 'm', + coordinateConvention: '+X length, +Y up, +Z width; y=0 is ground/base', + expectedDimensions: { + length: constraints.length?.value, + width: constraints.width?.value, + height: constraints.height?.value, + }, + requiredRoles: roles, + validationTargets: [ + 'hard user constraints override defaults', + 'generic reusable parts, not whole-object recipes', + ], + } +} + +function assemblyRequiredRoles(family: AssemblyObjectFamily): string[] { + switch (family) { + case 'vehicle': + return ['body_shell', 'wheel_set', 'window_strip', 'light_pair', 'bar_pair'] + case 'outdoor_ac': + return ['rounded_machine_body', 'fan_blade', 'pipe_port'] + case 'machine_tool': + return ['machine_base', 'control_panel'] + case 'distillation_tower': + return [ + 'distillation_column_shell', + 'tray_level', + 'inlet_port', + 'outlet_port', + 'access_platform', + 'ladder', + ] + case 'reactor': + return [ + 'reactor_vessel_shell', + 'agitator_motor', + 'agitator_shaft', + 'reactor_impeller', + 'inlet_port', + 'outlet_port', + ] + case 'compressor': + return [ + 'machine_base', + 'motor_body', + 'compressor_casing', + 'inlet_port', + 'outlet_port', + 'control_panel', + ] + case 'grate_cooler': + return [ + 'cooler_housing', + 'cooler_grate_bed', + 'cooling_air_box', + 'inlet_chute', + 'outlet_chute', + ] + case 'fan': + return ['motor_housing', 'fan_blade', 'protective_grill'] + case 'pump': + return ['volute_casing', 'inlet_port', 'outlet_port'] + case 'conveyor': + return ['conveyor_frame', 'roller_array', 'belt_surface'] + case 'tank': + return [] + case 'electrical': + return ['electrical_cabinet', 'control_box'] + case 'robot_arm': + return [ + 'robot_base', + 'base_joint', + 'shoulder_joint', + 'upper_arm', + 'elbow_joint', + 'forearm', + 'wrist_joint', + 'end_effector', + ] + default: + return [] + } +} + +export function composeAssemblyPrimitives(input: AssemblyComposeInput = {}): PrimitiveShapeInput[] { + const constraints = withInputConstraints(input) + const family = familyFor(input, constraints) + switch (family) { + case 'vehicle': + return composeVehicleAssembly(input, constraints) + case 'fan': + return composeFanAssembly(input, constraints) + case 'pump': + return composePumpAssembly(input, constraints) + case 'conveyor': + return composeConveyorAssembly(input, constraints) + case 'machine_tool': + return composeMachineToolAssembly(input, constraints) + case 'distillation_tower': + return composeDistillationTowerAssembly(input, constraints) + case 'reactor': + return composeReactorAssembly(input, constraints) + case 'compressor': + return composeCompressorAssembly(input, constraints) + case 'grate_cooler': + return composeGrateCoolerAssembly(input, constraints) + case 'outdoor_ac': + return composeOutdoorAcAssembly(input, constraints) + case 'tank': + return composeTankAssembly(input, constraints) + case 'electrical': + return composeElectricalAssembly(input, constraints) + case 'robot_arm': + return composeRobotArmAssembly(input, constraints) + default: + return family === 'unknown' ? [] : composeRegistryFamilyAssembly(input, constraints, family) + } +} diff --git a/packages/core/src/lib/assembly-constraints.test.ts b/packages/core/src/lib/assembly-constraints.test.ts new file mode 100644 index 000000000..a3c062a32 --- /dev/null +++ b/packages/core/src/lib/assembly-constraints.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'bun:test' +import { + alignPrimaryShapeColorsToConstraints, + extractUserGeometryConstraints, + inferAssemblyFamily, + validateAssemblyConstraints, +} from './assembly-constraints' +import { FAMILY_DEFINITIONS, getFamilyDefinition, normalizeFamilyId } from './family-registry' +import { composePartPrimitives } from './part-compose' + +describe('assembly family registry integration', () => { + test('uses the family registry as the canonical assembly family source', () => { + expect(normalizeFamilyId('cnc machine')).toBe('machine_tool') + expect(normalizeFamilyId('industrial robot')).toBe('robot_arm') + expect(normalizeFamilyId('condenser unit')).toBe('outdoor_ac') + + expect(inferAssemblyFamily('build a cnc machining center')).toBe('machine_tool') + expect(inferAssemblyFamily('build a six axis industrial robot')).toBe('robot_arm') + expect(inferAssemblyFamily('build a clinker cooler')).toBe('grate_cooler') + }) + + test('infers registry aliases as tokens instead of arbitrary substrings', () => { + expect(inferAssemblyFamily('build a FANUC M-710 industrial arm')).toBe('robot_arm') + expect(inferAssemblyFamily('build an industrial fan with a guard')).toBe('fan') + expect(inferAssemblyFamily('build a fantasy display object')).toBe('unknown') + }) + + test('does not let generic single-character Chinese aliases steal compound aliases', () => { + expect(inferAssemblyFamily('生成一个搅拌罐')).toBe('reactor') + expect(inferAssemblyFamily('生成一个罐')).toBe('tank') + }) + + test('keeps registry families available to assembly constraints', () => { + const expected = [ + 'fan', + 'outdoor_ac', + 'distillation_tower', + 'grate_cooler', + 'valve', + 'robot_arm', + 'machine_tool', + 'tank', + 'compressor', + 'heat_exchanger', + 'bicycle', + 'mixer', + 'forming_machine', + 'material_handling', + 'fluid_machine', + 'process_equipment', + ] + + for (const family of expected) { + expect(getFamilyDefinition(family)?.id).toBe(family) + } + }) + + test('advertises only part-compose compatible family parts', () => { + const advertisedKinds = new Set( + FAMILY_DEFINITIONS.flatMap((family) => [...family.requiredParts, ...family.optionalParts]), + ) + + for (const kind of advertisedKinds) { + const shapes = composePartPrimitives({ + registryPartPlan: true, + autoComplete: false, + parts: [{ kind }], + }) + + expect(shapes.length, `family registry part "${kind}" should compose`).toBeGreaterThan(0) + } + }) + + test('keeps primary shape metadata for every registry family', () => { + for (const family of FAMILY_DEFINITIONS) { + expect( + family.primarySemanticRoles.length, + `family ${family.id} must declare primarySemanticRoles`, + ).toBeGreaterThan(0) + } + }) + + test('validates pump and fan primary length constraints without regex fallback', () => { + const length = { value: 1.2, source: 'args' as const, priority: 'hard' as const } + + for (const [family, semanticRole] of [ + ['pump', 'volute_casing'], + ['fan', 'motor_housing'], + ] as const) { + const result = validateAssemblyConstraints( + [{ kind: 'box', semanticRole, length: 0.4, width: 0.2, height: 0.2 }], + { family, length }, + ) + + expect(result.ok, `${family} should fail mismatched primary length`).toBe(false) + expect(result.issues).toContain( + 'Hard constraint failed: expected primary length 1.2m, got 0.4m.', + ) + } + }) + + test('validates industrial assembly length from the largest primary part', () => { + const result = validateAssemblyConstraints( + [ + { kind: 'box', semanticRole: 'volute_casing', length: 0.43, width: 0.2, height: 0.2 }, + { kind: 'box', sourcePartKind: 'skid_base', length: 2.6, width: 1.1, height: 0.18 }, + ], + { family: 'pump', length: { value: 2.6, source: 'prompt', priority: 'hard' } }, + ) + + expect(result.ok).toBe(true) + }) + + test('does not hard-fail industrial assemblies on prompt-derived part colors', () => { + const result = validateAssemblyConstraints( + [ + { + kind: 'box', + semanticRole: 'conveyor_frame', + length: 4.2, + width: 0.85, + height: 0.75, + material: { properties: { color: '#94a3b8' } }, + }, + ], + { + family: 'conveyor', + length: { value: 4.2, source: 'prompt', priority: 'hard' }, + primaryColor: { value: '#111827', source: 'prompt', priority: 'hard' }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.warnings).toContain( + 'Soft constraint warning: expected primary color #111827, got #94a3b8.', + ) + + const extractedConstraints = extractUserGeometryConstraints( + 'black belt conveyor with a yellow warning label, length 4.2m', + { family: 'conveyor' }, + ) + const extracted = validateAssemblyConstraints( + [ + { + kind: 'box', + semanticRole: 'conveyor_frame', + length: 4.2, + width: 0.85, + height: 0.75, + material: { properties: { color: '#94a3b8' } }, + }, + ], + extractedConstraints, + ) + + expect(extractedConstraints.primaryColor).toBeUndefined() + expect(extracted.ok).toBe(true) + }) + + test('auto-aligns primary shape colors without changing non-primary details', () => { + const aligned = alignPrimaryShapeColorsToConstraints( + [ + { + kind: 'box', + semanticRole: 'motor_housing', + length: 0.4, + width: 0.2, + height: 0.2, + material: { properties: { color: '#30343b' } }, + }, + { + kind: 'torus', + semanticRole: 'protective_grill', + material: { properties: { color: '#d1d5db' } }, + }, + ], + { + family: 'fan', + primaryColor: { value: '#ef4444', source: 'prompt', priority: 'hard' }, + }, + ) + + expect(aligned.changedCount).toBe(1) + expect(aligned.shapes[0]?.material?.properties?.color).toBe('#ef4444') + expect(aligned.shapes[1]?.material?.properties?.color).toBe('#d1d5db') + }) + + test('does not parse hex color literals as meter dimensions', () => { + const constraints = extractUserGeometryConstraints( + 'Create an industrial pedestal fan. Use red and black colors.', + { + family: 'fan', + primaryColor: '#CC0000', + secondaryColor: '#111111', + parts: [ + { kind: 'motor_housing', params: { primaryColor: '#CC0000' } }, + { kind: 'protective_grill', params: { metalColor: '#111111' } }, + ], + }, + ) + + expect(constraints.length).toBeUndefined() + expect(constraints.primaryColor?.value).toBe('#CC0000') + }) +}) diff --git a/packages/core/src/lib/assembly-constraints.ts b/packages/core/src/lib/assembly-constraints.ts new file mode 100644 index 000000000..c33f6d82a --- /dev/null +++ b/packages/core/src/lib/assembly-constraints.ts @@ -0,0 +1,528 @@ +import { type FamilyId, getFamilyDefinition, inferFamilyDefinition } from './family-registry' +import type { PrimitiveMaterialInput, PrimitiveShapeInput } from './primitive-compose' + +export type AssemblyObjectFamily = FamilyId | 'unknown' + +export type HardGeometryConstraint = { + source: 'prompt' | 'args' + priority: 'hard' +} + +export type HardNumberConstraint = HardGeometryConstraint & { + value: number +} + +export type HardColorConstraint = HardGeometryConstraint & { + value: string +} + +export type UserGeometryConstraints = { + family: AssemblyObjectFamily + style?: string + length?: HardNumberConstraint + width?: HardNumberConstraint + height?: HardNumberConstraint + primaryColor?: HardColorConstraint +} + +export type AssemblyConstraintValidation = { + ok: boolean + issues: string[] + warnings: string[] +} + +const PROMPT_COLOR_AS_PART_DETAIL_FAMILIES = new Set([ + 'conveyor', + 'pump', + 'tank', + 'reactor', + 'compressor', + 'heat_exchanger', + 'machine_tool', + 'forming_machine', + 'material_handling', + 'fluid_machine', + 'process_equipment', + 'distillation_tower', + 'robot_arm', +]) + +const RAW_PRIMARY_LENGTH_UNRELIABLE_FAMILIES = new Set([ + 'aircraft', + 'robot_arm', + 'tank', + 'reactor', + 'compressor', + 'process_equipment', + 'distillation_tower', +]) + +function textOf(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hardNumber(value: number, source: HardGeometryConstraint['source']): HardNumberConstraint { + return { value, source, priority: 'hard' } +} + +function hardString(value: string, source: HardGeometryConstraint['source']): HardColorConstraint { + return { value, source, priority: 'hard' } +} + +export function inferAssemblyFamily( + prompt: string, + args?: Record, +): AssemblyObjectFamily { + return (inferFamilyDefinition({ + ...(args ?? {}), + prompt, + object: args?.object, + name: args?.name, + })?.id ?? 'unknown') as AssemblyObjectFamily +} + +function unitScale(unit: unknown): number { + if (typeof unit !== 'string') return 1 + switch (unit.trim().toLowerCase()) { + case 'mm': + case '毫米': + return 0.001 + case 'cm': + case '厘米': + return 0.01 + default: + return 1 + } +} + +function parseChineseNumber(value: string): number | undefined { + const numeric = Number(value) + if (Number.isFinite(numeric)) return numeric + + const normalized = value.replaceAll('\u5169', '\u4e8c') + const digitMap: Record = { + '\u4e00': 1, + '\u4e8c': 2, + '\u4e09': 3, + '\u56db': 4, + '\u4e94': 5, + '\u516d': 6, + '\u4e03': 7, + '\u516b': 8, + '\u4e5d': 9, + } + + if (normalized === '\u5341') return 10 + if (normalized.includes('\u5341')) { + const [tensRaw, onesRaw] = normalized.split('\u5341') + const tens = tensRaw ? digitMap[tensRaw] : 1 + const ones = onesRaw ? digitMap[onesRaw] : 0 + return tens != null && ones != null ? tens * 10 + ones : undefined + } + return digitMap[normalized] +} + +function parseDimensionMatch(match: RegExpMatchArray | null): number | undefined { + if (!match?.[1]) return undefined + const value = parseChineseNumber(match[1]) + if (value == null) return undefined + return Number((value * unitScale(match[2])).toFixed(4)) +} + +const COLOR_HEX: Array<[RegExp, string]> = [ + [/(绿色|綠色|green)/i, '#22c55e'], + [/(红色|紅色|\bred\b)/i, '#ef4444'], + [/(蓝色|藍色|blue)/i, '#2563eb'], + [/(黄色|黃色|yellow)/i, '#facc15'], + [/(黑色|black)/i, '#111827'], + [/(白色|white)/i, '#f8fafc'], + [/(灰色|grey|gray)/i, '#64748b'], + [/(紫色|purple)/i, '#8b5cf6'], + [/(橙色|orange)/i, '#f97316'], + [/(粉色|pink)/i, '#ec4899'], +] + +function promptColor(prompt: string): string | undefined { + return COLOR_HEX.find(([pattern]) => pattern.test(prompt))?.[1] +} + +function stripColorLiterals(text: string): string { + return text.replace(/#[0-9a-f]{3,8}\b/gi, ' ') +} + +function promptDimensions(prompt: string, family: AssemblyObjectFamily): Record { + const dimensions: Record = {} + const numberPattern = + '([0-9]+(?:\\.[0-9]+)?|[\\u4e00\\u4e8c\\u4e24\\u5169\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341]+)' + const requiredUnitPattern = '(mm|\\u6beb\\u7c73|cm|\\u5398\\u7c73|m|\\u7c73)' + const unitPattern = '(mm|\\u6beb\\u7c73|cm|\\u5398\\u7c73|m|\\u7c73)?' + const dimensionPattern = (labels: string) => + new RegExp( + `(?:${labels})\\s*(?:\\u4e3a|\\u662f|\\u7ea6|\\u7d04|:)?\\s*${numberPattern}\\s*${unitPattern}`, + 'i', + ) + const patterns: Array<[string, RegExp]> = [ + [ + 'length', + dimensionPattern('\\u957f\\u5ea6|\\u9577\\u5ea6|\\u8f66\\u957f|\\u8eca\\u9577|length|long'), + ], + ['width', dimensionPattern('\\u5bbd\\u5ea6|\\u5bec\\u5ea6|width|wide')], + ['width', dimensionPattern('\\u76f4\\u5f84|\\u76f4\\u5f91|diameter|dia\\.?')], + ['height', dimensionPattern('\\u9ad8\\u5ea6|height|tall')], + ] + + for (const [key, pattern] of patterns) { + const dimension = parseDimensionMatch(prompt.match(pattern)) + if (dimension != null) dimensions[key] = dimension + } + + if (family !== 'unknown' && family !== 'distillation_tower' && dimensions.length == null) { + const dimension = parseDimensionMatch( + prompt.match(new RegExp(`${numberPattern}\\s*${requiredUnitPattern}`, 'i')), + ) + if (dimension != null) dimensions.length = dimension + } + + if (family === 'outdoor_ac') { + const ordered = Array.from( + prompt.matchAll(new RegExp(`([0-9]+(?:\\.[0-9]+)?)\\s*${requiredUnitPattern}\\b`, 'gi')), + ) + .map((match) => parseDimensionMatch(match)) + .filter((value): value is number => value != null) + const length = dimensions.length ?? dimensions.width ?? ordered[0] + const width = + dimensions.depth ?? + (ordered.length >= 3 ? ordered[1] : undefined) ?? + (dimensions.length != null && dimensions.width != null ? dimensions.width : undefined) ?? + ordered[1] ?? + dimensions.width + const height = dimensions.height ?? ordered[2] + return { + ...(length ? { length } : {}), + ...(width ? { width } : {}), + ...(height ? { height } : {}), + } + } + + return dimensions +} + +function numberArg(args: Record, params: Record, key: string) { + const value = args[key] ?? params[key] + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function normalizeDimensionValue( + value: number | undefined, + family: AssemblyObjectFamily, +): number | undefined { + if (value == null) return undefined + if (family !== 'unknown' && value > 50) return Number((value * 0.001).toFixed(4)) + return value +} + +function scaledNumber(value: unknown, scale: number): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? Number((value * scale).toFixed(4)) + : undefined +} + +function dimensionArgs( + args: Record, + family: AssemblyObjectFamily, +): Record { + const dimensions = isRecord(args.dimensions) ? args.dimensions : undefined + if (!dimensions) return {} + const scale = unitScale(dimensions.units ?? args.units) + if (family === 'outdoor_ac') { + return { + ...(scaledNumber(dimensions.length ?? dimensions.width, scale) != null + ? { length: scaledNumber(dimensions.length ?? dimensions.width, scale) } + : {}), + ...(scaledNumber(dimensions.depth ?? dimensions.width, scale) != null + ? { width: scaledNumber(dimensions.depth ?? dimensions.width, scale) } + : {}), + ...(scaledNumber(dimensions.height, scale) != null + ? { height: scaledNumber(dimensions.height, scale) } + : {}), + } + } + return { + ...(scaledNumber(dimensions.length, scale) != null + ? { length: scaledNumber(dimensions.length, scale) } + : {}), + ...(scaledNumber(dimensions.width ?? dimensions.depth ?? dimensions.diameter, scale) != null + ? { width: scaledNumber(dimensions.width ?? dimensions.depth ?? dimensions.diameter, scale) } + : {}), + ...(scaledNumber(dimensions.height, scale) != null + ? { height: scaledNumber(dimensions.height, scale) } + : {}), + } +} + +function stringArg( + args: Record, + params: Record, + ...keys: string[] +) { + for (const key of keys) { + const value = args[key] ?? params[key] + if (typeof value === 'string' && value.trim()) return value + } + return undefined +} + +export function extractUserGeometryConstraints( + prompt: string, + args: Record = {}, +): UserGeometryConstraints { + const params = isRecord(args.params) ? args.params : {} + const family = inferAssemblyFamily(prompt, args) + const dimensions = promptDimensions(stripColorLiterals(`${prompt} ${textOf(args)}`), family) + const explicitDimensions = dimensionArgs(args, family) + const constraints: UserGeometryConstraints = { + family, + style: stringArg(args, params, 'vehicleStyle', 'style', 'variant'), + } + const length = normalizeDimensionValue( + numberArg(args, params, 'length') ?? explicitDimensions.length ?? dimensions.length, + family, + ) + const width = normalizeDimensionValue( + numberArg(args, params, 'width') ?? + numberArg(args, params, 'diameter') ?? + explicitDimensions.width ?? + dimensions.width, + family, + ) + const height = normalizeDimensionValue( + numberArg(args, params, 'height') ?? explicitDimensions.height ?? dimensions.height, + family, + ) + const color = stringArg(args, params, 'primaryColor', 'color') ?? promptColor(prompt) + if (length != null) + constraints.length = hardNumber( + length, + numberArg(args, params, 'length') != null ? 'args' : 'prompt', + ) + if (width != null) + constraints.width = hardNumber( + width, + numberArg(args, params, 'width') != null || numberArg(args, params, 'diameter') != null + ? 'args' + : 'prompt', + ) + if (height != null) + constraints.height = hardNumber( + height, + numberArg(args, params, 'height') != null ? 'args' : 'prompt', + ) + if (color && !PROMPT_COLOR_AS_PART_DETAIL_FAMILIES.has(family)) + constraints.primaryColor = hardString( + color, + stringArg(args, params, 'primaryColor', 'color') ? 'args' : 'prompt', + ) + return constraints +} + +function shapeColor(shape: PrimitiveShapeInput): string | undefined { + return shape.material?.properties?.color +} + +function primaryShapeText(shape: PrimitiveShapeInput): string { + return `${shape.semanticRole ?? ''} ${shape.sourcePartKind ?? ''} ${shape.name ?? ''}` + .trim() + .toLowerCase() +} + +function primaryShapeRoles(family: AssemblyObjectFamily): readonly string[] { + return getFamilyDefinition(family)?.primarySemanticRoles ?? [] +} + +function isPrimaryShape(shape: PrimitiveShapeInput, family: AssemblyObjectFamily): boolean { + const roles = primaryShapeRoles(family) + if (roles.length === 0) return false + const text = primaryShapeText(shape) + return roles.some((role) => text.includes(role.toLowerCase())) +} + +function primaryShapePriority(shape: PrimitiveShapeInput, family: AssemblyObjectFamily): number { + const text = primaryShapeText(shape) + const index = primaryShapeRoles(family).findIndex((role) => text.includes(role.toLowerCase())) + return index < 0 ? Number.MAX_SAFE_INTEGER : index +} + +function primaryLengthValue( + candidates: readonly PrimitiveShapeInput[], + family: AssemblyObjectFamily, +): number | undefined { + const values = candidates + .map((candidate) => + family === 'distillation_tower' + ? primaryDimension(candidate, 'length') + : (candidate.length ?? primaryDimension(candidate, 'length')), + ) + .filter((value): value is number => typeof value === 'number') + if (values.length === 0) return undefined + if ( + family === 'pump' || + family === 'conveyor' || + family === 'material_handling' || + family === 'fluid_machine' || + family === 'process_equipment' || + family === 'tank' || + family === 'reactor' || + family === 'compressor' || + family === 'heat_exchanger' || + family === 'machine_tool' || + family === 'forming_machine' || + family === 'robot_arm' + ) { + return Math.max(...values) + } + return values[0] +} + +function primaryShapes( + shapes: readonly PrimitiveShapeInput[], + family: AssemblyObjectFamily, +): PrimitiveShapeInput[] { + return shapes + .filter((shape) => isPrimaryShape(shape, family)) + .sort((left, right) => primaryShapePriority(left, family) - primaryShapePriority(right, family)) +} + +export function materialFromColor(color?: string): PrimitiveMaterialInput | undefined { + return color ? { properties: { color } } : undefined +} + +export function alignPrimaryShapeColorsToConstraints( + shapes: PrimitiveShapeInput[], + constraints: UserGeometryConstraints, +): { shapes: PrimitiveShapeInput[]; changedCount: number; warnings: string[] } { + if (!constraints.primaryColor) return { shapes, changedCount: 0, warnings: [] } + + const expected = constraints.primaryColor.value + const expectedLower = expected.toLowerCase() + const primary = new Set(primaryShapes(shapes, constraints.family)) + if (primary.size === 0) return { shapes, changedCount: 0, warnings: [] } + + let changedCount = 0 + const aligned = shapes.map((shape) => { + if (!primary.has(shape)) return shape + const actual = shapeColor(shape) + if (actual?.toLowerCase() === expectedLower) return shape + changedCount += 1 + return { + ...shape, + material: { + ...shape.material, + properties: { + ...shape.material?.properties, + color: expected, + }, + }, + } + }) + + return { + shapes: changedCount > 0 ? aligned : shapes, + changedCount, + warnings: + changedCount > 0 + ? [`Auto-aligned ${changedCount} primary shape color(s) to ${expected}.`] + : [], + } +} + +function primaryDimension( + shape: PrimitiveShapeInput, + dimension: 'length' | 'width' | 'height', +): number | undefined { + if (dimension === 'length') { + if (typeof shape.length === 'number') return shape.length + if (shape.axis === 'x' && typeof shape.height === 'number') return shape.height + if (typeof shape.radius === 'number') return shape.radius * 2 + } + if (dimension === 'width') { + if (typeof shape.width === 'number') return shape.width + if (typeof shape.radius === 'number') return shape.radius * 2 + } + if (dimension === 'height') { + if (typeof shape.height === 'number') return shape.height + if (typeof shape.radius === 'number') return shape.radius * 2 + } + return undefined +} + +export function validateAssemblyConstraints( + shapes: PrimitiveShapeInput[], + constraints: UserGeometryConstraints, +): AssemblyConstraintValidation { + const issues: string[] = [] + const warnings: string[] = [] + if ( + constraints.length != null && + !RAW_PRIMARY_LENGTH_UNRELIABLE_FAMILIES.has(constraints.family) + ) { + const candidates = primaryShapes(shapes, constraints.family) + const actual = primaryLengthValue(candidates, constraints.family) + if (candidates.length === 0 && constraints.family !== 'unknown') { + issues.push( + `Hard constraint failed: no primary shape found for family ${constraints.family}.`, + ) + } else if (actual == null && constraints.family !== 'unknown') { + issues.push( + `Hard constraint failed: primary shape for family ${constraints.family} has no measurable length.`, + ) + } else if ( + actual != null && + Math.abs(actual - constraints.length.value) > Math.max(0.03, constraints.length.value * 0.04) + ) { + issues.push( + `Hard constraint failed: expected primary length ${constraints.length.value}m, got ${actual}m.`, + ) + } + } + if (constraints.family === 'distillation_tower' && constraints.width != null) { + const candidates = primaryShapes(shapes, constraints.family) + const actual = candidates[0] ? primaryDimension(candidates[0], 'width') : undefined + if ( + typeof actual === 'number' && + Math.abs(actual - constraints.width.value) > Math.max(0.03, constraints.width.value * 0.04) + ) { + issues.push( + `Hard constraint failed: expected primary width ${constraints.width.value}m, got ${actual}m.`, + ) + } + } + if (constraints.family === 'distillation_tower' && constraints.height != null) { + const candidates = primaryShapes(shapes, constraints.family) + const actual = candidates[0] ? primaryDimension(candidates[0], 'height') : undefined + if ( + typeof actual === 'number' && + Math.abs(actual - constraints.height.value) > Math.max(0.03, constraints.height.value * 0.04) + ) { + issues.push( + `Hard constraint failed: expected primary height ${constraints.height.value}m, got ${actual}m.`, + ) + } + } + if (constraints.primaryColor) { + const candidates = primaryShapes(shapes, constraints.family) + const actual = candidates.map(shapeColor).find(Boolean) + if (actual && actual.toLowerCase() !== constraints.primaryColor.value.toLowerCase()) { + warnings.push( + `Soft constraint warning: expected primary color ${constraints.primaryColor.value}, got ${actual}.`, + ) + } + } + return { ok: issues.length === 0, issues, warnings } +} diff --git a/packages/core/src/lib/assembly-template-compose.test.ts b/packages/core/src/lib/assembly-template-compose.test.ts new file mode 100644 index 000000000..6ced3f3c9 --- /dev/null +++ b/packages/core/src/lib/assembly-template-compose.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'bun:test' +import { composeAssemblyPrimitives } from './assembly-compose' +import { extractUserGeometryConstraints } from './assembly-constraints' +import { + composeAssemblyFromConfig, + composeAssemblyTemplateParts, + getAssemblyTemplate, + resolveAssemblyTemplateStyle, +} from './assembly-template-compose' +import { resolvePrimitiveWorldTransforms } from './primitive-compose' +import { validatePrimitiveSemantics } from './primitive-semantic-validation' + +describe('assembly template composition', () => { + test('resolves vehicle template styles from request text', () => { + const template = getAssemblyTemplate('vehicle') + expect(template).toBeDefined() + if (!template) return + + expect( + resolveAssemblyTemplateStyle( + template, + { family: 'vehicle', prompt: 'compact sports car' }, + extractUserGeometryConstraints('compact sports car'), + ), + ).toBe('sports') + expect( + resolveAssemblyTemplateStyle( + template, + { family: 'vehicle', prompt: 'pickup truck' }, + extractUserGeometryConstraints('pickup truck'), + ), + ).toBe('truck') + }) + + test('expands a vehicle template into reusable part specs', () => { + const template = getAssemblyTemplate('vehicle') + expect(template).toBeDefined() + if (!template) return + + const parts = composeAssemblyTemplateParts( + template, + { family: 'vehicle', prompt: 'red suv' }, + extractUserGeometryConstraints('red suv'), + { primaryColor: '#ef4444', sizeScale: 1 }, + ) + + const body = parts.find((part) => part.semanticRole === 'vehicle_body') + expect(body?.kind).toBe('body_shell') + expect(body?.vehicleStyle).toBe('suv') + expect(body?.length).toBeCloseTo(4.4) + expect(body?.width).toBeCloseTo(1.892) + expect(body?.height).toBeCloseTo(1.672) + expect(body?.primaryColor).toBe('#ef4444') + expect(body?.cornerRadius).toBeCloseTo(0.13376) + expect(parts.some((part) => part.kind === 'wheel_set')).toBe(true) + expect(parts.some((part) => part.kind === 'window_strip')).toBe(true) + }) + + test('composes vehicle geometry directly from the TypeScript template config', () => { + const template = getAssemblyTemplate('vehicle') + expect(template).toBeDefined() + if (!template) return + + const shapes = composeAssemblyFromConfig( + template, + { family: 'vehicle', prompt: 'compact blue car', length: 2, primaryColor: '#1E90FF' }, + extractUserGeometryConstraints('compact blue car', { + family: 'vehicle', + length: 2, + primaryColor: '#1E90FF', + }), + { primaryColor: '#1E90FF', sizeScale: 0.8 }, + ) + + const body = shapes.find((shape) => shape.semanticRole === 'vehicle_body') + expect(body?.length).toBe(2) + expect(body?.material?.properties?.color).toBe('#1E90FF') + expect(shapes.filter((shape) => shape.semanticRole === 'vehicle_tire')).toHaveLength(4) + expect(shapes.some((shape) => shape.semanticRole === 'vehicle_window')).toBe(true) + }) + + test('composes configured vehicle assemblies with hard user constraints preserved', () => { + const shapes = composeAssemblyPrimitives({ + family: 'vehicle', + prompt: 'blue sports car', + length: 5, + width: 2, + height: 1, + }) + + const body = shapes.find((shape) => shape.semanticRole === 'vehicle_body') + expect(body?.length).toBe(5) + expect(body?.width).toBe(2) + expect(body?.material?.properties?.color).toBe('#2563eb') + expect(shapes.filter((shape) => shape.semanticRole === 'vehicle_tire')).toHaveLength(4) + expect(shapes.some((shape) => shape.semanticRole === 'vehicle_window')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'vehicle_roof')).toBe(true) + }) + + test('keeps fan head centered on the motor in direct assembly generation', () => { + const shapes = composeAssemblyPrimitives({ + family: 'fan', + prompt: '\u751f\u6210\u4e00\u53f0\u98ce\u6247', + }) + + const motor = shapes.find((shape) => shape.semanticRole === 'motor_housing') + const hub = shapes.find((shape) => shape.name?.includes('blade hub')) + const grill = shapes.find((shape) => shape.name?.includes('grill front ring 5')) + const pole = shapes.find((shape) => shape.semanticRole === 'vertical_pole') + + expect(motor?.position?.[1]).toBeCloseTo(hub?.position?.[1] ?? 0) + expect(grill?.position?.[1]).toBeCloseTo(hub?.position?.[1] ?? 0) + expect(motor?.position?.[1]).toBeCloseTo( + (pole?.position?.[1] ?? 0) + (pole?.height ?? 0) / 2 + (motor?.radius ?? 0), + ) + }) + + test('composes spherical tank assemblies with a supported sphere shell', () => { + const shapes = composeAssemblyPrimitives({ + family: 'tank', + object: '球罐', + prompt: '生成一个直径4米的球罐', + diameter: 4, + }) + + const shell = shapes.find((shape) => shape.semanticRole === 'vessel_shell') + expect(shell?.kind).toBe('sphere') + expect(shell?.sourcePartKind).toBe('process.spherical_vessel') + expect(shell?.radius).toBeCloseTo(2) + expect(shapes.filter((shape) => shape.semanticRole === 'support_leg')).toHaveLength(4) + expect(shapes.some((shape) => shape.semanticRole === 'inlet_port')).toBe(true) + }) + + test('keeps truck default proportions in the configured path', () => { + const shapes = composeAssemblyPrimitives({ family: 'vehicle', prompt: 'pickup truck' }) + const body = shapes.find((shape) => shape.semanticRole === 'vehicle_body') + + expect(body?.length).toBeCloseTo(5.2) + expect(body?.width).toBeCloseTo(2.236) + expect(body?.height).toBeCloseTo(0.63232) + }) + + test('accepts small blue car generation briefs that ask for taillights', () => { + const shapes = composeAssemblyPrimitives({ + family: 'vehicle', + prompt: '生成一蓝色小汽车,两米长度', + length: 2, + primaryColor: '#1E90FF', + }) + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: '生成一蓝色小汽车,两米长度', + sourceArgs: { + family: 'vehicle', + length: 2, + primaryColor: '#1E90FF', + }, + geometryBrief: { + category: 'vehicle', + requiredRoles: [ + 'vehicle_body', + 'vehicle_tire', + 'vehicle_window', + 'vehicle_headlight', + 'vehicle_taillight', + ], + }, + }, + ) + + const body = shapes.find((shape) => shape.semanticRole === 'vehicle_body') + expect(result.ok).toBe(true) + expect(result.issues).not.toContain('required semantic role "vehicle_taillight" is missing.') + expect(body?.length).toBe(2) + expect(body?.material?.properties?.color).toBe('#1E90FF') + expect(shapes.filter((shape) => shape.semanticRole === 'vehicle_tire')).toHaveLength(4) + }) +}) diff --git a/packages/core/src/lib/assembly-template-compose.ts b/packages/core/src/lib/assembly-template-compose.ts new file mode 100644 index 000000000..dc854886d --- /dev/null +++ b/packages/core/src/lib/assembly-template-compose.ts @@ -0,0 +1,264 @@ +import type { AssemblyComposeInput } from './assembly-compose' +import type { UserGeometryConstraints } from './assembly-constraints' +import { composePartPrimitives, type PartComposePartInput } from './part-compose' +import type { PrimitiveShapeInput } from './primitive-compose' + +type TemplateScope = Record + +type TemplateValue = string | number | boolean + +export type AssemblyTemplatePartSpec = { + kind: string + count?: TemplateValue + fields?: Record +} + +export type AssemblyTemplateStyleVariant = { + match?: string[] + lengthScale?: number + widthRatio?: number + heightRatio?: number + values?: Record +} + +export type AssemblyTemplateConfig = { + family: string + defaultDimensions: { + length: number + widthRatio: number + heightRatio: number + } + defaultStyle: string + styleVariants?: Record + parts: AssemblyTemplatePartSpec[] +} + +export const VEHICLE_ASSEMBLY_TEMPLATE: AssemblyTemplateConfig = { + family: 'vehicle', + defaultDimensions: { + length: 4.4, + widthRatio: 0.42, + heightRatio: 0.32, + }, + defaultStyle: 'sedan', + styleVariants: { + suv: { + match: ['suv', 'offroad'], + heightRatio: 0.38, + widthRatio: 0.43, + values: { cabinTopScale: 0.84 }, + }, + truck: { + match: ['truck', 'pickup'], + lengthScale: 1.1818181818, + widthRatio: 0.43, + values: { cabinTopScale: 0.84 }, + }, + van: { + match: ['van', 'mpv', 'bus'], + heightRatio: 0.36, + values: { cabinTopScale: 0.88 }, + }, + sports: { + match: ['sport', 'race'], + heightRatio: 0.26, + values: { cabinTopScale: 0.7 }, + }, + sedan: { + values: { cabinTopScale: 0.84 }, + }, + }, + parts: [ + { + kind: 'body_shell', + fields: { + semanticRole: 'vehicle_body', + vehicleStyle: '$style', + length: 'length', + width: 'width', + height: 'height', + primaryColor: '$primaryColor', + cornerRadius: 'min(length, width, height) * 0.08', + cornerSegments: 8, + cabinTopScale: 'cabinTopScale', + }, + }, + { kind: 'wheel_set', fields: { count: 4, semanticRole: 'vehicle_tire' } }, + { + kind: 'window_strip', + fields: { semanticRole: 'vehicle_window', variant: 'vehicle_glasshouse' }, + }, + { kind: 'light_pair', fields: { semanticRole: 'headlight' } }, + { kind: 'bar_pair' }, + { kind: 'seam_ring' }, + { kind: 'nameplate' }, + ], +} + +const ASSEMBLY_TEMPLATES: Record = { + vehicle: VEHICLE_ASSEMBLY_TEMPLATE, +} + +export function getAssemblyTemplate(family: string): AssemblyTemplateConfig | undefined { + return ASSEMBLY_TEMPLATES[family] +} + +export function resolveAssemblyTemplateStyle( + config: AssemblyTemplateConfig, + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, +): string { + const text = + `${input.family ?? ''} ${input.object ?? ''} ${input.name ?? ''} ${input.variant ?? ''} ${ + input.style ?? '' + } ${constraints.style ?? ''} ${input.prompt ?? ''}`.toLowerCase() + for (const [style, variant] of Object.entries(config.styleVariants ?? {})) { + if (style === config.defaultStyle) continue + if (variant.match?.some((token) => text.includes(token.toLowerCase()))) return style + } + return config.defaultStyle +} + +export function composeAssemblyTemplateParts( + config: AssemblyTemplateConfig, + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, + options: { + primaryColor: string + sizeScale: number + }, +): PartComposePartInput[] { + const style = resolveAssemblyTemplateStyle(config, input, constraints) + const variant = config.styleVariants?.[style] + const length = + constraints.length?.value ?? + numberValue(input.length, input.params?.length) ?? + round(config.defaultDimensions.length * options.sizeScale * (variant?.lengthScale ?? 1)) + const width = + constraints.width?.value ?? + numberValue(input.width, input.params?.width) ?? + round(length * (variant?.widthRatio ?? config.defaultDimensions.widthRatio)) + const height = + constraints.height?.value ?? + numberValue(input.height, input.params?.height) ?? + round(length * (variant?.heightRatio ?? config.defaultDimensions.heightRatio)) + const scope: TemplateScope = { + style, + length, + width, + height, + primaryColor: options.primaryColor, + ...(variant?.values ?? {}), + } + + return config.parts.map((part) => { + const fields = resolveTemplateFields(part.fields ?? {}, scope) + return { + kind: part.kind, + ...(part.count != null ? { count: numberFromTemplateValue(part.count, scope) } : {}), + ...fields, + } + }) +} + +export function composeAssemblyFromConfig( + config: AssemblyTemplateConfig, + input: AssemblyComposeInput, + constraints: UserGeometryConstraints, + options: { + primaryColor: string + sizeScale: number + }, +): PrimitiveShapeInput[] { + return composePartPrimitives({ + name: input.name ?? input.object ?? input.prompt ?? config.family, + position: input.position, + detail: 'high', + primaryColor: options.primaryColor, + darkColor: input.darkColor ?? '#111827', + metalColor: input.metalColor ?? '#cbd5e1', + accentColor: input.secondaryColor ?? '#2563eb', + enhanceVisualDetails: true, + parts: composeAssemblyTemplateParts(config, input, constraints, options), + }) +} + +function resolveTemplateFields( + fields: Record, + scope: TemplateScope, +): Record { + return Object.fromEntries( + Object.entries(fields).map(([key, value]) => [key, resolveTemplateValue(value, scope)]), + ) +} + +function resolveTemplateValue( + value: TemplateValue, + scope: TemplateScope, +): string | number | boolean { + if (typeof value !== 'string') return value + if (value.startsWith('$')) return stringFromScope(value.slice(1), scope) + const numeric = evaluateTemplateExpression(value, scope) + return numeric ?? value +} + +function numberFromTemplateValue(value: TemplateValue, scope: TemplateScope): number | undefined { + if (typeof value === 'number') return value + if (typeof value !== 'string') return undefined + return evaluateTemplateExpression(value, scope) +} + +function evaluateTemplateExpression(expression: string, scope: TemplateScope): number | undefined { + const trimmed = expression.trim() + if (!trimmed) return undefined + const literal = Number(trimmed) + if (Number.isFinite(literal)) return literal + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) return numberFromScope(trimmed, scope) + + const multiplyTerms = trimmed.split('*').map((term) => term.trim()) + if (multiplyTerms.length > 1) { + const values = multiplyTerms.map((term) => evaluateTemplateExpression(term, scope)) + if (values.every((value): value is number => value != null)) { + return values.reduce((product, value) => product * value, 1) + } + } + + const minMatch = trimmed.match(/^min\((.+)\)$/) + if (minMatch?.[1]) { + const values = minMatch[1] + .split(',') + .map((term) => evaluateTemplateExpression(term.trim(), scope)) + if (values.every((value): value is number => value != null)) return Math.min(...values) + } + + const maxMatch = trimmed.match(/^max\((.+)\)$/) + if (maxMatch?.[1]) { + const values = maxMatch[1] + .split(',') + .map((term) => evaluateTemplateExpression(term.trim(), scope)) + if (values.every((value): value is number => value != null)) return Math.max(...values) + } + + return undefined +} + +function numberFromScope(key: string, scope: TemplateScope): number | undefined { + const value = scope[key] + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function stringFromScope(key: string, scope: TemplateScope): string { + const value = scope[key] + return typeof value === 'string' ? value : '' +} + +function numberValue(...values: unknown[]): number | undefined { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value + } + return undefined +} + +function round(value: number): number { + return Number(value.toFixed(3)) +} diff --git a/packages/core/src/lib/asset-catalog.test.ts b/packages/core/src/lib/asset-catalog.test.ts new file mode 100644 index 000000000..0a8779605 --- /dev/null +++ b/packages/core/src/lib/asset-catalog.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { ItemNode } from '../schema' +import { CATALOG_ITEMS, findCatalogItem, searchCatalogItems } from './asset-catalog' + +describe('shared asset catalog', () => { + test('keeps catalog ids unique', () => { + const ids = CATALOG_ITEMS.map((item) => item.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + test('exposes factory items to shared search', () => { + expect(findCatalogItem('factory-extractor')?.name).toBe('Factory Extractor') + expect(searchCatalogItems({ query: 'factory pipe' }).map((item) => item.id)).toContain( + 'factory-straight-pipe', + ) + }) + + test('category filters also match catalog tags for legacy MCP queries', () => { + expect(searchCatalogItems({ query: 'bed', category: 'furniture' }).map((item) => item.id)).toContain( + 'double-bed', + ) + }) + + test('catalog item assets are accepted by item nodes', () => { + const asset = findCatalogItem('factory-electric-box') + expect(asset).toBeDefined() + expect(() => ItemNode.parse({ asset })).not.toThrow() + }) +}) diff --git a/packages/core/src/lib/asset-catalog.ts b/packages/core/src/lib/asset-catalog.ts new file mode 100644 index 000000000..69c7f1561 --- /dev/null +++ b/packages/core/src/lib/asset-catalog.ts @@ -0,0 +1,1523 @@ +import type { AssetInput } from '../schema' + +/** Items downloaded from Supabase: have thumbnail.png + floor-plan.png */ +function supabase(id: string) { + return { + thumbnail: `/items/${id}/thumbnail.png`, + src: `/items/${id}/model.glb`, + floorPlanUrl: `/items/${id}/floor-plan.png`, + } as const +} + +/** Items that only exist locally: have thumbnail.webp, no floorPlanUrl */ +function localOnly(id: string) { + return { + thumbnail: `/items/${id}/thumbnail.webp`, + src: `/items/${id}/model.glb`, + } as const +} + +function iconOnly(id: string, thumbnail: string) { + return { + thumbnail, + src: `/items/${id}/model.glb`, + } as const +} + +export const CATALOG_ITEMS: AssetInput[] = [ + // ═══════════════════════════════════════════════════════════════════ + // SAFETY / FIRE + // ═══════════════════════════════════════════════════════════════════ + { + id: 'sprinkler', + category: 'electronics', + name: 'Sprinkler', + tags: ['ceiling', 'safety'], + ...supabase('sprinkler'), + dimensions: [0.09, 0.04, 0.09], + offset: [0, 0.0386, 0], + rotation: [Math.PI, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + }, + { + id: 'smoke-detector', + category: 'electronics', + name: 'Smoke Detector', + tags: ['ceiling', 'safety'], + ...supabase('smoke-detector'), + dimensions: [0.16, 0.05, 0.16], + offset: [0, 0.0492, 0], + rotation: [Math.PI, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + }, + { + id: 'fire-detector', + category: 'electronics', + name: 'Fire Detector', + tags: ['wall', 'safety'], + ...localOnly('fire-detector'), + dimensions: [0.12, 0.19, 0.07], + offset: [0.0281, 0.012, 0.0015], + rotation: [0, 0, 0], + scale: [0.9, 1.4, 0.7], + attachTo: 'wall', + }, + { + id: 'fire-alarm', + category: 'electronics', + name: 'Fire Alarm', + tags: ['wall', 'safety'], + ...localOnly('fire-alarm'), + dimensions: [0.12, 0.12, 0.04], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + }, + { + id: 'fire-extinguisher', + category: 'electronics', + name: 'Fire Extinguisher', + tags: ['wall', 'safety'], + ...localOnly('fire-extinguisher'), + dimensions: [0.22, 0.5, 0.22], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + }, + { + id: 'hydrant', + category: 'electronics', + name: 'Hydrant', + tags: ['floor', 'safety', 'water'], + ...supabase('hydrant'), + dimensions: [0.64, 0.88, 0.64], + offset: [-0.0046, 0, -0.0008], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'exit-sign', + category: 'electronics', + name: 'Exit Sign', + tags: ['wall', 'safety', 'signage'], + ...localOnly('exit-sign'), + dimensions: [0.54, 0.27, 0.1], + offset: [0, 0.0036, 0.0452], + rotation: [0, 0, 0], + scale: [0.6, 0.5, 0.7], + attachTo: 'wall-side', + }, + + // ═══════════════════════════════════════════════════════════════════ + // ELECTRICAL + // ═══════════════════════════════════════════════════════════════════ + { + id: 'electric-panel', + category: 'electronics', + name: 'Electric Panel', + tags: ['wall', 'electrical', 'utility'], + ...supabase('electric-panel'), + dimensions: [0.4, 0.98, 0.11], + offset: [0, 0.0036, 0.0611], + rotation: [0, 0, 0], + scale: [0.61, 0.74, 0.7], + attachTo: 'wall-side', + }, + { + id: 'ev-wall-charger', + category: 'electronics', + name: 'EV Wall Charger', + tags: ['wall', 'electric', 'vehicle'], + ...localOnly('ev-wall-charger'), + dimensions: [0.29, 0.65, 0.17], + offset: [-0.0677, 0.2979, 0.1503], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'thermostat', + category: 'electronics', + name: 'Thermostat', + tags: ['wall', 'climate', 'electrical'], + ...localOnly('thermostat'), + dimensions: [0.1, 0.1, 0.01], + offset: [0, 0.0013, 0.0022], + rotation: [0, 0, 0], + scale: [2.08, 2.1, 2.59], + attachTo: 'wall-side', + }, + { + id: 'alarm-keypad', + category: 'electronics', + name: 'Alarm Keypad', + tags: ['wall', 'security', 'electronic'], + ...supabase('alarm-keypad'), + dimensions: [0.18, 0.13, 0.03], + offset: [0, 0.0671, -0.0002], + rotation: [1.5708, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + }, + { + id: 'factory-electric-box', + category: 'electronics', + name: 'Factory Electric Box', + tags: ['floor', 'electrical', 'industrial', 'factory'], + ...iconOnly('factory-electric-box', '/icons/appliance.webp'), + dimensions: [0.65, 1.0, 0.45], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // CLIMATE / APPLIANCES + // ═══════════════════════════════════════════════════════════════════ + { + id: 'ac-block', + category: 'electronics', + name: 'AC Block', + tags: ['floor', 'electrical', 'cooling'], + ...localOnly('ac-block'), + dimensions: [1.06, 0.95, 1.06], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [0.79, 0.79, 0.79], + }, + { + id: 'air-conditioning', + category: 'electronics', + name: 'Air Conditioning', + tags: ['wall', 'electrical', 'cooling'], + ...supabase('air-conditioning'), + dimensions: [1.56, 0.6, 0.41], + offset: [0, 0.3, 0.2018], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + }, + { + id: 'air-conditioner', + category: 'electronics', + name: 'Air Conditioner', + tags: ['wall', 'electrical', 'cooling'], + ...localOnly('air-conditioner'), + dimensions: [1.0, 0.4, 0.3], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + }, + { + id: 'air-conditioner-block', + category: 'electronics', + name: 'AC Condenser', + tags: ['floor', 'electrical', 'outdoor'], + ...localOnly('air-conditioner-block'), + dimensions: [1.0, 0.8, 0.4], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'ceiling-fan', + category: 'electronics', + name: 'Ceiling Fan', + tags: ['ceiling', 'electrical', 'ventilation'], + ...supabase('ceiling-fan'), + dimensions: [0.92, 0.35, 1.04], + offset: [-0.1371, 0.3448, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + interactive: { + controls: [{ kind: 'toggle' as const }], + effects: [{ kind: 'animation' as const, clips: { on: 'On' } }], + }, + }, + { + id: 'freezer', + category: 'electronics', + name: 'Freezer', + tags: ['floor', 'cold-storage', 'appliance'], + ...localOnly('freezer'), + dimensions: [0.7, 1.8, 0.7], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // LIGHTING + // ═══════════════════════════════════════════════════════════════════ + { + id: 'recessed-light', + category: 'electronics', + name: 'Recessed Light', + tags: ['ceiling', 'lighting'], + ...supabase('recessed-light'), + dimensions: [0.23, 0.06, 0.23], + offset: [0, 0.0057, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + interactive: { + controls: [ + { kind: 'toggle' as const }, + { kind: 'slider' as const, label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' as const, default: 100 }, + ], + effects: [{ kind: 'light' as const, intensityRange: [0, 2], color: '#ffffff', offset: [0, -0.1, 0] }], + }, + }, + { + id: 'ceiling-lamp', + category: 'electronics', + name: 'Ceiling Lamp', + tags: ['ceiling', 'lighting'], + ...localOnly('ceiling-lamp'), + dimensions: [0.55, 0.86, 0.55], + offset: [0, 0.8545, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + interactive: { + controls: [ + { kind: 'toggle' as const }, + { kind: 'slider' as const, label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' as const, default: 100 }, + ], + effects: [{ kind: 'light' as const, intensityRange: [0, 2], color: '#ffffff', offset: [0, -0.3, 0] }], + }, + }, + { + id: 'ceiling-light', + category: 'electronics', + name: 'Ceiling Light', + tags: ['ceiling', 'lighting'], + ...localOnly('ceiling-light'), + dimensions: [0.6, 0.12, 0.6], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + interactive: { + controls: [ + { kind: 'toggle' as const }, + { kind: 'slider' as const, label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' as const, default: 100 }, + ], + effects: [{ kind: 'light' as const, intensityRange: [0, 2], color: '#ffffff', offset: [0, -0.2, 0] }], + }, + }, + { + id: 'circular-ceiling-light', + category: 'electronics', + name: 'Circular Ceiling Light', + tags: ['ceiling', 'lighting'], + ...localOnly('circular-ceiling-light'), + dimensions: [0.6, 0.1, 0.6], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + interactive: { + controls: [ + { kind: 'toggle' as const }, + { kind: 'slider' as const, label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' as const, default: 100 }, + ], + effects: [{ kind: 'light' as const, intensityRange: [0, 2], color: '#ffffff', offset: [0, -0.2, 0] }], + }, + }, + { + id: 'rectangular-ceiling-light', + category: 'electronics', + name: 'Rectangular Ceiling Light', + tags: ['ceiling', 'lighting'], + ...localOnly('rectangular-ceiling-light'), + dimensions: [1.2, 0.08, 0.35], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'ceiling', + interactive: { + controls: [ + { kind: 'toggle' as const }, + { kind: 'slider' as const, label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' as const, default: 100 }, + ], + effects: [{ kind: 'light' as const, intensityRange: [0, 2], color: '#ffffff', offset: [0, -0.2, 0] }], + }, + }, + { + id: 'floor-lamp', + category: 'electronics', + name: 'Floor Lamp', + tags: ['floor', 'lighting'], + ...supabase('floor-lamp'), + dimensions: [0.7, 1.86, 0.69], + offset: [0.0341, 0.0045, 0.0219], + rotation: [0, 0, 0], + scale: [1, 1, 1], + interactive: { + controls: [ + { kind: 'toggle' as const }, + { kind: 'slider' as const, label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' as const, default: 100 }, + ], + effects: [{ kind: 'light' as const, intensityRange: [0, 2], color: '#ffffff', offset: [0, 1.4, 0] }], + }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // ELECTRONICS / MONITORING + // ═══════════════════════════════════════════════════════════════════ + { + id: 'computer', + category: 'electronics', + name: 'Computer', + tags: ['countertop', 'electronics', 'workstation'], + ...localOnly('computer'), + dimensions: [0.68, 0.48, 0.19], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'television', + category: 'electronics', + name: 'Television', + tags: ['floor', 'electronics', 'monitoring'], + ...localOnly('television'), + dimensions: [1.62, 1.07, 0.38], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'flat-screen-tv', + category: 'electronics', + name: 'Flat Screen TV', + tags: ['wall', 'electronics', 'monitoring'], + ...localOnly('flat-screen-tv'), + dimensions: [1.4, 0.9, 0.08], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + }, + { + id: 'stereo-speaker', + category: 'electronics', + name: 'Stereo Speaker', + tags: ['floor', 'electronics', 'audio'], + ...supabase('stereo-speaker'), + dimensions: [0.23, 1, 0.34], + offset: [0, 0, -0.0129], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // INDUSTRIAL EQUIPMENT + // ═══════════════════════════════════════════════════════════════════ + { + id: 'sewing-machine', + category: 'equipment', + name: 'Sewing Machine', + tags: ['countertop', 'equipment', 'industrial'], + ...supabase('sewing-machine'), + dimensions: [0.83, 0.68, 0.32], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'shelf', + category: 'equipment', + name: 'Shelf', + tags: ['wall', 'storage', 'shelving'], + ...localOnly('shelf'), + dimensions: [0.74, 0.04, 0.32], + offset: [0, 0.02, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + surface: { height: 0.04 }, + }, + { + id: 'trash-bin', + category: 'equipment', + name: 'Trash Bin', + tags: ['floor', 'waste'], + ...localOnly('trash-bin'), + dimensions: [0.35, 0.59, 0.42], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'desk', + category: 'equipment', + name: 'Desk', + tags: ['floor', 'office', 'furniture', 'factory'], + ...localOnly('desk'), + dimensions: [1.82, 0.92, 0.85], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 0.92 }, + }, + { + id: 'office-chair', + category: 'equipment', + name: 'Office Chair', + tags: ['floor', 'office', 'furniture', 'factory'], + ...localOnly('office-chair'), + floorPlanUrl: '/items/office-chair/floor-plan.svg', + dimensions: [0.65, 1.16, 0.69], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'barbell', + category: 'equipment', + name: 'Barbell', + tags: ['floor', 'fitness', 'equipment', 'factory'], + ...localOnly('barbell'), + dimensions: [0.38, 0.38, 1.72], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'basket-hoop', + category: 'equipment', + name: 'Basket Hoop', + tags: ['floor', 'recreation', 'equipment', 'factory'], + ...localOnly('basket-hoop'), + dimensions: [0.67, 1.77, 0.56], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'factory-straight-pipe', + category: 'equipment', + name: 'Factory Straight Pipe', + tags: ['floor', 'pipe', 'industrial', 'factory'], + ...iconOnly('factory-straight-pipe', '/icons/shelf.webp'), + dimensions: [1.6, 0.4, 0.4], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'factory-curved-pipe', + category: 'equipment', + name: 'Factory Curved Pipe', + tags: ['floor', 'pipe', 'industrial', 'factory'], + ...iconOnly('factory-curved-pipe', '/icons/shelf.webp'), + dimensions: [1.0, 0.75, 1.0], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'factory-t-pipe', + category: 'equipment', + name: 'Factory T Pipe', + tags: ['floor', 'pipe', 'industrial', 'factory'], + ...iconOnly('factory-t-pipe', '/icons/shelf.webp'), + dimensions: [1.35, 0.55, 1.0], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'factory-extractor', + category: 'equipment', + name: 'Factory Extractor', + tags: ['floor', 'ventilation', 'industrial', 'factory'], + ...iconOnly('factory-extractor', '/icons/appliance.webp'), + dimensions: [0.9, 1.4, 0.9], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'factory-barrel', + category: 'equipment', + name: 'Factory Barrel', + tags: ['floor', 'barrel', 'storage', 'industrial'], + ...iconOnly('factory-barrel', '/icons/shelf.webp'), + dimensions: [0.6, 0.9, 0.6], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // STRUCTURAL + // ═══════════════════════════════════════════════════════════════════ + { + id: 'bathroom-sink', + category: 'equipment', + name: 'Bathroom Sink', + tags: ['floor', 'bathroom', 'plumbing'], + ...localOnly('bathroom-sink'), + dimensions: [0.6, 0.85, 0.5], + }, + { + id: 'bathtub', + category: 'equipment', + name: 'Bathtub', + tags: ['floor', 'bathroom', 'plumbing'], + ...localOnly('bathtub'), + dimensions: [1.7, 0.55, 0.75], + }, + { + id: 'bedside-table', + category: 'equipment', + name: 'Bedside Table', + tags: ['floor', 'furniture', 'storage'], + ...localOnly('bedside-table'), + dimensions: [0.5, 0.55, 0.45], + surface: { height: 0.55 }, + }, + { + id: 'bookshelf', + category: 'equipment', + name: 'Bookshelf', + tags: ['floor', 'office', 'storage', 'furniture'], + ...localOnly('bookshelf'), + dimensions: [0.8, 1.8, 0.35], + }, + { + id: 'closet', + category: 'equipment', + name: 'Closet', + tags: ['floor', 'storage', 'furniture'], + ...localOnly('closet'), + dimensions: [1.2, 2, 0.6], + }, + { + id: 'coffee-table', + category: 'equipment', + name: 'Coffee Table', + tags: ['floor', 'office', 'furniture'], + ...localOnly('coffee-table'), + dimensions: [1, 0.45, 0.6], + surface: { height: 0.45 }, + }, + { + id: 'dining-chair', + category: 'equipment', + name: 'Dining Chair', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('dining-chair'), + dimensions: [0.48, 0.9, 0.52], + }, + { + id: 'dining-table', + category: 'equipment', + name: 'Dining Table', + tags: ['floor', 'office', 'furniture'], + ...localOnly('dining-table'), + dimensions: [1.6, 0.75, 0.9], + surface: { height: 0.75 }, + }, + { + id: 'double-bed', + category: 'equipment', + name: 'Double Bed', + tags: ['floor', 'furniture'], + ...localOnly('double-bed'), + dimensions: [2, 0.6, 1.6], + }, + { + id: 'dresser', + category: 'equipment', + name: 'Dresser', + tags: ['floor', 'storage', 'furniture'], + ...localOnly('dresser'), + dimensions: [0.9, 0.9, 0.45], + surface: { height: 0.9 }, + }, + { + id: 'fridge', + category: 'equipment', + name: 'Fridge', + tags: ['floor', 'kitchen', 'appliance'], + ...localOnly('fridge'), + dimensions: [0.75, 1.8, 0.7], + }, + { + id: 'kitchen', + category: 'equipment', + name: 'Kitchen', + tags: ['floor', 'kitchen', 'appliance'], + ...localOnly('kitchen'), + dimensions: [2, 2, 0.7], + }, + { + id: 'kitchen-counter', + category: 'equipment', + name: 'Kitchen Counter', + tags: ['floor', 'kitchen', 'countertop'], + ...localOnly('kitchen-counter'), + dimensions: [1.2, 0.9, 0.6], + surface: { height: 0.9 }, + }, + { + id: 'livingroom-chair', + category: 'equipment', + name: 'Livingroom Chair', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('livingroom-chair'), + dimensions: [0.75, 0.85, 0.75], + }, + { + id: 'lounge-chair', + category: 'equipment', + name: 'Lounge Chair', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('lounge-chair'), + dimensions: [0.75, 0.9, 0.85], + }, + { + id: 'single-bed', + category: 'equipment', + name: 'Single Bed', + tags: ['floor', 'furniture'], + ...localOnly('single-bed'), + dimensions: [2, 0.55, 1], + }, + { + id: 'sofa', + category: 'equipment', + name: 'Sofa', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('sofa'), + floorPlanUrl: '/items/sofa/floor-plan.svg', + dimensions: [2, 0.85, 0.9], + }, + { + id: 'stove', + category: 'equipment', + name: 'Stove', + tags: ['floor', 'kitchen', 'appliance'], + ...localOnly('stove'), + dimensions: [0.6, 0.9, 0.6], + }, + { + id: 'washing-machine', + category: 'equipment', + name: 'Washing Machine', + tags: ['floor', 'appliance', 'utility'], + ...localOnly('washing-machine'), + dimensions: [0.6, 0.9, 0.6], + }, + { + id: 'barbell-stand', + category: 'equipment', + name: 'Barbell Stand', + tags: ['floor', 'fitness', 'equipment', 'factory'], + ...localOnly('barbell-stand'), + dimensions: [1.1, 1.2, 0.8], + }, + { + id: 'bean-bag', + category: 'equipment', + name: 'Bean Bag', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('bean-bag'), + dimensions: [0.9, 0.55, 0.9], + }, + { + id: 'books', + category: 'equipment', + name: 'Books', + tags: ['countertop', 'office', 'decor'], + ...localOnly('books'), + dimensions: [0.35, 0.25, 0.25], + }, + { + id: 'bunkbed', + category: 'equipment', + name: 'Bunkbed', + tags: ['floor', 'furniture'], + ...localOnly('bunkbed'), + dimensions: [2, 1.7, 1], + }, + { + id: 'coffee-machine', + category: 'equipment', + name: 'Coffee Machine', + tags: ['countertop', 'office', 'kitchen', 'appliance'], + ...localOnly('coffee-machine'), + dimensions: [0.25, 0.35, 0.3], + }, + { + id: 'couch-medium', + category: 'equipment', + name: 'Couch Medium', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('couch-medium'), + dimensions: [1.8, 0.85, 0.85], + }, + { + id: 'couch-small', + category: 'equipment', + name: 'Couch Small', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('couch-small'), + dimensions: [1.2, 0.85, 0.85], + }, + { + id: 'herman-miller-aeron-mo8x36k9', + category: 'equipment', + name: 'Herman Miller Aeron', + tags: ['floor', 'office', 'furniture', 'seating'], + ...supabase('herman-miller-aeron-mo8x36k9'), + dimensions: [0.65, 1.16, 0.69], + }, + { + id: 'kettle', + category: 'equipment', + name: 'Kettle', + tags: ['countertop', 'kitchen', 'appliance'], + ...localOnly('kettle'), + dimensions: [0.2, 0.25, 0.2], + }, + { + id: 'kitchen-cabinet', + category: 'equipment', + name: 'Kitchen Cabinet', + tags: ['floor', 'kitchen', 'storage'], + ...localOnly('kitchen-cabinet'), + dimensions: [0.8, 0.9, 0.6], + surface: { height: 0.9 }, + }, + { + id: 'kitchen-fridge', + category: 'equipment', + name: 'Kitchen Fridge', + tags: ['floor', 'kitchen', 'appliance'], + ...localOnly('kitchen-fridge'), + dimensions: [0.7, 1.8, 0.7], + }, + { + id: 'kitchen-shelf', + category: 'equipment', + name: 'Kitchen Shelf', + tags: ['floor', 'kitchen', 'storage'], + ...localOnly('kitchen-shelf'), + dimensions: [0.8, 0.3, 0.25], + surface: { height: 0.3 }, + }, + { + id: 'microwave', + category: 'equipment', + name: 'Microwave', + tags: ['countertop', 'kitchen', 'appliance'], + ...localOnly('microwave'), + dimensions: [0.5, 0.32, 0.4], + }, + { + id: 'office-table', + category: 'equipment', + name: 'Office Table', + tags: ['floor', 'office', 'furniture', 'factory'], + ...localOnly('office-table'), + dimensions: [1.4, 0.75, 0.75], + surface: { height: 0.75 }, + }, + { + id: 'piano', + category: 'equipment', + name: 'Piano', + tags: ['floor', 'office', 'recreation'], + ...localOnly('piano'), + dimensions: [1.4, 1.1, 0.6], + }, + { + id: 'rectangular-carpet', + category: 'equipment', + name: 'Rectangular Carpet', + tags: ['floor', 'decor'], + ...localOnly('rectangular-carpet'), + dimensions: [1.6, 0.02, 2.3], + }, + { + id: 'round-carpet', + category: 'equipment', + name: 'Round Carpet', + tags: ['floor', 'decor'], + ...localOnly('round-carpet'), + dimensions: [1.5, 0.02, 1.5], + }, + { + id: 'shower', + category: 'equipment', + name: 'Shower', + tags: ['floor', 'bathroom', 'plumbing'], + ...iconOnly('shower', '/icons/appliance.webp'), + dimensions: [0.9, 2, 0.9], + }, + { + id: 'shower-rug', + category: 'equipment', + name: 'Shower Rug', + tags: ['floor', 'bathroom', 'decor'], + ...localOnly('shower-rug'), + dimensions: [0.8, 0.02, 0.5], + }, + { + id: 'sink-cabinet', + category: 'equipment', + name: 'Sink Cabinet', + tags: ['floor', 'bathroom', 'storage'], + ...localOnly('sink-cabinet'), + dimensions: [0.8, 0.9, 0.55], + surface: { height: 0.9 }, + }, + { + id: 'small-kitchen-cabinet', + category: 'equipment', + name: 'Small Kitchen Cabinet', + tags: ['floor', 'kitchen', 'storage'], + ...localOnly('small-kitchen-cabinet'), + dimensions: [0.6, 0.8, 0.45], + surface: { height: 0.8 }, + }, + { + id: 'standing-desk-mo8wgz95', + category: 'equipment', + name: 'Standing Desk', + tags: ['floor', 'office', 'furniture', 'factory'], + ...supabase('standing-desk-mo8wgz95'), + dimensions: [1.4, 1.1, 0.75], + surface: { height: 1.1 }, + }, + { + id: 'stool', + category: 'equipment', + name: 'Stool', + tags: ['floor', 'office', 'furniture', 'seating'], + ...localOnly('stool'), + dimensions: [0.4, 0.45, 0.4], + }, + { + id: 'table', + category: 'equipment', + name: 'Table', + tags: ['floor', 'office', 'furniture'], + ...localOnly('table'), + dimensions: [1.2, 0.75, 0.8], + surface: { height: 0.75 }, + }, + { + id: 'threadmill', + category: 'equipment', + name: 'Treadmill', + tags: ['floor', 'fitness', 'equipment', 'factory'], + ...localOnly('threadmill'), + dimensions: [0.8, 1.4, 1.8], + }, + { + id: 'toilet', + category: 'equipment', + name: 'Toilet', + tags: ['floor', 'bathroom', 'plumbing'], + ...localOnly('toilet'), + dimensions: [0.4, 0.75, 0.7], + }, + { + id: 'tub', + category: 'equipment', + name: 'Tub', + tags: ['floor', 'bathroom', 'plumbing'], + ...iconOnly('tub', '/icons/appliance.webp'), + dimensions: [1.7, 0.55, 0.75], + }, + { + id: 'wall-sink', + category: 'equipment', + name: 'Wall Sink', + tags: ['floor', 'bathroom', 'plumbing'], + ...localOnly('wall-sink'), + dimensions: [0.5, 0.45, 0.4], + }, + { + id: 'wine-bottle', + category: 'equipment', + name: 'Wine Bottle', + tags: ['countertop', 'kitchen', 'decor'], + ...localOnly('wine-bottle'), + dimensions: [0.08, 0.3, 0.08], + }, + { + id: 'power-outlet-moa09g0o', + category: 'electronics', + name: 'Power Outlet', + tags: ['wall', 'electrical', 'factory'], + ...supabase('power-outlet-moa09g0o'), + dimensions: [0.08, 0.08, 0.03], + attachTo: 'wall-side', + }, + { + id: 'picture', + category: 'equipment', + name: 'Picture', + tags: ['wall', 'office', 'decor'], + ...localOnly('picture'), + dimensions: [0.8, 0.6, 0.05], + attachTo: 'wall-side', + }, + { + id: 'rectangular-mirror', + category: 'equipment', + name: 'Rectangular Mirror', + tags: ['wall', 'bathroom', 'decor'], + ...localOnly('rectangular-mirror'), + dimensions: [0.6, 0.9, 0.05], + attachTo: 'wall-side', + }, + { + id: 'wall-art-06', + category: 'equipment', + name: 'Wall Art', + tags: ['wall', 'office', 'decor'], + ...localOnly('wall-art-06'), + dimensions: [0.8, 0.6, 0.05], + attachTo: 'wall-side', + }, + { + id: 'table-lamp', + category: 'electronics', + name: 'Table Lamp', + tags: ['countertop', 'lighting', 'office'], + ...localOnly('table-lamp'), + dimensions: [0.25, 0.5, 0.25], + }, + { + id: 'tv-stand', + category: 'electronics', + name: 'TV Stand', + tags: ['floor', 'electronics', 'furniture'], + ...localOnly('tv-stand'), + dimensions: [1.4, 0.55, 0.45], + surface: { height: 0.55 }, + }, + { + id: '1967-chevrolet-camaro-moa24wsf', + category: 'outdoor', + name: '1967 Chevrolet Camaro', + tags: ['floor', 'vehicle', 'car'], + ...supabase('1967-chevrolet-camaro-moa24wsf'), + dimensions: [1.9, 1.4, 4.8], + }, + { + id: 'car-toy', + category: 'outdoor', + name: 'Car Toy', + tags: ['floor', 'vehicle', 'toy'], + ...localOnly('car-toy'), + dimensions: [0.35, 0.2, 0.65], + }, + { + id: 'exercise-bike', + category: 'equipment', + name: 'Exercise Bike', + tags: ['floor', 'fitness', 'equipment', 'factory'], + ...localOnly('exercise-bike'), + dimensions: [0.6, 1.1, 1], + }, + { + id: 'skate', + category: 'outdoor', + name: 'Skate', + tags: ['floor', 'vehicle', 'recreation'], + ...localOnly('skate'), + dimensions: [0.25, 0.15, 0.75], + }, + { + id: 'dishwasher-movn72ls', + category: 'equipment', + name: 'Dishwasher', + tags: ['floor', 'kitchen', 'appliance'], + ...supabase('dishwasher-movn72ls'), + dimensions: [0.6, 0.85, 0.6], + }, + + { + id: 'column', + category: 'structural', + name: 'Column', + tags: ['floor', 'structural', 'architectural'], + ...supabase('column'), + dimensions: [0.5, 2.5, 0.5], + offset: [0, 1.25, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'pillar', + category: 'structural', + name: 'Pillar', + tags: ['floor', 'structural', 'stone'], + ...localOnly('pillar'), + dimensions: [0.34, 1.26, 0.3], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'stairs', + category: 'structural', + name: 'Stairs', + tags: ['floor', 'structural'], + ...localOnly('stairs'), + dimensions: [1.0, 3.0, 3.5], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // INFRASTRUCTURE + // ═══════════════════════════════════════════════════════════════════ + { + id: 'parking-spot', + category: 'outdoor', + name: 'Parking Spot', + tags: ['floor', 'parking', 'pavement'], + ...supabase('parking-spot'), + dimensions: [4.95, 0.12, 2.28], + offset: [0, 0.0121, 0.015], + rotation: [0, 0, 0], + scale: [0.9, 1, 0.78], + surface: { height: 0.015 }, + }, + { + id: 'fence', + category: 'outdoor', + name: 'Fence', + tags: ['floor', 'fencing', 'boundary'], + ...localOnly('fence'), + dimensions: [2.0, 1.2, 0.06], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'low-fence', + category: 'outdoor', + name: 'Low Fence', + tags: ['floor', 'fencing', 'boundary'], + ...localOnly('low-fence'), + dimensions: [2.0, 0.6, 0.06], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'medium-fence', + category: 'outdoor', + name: 'Medium Fence', + tags: ['floor', 'fencing', 'boundary'], + ...localOnly('medium-fence'), + dimensions: [2.0, 1.0, 0.06], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'high-fence', + category: 'outdoor', + name: 'High Fence', + tags: ['floor', 'fencing', 'security'], + ...localOnly('high-fence'), + dimensions: [2.0, 2.0, 0.06], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // OPENINGS (Doors + Windows) + // ═══════════════════════════════════════════════════════════════════ + { + id: 'door', + category: 'structural', + name: 'Door', + tags: ['floor', 'door', 'entry'], + ...localOnly('door'), + dimensions: [1.0, 2.1, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'door-bar', + category: 'structural', + name: 'Door Bar', + tags: ['floor', 'door', 'entry'], + ...localOnly('door-bar'), + dimensions: [1.0, 2.1, 0.12], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'door-with-bar', + category: 'structural', + name: 'Door with Bar', + tags: ['floor', 'door', 'entry'], + ...localOnly('door-with-bar'), + dimensions: [1.0, 2.1, 0.15], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'doorway-front', + category: 'structural', + name: 'Doorway Front', + tags: ['floor', 'door', 'entry'], + ...localOnly('doorway-front'), + dimensions: [1.0, 2.1, 0.2], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'glass-door', + category: 'structural', + name: 'Glass Door', + tags: ['floor', 'door', 'glass'], + ...localOnly('glass-door'), + dimensions: [1.0, 2.1, 0.08], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'window-double', + category: 'structural', + name: 'Double Window', + tags: ['wall', 'window'], + ...localOnly('window-double'), + dimensions: [2.0, 1.5, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window-large', + category: 'structural', + name: 'Large Window', + tags: ['wall', 'window'], + ...localOnly('window-large'), + dimensions: [2.4, 2.0, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window-rectangle', + category: 'structural', + name: 'Rectangle Window', + tags: ['wall', 'window'], + ...localOnly('window-rectangle'), + dimensions: [2.0, 1.5, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window-round', + category: 'structural', + name: 'Round Window', + tags: ['wall', 'window'], + ...localOnly('window-round'), + dimensions: [0.9, 0.9, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window-simple', + category: 'structural', + name: 'Simple Window', + tags: ['wall', 'window'], + ...localOnly('window-simple'), + dimensions: [1.5, 1.2, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window-small', + category: 'structural', + name: 'Small Window', + tags: ['wall', 'window'], + ...localOnly('window-small'), + dimensions: [0.8, 0.8, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window-small-2', + category: 'structural', + name: 'Small Window 2', + tags: ['wall', 'window'], + ...localOnly('window-small-2'), + dimensions: [0.7, 0.7, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window-square', + category: 'structural', + name: 'Square Window', + tags: ['wall', 'window'], + ...localOnly('window-square'), + dimensions: [1.2, 1.2, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + { + id: 'window1-black-open-1731', + category: 'structural', + name: 'Black Open Window', + tags: ['wall', 'window'], + ...localOnly('window1-black-open-1731'), + dimensions: [1.0, 1.5, 0.1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall', + }, + + // ═══════════════════════════════════════════════════════════════════ + // OUTDOOR / PLANTS + // ═══════════════════════════════════════════════════════════════════ + { + id: 'cactus', + category: 'outdoor', + name: 'Cactus', + tags: ['floor', 'plant', 'cactus', 'green'], + ...supabase('cactus'), + dimensions: [0.34, 0.39, 0.27], + offset: [-0.0039, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'small-indoor-plant', + category: 'outdoor', + name: 'Small Plant', + tags: ['countertop', 'plant', 'vegetation'], + ...localOnly('small-indoor-plant'), + dimensions: [0.4, 0.67, 0.38], + offset: [-0.0106, 0, 0.0067], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'indoor-plant', + category: 'outdoor', + name: 'Indoor Plant', + tags: ['floor', 'plant', 'greenery', 'foliage'], + ...localOnly('indoor-plant'), + dimensions: [0.69, 1.63, 0.83], + offset: [-0.0506, 0, 0.0664], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'bush', + category: 'outdoor', + name: 'Bush', + tags: ['floor', 'plant', 'shrub', 'landscaping'], + ...supabase('bush'), + dimensions: [3, 1.04, 1.01], + offset: [-0.1463, 0.0094, -0.113], + rotation: [0, 0, 0], + scale: [0.96, 0.96, 0.96], + }, + { + id: 'hedge', + category: 'outdoor', + name: 'Hedge', + tags: ['floor', 'plant', 'hedge', 'landscaping'], + ...localOnly('hedge'), + dimensions: [3.0, 1.5, 1.0], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'palm', + category: 'outdoor', + name: 'Palm', + tags: ['floor', 'tree', 'tropical', 'plant'], + ...localOnly('palm'), + dimensions: [0.525, 4.5, 0.496], + offset: [0, 0.08, 0], + rotation: [0, 0, 0], + scale: [0.37, 0.37, 0.37], + }, + { + id: 'fir-tree', + category: 'outdoor', + name: 'Fir', + tags: ['floor', 'tree', 'evergreen', 'conifer'], + ...localOnly('fir-tree'), + dimensions: [0.27, 3, 0.23], + offset: [0.02, 0.05, -0.06], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'tree', + category: 'outdoor', + name: 'Tree', + tags: ['floor', 'tree', 'vegetation'], + ...supabase('tree'), + dimensions: [0.79, 5, 0.85], + offset: [-0.02, 0.17, -0.04], + rotation: [0, 0, 0], + scale: [0.65, 0.65, 0.65], + }, + { + id: 'ball', + category: 'outdoor', + name: 'Stone Sphere', + tags: ['floor', 'stone', 'sculpture', 'decor'], + ...localOnly('ball'), + dimensions: [0.24, 0.24, 0.24], + offset: [-0.0001, 0.1194, -0.0001], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // OUTDOOR + // ═══════════════════════════════════════════════════════════════════ + { + id: 'patio-umbrella', + category: 'outdoor', + name: 'Patio Umbrella', + tags: ['floor', 'outdoor', 'shade'], + ...supabase('patio-umbrella'), + dimensions: [0.14, 3.6, 0.15], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'outdoor-playhouse', + category: 'outdoor', + name: 'Outdoor Playhouse', + tags: ['floor', 'outdoor', 'play'], + ...supabase('outdoor-playhouse'), + dimensions: [0.3, 0.47, 0.72], + offset: [-0.0062, 0, -0.0268], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'sunbed', + category: 'outdoor', + name: 'Sunbed', + tags: ['floor', 'outdoor', 'leisure'], + ...supabase('sunbed'), + dimensions: [0.86, 1.13, 1.01], + offset: [0, 0.0516, 0.0137], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'pool-table', + category: 'equipment', + name: 'Pool Table', + tags: ['floor', 'equipment', 'gaming'], + ...supabase('pool-table'), + dimensions: [2.11, 0.98, 3.5], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + + // ═══════════════════════════════════════════════════════════════════ + // OUTDOOR / VEHICLES + // ═══════════════════════════════════════════════════════════════════ + { + id: 'tesla', + category: 'outdoor', + name: 'Tesla Model Y', + tags: ['floor', 'car', 'vehicle', 'electric'], + ...localOnly('tesla'), + dimensions: [1.98, 1.62, 4.76], + offset: [0.0039, -0.0102, -0.0148], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'scooter', + category: 'outdoor', + name: 'Scooter', + tags: ['floor', 'scooter', 'transport', 'urban'], + ...supabase('scooter'), + dimensions: [0.85, 0.85, 0.45], + offset: [0.1127, 0.0017, 0.1744], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, +] + +export function getDefaultCatalogItem(category: string | null | undefined): AssetInput | null { + if (!category) return null + return CATALOG_ITEMS.find((item) => item.category === category) ?? CATALOG_ITEMS[0] ?? null +} + + +export function findCatalogItem(id: string): AssetInput | undefined { + return CATALOG_ITEMS.find((item) => item.id === id) +} + +export function searchCatalogItems(args: { + query: string + category?: string | undefined +}): AssetInput[] { + const terms = args.query.trim().toLowerCase().split(/\s+/).filter(Boolean) + const requestedCategory = args.category?.trim().toLowerCase() + + return CATALOG_ITEMS.filter((item) => { + const tags = item.tags ?? [] + if ( + requestedCategory && + item.category.toLowerCase() !== requestedCategory && + !tags.some((tag) => tag.toLowerCase() === requestedCategory) + ) { + return false + } + const haystack = [item.id, item.name, item.category, ...tags].join(' ').toLowerCase() + return terms.every((term) => haystack.includes(term)) + }) +} diff --git a/packages/core/src/lib/device-profile-registry.test.ts b/packages/core/src/lib/device-profile-registry.test.ts new file mode 100644 index 000000000..8b9fed982 --- /dev/null +++ b/packages/core/src/lib/device-profile-registry.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, test } from 'bun:test' +import { + applyDeviceProfileToPartInput, + buildDraftDeviceProfile, + DEVICE_PROFILE_DEFINITIONS, + evaluateDeviceProfileQuality, + getDeviceProfileDefinition, + inferDeviceProfileDefinition, + mergeDeviceProfiles, + normalizeDeviceProfileInput, + resolveEditableSchemaForProfile, + validateDeviceProfileDefinition, + validateDeviceProfileForExecution, + validateDeviceProfileSchema, + validateDeviceProfiles, +} from './device-profile-registry' + +describe('device profile registry', () => { + test('infers long-tail equipment profiles without requiring one family per device', () => { + expect( + inferDeviceProfileDefinition({ + prompt: 'make a skid mounted screw compressor package with control cabinet', + })?.id, + ).toBe('screw_compressor') + expect( + inferDeviceProfileDefinition({ + name: 'robot palletizer cell', + })?.id, + ).toBe('palletizer_cell') + }) + + test('respects explicit robot arm axis count when selecting profiles', () => { + const baseRobotProfile = { + ...getDeviceProfileDefinition('robot_welding_cell')!, + source: 'imported_pack' as const, + family: 'robot_arm', + layoutFamily: 'robot_workcell_layout' as const, + aliases: ['industrial robot arm', 'robot arm'], + layoutHints: { + robotArmDefaults: { + scope: 'arm_only', + includeWorkcell: false, + }, + }, + } + const sixAxisProfile = { + ...baseRobotProfile, + id: 'robotics.six_axis_industrial_robot_arm', + name: 'Six-axis industrial robot arm', + layoutHints: { + robotArmDefaults: { + axisCount: 6, + scope: 'arm_only', + includeWorkcell: false, + }, + }, + } + const fourAxisProfile = { + ...baseRobotProfile, + id: 'robotics.four_axis_industrial_robot_arm', + name: 'Four-axis industrial robot arm', + aliases: ['four-axis industrial robot arm', '4-axis robot arm', 'robot arm'], + layoutHints: { + robotArmDefaults: { + axisCount: 4, + scope: 'arm_only', + includeWorkcell: false, + }, + }, + } + + expect( + inferDeviceProfileDefinition({ prompt: 'create a four-axis industrial robot arm' }, [ + sixAxisProfile, + fourAxisProfile, + ])?.id, + ).toBe('robotics.four_axis_industrial_robot_arm') + expect( + inferDeviceProfileDefinition({ prompt: 'create a four-axis industrial robot arm' }, [ + sixAxisProfile, + ]), + ).toBeUndefined() + expect( + inferDeviceProfileDefinition({ prompt: 'create a six-axis industrial robot arm' }, [ + sixAxisProfile, + fourAxisProfile, + ])?.id, + ).toBe('robotics.six_axis_industrial_robot_arm') + }) + + test('maps device profiles to reusable archetype families and starter parts', () => { + const profile = getDeviceProfileDefinition('packaging_machine') + + expect(profile).toBeDefined() + const input = applyDeviceProfileToPartInput(profile!, { name: 'automatic packaging machine' }) + + expect(input).toMatchObject({ + family: 'machine_tool', + deviceProfile: 'packaging_machine', + archetypeFamily: 'enclosed_machine', + layoutFamily: 'box_enclosure_layout', + profileSource: 'builtin', + primarySemanticRole: 'machine_enclosure', + length: 2.6, + width: 1, + height: 1.6, + }) + expect(Array.isArray(input.parts)).toBe(true) + expect((input.parts as unknown[]).length).toBeGreaterThan(3) + }) + + test('infers default editable schemas for common industrial profile archetypes', () => { + expect( + resolveEditableSchemaForProfile(getDeviceProfileDefinition('packaging_machine')!)?.id, + ).toBe('enclosure.common') + expect( + resolveEditableSchemaForProfile(getDeviceProfileDefinition('screw_compressor')!)?.id, + ).toBe('rotary_equipment.common') + expect( + resolveEditableSchemaForProfile(getDeviceProfileDefinition('vertical_storage_tank')!)?.id, + ).toBe('vessel.common') + }) + + test('keeps builtin profiles schema-valid and primary-role aware', () => { + const validation = validateDeviceProfiles() + + expect(validation.ok, validation.issues.join('\n')).toBe(true) + for (const profile of DEVICE_PROFILE_DEFINITIONS) { + expect(profile.status).toBe('stable') + expect(profile.source).toBe('builtin') + expect(profile.primarySemanticRole.length).toBeGreaterThan(0) + expect(profile.layoutFamily).toBeDefined() + } + }) + + test('rejects profile drafts that reference unknown executable parts', () => { + const profile = getDeviceProfileDefinition('screw_compressor') + expect(profile).toBeDefined() + + const validation = validateDeviceProfileDefinition({ + ...profile!, + id: 'draft.invalid_machine', + status: 'draft', + source: 'generated_candidate', + parts: [{ kind: 'imaginary_part', semanticRole: 'imaginary_primary' }], + primarySemanticRole: 'imaginary_primary', + }) + + expect(validation.ok).toBe(false) + expect(validation.issues.join('\n')).toContain('imaginary_part') + }) + + test('merges profile sources with predictable priority', () => { + const builtin = getDeviceProfileDefinition('screw_compressor') + expect(builtin).toBeDefined() + const generated = { + ...builtin!, + source: 'generated_candidate' as const, + name: 'Generated Screw', + } + const workspace = { + ...builtin!, + source: 'workspace' as const, + name: 'Workspace Screw Compressor', + aliases: ['workspace screw'], + } + + const merged = mergeDeviceProfiles([[generated], [builtin!], [workspace]]) + + expect(merged.profiles.find((profile) => profile.id === 'screw_compressor')?.name).toBe( + 'Workspace Screw Compressor', + ) + expect(merged.warnings.join('\n')).toContain('overrides') + }) + + test('records when an imported resource-pack profile overrides a builtin fallback', () => { + const builtin = getDeviceProfileDefinition('screw_compressor') + expect(builtin).toBeDefined() + const imported = { + ...builtin!, + source: 'imported_pack' as const, + sourcePack: { + id: 'industry.robotics.basic', + version: '1.0.0', + }, + name: 'Pack Screw Compressor', + } + + const merged = mergeDeviceProfiles([[imported], [builtin!]]) + const winner = merged.profiles.find((profile) => profile.id === 'screw_compressor') + const applied = applyDeviceProfileToPartInput(winner!, {}) + + expect(winner).toMatchObject({ + name: 'Pack Screw Compressor', + source: 'imported_pack', + sourcePack: { id: 'industry.robotics.basic', version: '1.0.0' }, + }) + expect(winner?.overrides?.[0]).toMatchObject({ + id: 'screw_compressor', + source: 'builtin', + }) + expect(applied).toMatchObject({ + overrodeBuiltin: true, + profilePackId: 'industry.robotics.basic', + }) + }) + + test('builds validated draft profiles for unknown industrial devices', () => { + const freezeDryer = buildDraftDeviceProfile('generate a freeze dryer with sealed chamber') + + expect(freezeDryer.profile).toMatchObject({ + id: 'freeze_dryer_draft', + family: 'generic', + layoutFamily: 'generic_industrial_layout', + status: 'runtime_draft', + source: 'generated_candidate', + primarySemanticRole: 'vacuum_chamber', + }) + expect(freezeDryer.validation.ok, freezeDryer.validation.issues.join('\n')).toBe(true) + + const fallback = buildDraftDeviceProfile('generate a very unusual industrial machine') + expect(fallback.profile).toMatchObject({ + family: 'generic', + layoutFamily: 'generic_industrial_layout', + primarySemanticRole: 'main_body', + }) + expect(validateDeviceProfileForExecution(fallback.profile).ok).toBe(true) + }) + + test('scores generated shapes with profile-aware quality components', () => { + const profile = getDeviceProfileDefinition('screw_compressor') + expect(profile).toBeDefined() + + const quality = evaluateDeviceProfileQuality(profile!, [ + { + semanticRole: 'compressor_casing', + sourcePartKind: 'rounded_machine_body', + position: [0.2, 0.45, 0], + length: 1, + width: 0.5, + height: 0.5, + }, + { + semanticRole: 'drive_motor', + sourcePartKind: 'ribbed_motor_body', + position: [-0.5, 0.4, 0], + length: 0.7, + width: 0.4, + height: 0.4, + }, + { + semanticRole: 'support_base', + sourcePartKind: 'skid_base', + position: [0, 0.05, 0], + length: 1.8, + width: 0.8, + height: 0.1, + }, + ]) + + expect(quality.semanticScore).toBeGreaterThan(0.7) + expect(quality.geometryScore).toBeGreaterThan(0.6) + expect(quality.editabilityScore).toBe(1) + expect(quality.overallScore).toBeGreaterThan(0.7) + }) + + test('scores required roles through profile role aliases', () => { + const profile = { + ...getDeviceProfileDefinition('screw_compressor')!, + id: 'cement.rotary_kiln.test', + name: 'Rotary kiln', + family: 'tank', + layoutFamily: 'vessel_layout' as const, + primarySemanticRole: 'kiln_shell', + parts: [ + { kind: 'cylindrical_tank', semanticRole: 'kiln_shell', required: true }, + { kind: 'skid_base', semanticRole: 'kiln_support_base', required: true }, + ], + roleAliases: { + kiln_support_base: ['support_pier', 'support_base'], + }, + } + + const quality = evaluateDeviceProfileQuality(profile, [ + { semanticRole: 'kiln_shell', sourcePartKind: 'cylindrical_tank', length: 12, width: 2.4 }, + { semanticRole: 'support_pier', sourcePartKind: 'skid_base', length: 2, width: 1 }, + ]) + + expect(quality.metrics.requiredCoverage).toBe(1) + expect(quality.issues).toHaveLength(0) + }) + + test('normalizes resource-pack generation metadata and applies quality rules', () => { + const profile = { + ...getDeviceProfileDefinition('screw_compressor')!, + id: 'industry.robot.test_arm', + name: 'Pack robot arm', + industry: 'robotics', + layoutTemplate: 'articulated_robot.six_axis', + partPresets: { + shoulder_joint: 'robot_joint.large', + }, + qualityRules: { + requiredRoles: ['tool_flange'], + forbiddenRoles: ['work_table'], + shapeCount: { min: 4, max: 16 }, + dimensionExpectations: { + lengthToDiameterRatio: { min: 1.5, max: 8 }, + }, + }, + detailBudget: { + detailLevel: 'low' as const, + maxShapes: 16, + parts: { + upper_arm: { detailLevel: 'low' as const, count: 2 }, + generic_panel: { count: 1 }, + }, + }, + source: 'imported_pack' as const, + sourcePack: { + id: 'industry.robotics.basic', + version: '1.0.0', + industry: 'robotics', + }, + parts: [ + { kind: 'generic_base', semanticRole: 'robot_base', required: true }, + { kind: 'generic_body', semanticRole: 'upper_arm', required: true }, + { kind: 'generic_panel', semanticRole: 'tool_flange' }, + ], + primarySemanticRole: 'robot_base', + } + + const normalized = applyDeviceProfileToPartInput(profile, { prompt: 'make robot arm' }) + const quality = evaluateDeviceProfileQuality(profile, [ + { + semanticRole: 'robot_base', + sourcePartKind: 'generic_base', + length: 0.8, + width: 0.8, + height: 0.8, + }, + { + semanticRole: 'upper_arm', + sourcePartKind: 'generic_body', + length: 1.8, + width: 0.35, + height: 0.35, + }, + { + semanticRole: 'tool_flange', + sourcePartKind: 'generic_panel', + length: 0.25, + width: 0.2, + height: 0.2, + }, + { + semanticRole: 'wrist_joint', + sourcePartKind: 'generic_panel', + length: 0.2, + width: 0.2, + height: 0.2, + }, + ]) + + expect(normalized).toMatchObject({ + layoutTemplate: 'articulated_robot.six_axis', + profileIndustry: 'robotics', + profilePackId: 'industry.robotics.basic', + partPresets: { shoulder_joint: 'robot_joint.large' }, + qualityRules: profile.qualityRules, + detailBudget: profile.detailBudget, + }) + expect( + (normalized.parts as Record[]).find( + (part) => part.semanticRole === 'upper_arm', + ), + ).toMatchObject({ + detailLevel: 'low', + count: 2, + }) + expect(quality.metrics.requiredCoverage).toBe(1) + expect(quality.metrics.ratioExpectationScore).toBeGreaterThan(0) + expect(quality.issues).toHaveLength(0) + }) + + test('evaluates horizontal cylindrical equipment dimensions using the shape axis', () => { + const profile = normalizeDeviceProfileInput({ + id: 'test_rotary_kiln', + name: 'Test rotary kiln', + aliases: ['rotary kiln'], + family: 'tank', + archetypeFamily: 'thermal_equipment', + parts: [{ kind: 'cylindrical_tank', semanticRole: 'vessel_shell', required: true }], + primarySemanticRole: 'vessel_shell', + qualityRules: { + dimensionExpectations: { + lengthToDiameterRatio: { min: 8, max: 18 }, + }, + }, + }) + + const quality = evaluateDeviceProfileQuality(profile, [ + { + kind: 'hollow-cylinder', + axis: 'x', + semanticRole: 'vessel_shell', + sourcePartKind: 'cylindrical_tank', + height: 6, + radius: 0.2, + }, + ]) + + expect(quality.metrics.lengthToDiameterRatio).toBeCloseTo(15) + expect(quality.warnings).not.toContain( + 'Length-to-diameter ratio 15.00 is outside profile expectation.', + ) + }) + + test('normalizes JSON detail budgets and uses them as shape budget quality contracts', () => { + const profile = normalizeDeviceProfileInput({ + id: 'test_plate_stack', + name: 'Test plate stack', + aliases: ['test plate stack'], + family: 'generic', + archetypeFamily: 'thermal_equipment', + parts: [ + { kind: 'generic_body', semanticRole: 'plate_stack', required: true }, + { kind: 'generic_panel', semanticRole: 'heat_transfer_plate', required: true }, + ], + primarySemanticRole: 'plate_stack', + detailBudget: { + detailLevel: 'low', + maxShapes: 2, + parts: { + heat_transfer_plate: { count: 1, detailLevel: 'low' }, + }, + }, + }) + const normalized = applyDeviceProfileToPartInput(profile, {}) + const quality = evaluateDeviceProfileQuality(profile, [ + { semanticRole: 'plate_stack', sourcePartKind: 'generic_body' }, + { semanticRole: 'heat_transfer_plate', sourcePartKind: 'generic_panel' }, + { semanticRole: 'extra_detail', sourcePartKind: 'generic_panel' }, + ]) + + expect(validateDeviceProfileSchema(profile).ok).toBe(true) + expect((normalized.parts as Record[])[0]).toMatchObject({ + detailLevel: 'low', + }) + expect((normalized.parts as Record[])[1]).toMatchObject({ + detailLevel: 'low', + count: 1, + }) + expect(quality.issues).toContain('Shape count 3 exceeds profile maximum 2.') + }) +}) diff --git a/packages/core/src/lib/device-profile-registry.ts b/packages/core/src/lib/device-profile-registry.ts new file mode 100644 index 000000000..8751b4eab --- /dev/null +++ b/packages/core/src/lib/device-profile-registry.ts @@ -0,0 +1,2285 @@ +import { + FAMILY_DEFINITIONS, + getFamilyDefinition, + getLayoutFamilyDefinition, + type LayoutFamilyId, + normalizeLayoutFamilyId, +} from './family-registry' +import type { PartComposeKind, PartComposePartInput } from './part-compose' +import { getPartDefinitions } from './part-registry' + +export type DeviceArchetypeFamily = + | 'rotating_fluid_machine' + | 'material_handling' + | 'process_vessel' + | 'thermal_equipment' + | 'enclosed_machine' + | 'robotic_workcell' + | 'electrical_enclosure' + | 'pipe_valve_system' + | 'generic_industrial' + +export type DeviceProfileStatus = + | 'runtime_draft' + | 'candidate' + | 'pending_review' + | 'stable' + | 'draft' +export type DeviceProfileSource = 'builtin' | 'workspace' | 'imported_pack' | 'generated_candidate' + +const PROFILE_SOURCE_PRIORITY: Record = { + workspace: 4, + imported_pack: 3, + builtin: 2, + generated_candidate: 1, +} + +export interface DimensionDefaults { + length?: number + width?: number + height?: number + diameter?: number +} + +export interface DimensionRule { + from: 'length' | 'width' | 'height' | 'diameter' + toPart: string + toParam: string + scale?: number + offset?: number + min?: number + max?: number +} + +export interface ProfilePartSpec extends PartComposePartInput { + kind: PartComposeKind | string + semanticRole: string + required?: boolean + preset?: string + params?: Record + dimensionBindings?: Record +} + +export interface DeviceProfileSourcePack { + id: string + version: string + industry?: string +} + +export interface DeviceProfileOverrideInfo { + id: string + name: string + source: DeviceProfileSource + sourcePack?: DeviceProfileSourcePack +} + +export interface DeviceProfileShapeCountRule { + min?: number + max?: number +} + +export interface DeviceProfileRangeRule { + min?: number + max?: number +} + +export interface DeviceProfileQualityRules { + requiredRoles?: readonly string[] + forbiddenRoles?: readonly string[] + shapeCount?: DeviceProfileShapeCountRule + dimensionExpectations?: { + lengthToDiameterRatio?: DeviceProfileRangeRule + } +} + +export type DeviceProfileRuleRef = string | Record + +export type DeviceProfileDetailLevel = 'low' | 'medium' | 'high' + +export interface DeviceProfilePartDetailBudget { + detailLevel?: DeviceProfileDetailLevel + count?: number + ringCount?: number + spokeCount?: number + slatCount?: number + rungCount?: number + boltCount?: number + radialSegments?: number + levelCount?: number +} + +export interface DeviceProfileDetailBudget { + detailLevel?: DeviceProfileDetailLevel + maxShapes?: number + parts?: Record +} + +export type EditablePropertyType = 'number' | 'integer' | 'boolean' | 'enum' | 'color' | 'string' + +export type EditablePropertyRole = + | 'structure' + | 'dimension' + | 'pose' + | 'material' + | 'detail' + | 'workcell' + +export interface EditablePropertyDefinition { + type: EditablePropertyType + default?: unknown + min?: number + max?: number + values?: readonly unknown[] + unit?: string + aliases?: readonly string[] + role?: EditablePropertyRole + description?: string +} + +export interface EditableSchemaDefinition { + id: string + name: string + description?: string + properties: Record +} + +export interface DeviceProfileDefinition { + id: string + name: string + aliases: readonly string[] + industry?: string + layoutFamily?: LayoutFamilyId + layoutTemplate?: string + archetypeFamily: DeviceArchetypeFamily + family: string + defaultDimensions?: DimensionDefaults + parts: readonly ProfilePartSpec[] + primarySemanticRole: string + dimensionRules?: readonly DimensionRule[] + partPresets?: Record + resolvedPartPresets?: Record> + proportionRules?: DeviceProfileRuleRef + qualityRules?: DeviceProfileRuleRef + detailBudget?: DeviceProfileDetailBudget + visualCues?: readonly string[] + layoutHints?: Record + roleAliases?: Record + editableSchemaRef?: string + editableOverrides?: Record> + resolvedEditableSchema?: EditableSchemaDefinition + status: DeviceProfileStatus + source: DeviceProfileSource + sourcePack?: DeviceProfileSourcePack + overrides?: readonly DeviceProfileOverrideInfo[] + description: string + forbiddenRoles?: readonly string[] +} + +export interface DeviceProfileValidation { + ok: boolean + issues: string[] + warnings: string[] + score?: number +} + +export interface DeviceProfileResolver { + profiles: readonly DeviceProfileDefinition[] + get: (profile: unknown) => DeviceProfileDefinition | undefined + infer: (input: Record) => DeviceProfileDefinition | undefined + summary: () => string +} + +export interface DeviceProfileMergeResult { + profiles: DeviceProfileDefinition[] + warnings: string[] +} + +export interface DraftDeviceProfileResult { + profile: DeviceProfileDefinition + validation: DeviceProfileValidation +} + +export interface DeviceProfileExecutionValidation extends DeviceProfileValidation { + stages: { + schema: DeviceProfileValidation + registry: DeviceProfileValidation + execution?: DeviceProfileValidation + } +} + +export interface DeviceProfileQualityInputShape { + kind?: string + name?: string + semanticRole?: string + sourcePartKind?: string + semanticGroup?: string + position?: readonly number[] + axis?: string + scale?: readonly number[] + length?: number + width?: number + height?: number + depth?: number + radius?: number + majorRadius?: number + tubeRadius?: number + radiusTop?: number + radiusBottom?: number + thickness?: number +} + +export interface DeviceProfileQualityScore { + semanticScore: number + geometryScore: number + editabilityScore: number + visualCompletenessScore: number + overallScore: number + warnings: string[] + issues: string[] + metrics: Record +} + +export const DEVICE_PROFILE_DEFINITIONS = [ + { + id: 'centrifugal_pump', + name: 'Centrifugal pump', + aliases: ['centrifugal pump', 'process pump', 'chemical pump', 'li xin beng', 'gong yi beng'], + layoutFamily: 'rotating_machine_layout', + archetypeFamily: 'rotating_fluid_machine', + family: 'pump', + defaultDimensions: { length: 2.2, width: 0.9, height: 1.1 }, + parts: [ + { kind: 'skid_base', semanticRole: 'support_base' }, + { kind: 'volute_casing', semanticRole: 'volute_casing' }, + { kind: 'ribbed_motor_body', semanticRole: 'drive_motor' }, + { kind: 'inlet_port', semanticRole: 'inlet_port' }, + { kind: 'outlet_port', semanticRole: 'outlet_port' }, + { kind: 'flange_ring', semanticRole: 'flange' }, + { kind: 'control_box', semanticRole: 'control_box' }, + ], + primarySemanticRole: 'volute_casing', + status: 'stable', + source: 'builtin', + description: 'Skid-mounted centrifugal/process pump with motor, volute, ports, and flanges.', + }, + { + id: 'screw_compressor', + name: 'Screw compressor', + aliases: [ + 'screw compressor', + 'air compressor', + 'gas compressor', + 'skid compressor', + 'luo gan ya suo ji', + ], + layoutFamily: 'rotating_machine_layout', + archetypeFamily: 'rotating_fluid_machine', + family: 'compressor', + defaultDimensions: { length: 2.4, width: 0.9, height: 1.05 }, + parts: [ + { kind: 'skid_base', semanticRole: 'machine_base' }, + { kind: 'rounded_machine_body', semanticRole: 'compressor_casing' }, + { kind: 'ribbed_motor_body', semanticRole: 'motor_body' }, + { kind: 'inlet_port', semanticRole: 'inlet_port' }, + { kind: 'outlet_port', semanticRole: 'outlet_port' }, + { kind: 'control_box', semanticRole: 'control_box' }, + ], + primarySemanticRole: 'compressor_casing', + status: 'stable', + source: 'builtin', + description: + 'Skid-mounted compressor with drive motor, casing, process ports, and control box.', + }, + { + id: 'belt_conveyor', + name: 'Belt conveyor', + aliases: ['belt conveyor', 'conveyor line', 'material conveyor', 'pi dai shu song ji'], + layoutFamily: 'linear_transport_layout', + archetypeFamily: 'material_handling', + family: 'conveyor', + defaultDimensions: { length: 4, width: 0.8, height: 0.75 }, + parts: [ + { kind: 'conveyor_frame', semanticRole: 'conveyor_frame' }, + { kind: 'roller_array', semanticRole: 'roller_array' }, + { kind: 'belt_surface', semanticRole: 'belt_surface' }, + { kind: 'ribbed_motor_body', semanticRole: 'drive_motor' }, + ], + primarySemanticRole: 'conveyor_frame', + status: 'stable', + source: 'builtin', + description: + 'Long belt conveyor with frame, support legs, repeated rollers, belt, and drive motor.', + }, + { + id: 'vertical_storage_tank', + name: 'Vertical storage tank', + aliases: [ + 'vertical storage tank', + 'chemical storage tank', + 'vertical tank', + 'li shi chu guan', + 'hua gong chu guan', + ], + layoutFamily: 'vessel_layout', + archetypeFamily: 'process_vessel', + family: 'tank', + defaultDimensions: { diameter: 1.4, height: 3.2 }, + parts: [ + { kind: 'storage_tank_shell', semanticRole: 'vessel_shell' }, + { kind: 'skid_base', semanticRole: 'support_base' }, + { kind: 'inlet_port', semanticRole: 'inlet_port' }, + { kind: 'outlet_port', semanticRole: 'outlet_port' }, + { kind: 'platform_ladder', semanticRole: 'access_platform' }, + ], + primarySemanticRole: 'vessel_shell', + status: 'stable', + source: 'builtin', + description: + 'Vertical vessel with support base, top inlet, lower outlet, and access ladder/platform.', + }, + { + id: 'stirred_reactor', + name: 'Stirred reactor', + aliases: [ + 'stirred reactor', + 'reaction vessel', + 'agitator tank', + 'fan ying fu', + 'jiao ban guan', + ], + layoutFamily: 'vessel_layout', + archetypeFamily: 'process_vessel', + family: 'reactor', + defaultDimensions: { diameter: 1.2, height: 2.4 }, + parts: [ + { kind: 'agitator_tank', semanticRole: 'reactor_vessel_shell' }, + { kind: 'inlet_port', semanticRole: 'inlet_port' }, + { kind: 'outlet_port', semanticRole: 'outlet_port' }, + { kind: 'platform_ladder', semanticRole: 'access_platform' }, + ], + primarySemanticRole: 'reactor_vessel_shell', + status: 'stable', + source: 'builtin', + description: + 'Agitated reactor vessel with mixer, feed/discharge nozzles, and optional access platform.', + }, + { + id: 'shell_tube_heat_exchanger', + name: 'Shell-and-tube heat exchanger', + aliases: ['shell and tube heat exchanger', 'heat exchanger', 'condenser', 'huan re qi'], + layoutFamily: 'vessel_layout', + archetypeFamily: 'thermal_equipment', + family: 'heat_exchanger', + defaultDimensions: { length: 3, width: 0.55, height: 0.75, diameter: 0.5 }, + parts: [ + { kind: 'heat_exchanger', semanticRole: 'heat_exchanger_shell' }, + { kind: 'skid_base', semanticRole: 'support_base' }, + ], + primarySemanticRole: 'heat_exchanger_shell', + status: 'stable', + source: 'builtin', + description: + 'Horizontal shell-and-tube exchanger with channel heads, nozzles, and saddle supports.', + }, + { + id: 'cnc_machining_center', + name: 'CNC machining center', + aliases: [ + 'cnc machining center', + 'cnc machine', + 'cnc mill', + 'jia gong zhong xin', + 'shu kong ji chuang', + ], + layoutFamily: 'box_enclosure_layout', + archetypeFamily: 'enclosed_machine', + family: 'machine_tool', + defaultDimensions: { length: 2.8, width: 1.1, height: 1.7 }, + parts: [ + { kind: 'generic_base', semanticRole: 'machine_base' }, + { kind: 'generic_body', semanticRole: 'machine_enclosure' }, + { kind: 'viewing_panel', semanticRole: 'viewing_panel' }, + { kind: 'work_table', semanticRole: 'work_table' }, + { kind: 'generic_panel', semanticRole: 'spindle_head' }, + { kind: 'control_box', semanticRole: 'control_panel' }, + { kind: 'display_screen', semanticRole: 'display_screen' }, + { kind: 'vent_panel', semanticRole: 'vent_panel' }, + { kind: 'access_panel', semanticRole: 'access_panel' }, + ], + primarySemanticRole: 'machine_enclosure', + status: 'stable', + source: 'builtin', + description: + 'Enclosed CNC/machine-tool body with bed, viewing panel, spindle head, table, and controls.', + }, + { + id: 'packaging_machine', + name: 'Packaging machine', + aliases: ['packaging machine', 'bagging machine', 'cartoning machine', 'bao zhuang ji'], + layoutFamily: 'box_enclosure_layout', + archetypeFamily: 'enclosed_machine', + family: 'machine_tool', + defaultDimensions: { length: 2.6, width: 1, height: 1.6 }, + parts: [ + { kind: 'generic_base', semanticRole: 'machine_base' }, + { kind: 'generic_body', semanticRole: 'machine_enclosure' }, + { + kind: 'generic_spout', + semanticRole: 'feed_chute', + position: [-0.62, 0.92, 0.58], + axis: 'z', + length: 0.34, + radius: 0.08, + }, + { + kind: 'generic_spout', + semanticRole: 'discharge_chute', + position: [0.72, 0.48, -0.58], + axis: 'z', + length: 0.4, + radius: 0.09, + }, + { kind: 'control_box', semanticRole: 'control_panel' }, + { kind: 'display_screen', semanticRole: 'display_screen' }, + { kind: 'vent_panel', semanticRole: 'vent_panel' }, + ], + primarySemanticRole: 'machine_enclosure', + status: 'stable', + source: 'builtin', + description: 'Enclosed packaging/bagging machine mapped to the enclosed-machine archetype.', + }, + { + id: 'robot_welding_cell', + name: 'Robot welding cell', + aliases: ['robot welding cell', 'robotic welding cell', 'industrial robot workstation'], + layoutFamily: 'robot_workcell_layout', + archetypeFamily: 'robotic_workcell', + family: 'robot_arm', + defaultDimensions: { length: 2.2, width: 1.6, height: 1.8 }, + parts: [ + { kind: 'generic_base', semanticRole: 'robot_base' }, + { kind: 'generic_body', semanticRole: 'upper_arm' }, + { kind: 'generic_body', semanticRole: 'forearm' }, + { kind: 'generic_panel', semanticRole: 'work_table' }, + { kind: 'control_box', semanticRole: 'control_panel' }, + { kind: 'warning_label', semanticRole: 'warning_label' }, + ], + primarySemanticRole: 'robot_base', + status: 'stable', + source: 'builtin', + description: + 'Robot workcell profile with arm, fixture table, control cabinet, and safety cues.', + }, + { + id: 'palletizer_cell', + name: 'Palletizer cell', + aliases: ['palletizer', 'palletizing robot', 'robot palletizer', 'ma duo ji'], + layoutFamily: 'robot_workcell_layout', + archetypeFamily: 'robotic_workcell', + family: 'robot_arm', + defaultDimensions: { length: 2.6, width: 1.8, height: 2 }, + parts: [ + { kind: 'generic_base', semanticRole: 'robot_base' }, + { kind: 'generic_body', semanticRole: 'upper_arm' }, + { kind: 'generic_body', semanticRole: 'forearm' }, + { kind: 'generic_panel', semanticRole: 'work_table' }, + { kind: 'control_box', semanticRole: 'control_panel' }, + { kind: 'warning_label', semanticRole: 'warning_label' }, + ], + primarySemanticRole: 'robot_base', + status: 'stable', + source: 'builtin', + description: 'Palletizing robot cell mapped to the robotic-workcell archetype.', + }, +] as const satisfies readonly DeviceProfileDefinition[] + +export type DeviceProfileId = (typeof DEVICE_PROFILE_DEFINITIONS)[number]['id'] + +export const EDITABLE_SCHEMA_DEFINITIONS = [ + { + id: 'robot_arm.common', + name: 'Common robot arm editable properties', + description: 'Shared editable controls for articulated industrial robot arms.', + properties: { + axisCount: { + type: 'integer', + min: 3, + max: 7, + default: 6, + aliases: ['axis count', 'axes', 'joint count', '轴数', '几轴'], + role: 'structure', + }, + reach: { + type: 'number', + min: 0.8, + max: 8, + default: 1.58, + unit: 'm', + aliases: ['reach', 'arm span', 'working radius', '臂展', '工作半径'], + role: 'dimension', + }, + height: { + type: 'number', + min: 0.8, + max: 4, + default: 2.2, + unit: 'm', + aliases: ['height', 'overall height', '高度'], + role: 'dimension', + }, + pose: { + type: 'enum', + values: ['work-ready', 'reach-forward', 'rest'], + default: 'work-ready', + aliases: ['pose', 'posture', '姿态'], + role: 'pose', + }, + endEffector: { + type: 'enum', + values: ['tool-flange', 'gripper', 'suction'], + default: 'tool-flange', + aliases: ['end effector', 'tool', '末端', '末端工具', '夹爪', '吸盘', '法兰'], + role: 'structure', + }, + primaryColor: { + type: 'color', + default: '#facc15', + aliases: ['main color', 'body color', '主体颜色', '颜色'], + role: 'material', + }, + secondaryColor: { + type: 'color', + default: '#111827', + aliases: ['joint color', 'secondary color', '关节颜色'], + role: 'material', + }, + metalColor: { + type: 'color', + default: '#cbd5e1', + aliases: ['metal color', 'flange color', '金属色'], + role: 'material', + }, + includeCableHarness: { + type: 'boolean', + default: true, + aliases: ['cable', 'cable harness', '线缆', '线束'], + role: 'detail', + }, + includeWorkcell: { + type: 'boolean', + default: false, + aliases: ['workcell', 'station', '工作站', '工位'], + role: 'workcell', + }, + detailLevel: { + type: 'enum', + values: ['low', 'medium', 'high'], + default: 'medium', + aliases: ['detail', 'complexity', '细节', '精细度'], + role: 'detail', + }, + }, + }, + { + id: 'vessel.common', + name: 'Common vessel editable properties', + description: 'Shared editable controls for tanks, reactors, silos, and process vessels.', + properties: { + height: { + type: 'number', + min: 0.2, + max: 40, + default: 1.6, + unit: 'm', + aliases: ['height', 'overall height', 'vessel height'], + role: 'dimension', + }, + diameter: { + type: 'number', + min: 0.1, + max: 20, + default: 1, + unit: 'm', + aliases: ['diameter', 'vessel diameter', 'tank diameter'], + role: 'dimension', + }, + length: { + type: 'number', + min: 0.2, + max: 80, + default: 2, + unit: 'm', + aliases: ['length', 'shell length', 'tank length'], + role: 'dimension', + }, + primaryColor: { + type: 'color', + default: '#94a3b8', + aliases: ['main color', 'body color', 'color'], + role: 'material', + }, + metalColor: { + type: 'color', + default: '#cbd5e1', + aliases: ['metal color', 'stainless color'], + role: 'material', + }, + supportCount: { + type: 'integer', + min: 0, + max: 12, + default: 4, + aliases: ['support count', 'leg count'], + role: 'structure', + }, + }, + }, + { + id: 'conveyor.common', + name: 'Common conveyor editable properties', + description: 'Shared editable controls for belt, screw, and bucket conveying equipment.', + properties: { + length: { + type: 'number', + min: 0.5, + max: 80, + default: 4, + unit: 'm', + aliases: ['length', 'conveyor length'], + role: 'dimension', + }, + width: { + type: 'number', + min: 0.15, + max: 8, + default: 0.8, + unit: 'm', + aliases: ['width', 'belt width', 'trough width'], + role: 'dimension', + }, + height: { + type: 'number', + min: 0.1, + max: 12, + default: 0.8, + unit: 'm', + aliases: ['height', 'support height'], + role: 'dimension', + }, + inclineAngle: { + type: 'number', + min: -35, + max: 35, + default: 0, + unit: 'deg', + aliases: ['incline', 'slope angle'], + role: 'pose', + }, + primaryColor: { + type: 'color', + default: '#64748b', + aliases: ['main color', 'frame color', 'color'], + role: 'material', + }, + }, + }, + { + id: 'rotary_equipment.common', + name: 'Common rotary equipment editable properties', + description: 'Shared editable controls for kilns, mills, drums, and rotating shells.', + properties: { + length: { + type: 'number', + min: 0.5, + max: 120, + default: 6, + unit: 'm', + aliases: ['length', 'shell length', 'drum length'], + role: 'dimension', + }, + diameter: { + type: 'number', + min: 0.1, + max: 20, + default: 1, + unit: 'm', + aliases: ['diameter', 'shell diameter', 'drum diameter'], + role: 'dimension', + }, + primaryColor: { + type: 'color', + default: '#6b7280', + aliases: ['main color', 'shell color', 'color'], + role: 'material', + }, + metalColor: { + type: 'color', + default: '#9ca3af', + aliases: ['metal color', 'ring color'], + role: 'material', + }, + }, + }, + { + id: 'enclosure.common', + name: 'Common enclosure editable properties', + description: 'Shared editable controls for enclosed machines and electrical cabinets.', + properties: { + length: { + type: 'number', + min: 0.3, + max: 20, + default: 2, + unit: 'm', + aliases: ['length', 'machine length'], + role: 'dimension', + }, + width: { + type: 'number', + min: 0.2, + max: 12, + default: 1, + unit: 'm', + aliases: ['width', 'machine width', 'depth'], + role: 'dimension', + }, + height: { + type: 'number', + min: 0.2, + max: 12, + default: 1.4, + unit: 'm', + aliases: ['height', 'machine height'], + role: 'dimension', + }, + primaryColor: { + type: 'color', + default: '#94a3b8', + aliases: ['main color', 'body color', 'color'], + role: 'material', + }, + secondaryColor: { + type: 'color', + default: '#475569', + aliases: ['panel color', 'secondary color'], + role: 'material', + }, + }, + }, + { + id: 'mobile_platform.common', + name: 'Common mobile platform editable properties', + description: 'Shared editable controls for AGV and AMR mobile equipment.', + properties: { + length: { + type: 'number', + min: 0.3, + max: 8, + default: 1.4, + unit: 'm', + aliases: ['length', 'vehicle length'], + role: 'dimension', + }, + width: { + type: 'number', + min: 0.2, + max: 4, + default: 0.9, + unit: 'm', + aliases: ['width', 'vehicle width'], + role: 'dimension', + }, + height: { + type: 'number', + min: 0.08, + max: 2, + default: 0.32, + unit: 'm', + aliases: ['height', 'vehicle height'], + role: 'dimension', + }, + primaryColor: { + type: 'color', + default: '#e5e7eb', + aliases: ['main color', 'body color', 'color'], + role: 'material', + }, + secondaryColor: { + type: 'color', + default: '#334155', + aliases: ['bumper color', 'secondary color'], + role: 'material', + }, + }, + }, + { + id: 'tower_frame.common', + name: 'Common tower frame editable properties', + description: + 'Shared editable controls for industrial towers, frames, and preheater structures.', + properties: { + height: { + type: 'number', + min: 1, + max: 80, + default: 12, + unit: 'm', + aliases: ['height', 'tower height'], + role: 'dimension', + }, + width: { + type: 'number', + min: 0.5, + max: 30, + default: 4, + unit: 'm', + aliases: ['width', 'tower width', 'frame width'], + role: 'dimension', + }, + depth: { + type: 'number', + min: 0.5, + max: 30, + default: 3, + unit: 'm', + aliases: ['depth', 'tower depth', 'frame depth'], + role: 'dimension', + }, + levelCount: { + type: 'integer', + min: 1, + max: 12, + default: 5, + aliases: ['levels', 'floor count', 'platform count'], + role: 'structure', + }, + primaryColor: { + type: 'color', + default: '#64748b', + aliases: ['main color', 'frame color', 'color'], + role: 'material', + }, + }, + }, +] as const satisfies readonly EditableSchemaDefinition[] + +function editablePropertyDefinition(value: unknown): EditablePropertyDefinition | undefined { + if (!isRecord(value)) return undefined + const type = typeof value.type === 'string' ? value.type : undefined + if ( + type !== 'number' && + type !== 'integer' && + type !== 'boolean' && + type !== 'enum' && + type !== 'color' && + type !== 'string' + ) { + return undefined + } + const role = typeof value.role === 'string' ? value.role : undefined + return { + type, + ...(value.default != null ? { default: value.default } : {}), + ...(typeof value.min === 'number' && Number.isFinite(value.min) ? { min: value.min } : {}), + ...(typeof value.max === 'number' && Number.isFinite(value.max) ? { max: value.max } : {}), + ...(Array.isArray(value.values) ? { values: value.values } : {}), + ...(typeof value.unit === 'string' && value.unit.trim() ? { unit: value.unit.trim() } : {}), + ...(stringArray(value.aliases).length ? { aliases: stringArray(value.aliases) } : {}), + ...(role === 'structure' || + role === 'dimension' || + role === 'pose' || + role === 'material' || + role === 'detail' || + role === 'workcell' + ? { role } + : {}), + ...(typeof value.description === 'string' && value.description.trim() + ? { description: value.description.trim() } + : {}), + } +} + +export function normalizeEditableSchemaInput(value: unknown): EditableSchemaDefinition | undefined { + if (!isRecord(value)) return undefined + const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : undefined + const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : id + const properties = isRecord(value.properties) ? value.properties : undefined + if (!id || !name || !properties) return undefined + const normalizedProperties = Object.fromEntries( + Object.entries(properties).flatMap(([key, raw]) => { + const property = editablePropertyDefinition(raw) + return property ? [[key, property] as const] : [] + }), + ) + if (Object.keys(normalizedProperties).length === 0) return undefined + return { + id, + name, + ...(typeof value.description === 'string' && value.description.trim() + ? { description: value.description.trim() } + : {}), + properties: normalizedProperties, + } +} + +export function editableSchemaById( + schemas: readonly EditableSchemaDefinition[] = EDITABLE_SCHEMA_DEFINITIONS, +) { + return new Map(schemas.map((schema) => [normalizeKey(schema.id), schema])) +} + +function inferredEditableSchemaRef(profile: DeviceProfileDefinition): string | undefined { + const id = normalizeKey(profile.id) + const family = normalizeKey(profile.family) + const layoutFamily = normalizeKey(profile.layoutFamily) + const archetypeFamily = normalizeKey(profile.archetypeFamily) + const parts = profile.parts + .map((part) => normalizeKey([part.kind, part.semanticRole, part.id].filter(Boolean).join(' '))) + .join(' ') + const text = [id, family, layoutFamily, archetypeFamily, parts].join(' ') + if (family === 'robot_arm' || archetypeFamily === 'robotic_workcell') return 'robot_arm.common' + if (text.includes('mobile_platform') || /\bagv\b|\bamr\b/.test(text)) { + return 'mobile_platform.common' + } + if (text.includes('preheater') || text.includes('tower_frame') || text.includes('tower')) { + return 'tower_frame.common' + } + if ( + archetypeFamily === 'rotating_fluid_machine' || + archetypeFamily === 'thermal_equipment' || + text.includes('rotary_kiln') || + text.includes('mill_shell') || + text.includes('drum') + ) { + return 'rotary_equipment.common' + } + if (family === 'conveyor' || archetypeFamily === 'material_handling') return 'conveyor.common' + if ( + family === 'tank' || + family === 'reactor' || + layoutFamily === 'vessel_layout' || + archetypeFamily === 'process_vessel' + ) { + return 'vessel.common' + } + if ( + family === 'machine_tool' || + family === 'electrical' || + layoutFamily === 'box_enclosure_layout' || + archetypeFamily === 'enclosed_machine' || + archetypeFamily === 'electrical_enclosure' + ) { + return 'enclosure.common' + } + return undefined +} + +export function resolveEditableSchemaForProfile( + profile: DeviceProfileDefinition, + schemas: readonly EditableSchemaDefinition[] = EDITABLE_SCHEMA_DEFINITIONS, +): EditableSchemaDefinition | undefined { + const schemaRef = profile.editableSchemaRef ?? inferredEditableSchemaRef(profile) + const base = + profile.resolvedEditableSchema ?? + (schemaRef ? editableSchemaById(schemas).get(normalizeKey(schemaRef)) : undefined) + if (!base) return undefined + const overrides = profile.editableOverrides ?? {} + const properties = { ...base.properties } + for (const [key, override] of Object.entries(overrides)) { + const existing = properties[key] + if (!existing) continue + properties[key] = { ...existing, ...override } + } + return { ...base, properties } +} + +function normalizeKey(value: unknown): string { + return typeof value === 'string' + ? value + .trim() + .replace(/[\s_.-]+/g, '_') + .toLowerCase() + : '' +} + +function textOf(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function isAsciiWordChar(value: string | undefined): boolean { + return value != null && /^[a-z0-9]$/.test(value) +} + +function containsAliasToken(normalizedText: string, alias: string): boolean { + if (!alias) return false + if (!/[a-z0-9]/.test(alias)) return normalizedText.includes(alias) + + let index = normalizedText.indexOf(alias) + while (index >= 0) { + const before = normalizedText[index - 1] + const after = normalizedText[index + alias.length] + if (!isAsciiWordChar(before) && !isAsciiWordChar(after)) return true + index = normalizedText.indexOf(alias, index + 1) + } + return false +} + +function containsExplicitProfileId(text: string, profileId: string): boolean { + return text.toLowerCase().includes(profileId.toLowerCase()) +} + +function requestedRobotAxisCount(input: Record): number | undefined { + const text = textOf([input.object, input.name, input.category, input.prompt, input.style]) + if (/(seven[_\s-]?axis|7[_\s-]?axis|\u4e03\u8f74)/i.test(text)) return 7 + if (/(six[_\s-]?axis|6[_\s-]?axis|\u516d\u8f74|fanuc|kuka|abb)/i.test(text)) return 6 + if (/(five[_\s-]?axis|5[_\s-]?axis|\u4e94\u8f74)/i.test(text)) return 5 + if (/(four[_\s-]?axis|4[_\s-]?axis|\u56db\u8f74|scara)/i.test(text)) return 4 + if (/(three[_\s-]?axis|3[_\s-]?axis|\u4e09\u8f74)/i.test(text)) return 3 + return undefined +} + +function profileRobotAxisCount(profile: DeviceProfileDefinition): number | undefined { + const robotDefaults = isRecord(profile.layoutHints?.robotArmDefaults) + ? profile.layoutHints.robotArmDefaults + : undefined + const raw = robotDefaults?.axisCount ?? robotDefaults?.axes + return typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : undefined +} + +function profileCompatibleWithPromptAxis( + profile: DeviceProfileDefinition, + input: Record, +): boolean { + const requestedAxisCount = requestedRobotAxisCount(input) + if (requestedAxisCount == null) return true + if (profile.family !== 'robot_arm' && profile.layoutFamily !== 'robot_workcell_layout') + return true + const profileAxisCount = profileRobotAxisCount(profile) + return profileAxisCount == null || profileAxisCount === requestedAxisCount +} + +const profileAliasMap = new Map() +for (const profile of DEVICE_PROFILE_DEFINITIONS) { + profileAliasMap.set(normalizeKey(profile.id), profile) + for (const alias of profile.aliases) profileAliasMap.set(normalizeKey(alias), profile) +} + +function sourcePriority(profile: DeviceProfileDefinition): number { + return PROFILE_SOURCE_PRIORITY[profile.source] ?? 0 +} + +function compareProfilePriority( + left: DeviceProfileDefinition, + right: DeviceProfileDefinition, +): number { + return sourcePriority(right) - sourcePriority(left) +} + +function profileOverrideInfo(profile: DeviceProfileDefinition): DeviceProfileOverrideInfo { + return { + id: profile.id, + name: profile.name, + source: profile.source, + ...(profile.sourcePack ? { sourcePack: profile.sourcePack } : {}), + } +} + +function withProfileOverride( + winner: DeviceProfileDefinition, + overridden: DeviceProfileDefinition, +): DeviceProfileDefinition { + const existing = winner.overrides ?? [] + if ( + existing.some( + (entry) => + normalizeKey(entry.id) === normalizeKey(overridden.id) && + entry.source === overridden.source, + ) + ) { + return winner + } + return { + ...winner, + overrides: [...existing, profileOverrideInfo(overridden)], + } +} + +export function mergeDeviceProfiles( + profileSources: readonly (readonly DeviceProfileDefinition[])[], +): DeviceProfileMergeResult { + const warnings: string[] = [] + const byId = new Map() + + for (const profiles of profileSources) { + for (const profile of profiles) { + const key = normalizeKey(profile.id) + if (!key) { + warnings.push('Ignored device profile with empty id.') + continue + } + const existing = byId.get(key) + if (!existing || sourcePriority(profile) >= sourcePriority(existing)) { + if (existing && existing.source !== profile.source) { + warnings.push( + `Device profile "${profile.id}" from ${profile.source} overrides ${existing.source}.`, + ) + } + byId.set(key, existing ? withProfileOverride(profile, existing) : profile) + } else { + warnings.push( + `Device profile "${profile.id}" from ${profile.source} ignored because ${existing.source} has higher priority.`, + ) + byId.set(key, withProfileOverride(existing, profile)) + } + } + } + + const profiles = Array.from(byId.values()).sort((left, right) => { + const priority = compareProfilePriority(left, right) + return priority !== 0 ? priority : left.id.localeCompare(right.id) + }) + return { profiles, warnings } +} + +function buildProfileAliasMap(profiles: readonly DeviceProfileDefinition[]) { + const aliasMap = new Map() + for (const profile of [...profiles].sort(compareProfilePriority)) { + const aliases = [profile.id, profile.name, ...profile.aliases] + for (const alias of aliases) { + const key = normalizeKey(alias) + if (key && !aliasMap.has(key)) aliasMap.set(key, profile) + } + } + return aliasMap +} + +function inferDeviceProfileFromProfiles( + input: Record, + profiles: readonly DeviceProfileDefinition[], +): DeviceProfileDefinition | undefined { + const aliasMap = buildProfileAliasMap(profiles) + const explicit = + aliasMap.get(normalizeKey(input.deviceProfile)) ?? + aliasMap.get(normalizeKey(input.profile)) ?? + aliasMap.get(normalizeKey(input.deviceType)) + if (explicit) return explicit + + const text = textOf([input.object, input.name, input.category, input.prompt, input.style]) + const normalizedText = normalizeKey(text) + const matches = profiles + .flatMap((profile) => + [profile.id, profile.name, ...profile.aliases].map((alias, index) => ({ + profile, + alias: normalizeKey(alias), + isExplicitProfileId: index === 0 && containsExplicitProfileId(text, profile.id), + })), + ) + .filter((candidate) => containsAliasToken(normalizedText, candidate.alias)) + .filter((candidate) => profileCompatibleWithPromptAxis(candidate.profile, input)) + matches.sort((left, right) => { + if (left.isExplicitProfileId !== right.isExplicitProfileId) + return left.isExplicitProfileId ? -1 : 1 + const priority = compareProfilePriority(left.profile, right.profile) + return priority !== 0 ? priority : right.alias.length - left.alias.length + }) + return matches[0]?.profile +} + +export function createDeviceProfileResolver( + profiles: readonly DeviceProfileDefinition[] = DEVICE_PROFILE_DEFINITIONS, +): DeviceProfileResolver { + const merged = mergeDeviceProfiles([profiles]) + const aliasMap = buildProfileAliasMap(merged.profiles) + return { + profiles: merged.profiles, + get: (profile: unknown) => aliasMap.get(normalizeKey(profile)), + infer: (input: Record) => + inferDeviceProfileFromProfiles(input, merged.profiles), + summary: () => deviceProfileCapabilitySummary(merged.profiles), + } +} + +export function getDeviceProfileDefinition(profile: unknown): DeviceProfileDefinition | undefined { + return profileAliasMap.get(normalizeKey(profile)) +} + +export function inferDeviceProfileDefinition( + input: Record, + profiles: readonly DeviceProfileDefinition[] = DEVICE_PROFILE_DEFINITIONS, +): DeviceProfileDefinition | undefined { + if (profiles !== DEVICE_PROFILE_DEFINITIONS) { + const aliasMap = buildProfileAliasMap(profiles) + const explicit = + aliasMap.get(normalizeKey(input.deviceProfile)) ?? + aliasMap.get(normalizeKey(input.profile)) ?? + aliasMap.get(normalizeKey(input.deviceType)) + if (explicit) return explicit + + return inferDeviceProfileFromProfiles(input, profiles) + } + const explicit = + getDeviceProfileDefinition(input.deviceProfile) ?? + getDeviceProfileDefinition(input.profile) ?? + getDeviceProfileDefinition(input.deviceType) + if (explicit) return explicit + + const text = textOf([input.object, input.name, input.category, input.prompt, input.style]) + const normalizedText = normalizeKey(text) + const matches = DEVICE_PROFILE_DEFINITIONS.flatMap((profile) => + [profile.id, ...profile.aliases].map((alias, index) => ({ + profile, + alias: normalizeKey(alias), + isExplicitProfileId: index === 0 && containsExplicitProfileId(text, profile.id), + })), + ) + .filter((candidate) => containsAliasToken(normalizedText, candidate.alias)) + .filter((candidate) => profileCompatibleWithPromptAxis(candidate.profile, input)) + matches.sort((left, right) => { + if (left.isExplicitProfileId !== right.isExplicitProfileId) + return left.isExplicitProfileId ? -1 : 1 + return right.alias.length - left.alias.length + }) + return matches[0]?.profile +} + +function archetypeForLayoutFamily(layoutFamily: LayoutFamilyId): DeviceArchetypeFamily { + switch (layoutFamily) { + case 'rotating_machine_layout': + return 'rotating_fluid_machine' + case 'linear_transport_layout': + return 'material_handling' + case 'vessel_layout': + return 'process_vessel' + case 'box_enclosure_layout': + return 'enclosed_machine' + case 'robot_workcell_layout': + return 'robotic_workcell' + case 'pipe_valve_layout': + return 'pipe_valve_system' + case 'generic_industrial_layout': + return 'generic_industrial' + default: + return 'enclosed_machine' + } +} + +function draftProfileId(label: string): string { + const key = normalizeKey(label) + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + return `${key || 'unknown_device'}_draft` +} + +function draftProfileForKnownUnknown(prompt: string): DeviceProfileDefinition | undefined { + const text = prompt.toLowerCase() + if (/freeze\s*dryer|lyophili[sz]er|dong\s*gan|冻干/.test(text)) { + return { + id: 'freeze_dryer_draft', + name: 'Freeze dryer', + aliases: ['freeze dryer', 'lyophilizer', 'dong gan ji'], + layoutFamily: 'generic_industrial_layout', + archetypeFamily: 'generic_industrial', + family: 'generic', + defaultDimensions: { length: 2.2, width: 1.1, height: 1.8 }, + parts: [ + { kind: 'generic_base', semanticRole: 'machine_base', required: true }, + { kind: 'generic_body', semanticRole: 'vacuum_chamber', required: true }, + { kind: 'access_panel', semanticRole: 'sealed_door' }, + { kind: 'control_box', semanticRole: 'control_panel' }, + { kind: 'generic_display', semanticRole: 'display_screen' }, + { kind: 'generic_spout', semanticRole: 'vacuum_port' }, + { kind: 'generic_detail_accent', semanticRole: 'vent_panel' }, + ], + primarySemanticRole: 'vacuum_chamber', + status: 'runtime_draft', + source: 'generated_candidate', + description: 'Draft enclosed-machine profile for a freeze dryer / lyophilizer.', + } + } + if (/filter\s*press|plate\s*frame|plate-and-frame|ya\s*lv|压滤/.test(text)) { + return { + id: 'filter_press_draft', + name: 'Filter press', + aliases: ['filter press', 'plate frame filter press', 'ban kuang ya lv ji'], + layoutFamily: 'generic_industrial_layout', + archetypeFamily: 'generic_industrial', + family: 'generic', + defaultDimensions: { length: 3.2, width: 1.0, height: 1.35 }, + parts: [ + { kind: 'generic_base', semanticRole: 'machine_base', required: true }, + { kind: 'generic_body', semanticRole: 'press_frame', required: true }, + { kind: 'generic_panel', semanticRole: 'filter_plate_stack', required: true }, + { kind: 'generic_spout', semanticRole: 'slurry_inlet' }, + { kind: 'generic_spout', semanticRole: 'filtrate_outlet' }, + { kind: 'control_box', semanticRole: 'control_panel' }, + { kind: 'warning_label', semanticRole: 'warning_label' }, + ], + primarySemanticRole: 'press_frame', + status: 'runtime_draft', + source: 'generated_candidate', + description: 'Draft enclosed-machine/process profile for a plate-and-frame filter press.', + } + } + if (/screw\s*conveyor|auger|luo\s*xuan|螺旋输送/.test(text)) { + return { + id: 'screw_conveyor_draft', + name: 'Screw conveyor', + aliases: ['screw conveyor', 'auger conveyor', 'luo xuan shu song ji'], + layoutFamily: 'linear_transport_layout', + archetypeFamily: 'material_handling', + family: 'conveyor', + defaultDimensions: { length: 4.0, width: 0.65, height: 0.8 }, + parts: [ + { kind: 'conveyor_frame', semanticRole: 'conveyor_frame', required: true }, + { kind: 'roller_array', semanticRole: 'screw_flight', required: true }, + { kind: 'belt_surface', semanticRole: 'trough_cover' }, + { kind: 'ribbed_motor_body', semanticRole: 'drive_motor' }, + { kind: 'warning_label', semanticRole: 'warning_label' }, + ], + primarySemanticRole: 'conveyor_frame', + status: 'runtime_draft', + source: 'generated_candidate', + description: 'Draft linear-transport profile for a screw/auger conveyor.', + } + } + return undefined +} + +export function buildDraftDeviceProfile( + prompt: string, + intent: Record = {}, +): DraftDeviceProfileResult { + const explicitDraft = isRecord(intent.deviceProfileDraft) + ? normalizeDeviceProfileInput(intent.deviceProfileDraft, 'generated_candidate', 'runtime_draft') + : undefined + const profile = explicitDraft ?? + draftProfileForKnownUnknown(textOf([prompt, intent])) ?? { + id: draftProfileId( + textOf([intent.name, intent.object, intent.category, prompt]).slice(0, 64), + ), + name: + typeof intent.name === 'string' && intent.name.trim() + ? intent.name.trim() + : 'Generic industrial equipment', + aliases: [], + layoutFamily: 'generic_industrial_layout', + archetypeFamily: archetypeForLayoutFamily('generic_industrial_layout'), + family: 'generic', + parts: [ + { kind: 'generic_base', semanticRole: 'support_base', required: true }, + { kind: 'generic_body', semanticRole: 'main_body', required: true }, + { kind: 'generic_detail_accent', semanticRole: 'detail_accent' }, + ], + primarySemanticRole: 'main_body', + status: 'runtime_draft', + source: 'generated_candidate', + description: 'Generic industrial fallback draft profile.', + } + const validation = validateDeviceProfileDefinition(profile) + return { profile, validation } +} + +function partSpecMatchesDefinition( + part: ProfilePartSpec, + definition: { id: string; kind: string; semanticRole?: string; aliases: readonly string[] }, +) { + const candidates = [part.kind].map(normalizeKey).filter(Boolean) + const definitionKeys = [definition.id, definition.kind, ...definition.aliases] + .map(normalizeKey) + .filter(Boolean) + return candidates.some((candidate) => + definitionKeys.some((definitionKey) => candidate === definitionKey), + ) +} + +function resolveProfilePartDefinition(profile: DeviceProfileDefinition, part: ProfilePartSpec) { + const familyDefinition = getPartDefinitions(profile.family).find((definition) => + partSpecMatchesDefinition(part, definition), + ) + if (familyDefinition) return familyDefinition + + for (const family of FAMILY_DEFINITIONS) { + const definition = getPartDefinitions(family.id).find((candidate) => + partSpecMatchesDefinition(part, candidate), + ) + if (definition) return definition + } + return undefined +} + +export function validateDeviceProfileDefinition( + profile: DeviceProfileDefinition, +): DeviceProfileValidation { + const issues: string[] = [] + const warnings: string[] = [] + + if (!profile.id.trim()) issues.push('Profile id is required.') + if (!profile.name.trim()) issues.push(`Profile ${profile.id || ''} name is required.`) + if (!getFamilyDefinition(profile.family)) { + issues.push(`Profile ${profile.id} references unknown family "${profile.family}".`) + } + + const resolvedLayoutFamily = normalizeLayoutFamilyId(profile.layoutFamily ?? profile.family) + if (!resolvedLayoutFamily) { + issues.push(`Profile ${profile.id} cannot resolve a layout family.`) + } else if (profile.layoutFamily && profile.layoutFamily !== resolvedLayoutFamily) { + issues.push( + `Profile ${profile.id} layoutFamily "${profile.layoutFamily}" does not resolve to a known layout family.`, + ) + } + + const executableLayout = getLayoutFamilyDefinition(profile.family) + if ( + profile.layoutFamily && + executableLayout && + executableLayout.id !== profile.layoutFamily && + profile.family !== 'generic' + ) { + warnings.push( + `Profile ${profile.id} uses family "${profile.family}" from layout "${executableLayout.id}" but declares layoutFamily "${profile.layoutFamily}".`, + ) + } + + if (!profile.primarySemanticRole.trim()) { + issues.push(`Profile ${profile.id} primarySemanticRole is required.`) + } + + if (profile.parts.length === 0) { + issues.push(`Profile ${profile.id} must declare at least one part.`) + } + + const resolvedParts = profile.parts.map((part) => ({ + part, + definition: resolveProfilePartDefinition(profile, part), + })) + for (const { part, definition } of resolvedParts) { + if (!definition) { + issues.push( + `Profile ${profile.id} references unknown ${profile.family} part "${String( + part.kind ?? part.semanticRole ?? 'part', + )}".`, + ) + } + if (!part.semanticRole?.trim()) { + issues.push(`Profile ${profile.id} part "${String(part.kind)}" is missing semanticRole.`) + } + } + + const primaryRoleExists = resolvedParts.some(({ part, definition }) => { + const primary = normalizeKey(profile.primarySemanticRole) + return ( + normalizeKey(part.semanticRole) === primary || + normalizeKey(definition?.semanticRole) === primary || + normalizeKey(definition?.kind) === primary + ) + }) + if (profile.primarySemanticRole && !primaryRoleExists) { + issues.push( + `Profile ${profile.id} primarySemanticRole "${profile.primarySemanticRole}" is not represented by its parts.`, + ) + } + + return { ok: issues.length === 0, issues, warnings } +} + +export function validateDeviceProfileSchema( + profile: DeviceProfileDefinition, +): DeviceProfileValidation { + const issues: string[] = [] + const warnings: string[] = [] + if (!profile.id || typeof profile.id !== 'string') issues.push('Profile id must be a string.') + if (!profile.name || typeof profile.name !== 'string') { + issues.push(`Profile ${profile.id || ''} name must be a string.`) + } + if (!profile.family || typeof profile.family !== 'string') { + issues.push(`Profile ${profile.id || ''} family must be a string.`) + } + if (!Array.isArray(profile.aliases)) + warnings.push(`Profile ${profile.id} aliases should be an array.`) + if (profile.industry != null && typeof profile.industry !== 'string') { + issues.push(`Profile ${profile.id || ''} industry must be a string.`) + } + if (profile.layoutTemplate != null && typeof profile.layoutTemplate !== 'string') { + issues.push(`Profile ${profile.id || ''} layoutTemplate must be a string.`) + } + if ( + profile.partPresets != null && + (!isRecord(profile.partPresets) || + Object.values(profile.partPresets).some((value) => typeof value !== 'string')) + ) { + issues.push(`Profile ${profile.id || ''} partPresets must map roles to preset ids.`) + } + if ( + profile.resolvedPartPresets != null && + (!isRecord(profile.resolvedPartPresets) || + Object.values(profile.resolvedPartPresets).some((value) => !isRecord(value))) + ) { + issues.push(`Profile ${profile.id || ''} resolvedPartPresets must map ids to objects.`) + } + if ( + profile.proportionRules != null && + typeof profile.proportionRules !== 'string' && + !isRecord(profile.proportionRules) + ) { + issues.push(`Profile ${profile.id || ''} proportionRules must be a string or object.`) + } + if ( + profile.qualityRules != null && + typeof profile.qualityRules !== 'string' && + !isRecord(profile.qualityRules) + ) { + issues.push(`Profile ${profile.id || ''} qualityRules must be a string or object.`) + } + if (profile.detailBudget != null) { + if (!isRecord(profile.detailBudget)) { + issues.push(`Profile ${profile.id || ''} detailBudget must be an object.`) + } else { + if ( + profile.detailBudget.detailLevel != null && + !detailLevel(profile.detailBudget.detailLevel) + ) { + issues.push( + `Profile ${profile.id || ''} detailBudget.detailLevel must be low, medium, or high.`, + ) + } + if ( + profile.detailBudget.maxShapes != null && + optionalNonNegativeInteger(profile.detailBudget.maxShapes) == null + ) { + issues.push(`Profile ${profile.id || ''} detailBudget.maxShapes must be >= 0.`) + } + if (profile.detailBudget.parts != null && !isRecord(profile.detailBudget.parts)) { + issues.push(`Profile ${profile.id || ''} detailBudget.parts must be an object.`) + } + const qualityRules = qualityRulesObject(profile) + const shapeMax = optionalPositiveNumber(qualityRules?.shapeCount?.max) + const budgetMax = optionalNonNegativeInteger(profile.detailBudget.maxShapes) + if (shapeMax != null && budgetMax != null && budgetMax > shapeMax) { + warnings.push( + `Profile ${profile.id} detailBudget.maxShapes exceeds qualityRules.shapeCount.max.`, + ) + } + } + } + if ( + profile.sourcePack != null && + (!isRecord(profile.sourcePack) || + typeof profile.sourcePack.id !== 'string' || + typeof profile.sourcePack.version !== 'string') + ) { + issues.push(`Profile ${profile.id || ''} sourcePack must include id and version.`) + } + if (profile.editableSchemaRef != null && typeof profile.editableSchemaRef !== 'string') { + issues.push(`Profile ${profile.id || ''} editableSchemaRef must be a string.`) + } + if ( + profile.editableOverrides != null && + (!isRecord(profile.editableOverrides) || + Object.values(profile.editableOverrides).some((value) => !isRecord(value))) + ) { + issues.push( + `Profile ${profile.id || ''} editableOverrides must map properties to objects.`, + ) + } + if ( + profile.overrides != null && + (!Array.isArray(profile.overrides) || + profile.overrides.some( + (entry) => + !isRecord(entry) || + typeof entry.id !== 'string' || + typeof entry.name !== 'string' || + typeof entry.source !== 'string', + )) + ) { + issues.push(`Profile ${profile.id || ''} overrides must be an array of profiles.`) + } + if (!Array.isArray(profile.parts) || profile.parts.length === 0) { + issues.push(`Profile ${profile.id || ''} parts must be a non-empty array.`) + } + for (const [index, part] of profile.parts.entries()) { + if (!part.kind || typeof part.kind !== 'string') { + issues.push(`Profile ${profile.id} part[${index}].kind must be a string.`) + } + if (!part.semanticRole || typeof part.semanticRole !== 'string') { + issues.push(`Profile ${profile.id} part[${index}].semanticRole must be a string.`) + } + } + if (!profile.primarySemanticRole || typeof profile.primarySemanticRole !== 'string') { + issues.push(`Profile ${profile.id || ''} primarySemanticRole must be a string.`) + } + return { ok: issues.length === 0, issues, warnings, score: issues.length === 0 ? 1 : 0 } +} + +export function validateDeviceProfileForExecution( + profile: DeviceProfileDefinition, + execution?: DeviceProfileValidation, +): DeviceProfileExecutionValidation { + const schema = validateDeviceProfileSchema(profile) + const registry = validateDeviceProfileDefinition(profile) + const stages = { schema, registry, ...(execution ? { execution } : {}) } + const issues = [...schema.issues, ...registry.issues, ...(execution?.issues ?? [])] + const warnings = [...schema.warnings, ...registry.warnings, ...(execution?.warnings ?? [])] + const stageScores = [schema.score ?? (schema.ok ? 1 : 0), registry.score ?? (registry.ok ? 1 : 0)] + if (execution) stageScores.push(execution.score ?? (execution.ok ? 1 : 0)) + const score = stageScores.reduce((sum, value) => sum + value, 0) / stageScores.length + return { + ok: issues.length === 0 && (!execution || execution.ok), + issues, + warnings, + score, + stages, + } +} + +function clamp01(value: number) { + if (!Number.isFinite(value)) return 0 + return Math.max(0, Math.min(1, value)) +} + +function roleKey(value: unknown) { + return normalizeKey(typeof value === 'string' ? value : '') +} + +function shapeRoleTokens(shape: DeviceProfileQualityInputShape): string[] { + return [shape.semanticRole, shape.sourcePartKind, shape.semanticGroup, shape.name] + .map(roleKey) + .filter(Boolean) +} + +function profileRoleTokens(profile: DeviceProfileDefinition, role: string): string[] { + return [role, ...(profile.roleAliases?.[role] ?? [])].map(roleKey).filter(Boolean) +} + +function positiveNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined +} + +function scaleComponent(scale: readonly number[] | undefined, index: number): number { + const value = Array.isArray(scale) ? scale[index] : undefined + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 1 +} + +function shapeExtents(shape: DeviceProfileQualityInputShape): [number, number, number] { + const sx = scaleComponent(shape.scale, 0) + const sy = scaleComponent(shape.scale, 1) + const sz = scaleComponent(shape.scale, 2) + const radius = positiveNumber(shape.radius) ?? positiveNumber(shape.radiusTop) ?? 0 + const diameter = radius > 0 ? radius * 2 : undefined + const length = positiveNumber(shape.length) ?? diameter ?? positiveNumber(shape.thickness) ?? 0.05 + const width = + positiveNumber(shape.width) ?? + positiveNumber(shape.depth) ?? + diameter ?? + positiveNumber(shape.thickness) ?? + 0.05 + const height = positiveNumber(shape.height) ?? diameter ?? positiveNumber(shape.thickness) ?? 0.05 + const axis = shape.axis === 'x' || shape.axis === 'y' || shape.axis === 'z' ? shape.axis : 'y' + const kind = shape.kind + if ( + kind === 'cylinder' || + kind === 'hollow-cylinder' || + kind === 'cone' || + kind === 'frustum' || + kind === 'capsule' || + kind === 'half-cylinder' + ) { + const axialLength = height + const radialY = diameter ?? height + const radialZ = diameter ?? width + const radialX = diameter ?? length + if (axis === 'x') return [axialLength * sx, radialZ * sz, radialY * sy] + if (axis === 'z') return [radialX * sx, axialLength * sz, radialY * sy] + return [radialX * sx, radialZ * sz, axialLength * sy] + } + if (kind === 'torus') { + const ringDiameter = + (positiveNumber(shape.majorRadius) ?? + positiveNumber(shape.radius) ?? + positiveNumber(shape.radiusTop) ?? + positiveNumber(shape.length) ?? + 0.5) * + 2 + + (positiveNumber(shape.tubeRadius) ?? positiveNumber(shape.thickness) ?? 0.08) * 2 + const tubeDiameter = + (positiveNumber(shape.tubeRadius) ?? positiveNumber(shape.thickness) ?? 0.08) * 2 + if (axis === 'x') return [tubeDiameter * sx, ringDiameter * sz, ringDiameter * sy] + if (axis === 'z') return [ringDiameter * sx, tubeDiameter * sz, ringDiameter * sy] + return [ringDiameter * sx, ringDiameter * sz, tubeDiameter * sy] + } + return [length * sx, width * sz, height * sy] +} + +function profileShapeBounds(shapes: readonly DeviceProfileQualityInputShape[]) { + if (shapes.length === 0) return undefined + const min: [number, number, number] = [ + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + ] + const max: [number, number, number] = [ + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ] + for (const shape of shapes) { + const [x = 0, y = 0, z = 0] = Array.isArray(shape.position) ? shape.position : [] + const [length, width, height] = shapeExtents(shape) + const half: [number, number, number] = [length / 2, height / 2, width / 2] + const center: [number, number, number] = [x, y, z] + min[0] = Math.min(min[0], center[0] - half[0]) + min[1] = Math.min(min[1], center[1] - half[1]) + min[2] = Math.min(min[2], center[2] - half[2]) + max[0] = Math.max(max[0], center[0] + half[0]) + max[1] = Math.max(max[1], center[1] + half[1]) + max[2] = Math.max(max[2], center[2] + half[2]) + } + const size: [number, number, number] = [ + Math.max(0, max[0] - min[0]), + Math.max(0, max[1] - min[1]), + Math.max(0, max[2] - min[2]), + ] + return { min, max, size } +} + +function dimensionMatchScore(actual: number | undefined, expected: number | undefined) { + if (!actual || !expected || actual <= 0 || expected <= 0) return 0.75 + const ratio = actual > expected ? actual / expected : expected / actual + return clamp01(1 - Math.max(0, ratio - 1) / 2) +} + +function optionalPositiveNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined +} + +function qualityRulesObject( + profile: DeviceProfileDefinition, +): DeviceProfileQualityRules | undefined { + return isRecord(profile.qualityRules) + ? (profile.qualityRules as unknown as DeviceProfileQualityRules) + : undefined +} + +function uniqueStrings(values: readonly unknown[]) { + return Array.from( + new Set( + values.filter((value): value is string => typeof value === 'string' && value.trim() !== ''), + ), + ) +} + +function rangeContains(value: number, range: DeviceProfileRangeRule | undefined) { + if (!range) return true + if (typeof range.min === 'number' && value < range.min) return false + if (typeof range.max === 'number' && value > range.max) return false + return true +} + +function rangeScore(value: number, range: DeviceProfileRangeRule | undefined) { + if (!range || rangeContains(value, range)) return 1 + const min = optionalPositiveNumber(range.min) + const max = optionalPositiveNumber(range.max) + const target = min && value < min ? min : max && value > max ? max : value + if (!target || target <= 0) return 0.5 + const ratio = value > target ? value / target : target / value + return clamp01(1 - Math.max(0, ratio - 1)) +} + +export function evaluateDeviceProfileQuality( + profile: DeviceProfileDefinition, + shapes: readonly DeviceProfileQualityInputShape[], + options: { visualScore?: number; maxShapes?: number } = {}, +): DeviceProfileQualityScore { + const issues: string[] = [] + const warnings: string[] = [] + const qualityRules = qualityRulesObject(profile) + const shapeTokens = shapes.flatMap(shapeRoleTokens) + const hasToken = (role: string) => + profileRoleTokens(profile, role).some((token) => shapeTokens.includes(token)) + const requiredRoles = uniqueStrings([ + ...profile.parts.filter((part) => part.required).map((part) => part.semanticRole), + ...(qualityRules?.requiredRoles ?? []), + ]) + const requiredCoverage = + requiredRoles.length === 0 + ? 1 + : requiredRoles.filter((role) => hasToken(role)).length / requiredRoles.length + const primaryPresent = hasToken(profile.primarySemanticRole) + const forbiddenHits = uniqueStrings([ + ...(profile.forbiddenRoles ?? []), + ...(qualityRules?.forbiddenRoles ?? []), + ]).filter((role) => hasToken(role)) + if (!primaryPresent) issues.push(`Primary role "${profile.primarySemanticRole}" is missing.`) + if (requiredCoverage < 1) warnings.push('Not all required profile roles are represented.') + if (forbiddenHits.length > 0) { + issues.push(`Forbidden roles appeared: ${forbiddenHits.join(', ')}.`) + } + const shapeCountMin = optionalPositiveNumber(qualityRules?.shapeCount?.min) + const shapeCountMax = + options.maxShapes ?? + optionalPositiveNumber(qualityRules?.shapeCount?.max) ?? + optionalPositiveNumber(profile.detailBudget?.maxShapes) ?? + 96 + if (shapeCountMin && shapes.length < shapeCountMin) { + warnings.push(`Shape count ${shapes.length} is below profile minimum ${shapeCountMin}.`) + } + if (shapeCountMax && shapes.length > shapeCountMax) { + issues.push(`Shape count ${shapes.length} exceeds profile maximum ${shapeCountMax}.`) + } + + const semanticScore = clamp01( + (primaryPresent ? 0.45 : 0) + requiredCoverage * 0.45 + (forbiddenHits.length === 0 ? 0.1 : 0), + ) + const bounds = profileShapeBounds(shapes) + const defaults = profile.defaultDimensions ?? {} + const dimensionScore = bounds + ? (dimensionMatchScore(bounds.size[0], defaults.length) + + dimensionMatchScore(bounds.size[2], defaults.width) + + dimensionMatchScore(bounds.size[1], defaults.height)) / + 3 + : 0 + const shapeCountScore = + shapes.length === 0 + ? 0 + : shapeCountMin && shapes.length < shapeCountMin + ? shapes.length / shapeCountMin + : shapes.length > shapeCountMax + ? shapeCountMax / shapes.length + : 1 + const ratioRule = qualityRules?.dimensionExpectations?.lengthToDiameterRatio + const lengthToDiameterRatio = + bounds && Math.min(bounds.size[1], bounds.size[2]) > 0 + ? bounds.size[0] / Math.min(bounds.size[1], bounds.size[2]) + : undefined + if ( + typeof lengthToDiameterRatio === 'number' && + ratioRule && + !rangeContains(lengthToDiameterRatio, ratioRule) + ) { + warnings.push( + `Length-to-diameter ratio ${lengthToDiameterRatio.toFixed(2)} is outside profile expectation.`, + ) + } + const ratioExpectationScore = + typeof lengthToDiameterRatio === 'number' && ratioRule + ? rangeScore(lengthToDiameterRatio, ratioRule) + : 1 + const geometryScore = clamp01( + dimensionScore * 0.45 + shapeCountScore * 0.35 + ratioExpectationScore * 0.2, + ) + const editableShapes = shapes.filter( + (shape) => shape.sourcePartKind || shape.semanticRole || shape.semanticGroup, + ).length + const editabilityScore = shapes.length > 0 ? editableShapes / shapes.length : 0 + const visualCompletenessScore = clamp01( + semanticScore * 0.4 + shapeCountScore * 0.25 + (options.visualScore ?? 0.75) * 0.35, + ) + const overallScore = clamp01( + semanticScore * 0.35 + + geometryScore * 0.25 + + editabilityScore * 0.2 + + visualCompletenessScore * 0.2, + ) + + return { + semanticScore, + geometryScore, + editabilityScore, + visualCompletenessScore, + overallScore, + warnings, + issues, + metrics: { + shapeCount: shapes.length, + requiredCoverage, + primaryPresent: primaryPresent ? 1 : 0, + forbiddenRoleCount: forbiddenHits.length, + dimensionScore, + shapeCountScore, + ...(bounds + ? { + boundsLength: bounds.size[0], + boundsWidth: bounds.size[2], + boundsHeight: bounds.size[1], + } + : {}), + ...(typeof lengthToDiameterRatio === 'number' + ? { + lengthToDiameterRatio, + ratioExpectationScore, + } + : {}), + }, + } +} + +export function validateDeviceProfiles( + profiles: readonly DeviceProfileDefinition[] = DEVICE_PROFILE_DEFINITIONS, +): DeviceProfileValidation { + const issues: string[] = [] + const warnings: string[] = [] + for (const profile of profiles) { + const result = validateDeviceProfileDefinition(profile) + issues.push(...result.issues) + warnings.push(...result.warnings) + } + return { ok: issues.length === 0, issues, warnings } +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : [] +} + +function dimensionDefaults(value: unknown): DimensionDefaults | undefined { + if (!isRecord(value)) return undefined + const dimensions: DimensionDefaults = {} + for (const key of ['length', 'width', 'height', 'diameter'] as const) { + const raw = value[key] + if (typeof raw === 'number' && Number.isFinite(raw)) dimensions[key] = raw + } + return Object.keys(dimensions).length > 0 ? dimensions : undefined +} + +function stringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined + const entries = Object.entries(value).flatMap(([key, raw]) => + typeof raw === 'string' && raw.trim() ? [[key, raw.trim()] as const] : [], + ) + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +function ruleRef(value: unknown): DeviceProfileRuleRef | undefined { + if (typeof value === 'string' && value.trim()) return value.trim() + if (isRecord(value)) return value + return undefined +} + +function detailLevel(value: unknown): DeviceProfileDetailLevel | undefined { + return value === 'low' || value === 'medium' || value === 'high' ? value : undefined +} + +function optionalNonNegativeInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined +} + +function profilePartDetailBudget(value: unknown): DeviceProfilePartDetailBudget | undefined { + if (!isRecord(value)) return undefined + const budget: DeviceProfilePartDetailBudget = { + ...(detailLevel(value.detailLevel) ? { detailLevel: detailLevel(value.detailLevel) } : {}), + ...(optionalNonNegativeInteger(value.count) != null + ? { count: optionalNonNegativeInteger(value.count) } + : {}), + ...(optionalNonNegativeInteger(value.ringCount) != null + ? { ringCount: optionalNonNegativeInteger(value.ringCount) } + : {}), + ...(optionalNonNegativeInteger(value.spokeCount) != null + ? { spokeCount: optionalNonNegativeInteger(value.spokeCount) } + : {}), + ...(optionalNonNegativeInteger(value.slatCount) != null + ? { slatCount: optionalNonNegativeInteger(value.slatCount) } + : {}), + ...(optionalNonNegativeInteger(value.rungCount) != null + ? { rungCount: optionalNonNegativeInteger(value.rungCount) } + : {}), + ...(optionalNonNegativeInteger(value.boltCount) != null + ? { boltCount: optionalNonNegativeInteger(value.boltCount) } + : {}), + ...(optionalNonNegativeInteger(value.radialSegments) != null + ? { radialSegments: optionalNonNegativeInteger(value.radialSegments) } + : {}), + ...(optionalNonNegativeInteger(value.levelCount) != null + ? { levelCount: optionalNonNegativeInteger(value.levelCount) } + : {}), + } + return Object.keys(budget).length > 0 ? budget : undefined +} + +function profileDetailBudget(value: unknown): DeviceProfileDetailBudget | undefined { + if (!isRecord(value)) return undefined + const parts = isRecord(value.parts) + ? Object.fromEntries( + Object.entries(value.parts).flatMap(([key, raw]) => { + const budget = profilePartDetailBudget(raw) + return budget ? [[key, budget] as const] : [] + }), + ) + : undefined + const budget: DeviceProfileDetailBudget = { + ...(detailLevel(value.detailLevel) ? { detailLevel: detailLevel(value.detailLevel) } : {}), + ...(optionalNonNegativeInteger(value.maxShapes) != null + ? { maxShapes: optionalNonNegativeInteger(value.maxShapes) } + : {}), + ...(parts && Object.keys(parts).length > 0 ? { parts } : {}), + } + return Object.keys(budget).length > 0 ? budget : undefined +} + +function editableOverrides( + value: unknown, +): Record> | undefined { + if (!isRecord(value)) return undefined + const entries = Object.entries(value).flatMap(([key, raw]) => + isRecord(raw) ? [[key, raw as Partial] as const] : [], + ) + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +function sourcePack(value: unknown): DeviceProfileSourcePack | undefined { + if (!isRecord(value)) return undefined + const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : undefined + const version = + typeof value.version === 'string' && value.version.trim() ? value.version.trim() : undefined + if (!id || !version) return undefined + return { + id, + version, + ...(typeof value.industry === 'string' && value.industry.trim() + ? { industry: value.industry.trim() } + : {}), + } +} + +function profileOverrides(value: unknown): DeviceProfileOverrideInfo[] | undefined { + if (!Array.isArray(value)) return undefined + const overrides = value.filter(isRecord).flatMap((entry) => { + const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : undefined + const name = + typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : (id ?? undefined) + const source: DeviceProfileSource | undefined = + entry.source === 'builtin' || + entry.source === 'workspace' || + entry.source === 'imported_pack' || + entry.source === 'generated_candidate' + ? entry.source + : undefined + if (!id || !name || !source) return [] + return [ + { + id, + name, + source, + ...(sourcePack(entry.sourcePack) ? { sourcePack: sourcePack(entry.sourcePack) } : {}), + }, + ] + }) + return overrides.length > 0 ? overrides : undefined +} + +function profileParts(value: unknown): ProfilePartSpec[] { + if (!Array.isArray(value)) return [] + return value.filter(isRecord).flatMap((part) => { + const kind = typeof part.kind === 'string' ? part.kind : undefined + const semanticRole = typeof part.semanticRole === 'string' ? part.semanticRole : undefined + if (!kind || !semanticRole) return [] + return [{ ...part, kind, semanticRole } as ProfilePartSpec] + }) +} + +function detailBudgetForPart( + budget: DeviceProfileDetailBudget | undefined, + part: ProfilePartSpec, +): DeviceProfilePartDetailBudget | undefined { + const entries = budget?.parts ? Object.entries(budget.parts) : [] + const keys = [part.id, part.semanticRole, part.kind] + .filter(Boolean) + .map((key) => normalizeKey(key)) + const matched = entries.find(([key]) => keys.includes(normalizeKey(key)))?.[1] + if (matched) return matched + return budget?.detailLevel ? { detailLevel: budget.detailLevel } : undefined +} + +function applyDeviceProfileDetailBudget( + parts: readonly ProfilePartSpec[], + budget: DeviceProfileDetailBudget | undefined, +): ProfilePartSpec[] { + if (!budget) return [...parts] + return parts.map((part) => { + const partBudget = detailBudgetForPart(budget, part) + if (!partBudget) return part + return { + ...part, + ...(partBudget.detailLevel ? { detailLevel: partBudget.detailLevel } : {}), + ...(partBudget.count != null ? { count: partBudget.count } : {}), + ...(partBudget.ringCount != null ? { ringCount: partBudget.ringCount } : {}), + ...(partBudget.spokeCount != null ? { spokeCount: partBudget.spokeCount } : {}), + ...(partBudget.slatCount != null ? { slatCount: partBudget.slatCount } : {}), + ...(partBudget.rungCount != null ? { rungCount: partBudget.rungCount } : {}), + ...(partBudget.boltCount != null ? { boltCount: partBudget.boltCount } : {}), + ...(partBudget.radialSegments != null ? { radialSegments: partBudget.radialSegments } : {}), + ...(partBudget.levelCount != null ? { levelCount: partBudget.levelCount } : {}), + } + }) +} + +export function normalizeDeviceProfileInput( + value: Record, + source: DeviceProfileSource = 'workspace', + status: DeviceProfileStatus = 'stable', +): DeviceProfileDefinition { + const family = + typeof value.family === 'string' && value.family.trim() ? value.family.trim() : 'generic' + const layoutFamily = + typeof value.layoutFamily === 'string' + ? normalizeLayoutFamilyId(value.layoutFamily) + : normalizeLayoutFamilyId(family) + const inferredArchetype = layoutFamily + ? archetypeForLayoutFamily(layoutFamily) + : 'enclosed_machine' + const id = + typeof value.id === 'string' && value.id.trim() + ? value.id.trim() + : draftProfileId(String(value.name ?? value.deviceType ?? 'device')) + const name = + typeof value.name === 'string' && value.name.trim() + ? value.name.trim() + : id.replace(/[_-]+/g, ' ') + const parts = profileParts(value.parts) + const primarySemanticRole = + typeof value.primarySemanticRole === 'string' && value.primarySemanticRole.trim() + ? value.primarySemanticRole.trim() + : (parts.find((part) => part.required)?.semanticRole ?? parts[0]?.semanticRole ?? 'main_body') + return { + id, + name, + aliases: stringArray(value.aliases), + ...(typeof value.industry === 'string' && value.industry.trim() + ? { industry: value.industry.trim() } + : {}), + ...(layoutFamily ? { layoutFamily } : {}), + ...(typeof value.layoutTemplate === 'string' && value.layoutTemplate.trim() + ? { layoutTemplate: value.layoutTemplate.trim() } + : {}), + archetypeFamily: + typeof value.archetypeFamily === 'string' + ? (value.archetypeFamily as DeviceArchetypeFamily) + : inferredArchetype, + family, + ...(dimensionDefaults(value.defaultDimensions) + ? { defaultDimensions: dimensionDefaults(value.defaultDimensions) } + : {}), + parts, + primarySemanticRole, + dimensionRules: Array.isArray(value.dimensionRules) + ? (value.dimensionRules.filter(isRecord) as unknown as DimensionRule[]) + : undefined, + ...(stringRecord(value.partPresets) ? { partPresets: stringRecord(value.partPresets) } : {}), + ...(isRecord(value.resolvedPartPresets) + ? { + resolvedPartPresets: value.resolvedPartPresets as Record>, + } + : {}), + ...(ruleRef(value.proportionRules) ? { proportionRules: ruleRef(value.proportionRules) } : {}), + ...(ruleRef(value.qualityRules) ? { qualityRules: ruleRef(value.qualityRules) } : {}), + ...(profileDetailBudget(value.detailBudget) + ? { detailBudget: profileDetailBudget(value.detailBudget) } + : {}), + visualCues: stringArray(value.visualCues), + layoutHints: isRecord(value.layoutHints) ? value.layoutHints : undefined, + roleAliases: isRecord(value.roleAliases) + ? Object.fromEntries( + Object.entries(value.roleAliases).flatMap(([role, aliases]) => { + const normalizedAliases = stringArray(aliases) + return normalizedAliases.length > 0 ? [[role, normalizedAliases]] : [] + }), + ) + : undefined, + ...(typeof value.editableSchemaRef === 'string' && value.editableSchemaRef.trim() + ? { editableSchemaRef: value.editableSchemaRef.trim() } + : {}), + ...(editableOverrides(value.editableOverrides) + ? { editableOverrides: editableOverrides(value.editableOverrides) } + : {}), + ...(normalizeEditableSchemaInput(value.resolvedEditableSchema) + ? { resolvedEditableSchema: normalizeEditableSchemaInput(value.resolvedEditableSchema) } + : {}), + status: + value.status === 'runtime_draft' || + value.status === 'candidate' || + value.status === 'pending_review' || + value.status === 'stable' || + value.status === 'draft' + ? value.status + : status, + source: + value.source === 'generated' + ? 'generated_candidate' + : value.source === 'builtin' || + value.source === 'workspace' || + value.source === 'imported_pack' || + value.source === 'generated_candidate' + ? value.source + : source, + ...(sourcePack(value.sourcePack) ? { sourcePack: sourcePack(value.sourcePack) } : {}), + ...(profileOverrides(value.overrides) ? { overrides: profileOverrides(value.overrides) } : {}), + description: + typeof value.description === 'string' && value.description.trim() + ? value.description.trim() + : `Device profile for ${name}.`, + forbiddenRoles: stringArray(value.forbiddenRoles), + } +} + +export function applyDeviceProfileToPartInput( + profile: DeviceProfileDefinition, + input: Record, +): Record { + const dimensions = profile.defaultDimensions ?? {} + const explicitParts = Array.isArray(input.parts) ? input.parts.filter(isRecord) : [] + const parts = applyDeviceProfileDetailBudget( + [...explicitParts, ...profile.parts] as ProfilePartSpec[], + profile.detailBudget, + ) + return { + ...input, + family: profile.family, + deviceProfile: profile.id, + layoutFamily: profile.layoutFamily ?? normalizeLayoutFamilyId(profile.family), + layoutTemplate: profile.layoutTemplate, + archetypeFamily: profile.archetypeFamily, + profileIndustry: profile.industry, + profileSource: profile.source, + profileSourcePack: profile.sourcePack, + profilePackId: profile.sourcePack?.id, + profilePackVersion: profile.sourcePack?.version, + profileOverrides: profile.overrides, + overrodeBuiltin: profile.overrides?.some((entry) => entry.source === 'builtin') === true, + primarySemanticRole: profile.primarySemanticRole, + partPresets: profile.partPresets, + resolvedPartPresets: profile.resolvedPartPresets, + proportionRules: profile.proportionRules, + qualityRules: profile.qualityRules, + detailBudget: profile.detailBudget, + visualCues: profile.visualCues, + layoutHints: profile.layoutHints, + roleAliases: profile.roleAliases, + editableSchemaRef: profile.editableSchemaRef, + editableOverrides: profile.editableOverrides, + resolvedEditableSchema: profile.resolvedEditableSchema, + length: input.length ?? dimensions.length, + width: input.width ?? input.depth ?? input.diameter ?? dimensions.width ?? dimensions.diameter, + height: input.height ?? dimensions.height, + parts, + } +} + +export function deviceProfileCapabilitySummary( + profiles: readonly DeviceProfileDefinition[] = DEVICE_PROFILE_DEFINITIONS, +): string { + return (profiles as readonly DeviceProfileDefinition[]) + .map( + (profile) => + `${profile.id}: status=${profile.status} source=${profile.source}${profile.sourcePack ? ` pack=${profile.sourcePack.id}@${profile.sourcePack.version}` : ''} layoutFamily=${profile.layoutFamily ?? normalizeLayoutFamilyId(profile.family) ?? 'unknown'}${profile.layoutTemplate ? ` layoutTemplate=${profile.layoutTemplate}` : ''} family=${profile.family} primary=${profile.primarySemanticRole} aliases=${profile.aliases.join('|')} parts=${profile.parts + .map((part) => `${part.kind}:${part.semanticRole ?? part.kind}`) + .join(', ')}`, + ) + .join('\n') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/core/src/lib/dimension-semantics.test.ts b/packages/core/src/lib/dimension-semantics.test.ts new file mode 100644 index 000000000..d757bf54c --- /dev/null +++ b/packages/core/src/lib/dimension-semantics.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test' +import { + applyDimensionSemanticsToObjectInput, + parseDimensionSemantics, +} from './dimension-semantics' +import { composeObjectPrimitives } from './object-compose' + +describe('dimension semantics', () => { + test('parses natural language dimension separators', () => { + const dimensions = parseDimensionSemantics( + 'change heat exchanger length to 3m width to 1.2m height to 2m diameter to 0.7m', + ) + + expect(dimensions.length).toBeCloseTo(3) + expect(dimensions.width).toBeCloseTo(1.2) + expect(dimensions.height).toBeCloseTo(2) + expect(dimensions.diameter).toBeCloseTo(0.7) + expect(dimensions.radius).toBeCloseTo(0.35) + }) + + test('parses labeled Chinese dimensions and converts units to meters', () => { + const dimensions = parseDimensionSemantics('生成一个写字桌,长120cm,宽60cm,高75cm') + + expect(dimensions.length).toBeCloseTo(1.2) + expect(dimensions.width).toBeCloseTo(0.6) + expect(dimensions.height).toBeCloseTo(0.75) + }) + + test('parses compact dimensions with a shared trailing unit', () => { + const dimensions = parseDimensionSemantics('做一个桌子 120x60x75cm') + + expect(dimensions.length).toBeCloseTo(1.2) + expect(dimensions.width).toBeCloseTo(0.6) + expect(dimensions.height).toBeCloseTo(0.75) + }) + + test('parses diameter and derives radius', () => { + const dimensions = parseDimensionSemantics('直径300mm 高1.2m 的过滤器') + + expect(dimensions.diameter).toBeCloseTo(0.3) + expect(dimensions.radius).toBeCloseTo(0.15) + expect(dimensions.height).toBeCloseTo(1.2) + }) + + test('parses modern Chinese numeric and numeral dimensions', () => { + expect( + parseDimensionSemantics( + '\u751f\u6210\u4e00\u4e2a\u6ce2\u97f3717\u5ba2\u673a\uff0c\u957f\u5ea6\u4e94\u7c73', + ).length, + ).toBeCloseTo(5) + + const dimensions = parseDimensionSemantics('\u957f5\u7c73 \u5bbd\u4e24\u7c73 \u9ad81.2\u7c73') + + expect(dimensions.length).toBeCloseTo(5) + expect(dimensions.width).toBeCloseTo(2) + expect(dimensions.height).toBeCloseTo(1.2) + }) + + test('parses value-before-label Chinese dimensions', () => { + const dimensions = parseDimensionSemantics( + '\u751f\u6210\u4e00\u4e2a5\u7c73\u9ad8\u7684\u8def\u706f', + ) + + expect(dimensions.height).toBeCloseTo(5) + }) + + test('maps desk length and width to real table footprint', () => { + const input = applyDimensionSemanticsToObjectInput( + { category: 'table' as const }, + '生成一个写字桌,长120cm 宽60cm 高75cm', + ) + const shapes = composeObjectPrimitives(input) + const top = shapes.find((shape) => shape.name?.includes('top')) + + expect(input.width).toBeCloseTo(1.2) + expect(input.depth).toBeCloseTo(0.6) + expect(input.height).toBeCloseTo(0.75) + expect(top?.length).toBeCloseTo(1.2) + expect(top?.width).toBeCloseTo(0.6) + }) + + test('maps vehicle length to object length/depth axis', () => { + const input = applyDimensionSemanticsToObjectInput( + { category: 'vehicle' as const }, + '汽车长4.8米 宽1.9米 高1.6米', + ) + + expect(input.length).toBeCloseTo(4.8) + expect(input.width).toBeCloseTo(1.9) + expect(input.height).toBeCloseTo(1.6) + }) +}) diff --git a/packages/core/src/lib/dimension-semantics.ts b/packages/core/src/lib/dimension-semantics.ts new file mode 100644 index 000000000..03013e3ef --- /dev/null +++ b/packages/core/src/lib/dimension-semantics.ts @@ -0,0 +1,337 @@ +export interface DimensionSemantics { + length?: number + width?: number + depth?: number + height?: number + diameter?: number + radius?: number + thickness?: number +} + +interface DimensionMatch { + key: keyof DimensionSemantics + value: number +} + +const UNIT_PATTERN = '(mm|\u6beb\u7c73|cm|\u5398\u7c73|m|\u7c73|meter|meters|metre|metres)?' +const NUMBER_PATTERN = + '(\\d+(?:\\.\\d+)?|[\u96f6\u4e00\u4e8c\u4e24\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e]+)' +const VALUE_SEPARATOR = '(?:[:=]|to|\u4e3a|\u662f|\u5230|\u81f3)?' +const CHINESE_DIGITS: Record = { + '\u96f6': 0, + '\u4e00': 1, + '\u4e8c': 2, + '\u4e24': 2, + '\u4e09': 3, + '\u56db': 4, + '\u4e94': 5, + '\u516d': 6, + '\u4e03': 7, + '\u516b': 8, + '\u4e5d': 9, +} + +function normalizeText(text: string): string { + return text + .replace(/[\uff0c\u3001]/g, ',') + .replace(/[\u3002]/g, '.') + .replace(/[\uff1b]/g, ';') + .replace(/[\uff1a]/g, ':') + .replace(/[\u00d7]/g, 'x') + .replace(/\uff08/g, '(') + .replace(/\uff09/g, ')') + .replace(/\s+/g, ' ') + .trim() +} + +function parseChineseInteger(value: string): number | undefined { + if ( + !/[\u96f6\u4e00\u4e8c\u4e24\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e]/.test(value) + ) + return undefined + if (!value.includes('\u5341') && !value.includes('\u767e')) return CHINESE_DIGITS[value] + + let total = 0 + const [hundredHead, hundredTail = ''] = value.split('\u767e') + if (value.includes('\u767e')) { + total += (hundredHead ? (CHINESE_DIGITS[hundredHead] ?? 1) : 1) * 100 + } + const target = value.includes('\u767e') ? hundredTail : value + if (!target) return total + const [tenHead, tenTail = ''] = target.split('\u5341') + if (target.includes('\u5341')) { + total += (tenHead ? (CHINESE_DIGITS[tenHead] ?? 1) : 1) * 10 + if (tenTail) total += CHINESE_DIGITS[tenTail] ?? 0 + return total + } + return total + (CHINESE_DIGITS[target] ?? 0) +} + +function parseNumber(value: string): number | undefined { + const numeric = Number(value) + if (Number.isFinite(numeric)) return numeric + return parseChineseInteger(value) +} + +function toMeters(value: number, unit: string | undefined): number { + const normalizedUnit = unit?.toLowerCase() + if (normalizedUnit === 'mm' || unit === '\u6beb\u7c73') return value / 1000 + if (normalizedUnit === 'cm' || unit === '\u5398\u7c73') return value / 100 + if ( + normalizedUnit === 'm' || + normalizedUnit === 'meter' || + normalizedUnit === 'meters' || + normalizedUnit === 'metre' || + normalizedUnit === 'metres' || + unit === '\u7c73' + ) { + return value + } + + if (value >= 20) return value / 100 + return value +} + +function parseValue(value: string, unit: string | undefined, sharedUnit?: string): number { + return toMeters(parseNumber(value) ?? Number.NaN, unit || sharedUnit) +} + +function assignIfMissing( + dimensions: DimensionSemantics, + key: keyof DimensionSemantics, + value: number, +): void { + if (Number.isFinite(value) && value > 0 && dimensions[key] === undefined) { + dimensions[key] = Number(value.toFixed(4)) + } +} + +function labeledMatches(text: string): DimensionMatch[] { + const prefixPatterns: Array<[keyof DimensionSemantics, RegExp]> = [ + [ + 'length', + new RegExp( + `(?:\u957f\u5ea6|\u957f|length|long|\\bl\\b)\\s*${VALUE_SEPARATOR}\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}`, + 'gi', + ), + ], + [ + 'width', + new RegExp( + `(?:\u5bbd\u5ea6|\u5bbd|width|wide|\\bw\\b)\\s*${VALUE_SEPARATOR}\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}`, + 'gi', + ), + ], + [ + 'depth', + new RegExp( + `(?:\u6df1\u5ea6|\u6df1|depth|deep|\\bd\\b)\\s*${VALUE_SEPARATOR}\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}`, + 'gi', + ), + ], + [ + 'height', + new RegExp( + `(?:\u9ad8\u5ea6|\u9ad8|height|tall|\\bh\\b)\\s*${VALUE_SEPARATOR}\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}`, + 'gi', + ), + ], + [ + 'diameter', + new RegExp( + `(?:\u76f4\u5f84|\u76f4\u5f91|diameter|dia|\u03c6|\u03a6)\\s*${VALUE_SEPARATOR}\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}`, + 'gi', + ), + ], + [ + 'radius', + new RegExp( + `(?:\u534a\u5f84|\u534a\u5f91|radius|\\br\\b)\\s*${VALUE_SEPARATOR}\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}`, + 'gi', + ), + ], + [ + 'thickness', + new RegExp( + `(?:\u539a\u5ea6|\u539a|thickness|\\bt\\b)\\s*${VALUE_SEPARATOR}\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}`, + 'gi', + ), + ], + ] + const suffixPatterns: Array<[keyof DimensionSemantics, RegExp]> = [ + [ + 'length', + new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*(?:\u957f\u5ea6|\u957f|length|long)`, + 'gi', + ), + ], + [ + 'width', + new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*(?:\u5bbd\u5ea6|\u5bbd|width|wide)`, + 'gi', + ), + ], + [ + 'depth', + new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*(?:\u6df1\u5ea6|\u6df1|depth|deep)`, + 'gi', + ), + ], + [ + 'height', + new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*(?:\u9ad8\u5ea6|\u9ad8|height|tall)`, + 'gi', + ), + ], + [ + 'diameter', + new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*(?:\u76f4\u5f84|\u76f4\u5f91|diameter|dia)`, + 'gi', + ), + ], + [ + 'radius', + new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*(?:\u534a\u5f84|\u534a\u5f91|radius)`, + 'gi', + ), + ], + [ + 'thickness', + new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*(?:\u539a\u5ea6|\u539a|thickness)`, + 'gi', + ), + ], + ] + const matches: DimensionMatch[] = [] + + for (const [key, pattern] of [...prefixPatterns, ...suffixPatterns]) { + for (const match of text.matchAll(pattern)) { + const rawValue = match[1] + if (!rawValue) continue + matches.push({ key, value: parseValue(rawValue, match[2]) }) + } + } + + return matches +} + +function applyCompactDimensions(text: string, dimensions: DimensionSemantics): void { + const compactPattern = new RegExp( + `${NUMBER_PATTERN}\\s*${UNIT_PATTERN}\\s*x\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN}(?:\\s*x\\s*${NUMBER_PATTERN}\\s*${UNIT_PATTERN})?`, + 'gi', + ) + + for (const match of text.matchAll(compactPattern)) { + const first = match[1] + const firstUnit = match[2] + const second = match[3] + const secondUnit = match[4] + const third = match[5] + const thirdUnit = match[6] + if (!first || !second) continue + + const sharedUnit = thirdUnit || secondUnit || firstUnit + assignIfMissing(dimensions, 'length', parseValue(first, firstUnit, sharedUnit)) + assignIfMissing(dimensions, 'width', parseValue(second, secondUnit, sharedUnit)) + if (third) assignIfMissing(dimensions, 'height', parseValue(third, thirdUnit, sharedUnit)) + break + } +} + +export function parseDimensionSemantics(text: string | undefined): DimensionSemantics { + if (!text) return {} + const normalized = normalizeText(text) + const dimensions: DimensionSemantics = {} + + for (const match of labeledMatches(normalized)) { + assignIfMissing(dimensions, match.key, match.value) + } + applyCompactDimensions(normalized, dimensions) + + if (dimensions.diameter !== undefined && dimensions.radius === undefined) { + assignIfMissing(dimensions, 'radius', dimensions.diameter / 2) + } + + return dimensions +} + +function objectText(input: { + category?: unknown + name?: unknown + model?: unknown + style?: unknown +}): string { + return `${input.category ?? ''} ${input.name ?? ''} ${input.model ?? ''} ${input.style ?? ''}`.toLowerCase() +} + +function isVehicleLike(input: { + category?: unknown + name?: unknown + model?: unknown + style?: unknown +}): boolean { + return /(vehicle|car|sedan|suv|truck|\u6c7d\u8f66|\u8f66\u8f86)/i.test(objectText(input)) +} + +function isFurnitureLike(input: { + category?: unknown + name?: unknown + model?: unknown + style?: unknown +}): boolean { + return /(table|desk|chair|sofa|shelf|cabinet|monitor|keyboard|ac|\u684c|\u4e66\u684c|\u67dc|\u6c99\u53d1)/i.test( + objectText(input), + ) +} + +interface DimensionObjectInput { + category?: string + name?: string + model?: string + style?: string + width?: number + depth?: number + length?: number + height?: number +} + +export function applyDimensionSemanticsToObjectInput( + input: T, + prompt: string | undefined, +): T & DimensionObjectInput { + const dimensions = parseDimensionSemantics(prompt) + if (Object.keys(dimensions).length === 0) return input + + const next = { ...input } + if (dimensions.height !== undefined) next.height = dimensions.height + + if (isVehicleLike(input)) { + if (dimensions.length !== undefined) next.length = dimensions.length + if (dimensions.depth !== undefined && dimensions.length === undefined) + next.length = dimensions.depth + if (dimensions.width !== undefined) next.width = dimensions.width + return next + } + + if (isFurnitureLike(input)) { + if (dimensions.length !== undefined) next.width = dimensions.length + else if (dimensions.width !== undefined) next.width = dimensions.width + + if (dimensions.depth !== undefined) next.depth = dimensions.depth + else if (dimensions.length !== undefined && dimensions.width !== undefined) + next.depth = dimensions.width + return next + } + + if (dimensions.length !== undefined) next.length = dimensions.length + if (dimensions.width !== undefined) next.width = dimensions.width + if (dimensions.depth !== undefined) next.depth = dimensions.depth + return next +} diff --git a/packages/core/src/lib/family-registry.test.ts b/packages/core/src/lib/family-registry.test.ts new file mode 100644 index 000000000..0fffc98a7 --- /dev/null +++ b/packages/core/src/lib/family-registry.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' +import { + executableFamilyForLayoutFamily, + FAMILY_DEFINITIONS, + getLayoutFamilyDefinition, + LAYOUT_FAMILY_DEFINITIONS, + normalizeLayoutFamilyId, +} from './family-registry' + +describe('layout family registry', () => { + test('maps legacy executable families to stable layout families', () => { + expect(normalizeLayoutFamilyId('pump')).toBe('rotating_machine_layout') + expect(normalizeLayoutFamilyId('compressor')).toBe('rotating_machine_layout') + expect(normalizeLayoutFamilyId('tank')).toBe('vessel_layout') + expect(normalizeLayoutFamilyId('reactor')).toBe('vessel_layout') + expect(normalizeLayoutFamilyId('machine_tool')).toBe('box_enclosure_layout') + expect(normalizeLayoutFamilyId('electrical')).toBe('box_enclosure_layout') + expect(normalizeLayoutFamilyId('conveyor')).toBe('linear_transport_layout') + }) + + test('keeps every advertised executable family covered by a layout family', () => { + for (const family of FAMILY_DEFINITIONS) { + expect( + normalizeLayoutFamilyId(family.id), + `family ${family.id} should resolve to a layout family`, + ).toBeDefined() + } + }) + + test('resolves layout families back to their default executable families', () => { + expect(executableFamilyForLayoutFamily('rotating_machine_layout')).toBe('pump') + expect(executableFamilyForLayoutFamily('box_enclosure_layout', 'electrical')).toBe('electrical') + expect(getLayoutFamilyDefinition('enclosed machine')?.id).toBe('box_enclosure_layout') + expect(LAYOUT_FAMILY_DEFINITIONS.length).toBeGreaterThanOrEqual(6) + }) +}) diff --git a/packages/core/src/lib/family-registry.ts b/packages/core/src/lib/family-registry.ts new file mode 100644 index 000000000..8ba6ce319 --- /dev/null +++ b/packages/core/src/lib/family-registry.ts @@ -0,0 +1,813 @@ +import type { PartComposeKind } from './part-compose' +import { getPartDefinitions, type PartDefinition } from './part-registry' + +export type LayoutFamilyId = + | 'rotating_machine_layout' + | 'vessel_layout' + | 'linear_transport_layout' + | 'box_enclosure_layout' + | 'vehicle_layout' + | 'aircraft_layout' + | 'robot_workcell_layout' + | 'pipe_valve_layout' + | 'furniture_layout' + | 'generic_industrial_layout' + +export type LayoutFamilyGroup = + | 'rotating_machine' + | 'process_vessel' + | 'material_handling' + | 'box_enclosure' + | 'vehicle' + | 'aircraft' + | 'robotic_workcell' + | 'pipe_valve_system' + | 'furniture' + | 'generic_industrial' + +export interface LayoutFamilyDefinition { + id: LayoutFamilyId + aliases: readonly string[] + archetypeGroup: LayoutFamilyGroup + executableFamilies: readonly string[] + primaryExecutableFamily: string + layoutStrategy: string + description: string +} + +export interface FamilyDefinition { + id: string + aliases: readonly string[] + requiredParts: readonly PartComposeKind[] + optionalParts: readonly PartComposeKind[] + primarySemanticRoles: readonly string[] + layoutStrategy: string + layoutFamily?: LayoutFamilyId + archetypeGroup?: LayoutFamilyGroup + layoutCapability?: string + deprecatedDeviceFamily?: boolean + defaultDimensions: { + length?: number + width?: number + height?: number + } + description: string +} + +export const FAMILY_DEFINITIONS = [ + { + id: 'vehicle', + aliases: ['car', 'automobile', 'sedan', 'suv', 'truck', 'van', '汽车', '轿车', '车辆'], + requiredParts: ['body_shell', 'wheel_set', 'window_strip', 'light_pair', 'bar_pair'], + optionalParts: ['seam_ring', 'nameplate'], + primarySemanticRoles: ['vehicle_body', 'body_shell'], + layoutStrategy: 'vehicle_layout', + defaultDimensions: { length: 4.4, width: 1.8, height: 1.35 }, + description: 'Complete road vehicle assembled from semantic parts.', + }, + { + id: 'bicycle', + aliases: [ + 'bicycle', + 'bike', + 'cycle', + 'complete bicycle', + 'complete bike', + 'cargo bike', + 'tricycle', + ], + requiredParts: ['wheel_set', 'tube_frame', 'fork', 'handlebar', 'saddle', 'chain_loop'], + optionalParts: ['light_pair', 'nameplate'], + primarySemanticRoles: ['bicycle_frame', 'tube_frame'], + layoutStrategy: 'bicycle_layout', + defaultDimensions: { length: 1.7, width: 0.42, height: 1.05 }, + description: + 'Complete bicycle assembled from wheels, frame, fork, handlebar, saddle, and chain.', + }, + { + id: 'desk', + aliases: ['desk', 'table', 'office desk', 'writing desk', 'work table'], + requiredParts: ['desk_top', 'leg_set'], + optionalParts: ['drawer_stack'], + primarySemanticRoles: ['desk_top'], + layoutStrategy: 'desk_layout', + defaultDimensions: { length: 1.2, width: 0.6, height: 0.75 }, + description: 'Desk or table furniture assembled from semantic parts.', + }, + { + id: 'fan', + aliases: ['fan', 'standing fan', 'desk fan', 'ventilator', 'industrial fan'], + requiredParts: ['circular_base', 'vertical_pole', 'motor_housing', 'fan_blade'], + optionalParts: ['radial_blades', 'support_bracket', 'protective_grill', 'control_knob'], + primarySemanticRoles: ['motor_housing', 'fan_blade', 'radial_blades'], + layoutStrategy: 'fan_layout', + defaultDimensions: { length: 0.7, width: 0.7, height: 1.35 }, + description: 'Fan assembled from base, pole, motor housing, blades, and guard.', + }, + { + id: 'aircraft', + aliases: ['aircraft', 'airplane', 'airliner', 'plane', 'jet', 'boeing', 'airbus', 'fuselage'], + requiredParts: [ + 'aircraft_fuselage', + 'aircraft_wing', + 'aircraft_engine', + 'aircraft_vertical_stabilizer', + 'aircraft_horizontal_stabilizer', + 'aircraft_landing_gear', + ], + optionalParts: [], + primarySemanticRoles: ['aircraft_fuselage'], + layoutStrategy: 'aircraft_layout', + defaultDimensions: { length: 1.12, width: 0.14, height: 0.145 }, + description: + 'Complete aircraft assembled from fuselage, wings, engines, tail, and landing gear.', + }, + { + id: 'kiosk', + aliases: [ + 'kiosk', + 'booth', + 'small booth', + 'ticket booth', + 'vendor booth', + 'newsstand', + 'stall', + 'pavilion', + 'shed', + 'small building', + '小亭', + '亭子', + '售票亭', + '岗亭', + '摊位', + '小建筑', + ], + requiredParts: ['kiosk_body', 'kiosk_roof', 'kiosk_opening'], + optionalParts: ['kiosk_counter', 'kiosk_sign', 'kiosk_awning'], + primarySemanticRoles: ['kiosk_body'], + layoutStrategy: 'kiosk_layout', + defaultDimensions: { length: 1.8, width: 1.2, height: 2.1 }, + description: 'Small kiosk, booth, shed, or pavilion assembled from architectural parts.', + }, + { + id: 'generic', + aliases: ['generic', 'generic object', 'generic industrial', 'fallback object'], + requiredParts: ['generic_body'], + optionalParts: [ + 'generic_base', + 'generic_panel', + 'generic_spout', + 'generic_display', + 'generic_opening', + 'generic_detail_accent', + 'generic_foot_set', + ], + primarySemanticRoles: ['main_body', 'generic_body'], + layoutStrategy: 'generic_body_base_details_layout', + defaultDimensions: { length: 1.4, width: 0.8, height: 1.1 }, + description: 'Generic editable fallback object assembled from reusable generic parts.', + }, + { + id: 'pump', + aliases: [ + 'pump', + 'centrifugal pump', + 'water pump', + 'process pump', + 'chemical pump', + 'blower pump', + 'volute pump', + '离心泵', + '水泵', + '工业泵', + ], + requiredParts: ['skid_base', 'ribbed_motor_body', 'volute_casing', 'inlet_port', 'outlet_port'], + optionalParts: ['flange_ring', 'impeller_blades', 'control_box', 'nameplate', 'warning_label'], + primarySemanticRoles: ['volute_casing', 'skid_base', 'ribbed_motor_body'], + layoutStrategy: 'pump_layout', + defaultDimensions: { length: 1.2, width: 0.55, height: 0.6 }, + description: 'Industrial centrifugal pump assembled from skid, motor, volute, and ports.', + }, + { + id: 'conveyor', + aliases: [ + 'conveyor', + 'belt conveyor', + 'conveyor belt', + 'material conveyor', + 'roller conveyor', + '皮带输送机', + '输送机', + '输送线', + '传送带', + ], + requiredParts: ['conveyor_frame', 'roller_array', 'belt_surface'], + optionalParts: ['ribbed_motor_body', 'warning_label', 'nameplate'], + primarySemanticRoles: ['conveyor_frame'], + layoutStrategy: 'conveyor_layout', + defaultDimensions: { length: 3, width: 0.7, height: 0.65 }, + description: + 'Industrial conveyor assembled from frame, rollers, belt, and optional drive motor.', + }, + { + id: 'material_handling', + aliases: [ + 'material handling', + 'material_handling', + 'handling equipment', + 'roller conveyor', + 'belt conveyor', + ], + requiredParts: ['conveyor_frame', 'roller_array', 'belt_surface'], + optionalParts: ['ribbed_motor_body', 'warning_label', 'nameplate'], + primarySemanticRoles: ['conveyor_frame'], + layoutStrategy: 'material_handling_layout', + defaultDimensions: { length: 3, width: 0.8, height: 0.75 }, + description: 'Generic material-handling family for conveyors, roller lines, and grate coolers.', + }, + { + id: 'electrical', + aliases: [ + 'electrical cabinet', + 'control cabinet', + 'power cabinet', + 'electrical panel', + 'control panel', + 'switchgear', + 'switchgear cabinet', + '电控柜', + '控制柜', + '配电柜', + '开关柜', + ], + requiredParts: ['electrical_cabinet'], + optionalParts: ['cable_tray', 'nameplate', 'warning_label', 'vent_slats'], + primarySemanticRoles: ['electrical_cabinet'], + layoutStrategy: 'electrical_cabinet_layout', + defaultDimensions: { length: 0.8, width: 0.32, height: 1.6 }, + description: + 'Industrial electrical cabinet assembled from cabinet body and electrical details.', + }, + { + id: 'pipe_system', + aliases: [ + 'pipe system', + 'piping', + 'pipe run', + 'pipeline', + 'process piping', + 'industrial pipe', + '管路系统', + '管道系统', + '工艺管道', + '管线', + ], + requiredParts: ['pipe_run'], + optionalParts: ['pipe_elbow', 'flange_ring', 'valve_body'], + primarySemanticRoles: ['pipe_run'], + layoutStrategy: 'pipe_system_layout', + defaultDimensions: { length: 2, width: 0.12, height: 0.12 }, + description: 'Industrial process piping assembled from pipe runs, elbows, flanges, and valves.', + }, + { + id: 'tank', + aliases: [ + 'tank', + 'storage tank', + 'pressure vessel', + 'process vessel', + 'vertical tank', + 'horizontal tank', + '储罐', + '罐', + '容器', + ], + requiredParts: ['cylindrical_tank'], + optionalParts: ['skid_base', 'inlet_port', 'outlet_port', 'platform_ladder', 'nameplate'], + primarySemanticRoles: ['cylindrical_tank', 'vessel_shell'], + layoutStrategy: 'tank_layout', + defaultDimensions: { length: 1.2, width: 1.2, height: 2.4 }, + description: 'Industrial tank or pressure vessel assembled from vessel shell and nozzles.', + }, + { + id: 'reactor', + aliases: [ + 'reactor', + 'reaction kettle', + 'reaction vessel', + 'stirred tank', + 'agitator tank', + '反应釜', + '反应器', + '搅拌罐', + ], + requiredParts: ['agitator_tank', 'inlet_port', 'outlet_port'], + optionalParts: ['platform_ladder', 'flange_ring', 'control_box', 'nameplate'], + primarySemanticRoles: ['reactor_vessel_shell', 'agitator_tank'], + layoutStrategy: 'reactor_layout', + defaultDimensions: { length: 1.1, width: 1.1, height: 1.8 }, + description: + 'Stirred reactor assembled from agitator vessel, feed/discharge nozzles, and access details.', + }, + { + id: 'mixer', + aliases: [ + 'mixer', + 'mud mixer', + 'agitator', + 'impeller', + 'mixing paddle', + 'agitator paddle', + 'mixer impeller', + ], + requiredParts: ['mixer_blades'], + optionalParts: ['generic_base', 'generic_body', 'generic_panel'], + primarySemanticRoles: ['mixer_blade', 'mixer_blades'], + layoutStrategy: 'mixer_layout', + defaultDimensions: { length: 0.8, width: 0.8, height: 1 }, + description: 'Mixer or agitator assembled from shaft, hub, and radial impeller blades.', + }, + { + id: 'compressor', + aliases: [ + 'compressor', + 'air compressor', + 'gas compressor', + 'skid compressor', + '压缩机', + '空压机', + ], + requiredParts: [ + 'skid_base', + 'ribbed_motor_body', + 'rounded_machine_body', + 'inlet_port', + 'outlet_port', + ], + optionalParts: ['control_box', 'flange_ring', 'nameplate', 'warning_label'], + primarySemanticRoles: ['compressor_casing', 'rounded_machine_body', 'motor_body'], + layoutStrategy: 'compressor_layout', + defaultDimensions: { length: 1.8, width: 0.7, height: 0.75 }, + description: + 'Industrial compressor assembled from skid, drive motor, compressor casing, and ports.', + }, + { + id: 'heat_exchanger', + aliases: [ + 'heat exchanger', + 'shell and tube heat exchanger', + 'condenser', + 'cooler', + '换热器', + '冷凝器', + '冷却器', + ], + requiredParts: ['heat_exchanger'], + optionalParts: ['skid_base', 'flange_ring', 'nameplate'], + primarySemanticRoles: ['heat_exchanger_shell', 'heat_exchanger'], + layoutStrategy: 'heat_exchanger_layout', + defaultDimensions: { length: 1.6, width: 0.48, height: 0.55 }, + description: 'Shell-and-tube heat exchanger assembled from exchanger body and supports.', + }, + { + id: 'fluid_machine', + aliases: [ + 'fluid machine', + 'fluid_machine', + 'rotating fluid machine', + 'pump casing', + 'compressor casing', + 'blower', + 'centrifugal', + ], + requiredParts: ['rounded_machine_body', 'inlet_port', 'outlet_port'], + optionalParts: ['skid_base', 'ribbed_motor_body', 'volute_casing', 'impeller_blades'], + primarySemanticRoles: [ + 'volute_casing', + 'rounded_machine_body', + 'pump_casing', + 'compressor_casing', + ], + layoutStrategy: 'fluid_machine_layout', + defaultDimensions: { length: 1.2, width: 0.55, height: 0.65 }, + description: 'Generic fluid machine family for pumps, blowers, and compressor-like assemblies.', + }, + { + id: 'process_equipment', + aliases: [ + 'process equipment', + 'process_equipment', + 'process vessel', + 'reaction vessel', + 'vessel shell', + 'reactor vessel', + 'condenser', + ], + requiredParts: ['cylindrical_tank', 'inlet_port', 'outlet_port'], + optionalParts: ['platform_ladder', 'flange_ring', 'nameplate'], + primarySemanticRoles: [ + 'vessel_shell', + 'cylindrical_tank', + 'reactor_vessel_shell', + 'heat_exchanger_shell', + ], + layoutStrategy: 'process_equipment_layout', + defaultDimensions: { length: 1.4, width: 0.7, height: 1.5 }, + description: 'Generic process equipment family for vessels, reactors, and thermal equipment.', + }, + { + id: 'machine_tool', + aliases: [ + 'machine tool', + 'cnc', + 'cnc machine', + 'cnc mill', + 'machining center', + 'lathe', + 'milling machine', + 'grinder', + 'drill press', + '机床', + '数控机床', + '加工中心', + '车床', + '铣床', + ], + requiredParts: ['generic_base', 'generic_body', 'generic_panel', 'control_box'], + optionalParts: ['nameplate', 'warning_label', 'vent_slats'], + primarySemanticRoles: ['machine_enclosure', 'generic_body'], + layoutStrategy: 'machine_tool_layout', + defaultDimensions: { length: 2.4, width: 1, height: 1.6 }, + description: + 'Machine tool / CNC envelope assembled from bed, enclosure, spindle head, and control panel.', + }, + { + id: 'forming_machine', + aliases: [ + 'forming machine', + 'forming_machine', + 'injection molding', + 'injection molding machine', + 'hydraulic press', + 'press frame', + 'press machine', + ], + requiredParts: ['generic_base', 'generic_body', 'generic_panel', 'control_box'], + optionalParts: ['nameplate', 'warning_label'], + primarySemanticRoles: ['press_frame', 'generic_body', 'machine_enclosure'], + layoutStrategy: 'forming_machine_layout', + defaultDimensions: { length: 2.2, width: 0.9, height: 1.5 }, + description: 'Industrial forming machine family for presses and injection molding equipment.', + }, + { + id: 'outdoor_ac', + aliases: [ + 'outdoor ac', + 'outdoor_ac', + 'outdoor air conditioner', + 'air conditioner outdoor unit', + 'condenser unit', + 'hvac outdoor unit', + ], + requiredParts: ['rounded_machine_body', 'vent_grill', 'radial_blades'], + optionalParts: ['pipe_port', 'nameplate', 'warning_label'], + primarySemanticRoles: ['rounded_machine_body'], + layoutStrategy: 'outdoor_ac_layout', + defaultDimensions: { length: 0.86, width: 0.34, height: 0.62 }, + description: 'Outdoor air-conditioner unit assembled from enclosure, grille, fan, and ports.', + }, + { + id: 'distillation_tower', + aliases: [ + 'distillation tower', + 'distillation column', + 'fractionator', + 'rectification tower', + 'chemical tower', + 'process tower', + ], + requiredParts: ['cylindrical_tank', 'seam_ring', 'pipe_port', 'platform_ladder'], + optionalParts: ['flange_ring', 'nameplate'], + primarySemanticRoles: ['distillation_column_shell', 'cylindrical_tank'], + layoutStrategy: 'distillation_tower_layout', + defaultDimensions: { length: 8, width: 1, height: 8 }, + description: 'Tall process tower with tray levels, nozzles, platforms, and ladder.', + }, + { + id: 'grate_cooler', + aliases: ['grate cooler', 'grate_cooler', 'clinker cooler', 'cement cooler'], + requiredParts: ['generic_body', 'conveyor_frame', 'roller_array', 'radial_blades'], + optionalParts: ['generic_base', 'belt_surface', 'warning_label'], + primarySemanticRoles: ['cooler_grate_bed', 'generic_body', 'conveyor_frame'], + layoutStrategy: 'grate_cooler_layout', + defaultDimensions: { length: 4, width: 1.5, height: 1 }, + description: 'Industrial grate cooler with housing, grate bed, fans, and chutes.', + }, + { + id: 'valve', + aliases: ['valve', 'gate valve', 'ball valve', 'control valve', 'industrial valve'], + requiredParts: ['valve_body'], + optionalParts: ['handwheel', 'flange_ring', 'bolt_pattern'], + primarySemanticRoles: ['valve_body'], + layoutStrategy: 'valve_layout', + defaultDimensions: { length: 0.7, width: 0.3, height: 0.45 }, + description: 'Industrial valve body with optional handwheel, flanges, and bolts.', + }, + { + id: 'robot_arm', + aliases: [ + 'robot arm', + 'robot_arm', + 'industrial robot', + 'cobot', + 'manipulator', + 'six axis robot', + 'fanuc', + ], + requiredParts: [], + optionalParts: ['generic_base', 'generic_body', 'nameplate', 'warning_label'], + primarySemanticRoles: ['upper_arm', 'forearm', 'robot_base', 'generic_body'], + layoutStrategy: 'robot_arm_layout', + defaultDimensions: { length: 1.6, width: 1.1, height: 1.8 }, + description: 'Industrial robot arm with base, joints, links, wrist, and tool flange.', + }, +] as const satisfies readonly FamilyDefinition[] + +export type FamilyId = (typeof FAMILY_DEFINITIONS)[number]['id'] + +export const LAYOUT_FAMILY_DEFINITIONS = [ + { + id: 'rotating_machine_layout', + aliases: [ + 'rotating machine', + 'rotating_machine', + 'fluid machine', + 'pump layout', + 'compressor layout', + 'fan layout', + ], + archetypeGroup: 'rotating_machine', + executableFamilies: ['pump', 'compressor', 'fan', 'fluid_machine', 'mixer'], + primaryExecutableFamily: 'pump', + layoutStrategy: 'motor_casing_ports_layout', + description: + 'Rotating industrial equipment layout: base/skid, drive motor, casing, ports, and optional rotating internals.', + }, + { + id: 'vessel_layout', + aliases: [ + 'vessel', + 'process vessel', + 'vessel layout', + 'tank layout', + 'reactor layout', + 'thermal vessel', + ], + archetypeGroup: 'process_vessel', + executableFamilies: [ + 'tank', + 'reactor', + 'heat_exchanger', + 'distillation_tower', + 'process_equipment', + ], + primaryExecutableFamily: 'tank', + layoutStrategy: 'shell_ports_supports_layout', + description: + 'Process vessel layout: shell/body, nozzles, supports, optional platform/ladder, and vessel-specific internals.', + }, + { + id: 'linear_transport_layout', + aliases: ['linear transport', 'material handling', 'conveyor layout', 'transport line'], + archetypeGroup: 'material_handling', + executableFamilies: ['conveyor', 'material_handling', 'grate_cooler'], + primaryExecutableFamily: 'conveyor', + layoutStrategy: 'long_frame_repeating_surface_layout', + description: + 'Linear material transport layout: long frame, repeated rollers/slats, moving belt/surface, and optional drive.', + }, + { + id: 'box_enclosure_layout', + aliases: [ + 'box enclosure', + 'enclosed machine', + 'cabinet layout', + 'machine enclosure', + 'box_enclosure', + ], + archetypeGroup: 'box_enclosure', + executableFamilies: ['machine_tool', 'forming_machine', 'electrical', 'kiosk', 'outdoor_ac'], + primaryExecutableFamily: 'machine_tool', + layoutStrategy: 'base_body_front_panel_controls_layout', + description: + 'Enclosed machine/cabinet layout: base, enclosure/body, access or viewing panels, controls, vents, and labels.', + }, + { + id: 'vehicle_layout', + aliases: ['vehicle layout', 'road vehicle layout', 'bicycle layout'], + archetypeGroup: 'vehicle', + executableFamilies: ['vehicle', 'bicycle'], + primaryExecutableFamily: 'vehicle', + layoutStrategy: 'body_wheels_cabin_layout', + description: 'Vehicle layout for wheeled road vehicles and bicycle-like assemblies.', + }, + { + id: 'aircraft_layout', + aliases: ['aircraft layout', 'airplane layout', 'airliner layout'], + archetypeGroup: 'aircraft', + executableFamilies: ['aircraft'], + primaryExecutableFamily: 'aircraft', + layoutStrategy: 'fuselage_wings_tail_landing_gear_layout', + description: 'Aircraft layout with fuselage, wings, engines, stabilizers, and landing gear.', + }, + { + id: 'robot_workcell_layout', + aliases: ['robot workcell', 'robot arm layout', 'robotic workcell'], + archetypeGroup: 'robotic_workcell', + executableFamilies: ['robot_arm'], + primaryExecutableFamily: 'robot_arm', + layoutStrategy: 'robot_arm_cell_layout', + description: + 'Robot arm/workcell layout with base, joints, arm links, tooling, controls, and safety details.', + }, + { + id: 'pipe_valve_layout', + aliases: ['pipe valve', 'pipe layout', 'valve layout', 'piping layout'], + archetypeGroup: 'pipe_valve_system', + executableFamilies: ['pipe_system', 'valve'], + primaryExecutableFamily: 'pipe_system', + layoutStrategy: 'pipe_run_fittings_valve_layout', + description: + 'Pipe and valve system layout with runs, elbows, flanges, valves, and handwheel details.', + }, + { + id: 'furniture_layout', + aliases: ['furniture layout', 'desk layout', 'table layout'], + archetypeGroup: 'furniture', + executableFamilies: ['desk'], + primaryExecutableFamily: 'desk', + layoutStrategy: 'top_supports_storage_layout', + description: 'Furniture layout for desks and table-like objects.', + }, + { + id: 'generic_industrial_layout', + aliases: ['generic industrial', 'generic layout', 'fallback industrial layout'], + archetypeGroup: 'generic_industrial', + executableFamilies: ['generic'], + primaryExecutableFamily: 'generic', + layoutStrategy: 'generic_body_base_details_layout', + description: + 'Fallback layout using generic editable parts when no specific layout family applies.', + }, +] as const satisfies readonly LayoutFamilyDefinition[] + +const executableFamilyToLayoutFamily = new Map() +const layoutFamilyAliasMap = new Map() +for (const layoutFamily of LAYOUT_FAMILY_DEFINITIONS) { + layoutFamilyAliasMap.set(normalizeKey(layoutFamily.id), layoutFamily) + for (const alias of layoutFamily.aliases) + layoutFamilyAliasMap.set(normalizeKey(alias), layoutFamily) + for (const family of layoutFamily.executableFamilies) { + executableFamilyToLayoutFamily.set(normalizeKey(family), layoutFamily) + } +} + +const familyAliasMap = new Map() +for (const definition of FAMILY_DEFINITIONS) { + familyAliasMap.set(normalizeKey(definition.id), definition) + for (const alias of definition.aliases) familyAliasMap.set(normalizeKey(alias), definition) +} + +function normalizeKey(value: unknown): string { + return typeof value === 'string' + ? value + .trim() + .replace(/[\s_-]+/g, '_') + .toLowerCase() + : '' +} + +function textOf(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function isAsciiWordChar(value: string | undefined): boolean { + return value != null && /^[a-z0-9]$/.test(value) +} + +function isCjkChar(value: string | undefined): boolean { + return value != null && /^[\u3400-\u9fff\uf900-\ufaff]$/.test(value) +} + +function isSingleCjkAlias(value: string): boolean { + return value.length === 1 && isCjkChar(value) +} + +function isSingleCjkAliasLeftBoundary(value: string | undefined): boolean { + return ( + value == null || + !isCjkChar(value) || + /^[个個台臺座只件套根条條份种種类類为為是叫作做造建成一二两兩三四五六七八九十]$/.test(value) + ) +} + +function isSingleCjkAliasRightBoundary(value: string | undefined): boolean { + return value == null || !isCjkChar(value) || /^[子体體身型式类類]$/.test(value) +} + +function containsAliasToken(normalizedText: string, alias: string): boolean { + if (!alias) return false + if (isSingleCjkAlias(alias)) { + let index = normalizedText.indexOf(alias) + while (index >= 0) { + const before = normalizedText[index - 1] + const after = normalizedText[index + alias.length] + if (isSingleCjkAliasLeftBoundary(before) && isSingleCjkAliasRightBoundary(after)) return true + index = normalizedText.indexOf(alias, index + 1) + } + return false + } + if (!/[a-z0-9]/.test(alias)) return normalizedText.includes(alias) + + let index = normalizedText.indexOf(alias) + while (index >= 0) { + const before = normalizedText[index - 1] + const after = normalizedText[index + alias.length] + if (!isAsciiWordChar(before) && !isAsciiWordChar(after)) return true + index = normalizedText.indexOf(alias, index + 1) + } + return false +} + +export function getFamilyDefinition(family: unknown): FamilyDefinition | undefined { + return familyAliasMap.get(normalizeKey(family)) +} + +export function getLayoutFamilyDefinition(family: unknown): LayoutFamilyDefinition | undefined { + const direct = layoutFamilyAliasMap.get(normalizeKey(family)) + if (direct) return direct + const executable = getFamilyDefinition(family) + return executableFamilyToLayoutFamily.get(normalizeKey(executable?.id ?? family)) +} + +export function normalizeLayoutFamilyId(family: unknown): LayoutFamilyId | undefined { + return getLayoutFamilyDefinition(family)?.id +} + +export function executableFamilyForLayoutFamily( + layoutFamily: unknown, + preferredFamily?: unknown, +): string | undefined { + const definition = getLayoutFamilyDefinition(layoutFamily) + if (!definition) return undefined + const preferred = getFamilyDefinition(preferredFamily) + if (preferred && definition.executableFamilies.includes(preferred.id)) return preferred.id + return definition.primaryExecutableFamily +} + +export function normalizeFamilyId(family: unknown): FamilyId | undefined { + return getFamilyDefinition(family)?.id as FamilyId | undefined +} + +export function isFamilyId(family: unknown): family is FamilyId { + return normalizeFamilyId(family) === family +} + +export function inferFamilyDefinition( + input: Record, +): FamilyDefinition | undefined { + const explicit = getFamilyDefinition(input.family) + if (explicit) return explicit + const text = textOf([input.object, input.name, input.prompt, input.style, input.geometryBrief]) + const normalizedText = normalizeKey(text) + const matches = FAMILY_DEFINITIONS.flatMap((definition) => + [definition.id, ...definition.aliases].map((alias) => ({ + definition, + alias: normalizeKey(alias), + })), + ).filter((candidate) => containsAliasToken(normalizedText, candidate.alias)) + matches.sort((left, right) => right.alias.length - left.alias.length) + return matches[0]?.definition +} + +export function familyPartDefinitions(family: unknown): readonly PartDefinition[] { + const definition = getFamilyDefinition(family) + return definition ? getPartDefinitions(definition.id) : [] +} + +export function familyCapabilitySummary(): string { + return FAMILY_DEFINITIONS.map( + (definition) => + `${definition.id}: layoutFamily=${normalizeLayoutFamilyId(definition.id) ?? 'unknown'} required=${definition.requiredParts.join(', ')} optional=${definition.optionalParts.join(', ')} layout=${definition.layoutStrategy}`, + ).join('\n') +} + +export function layoutFamilyCapabilitySummary(): string { + return LAYOUT_FAMILY_DEFINITIONS.map( + (definition) => + `${definition.id}: group=${definition.archetypeGroup} executable=${definition.executableFamilies.join(', ')} strategy=${definition.layoutStrategy}`, + ).join('\n') +} diff --git a/packages/core/src/lib/geometry-golden-snapshot.test.ts b/packages/core/src/lib/geometry-golden-snapshot.test.ts new file mode 100644 index 000000000..ebc5b68b6 --- /dev/null +++ b/packages/core/src/lib/geometry-golden-snapshot.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from 'bun:test' +import { composeAssemblyPrimitives } from './assembly-compose' +import { + createGeometryGoldenSnapshot, + stringifyGeometryGoldenSnapshot, +} from './geometry-golden-snapshot' +import { composePartPrimitives } from './part-compose' + +describe('geometry golden snapshots', () => { + test('captures a stable structural snapshot for the strengthened standing fan', () => { + const snapshot = createGeometryGoldenSnapshot( + composePartPrimitives({ + name: 'Standing fan', + parts: [{ kind: 'protective_grill' }], + }), + { + id: 'standing-fan', + prompt: 'standing electric fan', + geometryBrief: { category: 'fan' }, + maxShapes: 12, + }, + ) + + expect(snapshot).toMatchObject({ + id: 'standing-fan', + family: 'fan', + shapeCount: 46, + dimensions: [0.733, 1.5465, 0.56], + roles: { + circular_base: 1, + fan_blade: 3, + fan_hub: 1, + motor_housing: 2, + protective_grill: 34, + support_bracket: 4, + vertical_pole: 1, + }, + visualQuality: { + family: 'fan', + score: 1, + issueCount: 0, + warningCount: 0, + }, + }) + expect(snapshot.shapes[0]).toMatchObject({ + kind: 'torus', + name: 'Standing fan grill front ring 1', + role: 'protective_grill', + }) + expect(snapshot.shapes.some((shape) => shape.name?.includes('rear inner support ring'))).toBe( + true, + ) + expect(snapshot.shapes.some((shape) => shape.name?.includes('grill center cap'))).toBe(true) + expect(stringifyGeometryGoldenSnapshot(snapshot)).toContain('"id": "standing-fan"') + }) + + test('captures a stable structural snapshot for an industrial assembly family', () => { + const snapshot = createGeometryGoldenSnapshot( + composeAssemblyPrimitives({ family: 'machine_tool', object: 'machining center' }), + { + id: 'machining-center', + prompt: 'cnc machining center', + geometryBrief: { category: 'industrial_equipment' }, + maxShapes: 12, + }, + ) + + expect(snapshot).toMatchObject({ + id: 'machining-center', + family: 'industrial_equipment', + shapeCount: 7, + dimensions: [2.244, 1.3622, 1.4439], + roles: { + control_panel: 1, + glass_panel: 1, + linear_rail: 1, + machine_base: 1, + machine_bed: 1, + machine_enclosure: 1, + spindle_head: 1, + }, + visualQuality: { + family: 'industrial_equipment', + score: 0.96, + issueCount: 0, + warningCount: 1, + }, + }) + expect(snapshot.shapes.map((shape) => shape.role)).toEqual([ + 'machine_base', + 'machine_enclosure', + 'machine_bed', + 'linear_rail', + 'spindle_head', + 'glass_panel', + 'control_panel', + ]) + }) + + test('captures stable curved surface kernel snapshots', () => { + const snapshot = createGeometryGoldenSnapshot( + composePartPrimitives({ + name: 'Curved shell kit', + autoComplete: false, + parts: [ + { + kind: 'ellipsoid_shell', + name: 'helmet shell', + length: 0.34, + width: 0.24, + height: 0.18, + shellThickness: 0.012, + }, + { + kind: 'lofted_shell', + name: 'transition fairing', + position: [0.55, 0.08, 0], + length: 0.5, + width: 0.18, + height: 0.1, + }, + ], + }), + { + id: 'curved-surface-kernels', + prompt: 'ellipsoid helmet shell and lofted transition shell', + geometryBrief: { category: 'curved_surface_kernel' }, + maxShapes: 12, + }, + ) + + expect(snapshot).toMatchObject({ + id: 'curved-surface-kernels', + family: 'curved_surface_kernel', + shapeCount: 8, + roles: { + ellipsoid_shell: 1, + ellipsoid_shell_opening: 1, + ellipsoid_shell_rim: 1, + lofted_panel_root: 1, + lofted_panel_section: 1, + lofted_panel_segment: 2, + lofted_panel_tip: 1, + }, + }) + expect(snapshot.sources).toMatchObject({ + ellipsoid_shell: 3, + lofted_panel: 5, + }) + }) +}) diff --git a/packages/core/src/lib/geometry-golden-snapshot.ts b/packages/core/src/lib/geometry-golden-snapshot.ts new file mode 100644 index 000000000..bcbc72459 --- /dev/null +++ b/packages/core/src/lib/geometry-golden-snapshot.ts @@ -0,0 +1,137 @@ +import type { + PrimitiveGeometryBrief, + PrimitiveShapeInput, + ResolvedPrimitiveTransform, + Vec3, +} from './primitive-compose' +import { resolvePrimitiveWorldTransforms } from './primitive-compose' +import { + buildPrimitiveGeometryFacts, + type PrimitiveGeometryFacts, + type PrimitiveShapeFact, +} from './primitive-facts' +import { assessPrimitiveVisualQuality } from './primitive-visual-quality' + +export interface GeometryGoldenSnapshotOptions { + id: string + prompt?: string + geometryBrief?: PrimitiveGeometryBrief + maxShapes?: number + precision?: number +} + +export interface GeometryGoldenShapeSnapshot { + index: number + kind: string + name?: string + role?: string + source?: string + center: Vec3 + size: Vec3 +} + +export interface GeometryGoldenSnapshot { + id: string + family: string + shapeCount: number + dimensions: Vec3 + roles: Record + sources: Record + visualQuality: { + family: string + score: number + issueCount: number + warningCount: number + } + shapes: GeometryGoldenShapeSnapshot[] +} + +function round(value: number, precision: number) { + return Number(value.toFixed(precision)) +} + +function roundVec(value: Vec3, precision: number): Vec3 { + return [round(value[0], precision), round(value[1], precision), round(value[2], precision)] +} + +function sortedRecord(record: Record) { + return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b))) +} + +function factToSnapshot(fact: PrimitiveShapeFact, precision: number): GeometryGoldenShapeSnapshot { + return { + index: fact.index, + kind: fact.kind, + name: fact.name, + role: fact.semanticRole, + source: fact.sourcePartKind, + center: roundVec(fact.center, precision), + size: roundVec( + [fact.halfExtents[0] * 2, fact.halfExtents[1] * 2, fact.halfExtents[2] * 2], + precision, + ), + } +} + +function selectGoldenShapes(facts: PrimitiveGeometryFacts, maxShapes: number) { + return facts.shapes + .filter( + (fact, index) => + index < maxShapes || + Boolean(fact.semanticRole) || + /body|base|frame|blade|grill|port|flange|panel|door|wheel|window|casing|hatch|seam/.test( + fact.name?.toLowerCase() ?? '', + ), + ) + .slice(0, maxShapes) +} + +function isResolvedTransformArray( + value: readonly ResolvedPrimitiveTransform[] | GeometryGoldenSnapshotOptions | undefined, +): value is readonly ResolvedPrimitiveTransform[] { + return Array.isArray(value) +} + +export function createGeometryGoldenSnapshot( + shapes: readonly PrimitiveShapeInput[], + transformsOrOptions?: readonly ResolvedPrimitiveTransform[] | GeometryGoldenSnapshotOptions, + maybeOptions?: GeometryGoldenSnapshotOptions, +): GeometryGoldenSnapshot { + let transforms: readonly ResolvedPrimitiveTransform[] + let options: GeometryGoldenSnapshotOptions + + if (isResolvedTransformArray(transformsOrOptions)) { + transforms = transformsOrOptions + options = maybeOptions ?? { id: 'geometry' } + } else { + transforms = resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }) + options = transformsOrOptions ?? { id: 'geometry' } + } + const precision = options.precision ?? 4 + const maxShapes = options.maxShapes ?? 32 + const facts = buildPrimitiveGeometryFacts(shapes, transforms) + const quality = assessPrimitiveVisualQuality(shapes, transforms, { + prompt: options.prompt, + geometryBrief: options.geometryBrief, + }) + + return { + id: options.id, + family: options.geometryBrief?.category ?? quality.family, + shapeCount: facts.shapeCount, + dimensions: roundVec(facts.dimensions, precision), + roles: sortedRecord(facts.roles), + sources: sortedRecord(facts.sourcePartKinds), + visualQuality: { + family: quality.family, + score: round(quality.score, precision), + issueCount: quality.issues.length, + warningCount: quality.warnings.length, + }, + shapes: selectGoldenShapes(facts, maxShapes).map((fact) => factToSnapshot(fact, precision)), + } +} + +export function stringifyGeometryGoldenSnapshot(snapshot: GeometryGoldenSnapshot) { + return JSON.stringify(snapshot, null, 2) +} diff --git a/packages/core/src/lib/geometry-layer-boundaries.test.ts b/packages/core/src/lib/geometry-layer-boundaries.test.ts new file mode 100644 index 000000000..2edd6a0ab --- /dev/null +++ b/packages/core/src/lib/geometry-layer-boundaries.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' +import { composePartPrimitives } from './part-compose' +import { normalizePartPlanForFamily } from './part-registry' +import { composeRecipePrimitives } from './primitive-recipes' +import { getPrimitiveDefinition, lowerDerivedPrimitiveShape } from './primitive-registry' + +describe('geometry layer boundaries', () => { + test('primitive registry stays pure geometry without family semantics', () => { + expect(getPrimitiveDefinition('oval')?.kind).toBe('ellipsoid') + + const lowered = lowerDerivedPrimitiveShape({ + kind: 'ellipse-panel', + length: 1.2, + width: 0.6, + thickness: 0.04, + segments: 16, + }) + + expect(lowered).toMatchObject({ kind: 'extrude', depth: 0.04 }) + expect(lowered.profile).toHaveLength(16) + }) + + test('recipe registry stays a closed standard-part generator', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'pipe.flange', + nominalDiameter: 0.28, + boltCount: 8, + }) + + expect(shapes.length).toBeGreaterThan(2) + expect(shapes.some((shape) => shape.semanticRole === 'flange_body')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'flange_bolt_hole')).toBe(true) + }) + + test('part registry normalizes loose LLM parts before composition', () => { + const plan = normalizePartPlanForFamily('pump', { + parts: [ + { kind: 'baseplate' }, + { kind: 'motor_body', params: { ribCount: 12 } }, + { kind: 'pump_body' }, + { kind: 'suction_port' }, + { kind: 'discharge_port' }, + ], + }) + + expect(plan?.warnings).toEqual([]) + expect(plan?.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'skid_base', semanticRole: 'support_base' }), + expect.objectContaining({ kind: 'ribbed_motor_body', semanticRole: 'drive_motor' }), + expect.objectContaining({ kind: 'volute_casing', semanticRole: 'volute_casing' }), + ]), + ) + + const shapes = composePartPrimitives({ + family: 'pump', + registryPartPlan: true, + autoComplete: false, + parts: plan?.parts ?? [], + }) + expect(shapes.length).toBeGreaterThan(6) + }) +}) diff --git a/packages/core/src/lib/industrial-archetype-compose.ts b/packages/core/src/lib/industrial-archetype-compose.ts new file mode 100644 index 000000000..a29abd9ec --- /dev/null +++ b/packages/core/src/lib/industrial-archetype-compose.ts @@ -0,0 +1,1299 @@ +import { + findIndustrialArchetype, + findIndustrialArchetypeByRecipeId, + type IndustrialArchetypeEntry, + type IndustrialArchetypeRecipeId, +} from './industrial-archetype-registry' +import { composePartPrimitives, type PartComposeInput } from './part-compose' +import type { PrimitiveGeometryBrief, PrimitiveShapeInput, Vec3 } from './primitive-compose' +import { type RecipeDimensionParams, resolveRecipeDimensions } from './recipe-dimensions' + +export interface IndustrialArchetypeComposeInput extends RecipeDimensionParams { + recipeId?: IndustrialArchetypeRecipeId | string + name?: string + color?: string + primaryColor?: string + accentColor?: string + darkColor?: string + metalColor?: string + detail?: PartComposeInput['detail'] | string + highFidelity?: boolean + enhanceVisualDetails?: boolean + position?: Vec3 + archetypeId?: string + archetype?: string + variant?: string + style?: string + params?: IndustrialArchetypeComposeInput +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function industrialComposeParams( + input: IndustrialArchetypeComposeInput, +): IndustrialArchetypeComposeInput { + return isRecord(input.params) ? { ...input, ...input.params } : input +} + +function textOf(value: unknown): string { + if (typeof value === 'string') return value.toLowerCase() + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function stringValue(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value + } + return undefined +} + +function nameFor(input: IndustrialArchetypeComposeInput, fallback: string): string { + const params = industrialComposeParams(input) + return stringValue(params.name, input.name, fallback) ?? fallback +} + +function colorFor(params: IndustrialArchetypeComposeInput, fallback: string): string { + return stringValue(params.primaryColor, params.color, fallback) ?? fallback +} + +function detailFor(params: IndustrialArchetypeComposeInput): PartComposeInput['detail'] { + return stringValue( + params.detail, + params.highFidelity || params.enhanceVisualDetails ? 'high' : undefined, + ) as PartComposeInput['detail'] +} + +function positionFor( + params: IndustrialArchetypeComposeInput, + input: IndustrialArchetypeComposeInput, +) { + return params.position ?? input.position ?? [0, 0, 0] +} + +function materialPalette(params: IndustrialArchetypeComposeInput) { + return { + body: { properties: { color: colorFor(params, '#64748b'), roughness: 0.46, metalness: 0.42 } }, + dark: { + properties: { + color: stringValue(params.darkColor, '#1f2937') ?? '#1f2937', + roughness: 0.58, + metalness: 0.25, + }, + }, + metal: { + properties: { + color: stringValue(params.metalColor, '#cbd5e1') ?? '#cbd5e1', + roughness: 0.32, + metalness: 0.78, + }, + }, + accent: { + properties: { + color: stringValue(params.accentColor, '#2563eb') ?? '#2563eb', + roughness: 0.38, + metalness: 0.2, + }, + }, + glass: { + properties: { + color: '#7dd3fc', + roughness: 0.12, + metalness: 0.02, + opacity: 0.38, + transparent: true, + }, + }, + } +} + +function resolveEntry( + recipeId: IndustrialArchetypeRecipeId, + input: IndustrialArchetypeComposeInput, +) { + const params = industrialComposeParams(input) + const explicit = [params.variant, params.style, params.archetypeId, params.archetype] + .filter(Boolean) + .join(' ') + const requestText = textOf([explicit, params.name, input.name]) + return ( + findIndustrialArchetype(requestText) ?? + findIndustrialArchetypeByRecipeId(recipeId) ?? + findIndustrialArchetypeByRecipeId(recipeId)! + ) +} + +function dims(recipeId: IndustrialArchetypeRecipeId, input: IndustrialArchetypeComposeInput) { + return resolveRecipeDimensions(recipeId, industrialComposeParams(input)) +} + +function tag( + shapes: PrimitiveShapeInput[], + entry: IndustrialArchetypeEntry, +): PrimitiveShapeInput[] { + return shapes.map((shape) => ({ + ...shape, + industrialArchetype: entry.archetypeId, + industrialVariant: entry.variant, + })) +} + +function box( + name: string, + semanticRole: string, + position: Vec3, + length: number, + width: number, + height: number, + material: PrimitiveShapeInput['material'], + cornerRadius = Math.min(length, width, height) * 0.08, +): PrimitiveShapeInput { + return { + kind: 'box', + name, + semanticRole, + position, + length, + width, + height, + cornerRadius, + material, + } +} + +function panel( + name: string, + semanticRole: string, + position: Vec3, + length: number, + width: number, + height: number, + material: PrimitiveShapeInput['material'], + rotation?: Vec3, +): PrimitiveShapeInput { + return { + kind: 'rounded-panel', + name, + semanticRole, + position, + rotation, + length, + width, + height, + cornerRadius: Math.min(length, height) * 0.06, + material, + } +} + +function cylinder( + name: string, + semanticRole: string, + position: Vec3, + axis: 'x' | 'y' | 'z', + radius: number, + height: number, + material: PrimitiveShapeInput['material'], +): PrimitiveShapeInput { + return { + kind: 'cylinder', + name, + semanticRole, + position, + axis, + radius, + height, + radialSegments: 32, + material, + } +} + +function latheBed(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const m = materialPalette(params) + const o = positionFor(params, input) + const name = nameFor(input, entry.label) + const workY = H * 0.36 + const rolling = entry.variant === 'thread_rolling_machine' + return [ + box( + `${name} machine bed`, + 'machine_bed', + [o[0], o[1] + H * 0.135, o[2]], + L * 0.72, + W * 0.32, + H * 0.13, + m.body, + ), + box( + `${name} sloped chip tray`, + 'machine_base', + [o[0], o[1] + H * 0.035, o[2]], + L * 0.78, + W * 0.38, + H * 0.07, + m.dark, + ), + box( + `${name} headstock`, + 'headstock', + [o[0] - L * 0.26, o[1] + workY, o[2]], + L * 0.14, + W * 0.28, + H * 0.22, + m.body, + ), + cylinder( + `${name} ${rolling ? 'rolling spindle' : 'spindle chuck'}`, + 'spindle_chuck', + [o[0] - L * 0.18, o[1] + workY + H * 0.02, o[2]], + 'x', + Math.min(W, H) * 0.065, + L * (rolling ? 0.08 : 0.035), + m.dark, + ), + box( + `${name} ${rolling ? 'rolling head' : 'tool post'}`, + 'tool_post', + [o[0] + L * 0.09, o[1] + workY, o[2] + W * 0.015], + L * 0.09, + W * 0.14, + H * 0.09, + m.metal, + ), + box( + `${name} front linear rail`, + 'linear_rail', + [o[0], o[1] + H * 0.24, o[2] - W * 0.12], + L * 0.5, + W * 0.018, + H * 0.014, + m.metal, + 0, + ), + box( + `${name} rear linear rail`, + 'linear_rail', + [o[0], o[1] + H * 0.24, o[2] + W * 0.12], + L * 0.5, + W * 0.018, + H * 0.014, + m.metal, + 0, + ), + panel( + `${name} sliding guard window`, + 'transparent_door', + [o[0] + L * 0.08, o[1] + H * 0.48, o[2] - W * 0.19], + L * 0.3, + W * 0.014, + H * 0.2, + m.glass, + ), + panel( + `${name} control panel`, + 'control_panel', + [o[0] + L * 0.35, o[1] + H * 0.42, o[2] - W * 0.23], + L * 0.08, + W * 0.02, + H * 0.23, + m.accent, + [0, 0.22, 0], + ), + ] +} + +interface MachineToolContext { + entry: IndustrialArchetypeEntry + params: IndustrialArchetypeComposeInput + L: number + W: number + H: number + m: ReturnType + o: Vec3 + name: string +} + +function machineToolContext( + entry: IndustrialArchetypeEntry, + input: IndustrialArchetypeComposeInput, +): MachineToolContext { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const m = materialPalette(params) + const o = positionFor(params, input) + const name = nameFor(input, entry.label) + return { entry, params, L, W, H, m, o, name } +} + +function bedColumnBase(ctx: MachineToolContext): PrimitiveShapeInput[] { + const { L, W, H, m, o, name } = ctx + return [ + box( + `${name} machine base`, + 'machine_base', + [o[0], o[1] + H * 0.08, o[2]], + L * 0.72, + W * 0.62, + H * 0.16, + m.body, + ), + box( + `${name} rear column`, + 'machine_column', + [o[0] - L * 0.17, o[1] + H * 0.5, o[2] + W * 0.16], + L * 0.18, + W * 0.22, + H * 0.7, + m.body, + ), + box( + `${name} work table`, + 'work_table', + [o[0] + L * 0.09, o[1] + H * 0.22, o[2]], + L * 0.36, + W * 0.28, + H * 0.04, + m.metal, + ), + box( + `${name} spindle head`, + 'spindle_head', + [o[0] - L * 0.08, o[1] + H * 0.55, o[2]], + L * 0.14, + W * 0.16, + H * 0.16, + m.dark, + ), + box( + `${name} x linear rail`, + 'linear_rail', + [o[0] + L * 0.06, o[1] + H * 0.28, o[2] - W * 0.18], + L * 0.42, + W * 0.012, + H * 0.012, + m.metal, + 0, + ), + panel( + `${name} transparent front door`, + 'transparent_door', + [o[0] + L * 0.08, o[1] + H * 0.45, o[2] - W * 0.33], + L * 0.42, + W * 0.014, + H * 0.34, + m.glass, + ), + panel( + `${name} side control panel`, + 'control_panel', + [o[0] + L * 0.35, o[1] + H * 0.42, o[2] - W * 0.28], + L * 0.1, + W * 0.016, + H * 0.24, + m.accent, + [0, -0.16, 0], + ), + ] +} + +function machiningCenterDetails(ctx: MachineToolContext): PrimitiveShapeInput[] { + const { L, W, H, m, o, name, entry } = ctx + const tool = + entry.variant === 'boring_machine' + ? 'boring bar' + : entry.variant === 'machining_center' + ? 'vertical spindle nose' + : 'vertical spindle' + return [ + cylinder( + `${name} ${tool}`, + 'tool_head', + [o[0] - L * 0.08, o[1] + H * 0.42, o[2]], + 'y', + Math.min(W, H) * (entry.variant === 'boring_machine' ? 0.018 : 0.022), + H * 0.11, + m.metal, + ), + ] +} + +function millingDetails(ctx: MachineToolContext): PrimitiveShapeInput[] { + const { L, W, H, m, o, name } = ctx + return [ + cylinder( + `${name} vertical milling spindle nose`, + 'tool_head', + [o[0] - L * 0.08, o[1] + H * 0.45, o[2]], + 'y', + Math.min(W, H) * 0.026, + H * 0.09, + m.metal, + ), + cylinder( + `${name} fluted end mill cutter`, + 'milling_cutter', + [o[0] - L * 0.08, o[1] + H * 0.365, o[2]], + 'y', + Math.min(W, H) * 0.018, + H * 0.08, + m.dark, + ), + ...[-0.09, 0, 0.09].map((zOffset, index) => + box( + `${name} T-slot table groove ${index + 1}`, + index === 0 ? 't_slot_table' : 'table_slot', + [o[0] + L * 0.09, o[1] + H * 0.248, o[2] + W * zOffset], + L * 0.34, + W * 0.012, + H * 0.012, + m.dark, + 0, + ), + ), + cylinder( + `${name} table feed handwheel`, + 'feed_handwheel', + [o[0] + L * 0.3, o[1] + H * 0.25, o[2] - W * 0.18], + 'x', + Math.min(W, H) * 0.035, + W * 0.018, + m.metal, + ), + ] +} + +function drillPressShapes(ctx: MachineToolContext): PrimitiveShapeInput[] { + const { L, W, H, m, o, name } = ctx + return [ + box( + `${name} heavy drill base`, + 'machine_base', + [o[0], o[1] + H * 0.06, o[2]], + L * 0.58, + W * 0.52, + H * 0.12, + m.body, + ), + cylinder( + `${name} round vertical column`, + 'machine_column', + [o[0] - L * 0.13, o[1] + H * 0.48, o[2]], + 'y', + Math.min(L, W) * 0.045, + H * 0.76, + m.metal, + ), + box( + `${name} lifting work table`, + 'work_table', + [o[0] + L * 0.08, o[1] + H * 0.28, o[2]], + L * 0.34, + W * 0.3, + H * 0.045, + m.metal, + ), + box( + `${name} crank lift bracket`, + 'lifting_table', + [o[0] - L * 0.04, o[1] + H * 0.3, o[2]], + L * 0.16, + W * 0.08, + H * 0.07, + m.dark, + ), + box( + `${name} radial drill arm`, + 'spindle_head', + [o[0] + L * 0.03, o[1] + H * 0.66, o[2]], + L * 0.38, + W * 0.12, + H * 0.09, + m.body, + ), + box( + `${name} drill head`, + 'spindle_head', + [o[0] + L * 0.16, o[1] + H * 0.55, o[2]], + L * 0.13, + W * 0.14, + H * 0.16, + m.dark, + ), + { + kind: 'cone', + name: `${name} tapered drill bit`, + semanticRole: 'drill_bit', + position: [o[0] + L * 0.16, o[1] + H * 0.42, o[2]], + axis: 'y', + radius: Math.min(W, H) * 0.018, + height: H * 0.15, + radialSegments: 24, + material: m.metal, + }, + panel( + `${name} side control panel`, + 'control_panel', + [o[0] + L * 0.29, o[1] + H * 0.48, o[2] - W * 0.22], + L * 0.09, + W * 0.016, + H * 0.2, + m.accent, + [0, -0.16, 0], + ), + ] +} + +function grinderDetails(ctx: MachineToolContext): PrimitiveShapeInput[] { + const { L, W, H, m, o, name } = ctx + return [ + box( + `${name} magnetic chuck table`, + 'magnetic_chuck', + [o[0] + L * 0.1, o[1] + H * 0.265, o[2]], + L * 0.48, + W * 0.34, + H * 0.035, + m.dark, + ), + cylinder( + `${name} grinding wheel`, + 'grinding_wheel', + [o[0] - L * 0.06, o[1] + H * 0.43, o[2]], + 'x', + Math.min(W, H) * 0.085, + W * 0.055, + m.metal, + ), + { + kind: 'half-cylinder', + name: `${name} half cover wheel guard`, + semanticRole: 'wheel_guard', + position: [o[0] - L * 0.06, o[1] + H * 0.45, o[2] - W * 0.005], + axis: 'x', + radius: Math.min(W, H) * 0.105, + height: W * 0.07, + radialSegments: 24, + material: m.dark, + }, + cylinder( + `${name} coolant nozzle`, + 'coolant_nozzle', + [o[0] - L * 0.015, o[1] + H * 0.39, o[2] - W * 0.08], + 'z', + Math.min(W, H) * 0.012, + W * 0.16, + m.metal, + ), + ] +} + +function planerShapes(ctx: MachineToolContext): PrimitiveShapeInput[] { + const { L, W, H, m, o, name } = ctx + return [ + box( + `${name} long planer bed`, + 'machine_base', + [o[0], o[1] + H * 0.08, o[2]], + L * 0.86, + W * 0.42, + H * 0.16, + m.body, + ), + box( + `${name} traveling work table`, + 'work_table', + [o[0] + L * 0.04, o[1] + H * 0.2, o[2]], + L * 0.62, + W * 0.34, + H * 0.055, + m.metal, + ), + box( + `${name} left upright`, + 'machine_column', + [o[0] - L * 0.18, o[1] + H * 0.5, o[2] - W * 0.24], + L * 0.1, + W * 0.08, + H * 0.62, + m.body, + ), + box( + `${name} right upright`, + 'machine_column', + [o[0] - L * 0.18, o[1] + H * 0.5, o[2] + W * 0.24], + L * 0.1, + W * 0.08, + H * 0.62, + m.body, + ), + box( + `${name} adjustable cross rail`, + 'cross_rail', + [o[0] - L * 0.08, o[1] + H * 0.64, o[2]], + L * 0.18, + W * 0.62, + H * 0.08, + m.dark, + ), + box( + `${name} reciprocating ram`, + 'reciprocating_ram', + [o[0] + L * 0.1, o[1] + H * 0.55, o[2]], + L * 0.34, + W * 0.13, + H * 0.09, + m.body, + ), + box( + `${name} single point tool head`, + 'tool_head', + [o[0] + L * 0.25, o[1] + H * 0.42, o[2]], + L * 0.055, + W * 0.08, + H * 0.16, + m.metal, + ), + panel( + `${name} pendant control panel`, + 'control_panel', + [o[0] + L * 0.34, o[1] + H * 0.48, o[2] - W * 0.31], + L * 0.1, + W * 0.016, + H * 0.24, + m.accent, + [0, -0.12, 0], + ), + ] +} + +function shaperShapes(ctx: MachineToolContext): PrimitiveShapeInput[] { + const { L, W, H, m, o, name } = ctx + return [ + box( + `${name} compact shaper base`, + 'machine_base', + [o[0], o[1] + H * 0.09, o[2]], + L * 0.66, + W * 0.5, + H * 0.18, + m.body, + ), + box( + `${name} vise work table`, + 'work_table', + [o[0] + L * 0.14, o[1] + H * 0.29, o[2]], + L * 0.28, + W * 0.28, + H * 0.075, + m.metal, + ), + box( + `${name} column housing`, + 'machine_column', + [o[0] - L * 0.18, o[1] + H * 0.45, o[2]], + L * 0.2, + W * 0.34, + H * 0.52, + m.body, + ), + box( + `${name} reciprocating ram head`, + 'reciprocating_ram', + [o[0] + L * 0.04, o[1] + H * 0.58, o[2]], + L * 0.38, + W * 0.16, + H * 0.1, + m.dark, + ), + box( + `${name} clapper box`, + 'clapper_box', + [o[0] + L * 0.24, o[1] + H * 0.48, o[2]], + L * 0.07, + W * 0.1, + H * 0.11, + m.metal, + ), + box( + `${name} single point cutting tool`, + 'tool_head', + [o[0] + L * 0.27, o[1] + H * 0.39, o[2]], + L * 0.035, + W * 0.06, + H * 0.14, + m.metal, + ), + cylinder( + `${name} ram stroke handwheel`, + 'feed_handwheel', + [o[0] - L * 0.28, o[1] + H * 0.38, o[2] - W * 0.22], + 'z', + Math.min(W, H) * 0.045, + W * 0.02, + m.metal, + ), + panel( + `${name} control panel`, + 'control_panel', + [o[0] + L * 0.32, o[1] + H * 0.4, o[2] - W * 0.26], + L * 0.085, + W * 0.016, + H * 0.2, + m.accent, + [0, -0.16, 0], + ), + ] +} + +function bedColumn(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const ctx = machineToolContext(entry, input) + if (entry.variant === 'drill_press') return drillPressShapes(ctx) + if (entry.variant === 'planer') return planerShapes(ctx) + if (entry.variant === 'shaper') return shaperShapes(ctx) + const base = bedColumnBase(ctx) + switch (entry.variant) { + case 'milling_machine': + return [...base, ...millingDetails(ctx)] + case 'grinder': + return [...base, ...grinderDetails(ctx)] + default: + return [...base, ...machiningCenterDetails(ctx)] + } +} + +function gantryTable(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const m = materialPalette(params) + const o = positionFor(params, input) + const name = nameFor(input, entry.label) + const head = + entry.variant === 'plasma_cutter' + ? 'plasma torch head' + : entry.variant === 'wire_edm' + ? 'wire head' + : 'laser head' + return [ + box( + `${name} cutting table`, + 'cutting_table', + [o[0], o[1] + H * 0.18, o[2]], + L * 0.82, + W * 0.72, + H * 0.12, + m.body, + ), + box( + `${name} slatted work bed`, + 'work_table', + [o[0], o[1] + H * 0.26, o[2]], + L * 0.7, + W * 0.56, + H * 0.025, + m.dark, + 0, + ), + box( + `${name} gantry beam`, + 'gantry_frame', + [o[0], o[1] + H * 0.62, o[2]], + L * 0.76, + W * 0.055, + H * 0.075, + m.metal, + ), + box( + `${name} left gantry upright`, + 'gantry_frame', + [o[0] - L * 0.36, o[1] + H * 0.46, o[2]], + L * 0.04, + W * 0.07, + H * 0.32, + m.metal, + 0, + ), + box( + `${name} right gantry upright`, + 'gantry_frame', + [o[0] + L * 0.36, o[1] + H * 0.46, o[2]], + L * 0.04, + W * 0.07, + H * 0.32, + m.metal, + 0, + ), + cylinder( + `${name} ${head}`, + 'laser_head', + [o[0] + L * 0.08, o[1] + H * 0.48, o[2]], + 'y', + Math.min(W, H) * 0.035, + H * 0.17, + m.dark, + ), + panel( + `${name} protective lid`, + 'guard_panel', + [o[0], o[1] + H * 0.42, o[2] - W * 0.39], + L * 0.72, + W * 0.014, + H * 0.27, + m.glass, + ), + panel( + `${name} control panel`, + 'control_panel', + [o[0] + L * 0.45, o[1] + H * 0.36, o[2] - W * 0.25], + L * 0.09, + W * 0.018, + H * 0.24, + m.accent, + ), + ] +} + +function injectionClamp(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const m = materialPalette(params) + const o = positionFor(params, input) + const name = nameFor(input, entry.label) + return [ + box( + `${name} long machine base`, + 'machine_base', + [o[0], o[1] + H * 0.08, o[2]], + L * 0.84, + W * 0.46, + H * 0.14, + m.body, + ), + cylinder( + `${name} injection barrel`, + 'injection_unit', + [o[0] - L * 0.22, o[1] + H * 0.32, o[2]], + 'x', + Math.min(W, H) * 0.055, + L * 0.32, + m.metal, + ), + { + kind: 'frustum', + name: `${name} material hopper`, + semanticRole: 'hopper', + position: [o[0] - L * 0.32, o[1] + H * 0.54, o[2]], + axis: 'y', + radiusTop: Math.min(W, H) * 0.105, + radiusBottom: Math.min(W, H) * 0.045, + height: H * 0.18, + radialSegments: 28, + material: m.body, + }, + box( + `${name} mold clamp frame`, + 'press_frame', + [o[0] + L * 0.14, o[1] + H * 0.36, o[2]], + L * 0.2, + W * 0.36, + H * 0.34, + m.dark, + ), + box( + `${name} moving platen`, + 'platen', + [o[0] + L * 0.04, o[1] + H * 0.36, o[2]], + L * 0.025, + W * 0.32, + H * 0.27, + m.metal, + 0, + ), + box( + `${name} fixed platen`, + 'platen', + [o[0] + L * 0.24, o[1] + H * 0.36, o[2]], + L * 0.025, + W * 0.32, + H * 0.27, + m.metal, + 0, + ), + panel( + `${name} safety guard door`, + 'guard_panel', + [o[0] + L * 0.14, o[1] + H * 0.36, o[2] - W * 0.25], + L * 0.22, + W * 0.018, + H * 0.28, + m.glass, + ), + panel( + `${name} control panel`, + 'control_panel', + [o[0] + L * 0.4, o[1] + H * 0.36, o[2] - W * 0.28], + L * 0.09, + W * 0.022, + H * 0.23, + m.accent, + ), + ] as PrimitiveShapeInput[] +} + +function pressFrame(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const m = materialPalette(params) + const o = positionFor(params, input) + const name = nameFor(input, entry.label) + const shapes: PrimitiveShapeInput[] = [ + box( + `${name} lower platen`, + 'press_bed', + [o[0], o[1] + H * 0.07, o[2]], + L * 0.68, + W * 0.62, + H * 0.07, + m.dark, + ), + box( + `${name} upper crown`, + 'press_frame', + [o[0], o[1] + H * 0.86, o[2]], + L * 0.68, + W * 0.62, + H * 0.075, + m.body, + ), + cylinder( + `${name} hydraulic cylinder`, + 'hydraulic_cylinder', + [o[0], o[1] + H * 0.75, o[2]], + 'y', + Math.min(W, H) * 0.075, + H * 0.2, + m.metal, + ), + box( + `${name} ram plate`, + 'ram', + [o[0], o[1] + H * 0.5, o[2]], + L * 0.36, + W * 0.38, + H * 0.04, + m.metal, + ), + panel( + `${name} control pendant`, + 'control_panel', + [o[0] + L * 0.43, o[1] + H * 0.48, o[2] - W * 0.35], + L * 0.12, + W * 0.025, + H * 0.18, + m.accent, + ), + ] + for (const x of [-0.32, 0.32]) { + for (const z of [-0.28, 0.28]) { + shapes.push( + cylinder( + `${name} press frame column`, + 'press_frame', + [o[0] + x * L, o[1] + H * 0.47, o[2] + z * W], + 'y', + Math.min(W, H) * 0.025, + H * 0.72, + m.metal, + ), + ) + } + } + return shapes +} + +function conveyor(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const isRoller = entry.variant === 'roller_conveyor' + const shapes = composePartPrimitives({ + name: nameFor(input, entry.label), + position: params.position ?? input.position, + detail: detailFor(params) ?? 'medium', + primaryColor: colorFor(params, '#64748b'), + metalColor: stringValue(params.metalColor, '#cbd5e1'), + darkColor: stringValue(params.darkColor, '#1f2937'), + enhanceVisualDetails: true, + parts: [ + { + kind: 'conveyor_frame', + position: [0, H * 0.38, 0], + length: L * 0.9, + width: W * 0.72, + height: H * 0.68, + radius: Math.min(W, H) * 0.035, + }, + { + kind: 'roller_array', + position: [0, H * 0.55, 0], + length: L * 0.82, + width: W * 0.66, + radius: Math.min(W, H) * 0.035, + count: Math.max(isRoller ? 12 : 7, Math.round(L * (isRoller ? 4.2 : 2.4))), + }, + { + kind: 'belt_surface', + position: [0, H * 0.6, 0], + length: L * 0.86, + width: W * 0.64, + height: H * (isRoller ? 0.01 : 0.025), + }, + ], + }).map((shape) => + shape.sourcePartKind === 'ribbed_motor_body' + ? { ...shape, semanticRole: 'drive_motor' } + : shape.sourcePartKind === 'belt_surface' + ? { ...shape, semanticRole: 'belt_surface' } + : shape.sourcePartKind === 'roller_array' + ? { ...shape, semanticRole: 'roller_array' } + : shape.sourcePartKind === 'conveyor_frame' + ? { ...shape, semanticRole: 'conveyor_frame' } + : shape, + ) + if (entry.archetypeId === 'packaging.inline_machine' || entry.variant.includes('line')) { + const m = materialPalette(params) + const o = positionFor(params, input) + shapes.push( + box( + `${entry.label} inline station`, + 'machine_base', + [o[0], o[1] + H * 0.82, o[2] - W * 0.38], + L * 0.14, + W * 0.16, + H * 0.32, + m.body, + ), + panel( + `${entry.label} control panel`, + 'control_panel', + [o[0] + L * 0.26, o[1] + H * 0.76, o[2] - W * 0.42], + L * 0.08, + W * 0.016, + H * 0.22, + m.accent, + ), + ) + } + return shapes +} + +function rotatingMachine(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const casingRadius = Math.min(W, H) * (entry.variant === 'compressor' ? 0.19 : 0.24) + const motorRadius = Math.min(W, H) * 0.17 + return composePartPrimitives({ + name: nameFor(input, entry.label), + position: params.position ?? input.position, + detail: detailFor(params) ?? 'high', + primaryColor: colorFor(params, '#64748b'), + metalColor: stringValue(params.metalColor, '#cbd5e1'), + darkColor: stringValue(params.darkColor, '#1f2937'), + enhanceVisualDetails: true, + parts: [ + { kind: 'skid_base', length: L * 0.95, width: W * 0.82, height: H * 0.09 }, + { + kind: 'ribbed_motor_body', + position: [-L * 0.24, H * 0.48, 0], + axis: 'x', + length: L * 0.42, + radius: motorRadius, + }, + { kind: 'volute_casing', position: [L * 0.22, H * 0.5, 0], radius: casingRadius }, + { + kind: 'inlet_port', + position: [L * 0.22, H * 0.5, W * 0.44], + axis: 'z', + radius: Math.min(W, H) * 0.09, + }, + { + kind: 'outlet_port', + position: [L * 0.36, H * 0.72, W * 0.04], + axis: 'x', + radius: Math.min(W, H) * 0.075, + }, + { + kind: 'flange_ring', + position: [L * 0.22, H * 0.5, W * 0.62], + axis: 'z', + radius: Math.min(W, H) * 0.14, + }, + ], + }).map((shape) => + shape.sourcePartKind === 'volute_casing' + ? { ...shape, semanticRole: 'pump_casing' } + : shape.sourcePartKind === 'ribbed_motor_body' + ? { ...shape, semanticRole: 'motor_body' } + : shape.sourcePartKind === 'skid_base' + ? { ...shape, semanticRole: 'machine_base' } + : shape, + ) +} + +function horizontalCylinder( + entry: IndustrialArchetypeEntry, + input: IndustrialArchetypeComposeInput, +) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const shellRadius = Math.min(W, H) * 0.28 + const shapes = composePartPrimitives({ + name: nameFor(input, entry.label), + position: params.position ?? input.position, + detail: detailFor(params) ?? 'high', + primaryColor: colorFor(params, '#64748b'), + metalColor: stringValue(params.metalColor, '#cbd5e1'), + darkColor: stringValue(params.darkColor, '#1f2937'), + enhanceVisualDetails: true, + parts: [ + { kind: 'heat_exchanger', position: [0, H * 0.48, 0], length: L * 0.82, radius: shellRadius }, + ], + }) + const o = positionFor(params, input) + const m = materialPalette(params) + shapes.push( + box( + `${nameFor(input, entry.label)} left saddle support`, + 'saddle_support', + [o[0] - L * 0.24, o[1] + H * 0.18, o[2]], + L * 0.08, + W * 0.26, + H * 0.16, + m.dark, + ), + box( + `${nameFor(input, entry.label)} right saddle support`, + 'saddle_support', + [o[0] + L * 0.24, o[1] + H * 0.18, o[2]], + L * 0.08, + W * 0.26, + H * 0.16, + m.dark, + ), + ) + return shapes.map((shape) => { + const name = shape.name?.toLowerCase() ?? '' + if (name.includes('saddle support')) return { ...shape, semanticRole: 'saddle_support' } + if (name.includes('top nozzle')) + return { ...shape, semanticRole: 'inlet_port', sourcePartKind: 'inlet_port' } + if (name.includes('bottom nozzle')) + return { ...shape, semanticRole: 'outlet_port', sourcePartKind: 'outlet_port' } + if (name.includes('shell')) return { ...shape, semanticRole: 'heat_exchanger_shell' } + return shape + }) +} + +function verticalVessel(entry: IndustrialArchetypeEntry, input: IndustrialArchetypeComposeInput) { + const params = industrialComposeParams(input) + const { length: L, width: W, height: H } = dims(entry.recipeId, params) + const m = materialPalette(params) + const o = positionFor(params, input) + const name = nameFor(input, entry.label) + const radius = Math.min(L, W) * 0.22 + return [ + cylinder( + `${name} vertical vessel shell`, + 'vessel_shell', + [o[0], o[1] + H * 0.48, o[2]], + 'y', + radius, + H * 0.72, + m.body, + ), + cylinder( + `${name} top manway`, + 'inlet_port', + [o[0], o[1] + H * 0.9, o[2]], + 'y', + radius * 0.26, + H * 0.05, + m.dark, + ), + cylinder( + `${name} side outlet port`, + 'outlet_port', + [o[0] + radius, o[1] + H * 0.32, o[2]], + 'x', + radius * 0.15, + radius * 0.45, + m.metal, + ), + box( + `${name} support base`, + 'support_base', + [o[0], o[1] + H * 0.05, o[2]], + radius * 1.8, + radius * 1.8, + H * 0.08, + m.dark, + ), + ] +} + +export function resolveIndustrialArchetypeEntry( + recipeId: IndustrialArchetypeRecipeId, + input: IndustrialArchetypeComposeInput = {}, +): IndustrialArchetypeEntry { + return resolveEntry(recipeId, input) +} + +export function composeIndustrialArchetype( + recipeId: IndustrialArchetypeRecipeId, + input: IndustrialArchetypeComposeInput = {}, +): PrimitiveShapeInput[] { + const entry = resolveEntry(recipeId, input) + const shapes = + entry.archetypeId === 'machine_tool.lathe_bed' + ? latheBed(entry, input) + : entry.archetypeId === 'machine_tool.bed_column' + ? bedColumn(entry, input) + : entry.archetypeId === 'machine_tool.gantry_table' + ? gantryTable(entry, input) + : entry.archetypeId === 'forming.injection_clamp' + ? injectionClamp(entry, input) + : entry.archetypeId === 'forming.press_frame' + ? pressFrame(entry, input) + : entry.archetypeId === 'material_handling.conveyor' || + entry.archetypeId === 'packaging.inline_machine' + ? conveyor(entry, input) + : entry.archetypeId === 'fluid.rotating_machine' + ? rotatingMachine(entry, input) + : entry.archetypeId === 'process.horizontal_cylinder' + ? horizontalCylinder(entry, input) + : entry.archetypeId === 'process.vertical_vessel' + ? verticalVessel(entry, input) + : [] + return tag(shapes, entry) +} + +export function industrialArchetypeBrief( + entry: IndustrialArchetypeEntry, + input: IndustrialArchetypeComposeInput, +): PrimitiveGeometryBrief { + const dimensions = dims(entry.recipeId, input) + return { + category: entry.category, + units: 'm', + coordinateConvention: '+X length, +Y up, +Z depth/width; y=0 is ground', + expectedDimensions: dimensions, + requiredRoles: entry.requiredRoles, + validationTargets: entry.validationTargets, + assumptions: [ + 'Industrial archetype library: equipment names map to reusable factory-equipment skeletons plus variant modules.', + 'The output is a readable factory-equipment silhouette, not manufacturer-specific geometry.', + 'Dimensions come from an internal real-world reference table and can be overridden by length/width/height params.', + ], + } +} diff --git a/packages/core/src/lib/industrial-archetype-registry.ts b/packages/core/src/lib/industrial-archetype-registry.ts new file mode 100644 index 000000000..3ed7805d9 --- /dev/null +++ b/packages/core/src/lib/industrial-archetype-registry.ts @@ -0,0 +1,687 @@ +export type IndustrialArchetypeRecipeId = + | 'machineTool.lathe' + | 'machineTool.machiningCenter' + | 'machineTool.laserCutter' + | 'forming.injectionMolding' + | 'forming.hydraulicPress' + | 'materialHandling.beltConveyor' + | 'fluidMachine.centrifugalPump' + | 'process.heatExchanger' + +export type IndustrialArchetypeId = + | 'machine_tool.lathe_bed' + | 'machine_tool.bed_column' + | 'machine_tool.gantry_table' + | 'forming.injection_clamp' + | 'forming.press_frame' + | 'material_handling.conveyor' + | 'fluid.rotating_machine' + | 'process.horizontal_cylinder' + | 'process.vertical_vessel' + | 'packaging.inline_machine' + +export type IndustrialVariantId = + | 'cnc_lathe' + | 'turning_machine' + | 'thread_rolling_machine' + | 'machining_center' + | 'milling_machine' + | 'drill_press' + | 'grinder' + | 'planer' + | 'shaper' + | 'boring_machine' + | 'laser_cutter' + | 'plasma_cutter' + | 'wire_edm' + | 'injection_molding' + | 'die_casting' + | 'vulcanizing_press' + | 'hydraulic_press' + | 'punch_press' + | 'forging_press' + | 'press_fit_machine' + | 'riveting_machine' + | 'belt_conveyor' + | 'roller_conveyor' + | 'assembly_line' + | 'packaging_line' + | 'centrifugal_pump' + | 'screw_pump' + | 'gear_pump' + | 'diaphragm_pump' + | 'vacuum_pump' + | 'fan_blower' + | 'compressor' + | 'heat_exchanger' + | 'cooler' + | 'condenser' + | 'evaporator' + | 'filter_vessel' + | 'horizontal_tank' + | 'vertical_storage_tank' + | 'settling_tank' + | 'oil_water_separator' + | 'filling_machine' + | 'sealing_machine' + | 'labeling_machine' + | 'coding_machine' + +export interface IndustrialArchetypeEntry { + recipeId: IndustrialArchetypeRecipeId + archetypeId: IndustrialArchetypeId + /** @deprecated Use archetypeId. Kept so older planner text remains meaningful. */ + archetype: IndustrialArchetypeId + variant: IndustrialVariantId + aliases: string[] + label: string + category: string + requiredRoles: string[] + validationTargets: string[] +} + +function entry( + recipeId: IndustrialArchetypeRecipeId, + archetypeId: IndustrialArchetypeId, + variant: IndustrialVariantId, + label: string, + aliases: string[], + category: string, + requiredRoles: string[], + validationTargets: string[], +): IndustrialArchetypeEntry { + return { + recipeId, + archetypeId, + archetype: archetypeId, + variant, + label, + aliases: [ + ...aliases.filter((alias) => !alias.includes('?')), + ...(CHINESE_ALIASES[variant] ?? []), + ], + category, + requiredRoles, + validationTargets, + } +} + +const MACHINE_TOOL_BED_COLUMN_ROLES = [ + 'machine_base', + 'machine_column', + 'work_table', + 'spindle_head', + 'control_panel', +] + +const GANTRY_TABLE_ROLES = ['cutting_table', 'gantry_frame', 'laser_head', 'control_panel'] +const ROTATING_MACHINE_ROLES = [ + 'machine_base', + 'motor_body', + 'pump_casing', + 'inlet_port', + 'outlet_port', +] +const HORIZONTAL_CYLINDER_ROLES = [ + 'heat_exchanger_shell', + 'inlet_port', + 'outlet_port', + 'saddle_support', +] + +const CHINESE_ALIASES: Partial> = { + cnc_lathe: ['\u6570\u63a7\u8f66\u5e8a', '\u8f66\u5e8a'], + turning_machine: ['\u666e\u901a\u8f66\u5e8a', '\u5367\u5f0f\u8f66\u5e8a'], + thread_rolling_machine: ['\u6eda\u4e1d\u673a'], + machining_center: [ + '\u52a0\u5de5\u4e2d\u5fc3', + 'cnc\u673a\u5e8a', + 'cnc \u673a\u5e8a', + '\u6570\u63a7\u52a0\u5de5\u4e2d\u5fc3', + ], + milling_machine: ['\u94e3\u5e8a', '\u7acb\u5f0f\u94e3\u5e8a'], + drill_press: ['\u94bb\u5e8a', '\u6447\u81c2\u94bb\u5e8a'], + grinder: ['\u78e8\u5e8a', '\u5e73\u9762\u78e8\u5e8a'], + planer: ['\u5228\u5e8a', '\u9f99\u95e8\u5228\u5e8a'], + shaper: ['\u725b\u5934\u5228\u5e8a', '\u63d2\u5e8a'], + boring_machine: ['\u9557\u5e8a'], + laser_cutter: ['\u6fc0\u5149\u5207\u5272\u673a', '\u5207\u5272\u673a'], + plasma_cutter: ['\u7b49\u79bb\u5b50\u5207\u5272\u673a'], + wire_edm: ['\u7ebf\u5207\u5272\u673a\u5e8a', '\u7ebf\u5207\u5272'], + injection_molding: ['\u6ce8\u5851\u673a'], + die_casting: ['\u538b\u94f8\u673a'], + vulcanizing_press: ['\u786b\u5316\u673a'], + hydraulic_press: ['\u6db2\u538b\u673a', '\u538b\u529b\u673a'], + punch_press: ['\u51b2\u5e8a', '\u51b2\u538b\u673a'], + forging_press: ['\u953b\u538b\u673a'], + press_fit_machine: ['\u538b\u88c5\u673a'], + riveting_machine: ['\u94c6\u63a5\u673a'], + belt_conveyor: ['\u76ae\u5e26\u8f93\u9001\u673a', '\u8f93\u9001\u673a'], + roller_conveyor: ['\u6eda\u7b52\u8f93\u9001\u673a'], + assembly_line: ['\u88c5\u914d\u6d41\u6c34\u7ebf', '\u6d41\u6c34\u7ebf'], + packaging_line: ['\u5305\u88c5\u6d41\u6c34\u7ebf'], + centrifugal_pump: ['\u79bb\u5fc3\u6cf5', '\u6cf5'], + screw_pump: ['\u87ba\u6746\u6cf5'], + gear_pump: ['\u9f7f\u8f6e\u6cf5'], + diaphragm_pump: ['\u9694\u819c\u6cf5'], + vacuum_pump: ['\u771f\u7a7a\u6cf5', '\u771f\u7a7a\u673a\u7ec4'], + fan_blower: ['\u98ce\u673a', '\u9f13\u98ce\u673a'], + compressor: ['\u538b\u7f29\u673a'], + heat_exchanger: ['\u6362\u70ed\u5668', '\u52a0\u70ed\u5668'], + cooler: ['\u51b7\u5374\u5668'], + condenser: ['\u51b7\u51dd\u5668'], + evaporator: ['\u84b8\u53d1\u5668'], + filter_vessel: ['\u8fc7\u6ee4\u5668'], + vertical_storage_tank: ['\u50a8\u7f50', '\u7acb\u7f50'], + settling_tank: ['\u6c89\u964d\u7f50'], + oil_water_separator: ['\u6cb9\u6c34\u5206\u79bb\u5668'], + filling_machine: ['\u704c\u88c5\u673a'], + sealing_machine: ['\u5c01\u53e3\u673a'], + labeling_machine: ['\u8d34\u6807\u673a'], + coding_machine: ['\u55b7\u7801\u673a'], +} + +export const INDUSTRIAL_ARCHETYPE_ENTRIES: IndustrialArchetypeEntry[] = [ + entry( + 'machineTool.lathe', + 'machine_tool.lathe_bed', + 'cnc_lathe', + 'CNC lathe', + ['cnc lathe', 'lathe', 'turning machine', '\u6570\u63a7\u8f66\u5e8a', '\u8f66\u5e8a'], + 'machine_tool', + ['machine_bed', 'headstock', 'spindle_chuck', 'tool_post', 'control_panel'], + ['lathe bed/headstock/chuck/tool post/control panel'], + ), + entry( + 'machineTool.lathe', + 'machine_tool.lathe_bed', + 'turning_machine', + 'Turning machine', + ['turning center', 'turning machine', '\u8f66\u524a\u4e2d\u5fc3', '\u8f66\u5e8a'], + 'machine_tool', + ['machine_bed', 'headstock', 'spindle_chuck', 'tool_post', 'control_panel'], + ['lathe bed/headstock/chuck/tool post/control panel'], + ), + entry( + 'machineTool.lathe', + 'machine_tool.lathe_bed', + 'thread_rolling_machine', + 'Thread rolling machine', + ['thread rolling machine', 'thread roller', '\u6eda\u4e1d\u673a'], + 'machine_tool', + ['machine_bed', 'headstock', 'spindle_chuck', 'tool_post', 'control_panel'], + ['lathe-like bed, opposing rolling head, tool post/control panel'], + ), + entry( + 'machineTool.machiningCenter', + 'machine_tool.bed_column', + 'machining_center', + 'CNC machining center', + [ + 'machining center', + 'cnc machine', + 'cnc milling', + 'cnc mill', + '\u52a0\u5de5\u4e2d\u5fc3', + '\u0063\u006e\u0063\u673a\u5e8a', + '\u0063\u006e\u0063\u0020\u673a\u5e8a', + '\u6570\u63a7\u673a\u5e8a', + '\u673a\u5e8a', + ], + 'machine_tool', + MACHINE_TOOL_BED_COLUMN_ROLES, + ['base, rear column, work table, spindle head, protective door'], + ), + entry( + 'machineTool.machiningCenter', + 'machine_tool.bed_column', + 'milling_machine', + 'Milling machine', + ['milling machine', 'mill machine', '??', '???'], + 'machine_tool', + [...MACHINE_TOOL_BED_COLUMN_ROLES, 'milling_cutter', 't_slot_table'], + ['base, column, T-slot work table, spindle head, milling cutter and control panel'], + ), + entry( + 'machineTool.machiningCenter', + 'machine_tool.bed_column', + 'drill_press', + 'Drilling machine', + ['drill press', 'drilling machine', 'radial drill', '??', '???'], + 'machine_tool', + [...MACHINE_TOOL_BED_COLUMN_ROLES, 'drill_bit', 'lifting_table'], + ['base, round column, radial arm/drill head, drill bit and lifting work table'], + ), + entry( + 'machineTool.machiningCenter', + 'machine_tool.bed_column', + 'grinder', + 'Grinding machine', + ['grinding machine', 'surface grinder', '??', '???'], + 'machine_tool', + [...MACHINE_TOOL_BED_COLUMN_ROLES, 'grinding_wheel', 'wheel_guard', 'magnetic_chuck'], + ['base, column, magnetic chuck table, grinding wheel, wheel guard and control panel'], + ), + entry( + 'machineTool.machiningCenter', + 'machine_tool.bed_column', + 'planer', + 'Planer', + ['planer', 'planing machine', 'gantry planer', 'metal planer'], + 'machine_tool', + ['machine_base', 'work_table', 'cross_rail', 'reciprocating_ram', 'tool_head', 'control_panel'], + ['long bed, traveling work table, cross rail, reciprocating ram and single-point tool head'], + ), + entry( + 'machineTool.machiningCenter', + 'machine_tool.bed_column', + 'shaper', + 'Shaper', + ['shaper', 'shaping machine', 'slotter', 'slotting machine'], + 'machine_tool', + [ + 'machine_base', + 'work_table', + 'reciprocating_ram', + 'clapper_box', + 'tool_head', + 'control_panel', + ], + ['short base, reciprocating ram head, clapper box, vise table and single-point cutting tool'], + ), + entry( + 'machineTool.machiningCenter', + 'machine_tool.bed_column', + 'boring_machine', + 'Boring machine', + ['boring machine', '??'], + 'machine_tool', + MACHINE_TOOL_BED_COLUMN_ROLES, + ['base, column, work table, boring spindle and control panel'], + ), + entry( + 'machineTool.laserCutter', + 'machine_tool.gantry_table', + 'laser_cutter', + 'Laser cutter', + [ + 'laser cutter', + 'laser cutting machine', + '\u6fc0\u5149\u5207\u5272\u673a', + '\u6fc0\u5149\u673a', + ], + 'machine_tool', + GANTRY_TABLE_ROLES, + ['cutting bed, gantry, laser head, protective panel'], + ), + entry( + 'machineTool.laserCutter', + 'machine_tool.gantry_table', + 'plasma_cutter', + 'Plasma cutting machine', + ['plasma cutter', 'plasma cutting machine', '\u7b49\u79bb\u5b50\u5207\u5272\u673a'], + 'machine_tool', + GANTRY_TABLE_ROLES, + ['cutting bed, gantry, plasma torch head, protective panel'], + ), + entry( + 'machineTool.laserCutter', + 'machine_tool.gantry_table', + 'wire_edm', + 'Wire EDM machine', + [ + 'wire edm', + 'wire cutting machine', + 'wire cut', + '\u7ebf\u5207\u5272\u673a', + '\u7535\u706b\u82b1', + ], + 'machine_tool', + GANTRY_TABLE_ROLES, + ['cutting bed, gantry/wire bridge, cutting head and control panel'], + ), + entry( + 'forming.injectionMolding', + 'forming.injection_clamp', + 'injection_molding', + 'Injection molding machine', + ['injection molding machine', 'injection molder', '???'], + 'forming_machine', + ['machine_base', 'injection_unit', 'hopper', 'press_frame', 'control_panel'], + ['long base, injection barrel, hopper, mold clamp frame'], + ), + entry( + 'forming.injectionMolding', + 'forming.injection_clamp', + 'die_casting', + 'Die casting machine', + ['die casting machine', '???'], + 'forming_machine', + ['machine_base', 'injection_unit', 'hopper', 'press_frame', 'control_panel'], + ['long base, injection sleeve, clamp frame and control panel'], + ), + entry( + 'forming.injectionMolding', + 'forming.injection_clamp', + 'vulcanizing_press', + 'Vulcanizing machine', + ['vulcanizing machine', 'vulcanizing press', '???'], + 'forming_machine', + ['machine_base', 'injection_unit', 'hopper', 'press_frame', 'control_panel'], + ['long machine base, heated clamp frame and control panel'], + ), + entry( + 'forming.hydraulicPress', + 'forming.press_frame', + 'hydraulic_press', + 'Hydraulic press', + ['hydraulic press', 'press machine', '???', '???'], + 'forming_machine', + ['press_frame', 'hydraulic_cylinder', 'press_bed', 'ram', 'control_panel'], + ['four-column press frame, hydraulic cylinder, ram and bed'], + ), + entry( + 'forming.hydraulicPress', + 'forming.press_frame', + 'punch_press', + 'Punch press', + ['punch press', 'stamping press', '??', '???'], + 'forming_machine', + ['press_frame', 'hydraulic_cylinder', 'press_bed', 'ram', 'control_panel'], + ['press frame, ram, bed and control panel'], + ), + entry( + 'forming.hydraulicPress', + 'forming.press_frame', + 'forging_press', + 'Forging press', + ['forging press', '???'], + 'forming_machine', + ['press_frame', 'hydraulic_cylinder', 'press_bed', 'ram', 'control_panel'], + ['heavy press frame, ram, bed and control panel'], + ), + entry( + 'forming.hydraulicPress', + 'forming.press_frame', + 'press_fit_machine', + 'Press-fit machine', + ['press fit machine', 'press fitting machine', '???'], + 'forming_machine', + ['press_frame', 'hydraulic_cylinder', 'press_bed', 'ram', 'control_panel'], + ['compact press frame, ram, bed and control pendant'], + ), + entry( + 'forming.hydraulicPress', + 'forming.press_frame', + 'riveting_machine', + 'Riveting machine', + ['riveting machine', '???'], + 'forming_machine', + ['press_frame', 'hydraulic_cylinder', 'press_bed', 'ram', 'control_panel'], + ['compact press frame, riveting ram, bed and control panel'], + ), + entry( + 'materialHandling.beltConveyor', + 'material_handling.conveyor', + 'belt_conveyor', + 'Belt conveyor', + ['belt conveyor', 'conveyor', '\u76ae\u5e26\u8f93\u9001\u673a', '\u8f93\u9001\u673a'], + 'material_handling', + ['conveyor_frame', 'belt_surface', 'roller_array', 'drive_motor'], + ['conveyor frame, belt, rollers, drive motor'], + ), + entry( + 'materialHandling.beltConveyor', + 'material_handling.conveyor', + 'roller_conveyor', + 'Roller conveyor', + ['roller conveyor', '\u6eda\u7b52\u8f93\u9001\u673a'], + 'material_handling', + ['conveyor_frame', 'belt_surface', 'roller_array', 'drive_motor'], + ['conveyor frame, dense rollers and drive motor'], + ), + entry( + 'materialHandling.beltConveyor', + 'material_handling.conveyor', + 'assembly_line', + 'Assembly line', + ['assembly line', '\u88c5\u914d\u6d41\u6c34\u7ebf', '\u88c5\u914d\u7ebf'], + 'material_handling', + ['conveyor_frame', 'belt_surface', 'roller_array', 'drive_motor'], + ['long conveyor frame, belt/rollers and station modules'], + ), + entry( + 'materialHandling.beltConveyor', + 'material_handling.conveyor', + 'packaging_line', + 'Packaging line', + ['packaging line', '???'], + 'material_handling', + ['conveyor_frame', 'belt_surface', 'roller_array', 'drive_motor'], + ['conveyor frame, belt/rollers and packaging stations'], + ), + entry( + 'fluidMachine.centrifugalPump', + 'fluid.rotating_machine', + 'centrifugal_pump', + 'Centrifugal pump', + ['centrifugal pump', 'water pump', 'pump', '???', '?'], + 'fluid_machine', + ROTATING_MACHINE_ROLES, + ['skid, ribbed motor, volute casing, inlet and outlet ports'], + ), + entry( + 'fluidMachine.centrifugalPump', + 'fluid.rotating_machine', + 'screw_pump', + 'Screw pump', + ['screw pump', '???'], + 'fluid_machine', + ROTATING_MACHINE_ROLES, + ['skid, ribbed motor, elongated pump casing, inlet and outlet ports'], + ), + entry( + 'fluidMachine.centrifugalPump', + 'fluid.rotating_machine', + 'gear_pump', + 'Gear pump', + ['gear pump', '???'], + 'fluid_machine', + ROTATING_MACHINE_ROLES, + ['skid, ribbed motor, compact pump casing, inlet and outlet ports'], + ), + entry( + 'fluidMachine.centrifugalPump', + 'fluid.rotating_machine', + 'diaphragm_pump', + 'Diaphragm pump', + ['diaphragm pump', '???'], + 'fluid_machine', + ROTATING_MACHINE_ROLES, + ['skid, motor body, double pump chamber, inlet and outlet ports'], + ), + entry( + 'fluidMachine.centrifugalPump', + 'fluid.rotating_machine', + 'vacuum_pump', + 'Vacuum pump', + ['vacuum pump', 'vacuum unit', '???', '???'], + 'fluid_machine', + ROTATING_MACHINE_ROLES, + ['skid, ribbed motor, pump chamber and inlet/outlet ports'], + ), + entry( + 'fluidMachine.centrifugalPump', + 'fluid.rotating_machine', + 'fan_blower', + 'Industrial fan / blower', + ['industrial fan', 'blower', 'fan blower', '??', '???'], + 'fluid_machine', + ROTATING_MACHINE_ROLES, + ['skid, motor body, volute blower casing and outlet port'], + ), + entry( + 'fluidMachine.centrifugalPump', + 'fluid.rotating_machine', + 'compressor', + 'Compressor', + ['compressor', 'air compressor', '???'], + 'fluid_machine', + ROTATING_MACHINE_ROLES, + ['skid, motor body, compressor casing and inlet/outlet ports'], + ), + entry( + 'process.heatExchanger', + 'process.horizontal_cylinder', + 'heat_exchanger', + 'Shell-and-tube heat exchanger', + ['heat exchanger', '???', '???'], + 'process_equipment', + HORIZONTAL_CYLINDER_ROLES, + ['cylindrical shell, tube-sheet caps, ports and saddle supports'], + ), + entry( + 'process.heatExchanger', + 'process.horizontal_cylinder', + 'cooler', + 'Cooler', + ['cooler', '???'], + 'process_equipment', + HORIZONTAL_CYLINDER_ROLES, + ['horizontal shell, ports and saddle supports'], + ), + entry( + 'process.heatExchanger', + 'process.horizontal_cylinder', + 'condenser', + 'Condenser', + ['condenser', '???'], + 'process_equipment', + HORIZONTAL_CYLINDER_ROLES, + ['horizontal shell, top/bottom ports and saddle supports'], + ), + entry( + 'process.heatExchanger', + 'process.horizontal_cylinder', + 'evaporator', + 'Evaporator', + ['evaporator', '???'], + 'process_equipment', + HORIZONTAL_CYLINDER_ROLES, + ['horizontal shell, nozzles and saddle supports'], + ), + entry( + 'process.heatExchanger', + 'process.horizontal_cylinder', + 'filter_vessel', + 'Filter vessel', + ['filter', 'industrial filter', '???'], + 'process_equipment', + HORIZONTAL_CYLINDER_ROLES, + ['horizontal pressure vessel, ports and saddle supports'], + ), + entry( + 'process.heatExchanger', + 'process.vertical_vessel', + 'vertical_storage_tank', + 'Vertical storage tank', + ['storage tank', 'vertical tank', '??', '??'], + 'process_equipment', + ['vessel_shell', 'inlet_port', 'outlet_port', 'support_base'], + ['vertical cylindrical shell, top/bottom ports and base support'], + ), + entry( + 'process.heatExchanger', + 'process.vertical_vessel', + 'settling_tank', + 'Settling tank', + ['settling tank', '???'], + 'process_equipment', + ['vessel_shell', 'inlet_port', 'outlet_port', 'support_base'], + ['vertical vessel shell, liquid zone, inlet/outlet ports and base'], + ), + entry( + 'process.heatExchanger', + 'process.vertical_vessel', + 'oil_water_separator', + 'Oil-water separator', + ['oil water separator', 'oil-water separator', '\u6cb9\u6c34\u5206\u79bb\u5668'], + 'process_equipment', + ['vessel_shell', 'inlet_port', 'outlet_port', 'support_base'], + ['vertical separator shell, ports and visible separation band'], + ), + entry( + 'materialHandling.beltConveyor', + 'packaging.inline_machine', + 'filling_machine', + 'Filling machine', + ['filling machine', '???'], + 'packaging_machine', + ['conveyor_frame', 'machine_base', 'control_panel'], + ['inline conveyor, filling head station and control panel'], + ), + entry( + 'materialHandling.beltConveyor', + 'packaging.inline_machine', + 'sealing_machine', + 'Sealing machine', + ['sealing machine', '???'], + 'packaging_machine', + ['conveyor_frame', 'machine_base', 'control_panel'], + ['inline conveyor, sealing head station and control panel'], + ), + entry( + 'materialHandling.beltConveyor', + 'packaging.inline_machine', + 'labeling_machine', + 'Labeling machine', + ['labeling machine', '???'], + 'packaging_machine', + ['conveyor_frame', 'machine_base', 'control_panel'], + ['inline conveyor, label head station and control panel'], + ), + entry( + 'materialHandling.beltConveyor', + 'packaging.inline_machine', + 'coding_machine', + 'Coding machine', + ['coding machine', '???'], + 'packaging_machine', + ['conveyor_frame', 'machine_base', 'control_panel'], + ['inline conveyor, coding head station and control panel'], + ), +] + +function normalize(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ' ') +} + +export function findIndustrialArchetype(request: string): IndustrialArchetypeEntry | undefined { + const text = normalize(request) + return INDUSTRIAL_ARCHETYPE_ENTRIES.map((entry) => ({ + entry, + matchLength: Math.max( + 0, + ...entry.aliases + .filter((alias) => text.includes(normalize(alias))) + .map((alias) => normalize(alias).length), + ), + })) + .sort((a, b) => b.matchLength - a.matchLength) + .find((match) => match.matchLength > 0)?.entry +} + +export function findIndustrialArchetypeByRecipeId( + recipeId: IndustrialArchetypeRecipeId, +): IndustrialArchetypeEntry | undefined { + return INDUSTRIAL_ARCHETYPE_ENTRIES.find((entry) => entry.recipeId === recipeId) +} + +export function industrialAliasesForRecipe(recipeId: IndustrialArchetypeRecipeId): string[] { + return INDUSTRIAL_ARCHETYPE_ENTRIES.filter((entry) => entry.recipeId === recipeId).flatMap( + (entry) => entry.aliases, + ) +} diff --git a/packages/core/src/lib/level-name.ts b/packages/core/src/lib/level-name.ts new file mode 100644 index 000000000..dd4f904f4 --- /dev/null +++ b/packages/core/src/lib/level-name.ts @@ -0,0 +1,11 @@ +import type { LevelNode } from '../schema' + +export function getDefaultLevelName(level: number): string { + if (level === 0) return 'Ground Floor' + if (level > 0) return `Floor ${level}` + return `Basement ${-level}` +} + +export function getLevelDisplayName(level: Pick): string { + return level.name || getDefaultLevelName(level.level) +} diff --git a/packages/core/src/lib/object-compose.test.ts b/packages/core/src/lib/object-compose.test.ts new file mode 100644 index 000000000..a53787a28 --- /dev/null +++ b/packages/core/src/lib/object-compose.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' +import { composeObjectPrimitives } from './object-compose' + +describe('composeObjectPrimitives', () => { + test('vehicle template uses rounded body boxes and x-axis wheels', () => { + const shapes = composeObjectPrimitives({ category: 'vehicle', model: 'Tesla Model Y' }) + + const lowerBody = shapes.find((shape) => shape.name?.includes('lower body')) + expect(lowerBody?.kind).toBe('box') + expect(lowerBody?.cornerRadius).toBeGreaterThan(0) + + const canopy = shapes.find((shape) => shape.name?.includes('cabin canopy')) + expect(canopy?.kind).toBe('sphere') + + const tires = shapes.filter((shape) => shape.name?.includes('tire')) + expect(tires).toHaveLength(4) + expect(tires.every((shape) => shape.axis === 'x')).toBe(true) + }) + + test('outdoor AC template includes rounded case, hollow fan grille, and fan blades', () => { + const shapes = composeObjectPrimitives({ category: 'outdoor-ac' }) + + const caseShape = shapes.find((shape) => shape.name?.includes('metal case')) + expect(caseShape?.kind).toBe('box') + expect(caseShape?.cornerRadius).toBeGreaterThan(0) + + const fanGrille = shapes.find((shape) => shape.name?.includes('circular fan grille')) + expect(fanGrille?.kind).toBe('cylinder') + expect(fanGrille?.wallThickness).toBeGreaterThan(0) + + const blades = shapes.filter((shape) => shape.name?.includes('fan blade')) + expect(blades).toHaveLength(4) + expect(blades.every((shape) => shape.kind === 'box' && (shape.cornerRadius ?? 0) > 0)).toBe( + true, + ) + }) + + test('sofa template uses soft capsule arms and rounded cushions', () => { + const shapes = composeObjectPrimitives({ category: 'sofa' }) + expect(shapes.some((shape) => shape.kind === 'capsule' && shape.name?.includes('arm'))).toBe( + true, + ) + expect(shapes.some((shape) => shape.kind === 'rounded-panel')).toBe(true) + }) + + test('keyboard template uses rounded keycaps and a sweep cable', () => { + const shapes = composeObjectPrimitives({ category: 'keyboard' }) + const keys = shapes.filter((shape) => shape.name?.includes('key ')) + expect(keys.length).toBeGreaterThan(12) + expect(keys.every((shape) => shape.kind === 'rounded-panel')).toBe(true) + expect(shapes.some((shape) => shape.kind === 'sweep' && shape.name?.includes('cable'))).toBe( + true, + ) + }) + + test('monitor template uses rounded screen panels, capsule stand, and cable sweep', () => { + const shapes = composeObjectPrimitives({ category: 'monitor' }) + expect( + shapes.some((shape) => shape.kind === 'rounded-panel' && shape.name?.includes('screen')), + ).toBe(true) + expect(shapes.some((shape) => shape.kind === 'capsule' && shape.name?.includes('stand'))).toBe( + true, + ) + expect(shapes.some((shape) => shape.kind === 'sweep' && shape.name?.includes('cable'))).toBe( + true, + ) + }) +}) diff --git a/packages/core/src/lib/object-compose.ts b/packages/core/src/lib/object-compose.ts new file mode 100644 index 000000000..dd40f58b1 --- /dev/null +++ b/packages/core/src/lib/object-compose.ts @@ -0,0 +1,815 @@ +import type { PrimitiveShapeInput, Vec3 } from './primitive-compose' + +export type ObjectComposeCategory = + | 'vehicle' + | 'chair' + | 'outdoor-ac' + | 'sofa' + | 'keyboard' + | 'monitor' + | 'table' + | 'shelf' + | 'cabinet' + | 'generic' +export type ObjectComposeDetail = 'low' | 'medium' | 'high' + +export interface ObjectComposeInput { + name?: string + category?: ObjectComposeCategory | string + objectType?: ObjectComposeCategory | string + model?: string + style?: string + position?: Vec3 + width?: number + depth?: number + length?: number + height?: number + primaryColor?: string + secondaryColor?: string + bodyColor?: string + glassColor?: string + wheelColor?: string + detail?: ObjectComposeDetail | string +} + +function clamp(value: unknown, fallback: number, min: number, max: number): number { + return Math.max( + min, + Math.min(max, typeof value === 'number' && Number.isFinite(value) ? value : fallback), + ) +} + +function normalizeCategory(input: ObjectComposeInput): ObjectComposeCategory { + const text = + `${input.category ?? ''} ${input.objectType ?? ''} ${input.model ?? ''} ${input.name ?? ''} ${input.style ?? ''}`.toLowerCase() + if (/(tesla|model\s*y|model-y|car|vehicle|sedan|suv|crossover|truck|汽车|车辆|车)/.test(text)) + return 'vehicle' + if (/(chair|stool|seat|椅|凳)/.test(text)) return 'chair' + if (/(outdoor.?ac|air.?condition|ac unit|condenser|空调外机|空调|外机)/.test(text)) + return 'outdoor-ac' + if (/(sofa|couch|loveseat|沙发)/.test(text)) return 'sofa' + if (/(keyboard|keycap|键盘|按键)/.test(text)) return 'keyboard' + if (/(monitor|display|screen|显示器|屏幕)/.test(text)) return 'monitor' + if (/(table|desk|桌|台)/.test(text)) return 'table' + if (/(shelf|bookshelf|rack|架|书架)/.test(text)) return 'shelf' + if (/(cabinet|柜|橱)/.test(text)) return 'cabinet' + return 'generic' +} + +function isTeslaModelY(input: ObjectComposeInput): boolean { + const text = `${input.name ?? ''} ${input.model ?? ''}`.toLowerCase() + return text.includes('tesla') || text.includes('model y') || text.includes('model-y') +} + +function roundSegments(detail: ObjectComposeInput['detail']): number { + switch (detail) { + case 'high': + return 48 + case 'low': + return 20 + default: + return 32 + } +} + +function material( + color: string, + roughness = 0.45, + metalness = 0.05, + opacity = 1, +): PrimitiveShapeInput['material'] { + return { + properties: { + color, + roughness, + metalness, + opacity, + transparent: opacity < 1, + }, + } +} + +function composeVehicle(input: ObjectComposeInput): PrimitiveShapeInput[] { + const tesla = isTeslaModelY(input) + const length = clamp(input.length ?? input.depth, tesla ? 4.76 : 4.4, 2.0, 8.0) + const width = clamp(input.width, tesla ? 1.98 : 1.85, 1.0, 3.0) + const height = clamp(input.height, tesla ? 1.62 : 1.45, 0.8, 3.0) + const position = input.position ?? [0, 0, 0] + const baseY = position[1] + const name = input.name ?? (tesla ? 'Tesla Model Y low-poly' : 'Low-poly vehicle') + const bodyMat = material( + input.bodyColor ?? input.primaryColor ?? (tesla ? '#f4f6f8' : '#e7e7e7'), + 0.34, + 0.2, + ) + const darkBodyMat = material('#1f2933', 0.45, 0.1) + const glassMat = material(input.glassColor ?? input.secondaryColor ?? '#111827', 0.08, 0.05, 0.86) + const wheelMat = material(input.wheelColor ?? '#111111', 0.7, 0.05) + const rimMat = material('#c9ced6', 0.28, 0.55) + const lightMat = material('#f8fafc', 0.18, 0.0) + const tailMat = material('#b91c1c', 0.3, 0.0) + const segments = roundSegments(input.detail) + const wheelRadius = height * 0.22 + const wheelThickness = width * 0.13 + const wheelY = baseY + wheelRadius + const axleX = width / 2 + wheelThickness * 0.28 + const frontZ = length * 0.31 + const rearZ = -length * 0.31 + + return [ + { + kind: 'box', + name: `${name} lower body`, + position: [position[0], baseY + height * 0.43, position[2]], + length: width * 0.9, + width: length * 0.78, + height: height * 0.32, + cornerRadius: height * 0.06, + cornerSegments: 6, + material: bodyMat, + }, + { + kind: 'box', + name: `${name} sloped hood`, + position: [position[0], baseY + height * 0.56, position[2] + length * 0.24], + rotation: [-0.08, 0, 0], + length: width * 0.86, + width: length * 0.34, + height: height * 0.11, + cornerRadius: height * 0.035, + cornerSegments: 5, + material: bodyMat, + }, + { + kind: 'box', + name: `${name} rear hatch`, + position: [position[0], baseY + height * 0.67, position[2] - length * 0.24], + rotation: [0.16, 0, 0], + length: width * 0.9, + width: length * 0.31, + height: height * 0.16, + cornerRadius: height * 0.04, + cornerSegments: 5, + material: bodyMat, + }, + { + kind: 'sphere', + name: `${name} flattened cabin canopy`, + position: [position[0], baseY + height * 0.84, position[2] - length * 0.03], + radius: 1, + scale: [width * 0.39, height * 0.22, length * 0.28], + widthSegments: segments, + heightSegments: Math.max(16, Math.round(segments * 0.55)), + material: glassMat, + }, + { + kind: 'box', + name: `${name} rocker shadow`, + position: [position[0], baseY + height * 0.29, position[2]], + length: width * 0.94, + width: length * 0.72, + height: height * 0.08, + cornerRadius: height * 0.025, + cornerSegments: 4, + material: darkBodyMat, + }, + ...[ + ['front left', -axleX, frontZ], + ['front right', axleX, frontZ], + ['rear left', -axleX, rearZ], + ['rear right', axleX, rearZ], + ].flatMap(([label, x, z]) => [ + { + kind: 'cylinder' as const, + name: `${name} ${label} tire`, + position: [position[0] + (x as number), wheelY, position[2] + (z as number)] as Vec3, + axis: 'x' as const, + radius: wheelRadius, + height: wheelThickness, + radialSegments: segments, + material: wheelMat, + }, + { + kind: 'cylinder' as const, + name: `${name} ${label} rim`, + position: [position[0] + (x as number), wheelY, position[2] + (z as number)] as Vec3, + axis: 'x' as const, + radius: wheelRadius * 0.58, + height: wheelThickness * 1.04, + radialSegments: Math.max(20, Math.round(segments * 0.75)), + material: rimMat, + }, + ]), + { + kind: 'box', + name: `${name} front light bar`, + position: [position[0], baseY + height * 0.53, position[2] + length * 0.405], + length: width * 0.72, + width: length * 0.018, + height: height * 0.035, + cornerRadius: height * 0.012, + cornerSegments: 3, + material: lightMat, + }, + { + kind: 'box', + name: `${name} rear tail light bar`, + position: [position[0], baseY + height * 0.56, position[2] - length * 0.405], + length: width * 0.7, + width: length * 0.018, + height: height * 0.035, + cornerRadius: height * 0.012, + cornerSegments: 3, + material: tailMat, + }, + ] +} + +function composeChair(input: ObjectComposeInput): PrimitiveShapeInput[] { + const width = clamp(input.width, 0.55, 0.3, 1.2) + const depth = clamp(input.depth ?? input.length, 0.55, 0.3, 1.2) + const height = clamp(input.height, 0.95, 0.45, 1.8) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? 'Low-poly chair' + const wood = material(input.primaryColor ?? '#9a6a3a', 0.62, 0.02) + const cushion = material(input.secondaryColor ?? '#374151', 0.7, 0.0) + const seatY = position[1] + height * 0.46 + const legHeight = height * 0.42 + const legRadius = Math.min(width, depth) * 0.045 + const halfX = width * 0.4 + const halfZ = depth * 0.36 + + return [ + { + kind: 'box', + name: `${name} seat cushion`, + position: [position[0], seatY, position[2] + depth * 0.04], + length: width, + width: depth, + height: height * 0.1, + material: cushion, + }, + { + kind: 'box', + name: `${name} back rest`, + position: [position[0], position[1] + height * 0.72, position[2] - depth * 0.34], + rotation: [0.16, 0, 0], + length: width, + width: depth * 0.08, + height: height * 0.55, + material: wood, + }, + ...( + [ + [-halfX, -halfZ], + [halfX, -halfZ], + [-halfX, halfZ], + [halfX, halfZ], + ] as [number, number][] + ).map(([x, z], index) => ({ + kind: 'cylinder' as const, + name: `${name} leg ${index + 1}`, + position: [position[0] + x, position[1] + legHeight / 2, position[2] + z] as Vec3, + axis: 'y' as const, + radius: legRadius, + height: legHeight, + radialSegments: 16, + material: wood, + })), + { + kind: 'box', + name: `${name} front stretcher`, + position: [position[0], position[1] + legHeight * 0.48, position[2] + halfZ], + length: width * 0.82, + width: legRadius * 1.4, + height: legRadius * 1.4, + material: wood, + }, + ] +} + +function composeOutdoorAc(input: ObjectComposeInput): PrimitiveShapeInput[] { + const width = clamp(input.width, 0.9, 0.35, 2.0) + const depth = clamp(input.depth ?? input.length, 0.38, 0.18, 1.0) + const height = clamp(input.height, 0.65, 0.3, 1.5) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? 'Outdoor AC unit' + const body = material(input.primaryColor ?? input.bodyColor ?? '#e5e7eb', 0.55, 0.05) + const dark = material(input.secondaryColor ?? '#111827', 0.7, 0.02) + const grille = material('#4b5563', 0.65, 0.05) + const blade = material('#1f2937', 0.55, 0.05) + const zFront = position[2] + depth / 2 + 0.01 + const centerY = position[1] + height / 2 + const segments = roundSegments(input.detail) + const fanX = position[0] - width * 0.16 + const fanRadius = Math.min(width, height) * 0.24 + const fanZ = zFront + depth * 0.06 + + return [ + { + kind: 'box', + name: `${name} metal case`, + position: [position[0], centerY, position[2]], + length: width, + width: depth, + height, + cornerRadius: Math.min(width, depth, height) * 0.08, + cornerSegments: 5, + material: body, + }, + { + kind: 'box', + name: `${name} dark front grille panel`, + position: [position[0], centerY, zFront], + length: width * 0.78, + width: depth * 0.035, + height: height * 0.72, + cornerRadius: Math.min(width, height) * 0.035, + cornerSegments: 4, + material: dark, + }, + { + kind: 'cylinder', + name: `${name} circular fan grille`, + semanticRole: 'vent_grill', + semanticGroup: 'front_fan', + sourcePartKind: 'vent_grill', + position: [fanX, centerY, zFront + depth * 0.025], + axis: 'z', + radius: fanRadius, + height: depth * 0.05, + radialSegments: segments, + wallThickness: Math.min(width, height) * 0.035, + material: grille, + }, + ...[0, Math.PI / 2, Math.PI, (Math.PI * 3) / 2].map((angle, index) => ({ + kind: 'box' as const, + name: `${name} fan blade ${index + 1}`, + semanticRole: 'fan_blade', + semanticGroup: 'front_fan', + sourcePartKind: 'radial_blades', + editableHints: { + primaryDimension: 'length', + canScale: ['length', 'width', 'height'], + minFactor: 0.35, + maxFactor: 2.2, + }, + position: [ + fanX + Math.cos(angle) * fanRadius * 0.24, + centerY + Math.sin(angle) * fanRadius * 0.24, + fanZ, + ] as Vec3, + rotation: [0, 0, angle + 0.34] as Vec3, + length: fanRadius * 0.52, + width: depth * 0.026, + height: fanRadius * 0.11, + cornerRadius: fanRadius * 0.03, + cornerSegments: 3, + material: blade, + })), + { + kind: 'cylinder', + name: `${name} fan hub`, + semanticRole: 'fan_hub', + semanticGroup: 'front_fan', + sourcePartKind: 'radial_blades', + position: [fanX, centerY, zFront + depth * 0.07], + axis: 'z', + radius: Math.min(width, height) * 0.06, + height: depth * 0.06, + radialSegments: Math.max(20, Math.round(segments * 0.75)), + material: grille, + }, + ...[-0.24, -0.12, 0, 0.12, 0.24].map((offset, index) => ({ + kind: 'box' as const, + name: `${name} vent slat ${index + 1}`, + position: [ + position[0] + width * 0.26, + centerY + height * offset, + zFront + depth * 0.05, + ] as Vec3, + length: width * 0.28, + width: depth * 0.035, + height: height * 0.035, + cornerRadius: height * 0.01, + cornerSegments: 3, + material: grille, + })), + { + kind: 'box', + name: `${name} base feet`, + position: [position[0], position[1] + height * 0.035, position[2]], + length: width * 0.85, + width: depth * 0.82, + height: height * 0.07, + cornerRadius: height * 0.015, + cornerSegments: 3, + material: grille, + }, + ] +} + +function composeSofa(input: ObjectComposeInput): PrimitiveShapeInput[] { + const width = clamp(input.width, 2.2, 0.9, 4.0) + const depth = clamp(input.depth ?? input.length, 0.92, 0.45, 1.8) + const height = clamp(input.height, 0.82, 0.45, 1.5) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? 'Rounded sofa' + const fabric = material(input.primaryColor ?? '#7c3f2c', 0.82, 0.0) + const shadow = material(input.secondaryColor ?? '#2f1f1a', 0.9, 0.0) + const seam = material('#1f2933', 0.72, 0.0) + const y = position[1] + + return [ + { + kind: 'rounded-panel', + name: `${name} seat cushion deck`, + position: [position[0], y + height * 0.32, position[2] + depth * 0.05], + length: width, + width: depth * 0.72, + thickness: height * 0.2, + cornerRadius: Math.min(width, depth) * 0.055, + cornerSegments: 6, + material: fabric, + }, + { + kind: 'rounded-panel', + name: `${name} back cushion slab`, + position: [position[0], y + height * 0.58, position[2] - depth * 0.32], + rotation: [-0.14, 0, 0], + length: width, + width: height * 0.62, + thickness: depth * 0.13, + cornerRadius: height * 0.055, + cornerSegments: 6, + material: fabric, + }, + ...[-0.5, 0, 0.5].map((offset, index) => ({ + kind: 'rounded-panel' as const, + name: `${name} seat pad ${index + 1}`, + position: [ + position[0] + offset * width * 0.55, + y + height * 0.45, + position[2] + depth * 0.1, + ] as Vec3, + length: width * 0.29, + width: depth * 0.58, + thickness: height * 0.08, + cornerRadius: height * 0.035, + cornerSegments: 5, + material: fabric, + })), + { + kind: 'capsule', + name: `${name} left rounded arm`, + position: [position[0] - width * 0.53, y + height * 0.48, position[2] + depth * 0.04], + axis: 'z', + radius: height * 0.18, + height: depth * 0.84, + capSegments: 6, + radialSegments: roundSegments(input.detail), + material: fabric, + }, + { + kind: 'capsule', + name: `${name} right rounded arm`, + position: [position[0] + width * 0.53, y + height * 0.48, position[2] + depth * 0.04], + axis: 'z', + radius: height * 0.18, + height: depth * 0.84, + capSegments: 6, + radialSegments: roundSegments(input.detail), + material: fabric, + }, + { + kind: 'box', + name: `${name} recessed shadow base`, + position: [position[0], y + height * 0.12, position[2] + depth * 0.08], + length: width * 0.88, + width: depth * 0.62, + height: height * 0.1, + cornerRadius: height * 0.018, + cornerSegments: 3, + material: shadow, + }, + ...[-0.18, 0.18].map((offset, index) => ({ + kind: 'box' as const, + name: `${name} vertical cushion seam ${index + 1}`, + position: [ + position[0] + offset * width, + y + height * 0.48, + position[2] + depth * 0.46, + ] as Vec3, + length: width * 0.012, + width: depth * 0.018, + height: height * 0.18, + cornerRadius: height * 0.004, + cornerSegments: 2, + material: seam, + })), + ] +} + +function composeKeyboard(input: ObjectComposeInput): PrimitiveShapeInput[] { + const width = clamp(input.width, 0.72, 0.25, 1.4) + const depth = clamp(input.depth ?? input.length, 0.26, 0.12, 0.7) + const height = clamp(input.height, 0.055, 0.02, 0.2) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? 'Compact keyboard' + const base = material(input.primaryColor ?? '#111827', 0.45, 0.05) + const key = material(input.secondaryColor ?? '#e5e7eb', 0.62, 0.0) + const accent = material('#9ca3af', 0.5, 0.0) + const y = position[1] + const keyW = width * 0.085 + const keyD = depth * 0.18 + const keyH = height * 0.38 + const rows = [-0.26, 0, 0.26] + const cols = [-0.36, -0.24, -0.12, 0, 0.12, 0.24, 0.36] + + return [ + { + kind: 'rounded-panel', + name: `${name} bevelled base tray`, + position: [position[0], y + height * 0.34, position[2]], + length: width, + width: depth, + thickness: height * 0.68, + cornerRadius: Math.min(width, depth) * 0.08, + cornerSegments: 6, + material: base, + }, + ...rows.flatMap((row, rowIndex) => + cols.map((col, colIndex) => ({ + kind: 'rounded-panel' as const, + name: `${name} key ${rowIndex + 1}-${colIndex + 1}`, + position: [position[0] + col * width, y + height * 0.88, position[2] + row * depth] as Vec3, + length: keyW, + width: keyD, + thickness: keyH, + cornerRadius: Math.min(keyW, keyD) * 0.18, + cornerSegments: 4, + material: key, + })), + ), + { + kind: 'rounded-panel', + name: `${name} long spacebar`, + position: [position[0], y + height * 0.9, position[2] + depth * 0.36], + length: width * 0.38, + width: keyD, + thickness: keyH, + cornerRadius: Math.min(keyW, keyD) * 0.18, + cornerSegments: 4, + material: accent, + }, + { + kind: 'sweep', + name: `${name} cable`, + position: [position[0], y + height * 0.72, position[2] - depth * 0.62], + path: [ + [0, 0, depth * 0.12], + [0, height * 0.03, -depth * 0.1], + [width * 0.12, height * 0.02, -depth * 0.36], + ], + radius: height * 0.06, + tubularSegments: 18, + radialSegments: 8, + material: base, + }, + ] +} + +function composeMonitor(input: ObjectComposeInput): PrimitiveShapeInput[] { + const width = clamp(input.width, 0.9, 0.35, 2.0) + const height = clamp(input.height, 0.62, 0.25, 1.4) + const depth = clamp(input.depth ?? input.length, 0.12, 0.04, 0.45) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? 'Modern monitor' + const dark = material(input.primaryColor ?? '#111827', 0.42, 0.15) + const screen = material(input.secondaryColor ?? '#0f172a', 0.18, 0.0) + const metal = material('#6b7280', 0.38, 0.45) + const y = position[1] + const screenY = y + height * 0.72 + + return [ + { + kind: 'rounded-panel', + name: `${name} thin outer bezel`, + position: [position[0], screenY, position[2]], + length: width, + width: height * 0.62, + thickness: depth, + cornerRadius: Math.min(width, height) * 0.035, + cornerSegments: 6, + material: dark, + }, + { + kind: 'rounded-panel', + name: `${name} recessed dark screen`, + position: [position[0], screenY, position[2] + depth * 0.53], + length: width * 0.9, + width: height * 0.52, + thickness: depth * 0.08, + cornerRadius: Math.min(width, height) * 0.025, + cornerSegments: 5, + material: screen, + }, + { + kind: 'capsule', + name: `${name} curved neck stand`, + position: [position[0], y + height * 0.34, position[2] - depth * 0.18], + axis: 'y', + radius: width * 0.035, + height: height * 0.34, + capSegments: 5, + radialSegments: 24, + material: metal, + }, + { + kind: 'rounded-panel', + name: `${name} weighted foot base`, + position: [position[0], y + height * 0.08, position[2] - depth * 0.12], + length: width * 0.46, + width: depth * 2.4, + thickness: height * 0.08, + cornerRadius: width * 0.025, + cornerSegments: 5, + material: metal, + }, + { + kind: 'sweep', + name: `${name} rear cable`, + position: [position[0] + width * 0.16, y + height * 0.24, position[2] - depth * 0.42], + path: [ + [0, height * 0.16, 0], + [width * 0.05, 0, -depth * 0.42], + [width * 0.12, -height * 0.16, -depth * 0.64], + ], + radius: width * 0.01, + tubularSegments: 20, + radialSegments: 8, + material: dark, + }, + ] +} + +function composeTable(input: ObjectComposeInput): PrimitiveShapeInput[] { + const width = clamp(input.width, 1.2, 0.5, 4.0) + const depth = clamp(input.depth ?? input.length, 0.75, 0.4, 3.0) + const height = clamp(input.height, 0.75, 0.35, 1.4) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? 'Low-poly table' + const topMat = material(input.primaryColor ?? '#8b5a2b', 0.6, 0.02) + const legMat = material(input.secondaryColor ?? input.primaryColor ?? '#6b3f1d', 0.65, 0.02) + const topThickness = height * 0.08 + const legHeight = height - topThickness + const legRadius = Math.min(width, depth) * 0.035 + const halfX = width * 0.42 + const halfZ = depth * 0.42 + + return [ + { + kind: 'box', + name: `${name} top`, + position: [position[0], position[1] + legHeight + topThickness / 2, position[2]], + length: width, + width: depth, + height: topThickness, + material: topMat, + }, + ...( + [ + [-halfX, -halfZ], + [halfX, -halfZ], + [-halfX, halfZ], + [halfX, halfZ], + ] as [number, number][] + ).map(([x, z], index) => ({ + kind: 'cylinder' as const, + name: `${name} leg ${index + 1}`, + position: [position[0] + x, position[1] + legHeight / 2, position[2] + z] as Vec3, + axis: 'y' as const, + radius: legRadius, + height: legHeight, + radialSegments: 16, + material: legMat, + })), + ] +} + +function composeShelf(input: ObjectComposeInput, cabinet = false): PrimitiveShapeInput[] { + const width = clamp(input.width, cabinet ? 0.9 : 1.0, 0.4, 3.0) + const depth = clamp(input.depth ?? input.length, cabinet ? 0.42 : 0.3, 0.15, 1.2) + const height = clamp(input.height, cabinet ? 1.2 : 1.6, 0.5, 3.0) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? (cabinet ? 'Low-poly cabinet' : 'Low-poly shelf') + const frame = material(input.primaryColor ?? '#8b5a2b', 0.62, 0.02) + const dark = material(input.secondaryColor ?? '#374151', 0.68, 0.0) + const t = Math.min(width, height) * 0.045 + const shelves = cabinet ? [0.36, 0.68] : [0.25, 0.5, 0.75] + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${name} left side`, + position: [position[0] - width / 2 + t / 2, position[1] + height / 2, position[2]], + length: t, + width: depth, + height, + material: frame, + }, + { + kind: 'box', + name: `${name} right side`, + position: [position[0] + width / 2 - t / 2, position[1] + height / 2, position[2]], + length: t, + width: depth, + height, + material: frame, + }, + { + kind: 'box', + name: `${name} top`, + position: [position[0], position[1] + height - t / 2, position[2]], + length: width, + width: depth, + height: t, + material: frame, + }, + { + kind: 'box', + name: `${name} bottom`, + position: [position[0], position[1] + t / 2, position[2]], + length: width, + width: depth, + height: t, + material: frame, + }, + ...shelves.map((ratio, index) => ({ + kind: 'box' as const, + name: `${name} shelf ${index + 1}`, + position: [position[0], position[1] + height * ratio, position[2]] as Vec3, + length: width - t * 2, + width: depth * 0.96, + height: t, + material: frame, + })), + ] + + if (cabinet) { + shapes.push({ + kind: 'box', + name: `${name} front door hint`, + position: [position[0], position[1] + height * 0.48, position[2] + depth / 2 + t * 0.2], + length: width * 0.82, + width: t * 0.5, + height: height * 0.45, + material: dark, + }) + } + + return shapes +} + +function composeGeneric(input: ObjectComposeInput): PrimitiveShapeInput[] { + const width = clamp(input.width, 1.0, 0.1, 5.0) + const depth = clamp(input.depth ?? input.length, 1.0, 0.1, 5.0) + const height = clamp(input.height, 1.0, 0.1, 5.0) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? input.model ?? 'Low-poly object' + return [ + { + kind: 'box', + name: `${name} main volume`, + position: [position[0], position[1] + height / 2, position[2]], + length: width, + width: depth, + height, + material: material(input.primaryColor ?? input.bodyColor ?? '#d1d5db', 0.6, 0.02), + }, + ] +} + +export function composeObjectPrimitives(input: ObjectComposeInput = {}): PrimitiveShapeInput[] { + switch (normalizeCategory(input)) { + case 'vehicle': + return composeVehicle(input) + case 'chair': + return composeChair(input) + case 'outdoor-ac': + return composeOutdoorAc(input) + case 'sofa': + return composeSofa(input) + case 'keyboard': + return composeKeyboard(input) + case 'monitor': + return composeMonitor(input) + case 'table': + return composeTable(input) + case 'shelf': + return composeShelf(input) + case 'cabinet': + return composeShelf(input, true) + default: + return composeGeneric(input) + } +} diff --git a/packages/core/src/lib/orientation-utils.test.ts b/packages/core/src/lib/orientation-utils.test.ts new file mode 100644 index 000000000..3e6e387a5 --- /dev/null +++ b/packages/core/src/lib/orientation-utils.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import { + angularStep, + normalizedRadialDirection, + radialExtrudeRotationInHorizontalPlane, + radialExtrudeRotationInLocalPlane, + transformedLocalAxis, +} from './orientation-utils' + +function expectVecClose(actual: number[], expected: number[], precision = 5) { + expect(actual).toHaveLength(expected.length) + actual.forEach((value, index) => { + expect(value).toBeCloseTo(expected[index] ?? 0, precision) + }) +} + +describe('orientation utils', () => { + test('orients horizontal radial extrudes with profile X along radial and depth Z upward', () => { + for (const angle of [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]) { + const rotation = radialExtrudeRotationInHorizontalPlane(angle, 0) + + expectVecClose(transformedLocalAxis(rotation, 'x'), normalizedRadialDirection(angle)) + expectVecClose(transformedLocalAxis(rotation, 'z'), [0, 1, 0]) + expect(rotation[1]).toBeCloseTo(0) + expect(rotation[2]).toBeCloseTo(-angle) + } + }) + + test('orients local-plane radial extrudes without laying them flat', () => { + const angle = Math.PI / 3 + const rotation = radialExtrudeRotationInLocalPlane(angle, 0) + + expectVecClose(transformedLocalAxis(rotation, 'x'), [Math.cos(angle), Math.sin(angle), 0]) + expectVecClose(transformedLocalAxis(rotation, 'z'), [0, 0, 1]) + }) + + test('computes stable circular angular steps', () => { + expect(angularStep(0, 3)).toBeCloseTo(0) + expect(angularStep(1, 3)).toBeCloseTo((Math.PI * 2) / 3) + expect(angularStep(2, 3)).toBeCloseTo((Math.PI * 4) / 3) + expect(angularStep(1, 4, Math.PI / 4)).toBeCloseTo((Math.PI * 3) / 4) + }) +}) diff --git a/packages/core/src/lib/orientation-utils.ts b/packages/core/src/lib/orientation-utils.ts new file mode 100644 index 000000000..05c07c7fb --- /dev/null +++ b/packages/core/src/lib/orientation-utils.ts @@ -0,0 +1,36 @@ +import type { Vec3 } from './primitive-compose' + +export function radialExtrudeRotationInHorizontalPlane(angle: number, pitch = 0): Vec3 { + return [-Math.PI / 2 + pitch, 0, -angle] +} + +export function radialExtrudeRotationInLocalPlane(angle: number, pitch = 0): Vec3 { + return [pitch, 0, angle] +} + +export function transformedLocalAxis(rotation: Vec3, axis: 'x' | 'y' | 'z'): Vec3 { + const [rx, ry, rz] = rotation + const sx = Math.sin(rx) + const cx = Math.cos(rx) + const sy = Math.sin(ry) + const cy = Math.cos(ry) + const sz = Math.sin(rz) + const cz = Math.cos(rz) + const vector: Vec3 = axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1] + + const afterZ: Vec3 = [vector[0] * cz - vector[1] * sz, vector[0] * sz + vector[1] * cz, vector[2]] + const afterY: Vec3 = [ + afterZ[0] * cy + afterZ[2] * sy, + afterZ[1], + -afterZ[0] * sy + afterZ[2] * cy, + ] + return [afterY[0], afterY[1] * cx - afterY[2] * sx, afterY[1] * sx + afterY[2] * cx] +} + +export function normalizedRadialDirection(angle: number): Vec3 { + return [Math.cos(angle), 0, Math.sin(angle)] +} + +export function angularStep(index: number, count: number, startAngle = 0): number { + return startAngle + (index * Math.PI * 2) / Math.max(1, count) +} diff --git a/packages/core/src/lib/part-compose.test.ts b/packages/core/src/lib/part-compose.test.ts new file mode 100644 index 000000000..5991cc2bf --- /dev/null +++ b/packages/core/src/lib/part-compose.test.ts @@ -0,0 +1,2809 @@ +import { describe, expect, test } from 'bun:test' +import { + assessPartBlueprint, + assessPartVisualDetails, + composePartPrimitives, + resolveLayout, +} from './part-compose' + +function expectBladeRotationMatchesRadialPlacement(shape: { + position?: number[] + rotation?: number[] +}) { + const [x = 0, , z = 0] = shape.position ?? [] + const radialLength = Math.hypot(x, z) + expect(radialLength).toBeGreaterThan(0.001) + const expectedAngle = Math.atan2(z, x) + const actualY = shape.rotation?.[1] ?? 0 + const actualZ = shape.rotation?.[2] ?? 0 + const wrappedDelta = Math.atan2( + Math.sin(actualZ + expectedAngle), + Math.cos(actualZ + expectedAngle), + ) + expect(actualY).toBeCloseTo(0, 4) + expect(wrappedDelta).toBeCloseTo(0, 4) +} + +function expectLocalPlaneBladeRotationMatchesRadialPlacement( + shape: { + position?: number[] + rotation?: number[] + }, + center: [number, number] = [0, 0], +) { + const [x = 0, y = 0] = shape.position ?? [] + const expectedAngle = Math.atan2(y - center[1], x - center[0]) + const actualY = shape.rotation?.[1] ?? 0 + const actualZ = shape.rotation?.[2] ?? 0 + const wrappedDelta = Math.atan2( + Math.sin(actualZ - expectedAngle), + Math.cos(actualZ - expectedAngle), + ) + expect(actualY).toBeCloseTo(0, 4) + expect(wrappedDelta).toBeCloseTo(0, 4) +} + +describe('resolveLayout', () => { + test('resolves explicit part relationship plans before primitive composition', () => { + const [body, flange] = resolveLayout({ + parts: [ + { id: 'body', kind: 'valve_body', position: [0, 0.38, 0], axis: 'x', length: 0.7 }, + { + id: 'flange', + kind: 'flange_ring', + connectTo: 'body', + connectPoint: 'outlet', + childPoint: 'back', + axis: 'x', + radius: 0.12, + }, + ], + }) + + expect(body?.position).toEqual([0, 0.38, 0]) + expect(flange?.position?.[0]).toBeGreaterThan(0.35) + expect(flange?.position?.[1]).toBeCloseTo(0.38, 5) + }) + + test('builds shared rotating machine layout plans for pumps and compressors', () => { + const pump = resolveLayout( + { family: 'pump', layoutFamily: 'rotating_machine_layout' }, + [ + { kind: 'skid_base', semanticRole: 'support_base' }, + { kind: 'ribbed_motor_body', semanticRole: 'drive_motor' }, + { kind: 'volute_casing', semanticRole: 'volute_casing' }, + ], + { length: 2.2, width: 0.9, height: 1.1 }, + ) + const compressor = resolveLayout( + { family: 'compressor', layoutFamily: 'rotating_machine_layout' }, + [ + { kind: 'skid_base', semanticRole: 'support_base' }, + { kind: 'ribbed_motor_body', semanticRole: 'drive_motor' }, + { kind: 'rounded_machine_body', semanticRole: 'compressor_casing' }, + ], + { length: 2.2, width: 0.9, height: 1.1 }, + ) + + expect(pump.layoutFamily).toBe('rotating_machine_layout') + expect(compressor.layoutFamily).toBe('rotating_machine_layout') + expect(pump.anchors.map((anchor) => anchor.id)).toContain('drive') + expect( + compressor.placements.find((part) => part.semanticRole === 'drive_motor')?.anchorId, + ).toBe('drive') + }) + + test('builds shared vessel layout plans for tanks and reactors', () => { + const tank = resolveLayout( + { family: 'tank', layoutFamily: 'vessel_layout' }, + [ + { kind: 'cylindrical_tank', semanticRole: 'vessel_shell' }, + { kind: 'skid_base', semanticRole: 'support_base' }, + ], + { height: 3, diameter: 1.2 }, + ) + const reactor = resolveLayout( + { family: 'reactor', layoutFamily: 'vessel_layout' }, + [ + { kind: 'agitator_tank', semanticRole: 'vessel_shell' }, + { kind: 'platform_ladder', semanticRole: 'access_platform' }, + ], + { height: 2.4, diameter: 1.1 }, + ) + + expect(tank.layoutFamily).toBe('vessel_layout') + expect(reactor.layoutFamily).toBe('vessel_layout') + expect(tank.placements.find((part) => part.semanticRole === 'vessel_shell')?.anchorId).toBe( + 'shell', + ) + expect( + reactor.placements.find((part) => part.semanticRole === 'access_platform')?.anchorId, + ).toBe('access') + }) + + test('builds shared enclosure layout plans for packaging, CNC, and electrical equipment', () => { + const packaging = resolveLayout( + { family: 'machine_tool', layoutFamily: 'box_enclosure_layout' }, + [ + { kind: 'generic_body', semanticRole: 'machine_enclosure' }, + { kind: 'control_box', semanticRole: 'control_panel' }, + ], + { length: 2.6, width: 1, height: 1.6 }, + ) + const cnc = resolveLayout( + { family: 'machine_tool', layoutFamily: 'box_enclosure_layout' }, + [ + { kind: 'generic_body', semanticRole: 'machine_enclosure' }, + { kind: 'generic_panel', semanticRole: 'viewing_window' }, + ], + { length: 2.8, width: 1.1, height: 1.7 }, + ) + const electrical = resolveLayout( + { family: 'electrical', layoutFamily: 'box_enclosure_layout' }, + [ + { kind: 'electrical_cabinet', semanticRole: 'electrical_cabinet' }, + { kind: 'vent_slats', semanticRole: 'vent_panel' }, + ], + { length: 1.2, width: 0.5, height: 1.8 }, + ) + + expect(packaging.layoutFamily).toBe('box_enclosure_layout') + expect(cnc.layoutFamily).toBe('box_enclosure_layout') + expect(electrical.layoutFamily).toBe('box_enclosure_layout') + expect( + packaging.placements.find((part) => part.semanticRole === 'control_panel')?.anchorId, + ).toBe('controls') + expect(cnc.placements.find((part) => part.semanticRole === 'viewing_window')?.anchorId).toBe( + 'front_panel', + ) + expect(electrical.bounds.size[1]).toBeCloseTo(1.8, 5) + }) + + test('places vessel parts with internal anchors and attachToRole', () => { + const plan = resolveLayout( + { family: 'reactor', layoutFamily: 'vessel_layout' }, + [ + { kind: 'agitator_tank', semanticRole: 'reactor_vessel_shell', height: 2.4, radius: 0.55 }, + { + kind: 'flanged_nozzle', + semanticRole: 'feed_nozzle', + attachToRole: 'reactor_vessel_shell', + anchor: 'top', + offset: [0.18, 0, 0], + }, + { + kind: 'platform_ladder', + semanticRole: 'access_platform', + attachToRole: 'reactor_vessel_shell', + anchor: 'service_side', + }, + { + kind: 'skid_base', + semanticRole: 'support_base', + attachToRole: 'reactor_vessel_shell', + anchor: 'bottom', + }, + ], + { height: 2.6, diameter: 1.2 }, + ) + + const shell = plan.placements.find((part) => part.semanticRole === 'reactor_vessel_shell') + const nozzle = plan.placements.find((part) => part.semanticRole === 'feed_nozzle') + const platform = plan.placements.find((part) => part.semanticRole === 'access_platform') + const support = plan.placements.find((part) => part.semanticRole === 'support_base') + + expect(plan.anchors.map((anchor) => anchor.id)).toEqual( + expect.arrayContaining([ + 'top', + 'bottom', + 'front', + 'back', + 'left', + 'right', + 'shell_center', + 'drive_side', + 'service_side', + ]), + ) + expect(nozzle?.position[1]).toBeGreaterThan(shell?.position[1] ?? 0) + expect(nozzle?.position[0]).toBeCloseTo(0.18, 5) + expect(platform?.position[2]).toBeGreaterThan(shell?.position[2] ?? 0) + expect(support?.position[1]).toBeLessThan(shell?.position[1] ?? 0) + }) + + test('aligns rotating equipment rings, rollers, and drive units by role', () => { + const plan = resolveLayout( + { family: 'tank', layoutFamily: 'rotating_machine_layout' }, + [ + { + kind: 'cylindrical_tank', + semanticRole: 'vessel_shell', + length: 4.8, + radius: 0.55, + axis: 'x', + }, + { + id: 'riding-ring', + kind: 'flange_ring', + semanticRole: 'riding_ring', + attachToRole: 'vessel_shell', + anchor: 'shell_center', + arrayAlong: 'length', + count: 2, + }, + { + id: 'support-roller', + kind: 'bearing_block', + semanticRole: 'support_roller', + attachToRole: 'vessel_shell', + anchor: 'bottom', + arrayAlong: 'length', + count: 2, + }, + { + kind: 'motor_gearbox_unit', + semanticRole: 'kiln_drive_unit', + attachToRole: 'vessel_shell', + anchor: 'drive_side', + }, + ], + { length: 5, width: 1.4, height: 1.2, diameter: 1.1 }, + ) + + const shell = plan.placements.find((part) => part.semanticRole === 'vessel_shell') + const rings = plan.placements.filter((part) => part.semanticRole === 'riding_ring') + const rollers = plan.placements.filter((part) => part.semanticRole === 'support_roller') + const drive = plan.placements.find((part) => part.semanticRole === 'kiln_drive_unit') + + expect(rings).toHaveLength(2) + expect(rollers).toHaveLength(2) + expect(rings[0]?.position[0]).toBeLessThan(rings[1]?.position[0] ?? 0) + expect(rollers[0]?.position[0]).toBeCloseTo(rings[0]?.position[0] ?? 0, 5) + expect(drive?.position[0]).toBeLessThan(shell?.position[0] ?? 0) + }) +}) + +describe('industrial detail parts', () => { + test('emits bottom-layer contracts for ports, ducts, cutouts, and patterns', () => { + const shapes = composePartPrimitives({ + name: 'industrial contract kit', + family: 'generic', + parts: [ + { id: 'pipe', kind: 'pipe_run', axis: 'x', length: 1.2, radius: 0.06 }, + { id: 'elbow', kind: 'pipe_elbow', position: [0, 0.4, 0], radius: 0.05 }, + { id: 'rollers', kind: 'roller_array', count: 4, length: 1.2 }, + { id: 'bolts', kind: 'bolt_pattern', count: 6, radius: 0.2 }, + { id: 'cabinet', kind: 'electrical_cabinet', position: [1, 0.5, 0], doorCount: 2 }, + ], + }) + + const pipe = shapes.find( + (shape) => shape.name?.includes('pipe run') && shape.kind === 'hollow-cylinder', + ) + const elbow = shapes.find( + (shape) => shape.name?.includes('pipe elbow') && shape.kind === 'sweep', + ) + const roller = shapes.find((shape) => shape.name?.includes('conveyor roller')) + const bolt = shapes.find((shape) => shape.name?.includes('bolt 1')) + const cabinet = shapes.find((shape) => shape.name?.includes('electrical cabinet body')) + + expect(pipe?.duct).toMatchObject({ crossSection: 'round', radius: 0.06 }) + expect(pipe?.ports).toHaveLength(2) + expect(elbow?.duct).toMatchObject({ crossSection: 'round', radius: 0.05 }) + expect(elbow?.ports?.map((port) => port.kind)).toEqual(['inlet', 'outlet']) + expect(roller?.pattern).toMatchObject({ kind: 'linear', count: 4, mode: 'expanded' }) + expect(bolt?.pattern).toMatchObject({ kind: 'radial', count: 6, mode: 'expanded' }) + expect(cabinet?.cutouts?.map((cutout) => cutout.semanticRole)).toEqual( + expect.arrayContaining(['access_door', 'nameplate', 'vent']), + ) + expect(cabinet?.ports?.[0]).toMatchObject({ kind: 'access', semanticRole: 'access_door' }) + }) + + test('composes process-vessel details without falling back to generic pipe ports', () => { + const shapes = composePartPrimitives({ + name: 'test process vessel', + family: 'generic', + length: 1.2, + width: 1.2, + height: 1.8, + parts: [ + { + kind: 'manway_lid', + semanticRole: 'offset_manway_lid', + position: [0.25, 1.72, 0], + axis: 'y', + }, + { + kind: 'sanitary_nozzle', + semanticRole: 'top_feed_nozzle', + position: [-0.25, 1.76, 0], + axis: 'y', + }, + { + kind: 'jacket_shell', + semanticRole: 'thermal_jacket', + radius: 0.58, + height: 1.15, + position: [0, 0.78, 0], + }, + { kind: 'sight_glass', semanticRole: 'front_sight_glass', side: 'front' }, + { kind: 'sample_valve', semanticRole: 'sample_valve', side: 'right' }, + { + kind: 'instrument_port', + semanticRole: 'temperature_probe', + position: [0, 1.95, 0.16], + axis: 'y', + }, + { + kind: 'stainless_highlight_panel', + semanticRole: 'polished_shell_highlight', + side: 'front', + }, + ], + }) + + const roles = new Set(shapes.map((shape) => shape.semanticRole)) + const sourceKinds = new Set(shapes.map((shape) => shape.sourcePartKind)) + + expect([...sourceKinds]).toEqual( + expect.arrayContaining([ + 'manway_lid', + 'sanitary_nozzle', + 'jacket_shell', + 'sight_glass', + 'sample_valve', + 'instrument_port', + 'stainless_highlight_panel', + ]), + ) + expect([...roles]).toEqual( + expect.arrayContaining([ + 'offset_manway_lid', + 'top_feed_nozzle', + 'thermal_jacket', + 'front_sight_glass', + 'sample_valve', + 'temperature_probe', + 'polished_shell_highlight', + ]), + ) + expect(shapes.some((shape) => shape.semanticRole === 'inlet_port')).toBe(false) + }) + + test('composes reusable industrial utility details for profile packs', () => { + const shapes = composePartPrimitives({ + name: 'industrial detail kit', + family: 'generic', + length: 1.4, + width: 1.1, + height: 1.6, + parts: [ + { + kind: 'flanged_nozzle', + semanticRole: 'side_flanged_nozzle', + side: 'front', + radius: 0.08, + length: 0.25, + }, + { + kind: 'inspection_hatch', + semanticRole: 'front_inspection_hatch', + side: 'front', + radius: 0.16, + }, + { + kind: 'conical_hopper', + semanticRole: 'bottom_conical_hopper', + radiusTop: 0.42, + radiusBottom: 0.08, + height: 0.75, + position: [0, 0.35, 0], + }, + { + kind: 'platform_with_ladder', + semanticRole: 'service_platform', + length: 1.1, + width: 0.55, + height: 0.9, + rungCount: 5, + position: [0.95, 0.9, 0], + }, + ], + }) + + const roles = new Set(shapes.map((shape) => shape.semanticRole)) + const sourceKinds = new Set(shapes.map((shape) => shape.sourcePartKind)) + + expect([...sourceKinds]).toEqual( + expect.arrayContaining([ + 'flanged_nozzle', + 'inspection_hatch', + 'conical_hopper', + 'platform_with_ladder', + ]), + ) + expect([...roles]).toEqual( + expect.arrayContaining([ + 'side_flanged_nozzle', + 'nozzle_flange', + 'front_inspection_hatch', + 'hatch_handle', + 'bottom_conical_hopper', + 'hopper_outlet_collar', + 'support_leg', + 'service_platform', + 'ladder_rung', + ]), + ) + expect(shapes.filter((shape) => shape.semanticRole === 'ladder_rung')).toHaveLength(5) + expect(shapes.some((shape) => shape.sourcePartKind === 'pipe_port')).toBe(false) + }) +}) + +function cylinderEndpoints(shape: { + position?: number[] + rotation?: number[] + height?: number +}): [[number, number, number], [number, number, number]] { + const [x = 0, y = 0, z = 0] = shape.position ?? [] + const yaw = shape.rotation?.[2] ?? 0 + const half = (shape.height ?? 0) / 2 + const dx = Math.cos(yaw) * half + const dy = Math.sin(yaw) * half + return [ + [x - dx, y - dy, z], + [x + dx, y + dy, z], + ] +} + +function cylinderEndpointByAxis( + shape: { + position?: number[] + rotation?: number[] + height?: number + }, + axisIndex: 0 | 1 | 2, + side: 'min' | 'max', +): [number, number, number] { + const endpoints = cylinderEndpoints(shape) + return ( + endpoints.sort((a, b) => + side === 'max' ? b[axisIndex] - a[axisIndex] : a[axisIndex] - b[axisIndex], + )[0] ?? [0, 0, 0] + ) +} + +describe('composePartPrimitives', () => { + test('composes a standing fan from reusable mechanical parts', () => { + const shapes = composePartPrimitives({ + name: 'Standing fan', + detail: 'medium', + parts: [ + { kind: 'circular_base', radius: 0.28, height: 0.08, position: [0, 0.04, 0] }, + { kind: 'vertical_pole', radius: 0.025, height: 1.05, position: [0, 0.6, 0] }, + { kind: 'support_bracket', position: [0, 1.08, 0], width: 0.24, height: 0.16 }, + { kind: 'motor_housing', position: [0, 1.18, -0.06], radius: 0.1, depth: 0.16 }, + { kind: 'radial_blades', position: [0, 1.18, 0.03], count: 3, bladeRadius: 0.28 }, + { + kind: 'protective_grill', + position: [0, 1.18, 0.03], + radius: 0.36, + ringCount: 4, + spokeCount: 18, + }, + ], + }) + + expect(shapes.some((shape) => shape.name?.includes('circular base'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('vertical pole'))).toBe(true) + const blades = shapes.filter((shape) => Boolean(shape.name?.match(/ blade \d+$/))) + expect(blades).toHaveLength(3) + expect(blades.every((shape) => shape.kind === 'extrude')).toBe(true) + expect(blades.every((shape) => (shape.profile?.length ?? 0) >= 8)).toBe(true) + blades.forEach((shape) => { + expectLocalPlaneBladeRotationMatchesRadialPlacement(shape, [0, 1.18]) + }) + expect(shapes.filter((shape) => shape.name?.includes('blade root'))).toHaveLength(3) + const frontRings = shapes.filter((shape) => shape.name?.includes('grill front ring')) + expect(frontRings).toHaveLength(4) + expect( + new Set(frontRings.map((shape) => shape.position?.[2]?.toFixed(4))).size, + ).toBeGreaterThan(1) + expect(shapes.filter((shape) => shape.name?.includes('grill spoke'))).toHaveLength(18) + expect(shapes.some((shape) => shape.name?.includes('grill side rib'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('rear outer ring'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('rear inner support ring'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('grill center cap'))).toBe(true) + }) + + test('supports common aliases for part kinds', () => { + const shapes = composePartPrimitives({ + parts: [ + { kind: 'grille', radius: 0.2, spokeCount: 6, ringCount: 2 }, + { kind: 'fan-blades', count: 4, radius: 0.15 }, + ], + }) + + expect(shapes.filter((shape) => shape.name?.includes('grill spoke'))).toHaveLength(6) + const blades = shapes.filter((shape) => Boolean(shape.name?.match(/ blade \d+$/))) + expect(blades).toHaveLength(4) + expect(blades.every((shape) => shape.kind === 'extrude')).toBe(true) + }) + + test('applies LLM-safe params to explicit part dimensions and materials', () => { + const shapes = composePartPrimitives({ + autoComplete: false, + enhanceVisualDetails: false, + parts: [ + { + kind: 'generic_body', + semanticRole: 'test_body', + params: { + length: 0.13, + width: 0.14, + height: 0.26, + primaryColor: '#123456', + cornerRadius: 0.02, + }, + }, + ], + }) + + const body = shapes.find((shape) => shape.semanticRole === 'test_body') + + expect(body).toMatchObject({ + kind: 'box', + length: 0.13, + width: 0.14, + height: 0.26, + cornerRadius: 0.02, + }) + expect(body?.material?.properties?.color).toBe('#123456') + }) + + test('composes independent editable fan blade arrays', () => { + const shapes = composePartPrimitives({ + name: 'Industrial pedestal fan', + detail: 'medium', + parts: [ + { + id: 'fan_blades', + kind: 'fan_blade', + count: 6, + length: 0.32, + width: 0.09, + thickness: 0.018, + primaryColor: '#ef4444', + }, + ], + }) + + const blades = shapes.filter((shape) => shape.semanticRole === 'fan_blade') + expect(blades).toHaveLength(6) + expect(blades.every((shape) => shape.sourcePartKind === 'fan_blade')).toBe(true) + expect(new Set(blades.map((shape) => shape.sourcePartId)).size).toBe(6) + expect(blades.every((shape) => shape.editableHints?.primaryDimension === 'length')).toBe(true) + expect(blades.every((shape) => shape.material?.properties?.color === '#ef4444')).toBe(true) + }) + + test('composes cylindrical tank parts as hollow vessels with heads, seams, and supports', () => { + const shapes = composePartPrimitives({ + name: 'Horizontal process vessel', + parts: [{ kind: 'cylindrical_tank', length: 1.8, radius: 0.32, axis: 'x' }], + }) + + expect(shapes.find((shape) => shape.semanticRole === 'vessel_shell')?.kind).toBe( + 'hollow-cylinder', + ) + expect(shapes.filter((shape) => shape.semanticRole === 'vessel_head')).toHaveLength(2) + expect(shapes.filter((shape) => shape.semanticRole === 'vessel_seam')).toHaveLength(2) + expect(shapes.some((shape) => shape.semanticRole === 'top_nozzle')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'saddle_support')).toHaveLength(2) + }) + + test('preserves tall vertical cylindrical tanks for process towers', () => { + const shapes = composePartPrimitives({ + name: 'Tall distillation column', + parts: [ + { + kind: 'cylindrical_tank', + semanticRole: 'distillation_column_shell', + height: 13.2, + radius: 0.78, + axis: 'y', + position: [0, 6.6, 0], + }, + ], + }) + + const shell = shapes.find((shape) => shape.semanticRole === 'distillation_column_shell') + expect(shell?.kind).toBe('hollow-cylinder') + expect(shell?.height).toBe(13.2) + }) + + test('composes vertical storage tank shells as straight atmospheric tanks', () => { + const shapes = composePartPrimitives({ + name: 'Crude storage tank', + parts: [ + { + kind: 'storage_tank_shell', + semanticRole: 'vessel_shell', + height: 3.4, + radius: 0.9, + axis: 'y', + position: [0, 1.9, 0], + material: { properties: { color: '#cbd5e1', opacity: 0.34, transparent: true } }, + }, + ], + }) + + const shell = shapes.find((shape) => shape.semanticRole === 'vessel_shell') + const roof = shapes.find((shape) => shape.semanticRole === 'vessel_roof') + expect(shell?.kind).toBe('hollow-cylinder') + expect(shell?.height).toBe(3.4) + expect(shell?.radius).toBe(0.9) + expect(roof?.kind).toBe('cylinder') + expect(roof?.material?.properties?.opacity).toBeGreaterThan( + shell?.material?.properties?.opacity ?? 1, + ) + expect(shapes.some((shape) => shape.semanticRole === 'foundation_ring')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'vessel_head')).toHaveLength(0) + expect(shapes.filter((shape) => shape.semanticRole === 'support_leg')).toHaveLength(0) + expect(shapes.every((shape) => shape.sourcePartKind === 'storage_tank_shell')).toBe(true) + }) + + test('composes tank liquid volume as an editable transparent cylinder', () => { + const shapes = composePartPrimitives({ + name: 'Storage tank liquid preview', + parts: [ + { + kind: 'liquid_volume', + semanticRole: 'liquid_volume', + height: 2.4, + radius: 0.9, + position: [0, 1.2, 0], + color: '#38bdf8', + opacity: 0.58, + }, + ], + }) + + const liquid = shapes.find((shape) => shape.semanticRole === 'liquid_volume') + expect(liquid?.kind).toBe('cylinder') + expect(liquid?.height).toBe(2.4) + expect(liquid?.radius).toBe(0.9) + expect(liquid?.material?.properties?.color).toBe('#38bdf8') + expect(liquid?.material?.properties?.transparent).toBe(true) + expect(liquid?.material?.properties?.opacity).toBe(0.58) + }) + + test('composes agitator tank parts with vessel shell, heads, mixer, nozzles, and legs', () => { + const shapes = composePartPrimitives({ + name: 'Stirred reactor', + parts: [{ kind: 'agitator_tank', height: 1.1, radius: 0.34 }], + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole).filter(Boolean)) + + expect(shapes.find((shape) => shape.semanticRole === 'reactor_vessel_shell')?.kind).toBe( + 'hollow-cylinder', + ) + expect(shapes.filter((shape) => shape.semanticRole === 'vessel_head')).toHaveLength(2) + expect(roles.has('agitator_motor')).toBe(true) + expect(roles.has('feed_nozzle')).toBe(true) + expect(roles.has('manway_flange')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'support_leg')).toHaveLength(4) + }) + + test('composes vehicle wheel sets with tire, rim, spokes, hubs, and axles', () => { + const shapes = composePartPrimitives({ + name: 'Factory AGV vehicle', + detail: 'high', + parts: [{ kind: 'wheel_set', semanticRole: 'vehicle_tire', count: 4 }], + }) + + expect(shapes.filter((shape) => shape.semanticRole === 'vehicle_tire')).toHaveLength(4) + expect(shapes.filter((shape) => shape.semanticRole === 'wheel_rim')).toHaveLength(4) + expect(shapes.filter((shape) => shape.semanticRole === 'wheel_hub')).toHaveLength(4) + expect(shapes.filter((shape) => shape.semanticRole === 'wheel_axle')).toHaveLength(2) + expect(shapes.filter((shape) => shape.semanticRole === 'wheel_spoke')).toHaveLength(20) + }) + + test('composes mobile platform industrial details for AGV profiles', () => { + const shapes = composePartPrimitives({ + name: 'Factory AGV', + length: 1.45, + width: 0.9, + height: 0.48, + parts: [ + { kind: 'mobile_platform_chassis', semanticRole: 'vehicle_body' }, + { + kind: 'lidar_sensor', + semanticRole: 'front_navigation_sensor', + axis: 'x', + position: [0.75, 0.21, 0], + }, + { + kind: 'status_light_strip', + semanticRole: 'left_status_light_strip', + side: 'left', + }, + { + kind: 'emergency_stop_button', + semanticRole: 'emergency_stop_button', + position: [0.42, 0.37, 0.22], + }, + ], + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole)) + + expect(roles.has('vehicle_body')).toBe(true) + expect(roles.has('lower_bumper_skirt')).toBe(true) + expect(roles.has('cargo_platform')).toBe(true) + expect(roles.has('front_navigation_sensor')).toBe(true) + expect(roles.has('sensor_lens')).toBe(true) + expect(roles.has('left_status_light_strip')).toBe(true) + expect(roles.has('emergency_stop_button')).toBe(true) + expect(roles.has('emergency_stop_guard')).toBe(true) + expect(shapes.every((shape) => shape.sourcePartKind !== 'generic_body')).toBe(true) + }) + + test('composes reusable industrial workcell accessory parts', () => { + const shapes = composePartPrimitives({ + name: 'Robot workcell', + length: 2, + width: 1.2, + height: 1.4, + parts: [ + { kind: 'operator_panel', semanticRole: 'control_panel' }, + { kind: 'guard_fence', semanticRole: 'safety_barrier', count: 5 }, + { kind: 'pallet_table', semanticRole: 'pallet_table' }, + { kind: 'bearing_block', semanticRole: 'bearing_block' }, + { kind: 'coupling_guard', semanticRole: 'coupling_guard' }, + { kind: 'motor_gearbox_unit', semanticRole: 'drive_unit' }, + { kind: 'pipe_manifold', semanticRole: 'pipe_manifold', count: 3 }, + { kind: 'hopper_body', semanticRole: 'hopper_body' }, + { kind: 'service_platform', semanticRole: 'service_platform' }, + ], + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole)) + + expect(roles.has('control_panel')).toBe(true) + expect(roles.has('display_screen')).toBe(true) + expect(roles.has('control_button')).toBe(true) + expect(roles.has('safety_barrier')).toBe(true) + expect(roles.has('guard_fence_post')).toBe(true) + expect(roles.has('pallet_table')).toBe(true) + expect(roles.has('support_leg')).toBe(true) + expect(roles.has('bearing_block')).toBe(true) + expect(roles.has('bearing_ring')).toBe(true) + expect(roles.has('coupling_guard')).toBe(true) + expect(roles.has('drive_motor')).toBe(true) + expect(roles.has('drive_unit')).toBe(true) + expect(roles.has('output_shaft')).toBe(true) + expect(roles.has('pipe_manifold')).toBe(true) + expect(roles.has('manifold_branch')).toBe(true) + expect(roles.has('hopper_body')).toBe(true) + expect(roles.has('hopper_outlet')).toBe(true) + expect(roles.has('service_platform')).toBe(true) + expect(roles.has('access_ladder')).toBe(true) + }) + + test('composes access platform ladder parts with guard rails and ladder side rails', () => { + const shapes = composePartPrimitives({ + name: 'Inspection platform', + parts: [{ kind: 'platform_ladder', height: 1.4, length: 1, width: 0.6, count: 6 }], + }) + + expect(shapes.some((shape) => shape.semanticRole === 'access_platform')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'platform_post')).toHaveLength(4) + expect(shapes.filter((shape) => shape.semanticRole === 'guard_rail').length).toBeGreaterThan(3) + expect(shapes.filter((shape) => shape.semanticRole === 'ladder_side_rail')).toHaveLength(2) + expect(shapes.filter((shape) => shape.semanticRole === 'ladder_rung')).toHaveLength(6) + }) + + test('composes helical stair parts as wraparound tower access', () => { + const shapes = composePartPrimitives({ + name: 'Tower access', + detail: 'low', + parts: [ + { + kind: 'helical_stair', + height: 6, + innerRadius: 0.9, + outerRadius: 1.2, + stepCount: 12, + ringCount: 8, + sweepAngle: Math.PI * 3, + }, + ], + }) + + expect( + shapes.filter((shape) => shape.semanticRole === 'helical_stair_tread').length, + ).toBeGreaterThan(12) + expect( + shapes.filter((shape) => shape.semanticRole === 'helical_stair_guard_rail'), + ).toHaveLength(1) + expect(shapes.filter((shape) => shape.semanticRole === 'helical_stair_mid_rail')).toHaveLength( + 1, + ) + expect(shapes.filter((shape) => shape.semanticRole === 'helical_stair_stringer')).toHaveLength( + 2, + ) + expect(shapes.filter((shape) => shape.semanticRole === 'helical_stair_landing')).toHaveLength(2) + expect(shapes.some((shape) => shape.semanticRole === 'helical_stair_post')).toBe(true) + expect(shapes.every((shape) => shape.sourcePartKind === 'helical_stair')).toBe(true) + }) + + test('composes dedicated helical ladder parts for process columns', () => { + const shapes = composePartPrimitives({ + name: 'Column access ladder', + detail: 'low', + parts: [ + { + kind: 'helical_ladder', + height: 5.5, + innerRadius: 0.8, + outerRadius: 1.12, + stepCount: 14, + ringCount: 10, + sweepAngle: Math.PI * 3.5, + }, + ], + }) + + expect( + shapes.filter((shape) => shape.semanticRole === 'helical_ladder_tread').length, + ).toBeGreaterThan(14) + expect( + shapes.filter((shape) => shape.semanticRole === 'helical_ladder_guard_rail'), + ).toHaveLength(1) + expect(shapes.filter((shape) => shape.semanticRole === 'helical_ladder_stringer')).toHaveLength( + 2, + ) + expect(shapes.filter((shape) => shape.semanticRole === 'helical_ladder_landing')).toHaveLength( + 2, + ) + expect(shapes.some((shape) => shape.semanticRole === 'helical_ladder_post')).toBe(true) + expect(shapes.every((shape) => shape.sourcePartKind === 'helical_ladder')).toBe(true) + }) + + test('composes preheater tower frame and cyclone separator units', () => { + const shapes = composePartPrimitives({ + name: 'Cement preheater', + parts: [ + { + kind: 'structural_tower_frame', + semanticRole: 'preheater_tower_body', + length: 2.4, + width: 1.5, + height: 6, + levelCount: 5, + stairFlights: 5, + }, + { + kind: 'cyclone_separator_unit', + semanticRole: 'preheater_cyclone', + height: 1.2, + radius: 0.24, + position: [-0.5, 5.1, 0], + }, + ], + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole)) + + expect(roles.has('preheater_tower_body')).toBe(true) + expect(roles.has('multi_level_platform')).toBe(true) + expect(roles.has('tower_column')).toBe(true) + expect(roles.has('tower_beam')).toBe(true) + expect(roles.has('tower_diagonal_brace')).toBe(true) + expect(roles.has('external_stair_flight')).toBe(true) + expect(roles.has('external_stair_landing')).toBe(true) + expect(roles.has('preheater_cyclone')).toBe(true) + expect(roles.has('cyclone_cone')).toBe(true) + expect(roles.has('preheater_gas_duct')).toBe(true) + expect(roles.has('meal_drop_pipe')).toBe(true) + }) + + test('composes pyramid parts as four-sided cones', () => { + const shapes = composePartPrimitives({ + name: 'Pyramid marker', + primaryColor: '#d97706', + parts: [ + { + kind: 'pyramid', + length: 2, + width: 1.2, + height: 1.5, + semanticRole: 'marker_pyramid', + }, + { + kind: 'square-pyramid', + id: 'small_top', + length: 0.5, + height: 0.7, + alignAbove: 0, + }, + ], + }) + + expect(shapes).toHaveLength(2) + expect(shapes[0]?.kind).toBe('cone') + expect(shapes[0]?.name).toContain('pyramid') + expect(shapes[0]?.semanticRole).toBe('marker_pyramid') + expect(shapes[0]?.sourcePartKind).toBe('pyramid') + expect(shapes[0]?.radialSegments).toBe(4) + expect(shapes[0]?.height).toBe(1.5) + expect(shapes[0]?.scale).toEqual([1, 1, 0.6]) + expect(shapes[0]?.material?.properties?.color).toBe('#d97706') + expect(shapes[1]?.semanticRole).toBe('pyramid') + expect(shapes[1]?.sourcePartKind).toBe('pyramid') + expect(shapes[1]?.position?.[1]).toBeGreaterThan(shapes[0]?.position?.[1] ?? 0) + }) + + test('composes truncated pyramid parts as four-sided frustums', () => { + const shapes = composePartPrimitives({ + name: 'Flat top pyramid marker', + parts: [ + { + kind: 'pyramid', + truncated: true, + length: 2, + width: 1.2, + height: 1.5, + }, + { + kind: 'pyramid', + topScale: 0.5, + length: 1, + width: 1, + height: 0.8, + }, + ], + }) + + expect(shapes).toHaveLength(2) + expect(shapes[0]?.kind).toBe('frustum') + expect(shapes[0]?.radialSegments).toBe(4) + expect(shapes[0]?.radiusBottom).toBe(1) + expect(shapes[0]?.radiusTop).toBeCloseTo(0.35) + expect(shapes[0]?.scale).toEqual([1, 1, 0.6]) + expect(shapes[1]?.kind).toBe('frustum') + expect(shapes[1]?.radiusTop).toBeCloseTo(0.25) + }) + + test('composes hemisphere parts as native hemisphere shapes', () => { + const shapes = composePartPrimitives({ + name: 'Half sphere marker', + parts: [ + { + kind: 'hemisphere', + semanticRole: 'marker_hemisphere', + diameter: 2, + height: 0.75, + }, + ], + }) + + expect(shapes).toHaveLength(1) + expect(shapes[0]).toMatchObject({ + kind: 'hemisphere', + semanticRole: 'marker_hemisphere', + sourcePartKind: 'hemisphere', + position: [0, 0.375, 0], + radius: 1, + scale: [1, 0.75, 1], + }) + }) + + test('normalizes mixer propeller part blueprints without duplicating or adding fan details', () => { + const shapes = composePartPrimitives({ + geometryBrief: { category: 'mixer' }, + parts: [ + { kind: 'vertical_pole', id: 'shaft', height: 1.4, radius: 0.025 }, + { kind: 'circular_base', id: 'hub', radius: 0.07, height: 0.1, alignAbove: 'shaft' }, + { + kind: 'propeller_blade_set', + id: 'blades', + count: 3, + hubRadius: 0.07, + bladeRadius: 0.38, + bladeWidth: 0.15, + bladeShape: 'taiji_half', + bladePitch: 0.55, + verticalCurve: 0.07, + around: 'hub', + aroundCount: 3, + }, + ], + enhanceVisualDetails: true, + }) + + expect(shapes.filter((shape) => shape.semanticRole === 'mixer_shaft')).toHaveLength(1) + expect(shapes.filter((shape) => shape.semanticRole === 'mixer_hub')).toHaveLength(1) + expect(shapes.filter((shape) => shape.semanticRole === 'mixer_blade')).toHaveLength(3) + expect(shapes.some((shape) => shape.sourcePartKind === 'protective_grill')).toBe(false) + expect(shapes.some((shape) => shape.sourcePartKind === 'motor_housing')).toBe(false) + }) + + test('does not infer a fan from a standalone chimney pole blueprint', () => { + const shapes = composePartPrimitives({ + name: 'large chimney', + geometryBrief: { category: 'industrial chimney', requiredRoles: ['chimney_body'] }, + parts: [ + { + id: 'chimney_shaft', + kind: 'vertical_pole', + semanticRole: 'chimney_body', + dimensions: { height: 10, radius: 0.5 }, + }, + ], + }) + + expect(shapes).toHaveLength(1) + expect(shapes[0]?.semanticRole).toBe('chimney_body') + expect(shapes[0]?.height).toBe(10) + expect(shapes[0]?.radius).toBe(0.5) + expect(shapes.some((shape) => shape.sourcePartKind === 'protective_grill')).toBe(false) + expect(shapes.some((shape) => shape.sourcePartKind === 'radial_blades')).toBe(false) + expect(shapes.some((shape) => shape.sourcePartKind === 'motor_housing')).toBe(false) + }) + + test('composes a tapered red-white industrial chimney stack', () => { + const shapes = composePartPrimitives({ + name: 'red white factory chimney', + detail: 'medium', + parts: [ + { + kind: 'smokestack', + height: 10, + radius: 0.55, + warningStripes: true, + stripeCount: 5, + }, + ], + }) + + const shell = shapes.find((shape) => shape.semanticRole === 'chimney_body') + const redBands = shapes.filter((shape) => shape.semanticRole === 'chimney_warning_red_band') + const whiteBands = shapes.filter((shape) => shape.semanticRole === 'chimney_warning_white_band') + + expect(shell?.kind).toBe('frustum') + expect(shell?.radiusBottom).toBeCloseTo(0.55) + expect(shell?.radiusTop).toBeLessThan(shell?.radiusBottom ?? 0) + expect(shapes.some((shape) => shape.semanticRole === 'chimney_base')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'chimney_top_rim')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'access_door')).toBe(true) + expect( + shapes.filter((shape) => shape.semanticRole === 'chimney_seam_ring').length, + ).toBeGreaterThan(4) + expect(redBands).toHaveLength(3) + expect(whiteBands).toHaveLength(2) + expect(redBands[0]?.material?.properties?.color).toBe('#b91c1c') + expect(whiteBands[0]?.material?.properties?.color).toBe('#f8fafc') + }) + + test('keeps explicitly positioned chimney stacks above the local ground plane', () => { + const shapes = composePartPrimitives({ + name: 'grounded chimney', + parts: [ + { + kind: 'chimney_stack', + semanticRole: 'flare_stack', + height: 8, + radius: 0.3, + position: [0, 0, 0], + }, + ], + }) + + const shell = shapes.find((shape) => shape.semanticRole === 'flare_stack') + const base = shapes.find((shape) => shape.semanticRole === 'chimney_base') + + expect(shell?.position?.[1]).toBe(4) + expect(base?.position?.[1]).toBeGreaterThan(0) + }) + + test('composes common factory pump and blower structures', () => { + const shapes = composePartPrimitives({ + name: 'Factory pump', + detail: 'medium', + parts: [ + { kind: 'skid_base', length: 1.2, width: 0.46, height: 0.08 }, + { + kind: 'rounded_machine_body', + position: [-0.26, 0.35, 0], + length: 0.55, + width: 0.32, + height: 0.34, + }, + { kind: 'volute_casing', position: [0.22, 0.42, 0.05], radius: 0.22, depth: 0.16 }, + { kind: 'impeller_blades', position: [0.22, 0.42, 0.16], count: 7, radius: 0.14 }, + { kind: 'inlet_port', position: [0.22, 0.42, 0.29], axis: 'z', radius: 0.07 }, + { kind: 'outlet_port', position: [0.48, 0.5, 0.05], axis: 'x', radius: 0.06 }, + { + kind: 'flange_ring', + position: [0.22, 0.42, 0.39], + axis: 'z', + radius: 0.11, + boltCount: 6, + }, + { kind: 'bolt_pattern', position: [0.22, 0.42, 0.43], axis: 'z', radius: 0.09, count: 6 }, + { kind: 'control_box', position: [-0.26, 0.58, 0.2] }, + ], + }) + + expect(shapes.some((shape) => shape.name?.includes('skid left rail'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('rounded machine body'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('volute scroll casing'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('volute discharge neck'))).toBe(true) + const impellerVanes = shapes.filter((shape) => shape.name?.includes('impeller vane')) + expect(impellerVanes).toHaveLength(7) + impellerVanes.forEach((shape) => { + expectLocalPlaneBladeRotationMatchesRadialPlacement(shape, [0.22, 0.42]) + }) + expect(shapes.some((shape) => shape.name?.includes('inlet port'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('outlet port'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('flange ring'))).toBe(true) + expect(shapes.filter((shape) => shape.name?.includes('bolt'))).toHaveLength(12) + expect(shapes.some((shape) => shape.name?.includes('control box'))).toBe(true) + }) + + test('composes stronger vent grilles and rounded industrial machine bodies', () => { + const shapes = composePartPrimitives({ + name: 'Industrial body detail', + autoComplete: false, + detail: 'high', + parts: [ + { kind: 'rounded_machine_body', length: 1, width: 0.5, height: 0.45 }, + { + kind: 'vent_grill', + position: [0, 0.5, 0.28], + width: 0.42, + count: 7, + depth: 0.04, + }, + ], + }) + + expect(shapes.some((shape) => shape.name?.includes('raised top service hatch'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('lower shadow plinth'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('front horizontal seam'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('vent recess panel'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('vent top frame'))).toBe(true) + expect(shapes.filter((shape) => shape.name?.includes('vent vertical mullion'))).toHaveLength(2) + expect(shapes.filter((shape) => shape.name?.includes('vent slat'))).toHaveLength(7) + }) + + test('places pipe rims on the open end and supports flange bolt control', () => { + const shapes = composePartPrimitives({ + name: 'Pipe detail', + autoComplete: false, + parts: [ + { + kind: 'pipe_port', + position: [0, 0, 0], + axis: 'x', + side: 'right', + length: 0.4, + radius: 0.08, + }, + { + kind: 'flange_ring', + position: [0.2, 0, 0], + axis: 'x', + radius: 0.12, + includeBolts: false, + }, + ], + }) + + const rim = shapes.find((shape) => shape.name?.includes('pipe port rim')) + expect(rim?.position?.[0]).toBeCloseTo(0.2) + expect(rim?.axis).toBe('x') + expect(shapes.find((shape) => shape.name?.includes('flange ring'))?.axis).toBe('x') + expect(shapes.some((shape) => shape.name?.includes('flange gasket'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('flange bolt'))).toBe(false) + }) + + test('supports volute outlet angles and factory blueprint auto-completion', () => { + const shapes = composePartPrimitives({ + name: 'Auto pump', + parts: [ + { + kind: 'volute_casing', + position: [0.2, 0.4, 0.05], + radius: 0.2, + outletAngle: Math.PI / 2, + }, + ], + }) + + const discharge = shapes.find((shape) => shape.name?.includes('volute discharge neck')) + expect(discharge?.position?.[0]).toBeCloseTo(0.2) + expect(discharge?.position?.[1]).toBeGreaterThan(0.5) + expect(discharge?.rotation?.[2]).toBeCloseTo(Math.PI / 2) + expect(shapes.some((shape) => shape.name?.includes('skid left rail'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('ribbed motor body'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('inlet port'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('outlet port'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('flange ring'))).toBe(true) + }) + + test('keeps pump ports attached when callers provide connectTo without positions', () => { + const shapes = composePartPrimitives({ + name: 'Connected pump', + autoComplete: false, + parts: [ + { id: 'casing', kind: 'volute_casing', position: [0.24, 0.42, 0.04], radius: 0.22 }, + { id: 'suction', kind: 'inlet_port', connectTo: 'casing', radius: 0.07 }, + { id: 'discharge', kind: 'outlet_port', connectTo: 'casing', radius: 0.06 }, + ], + }) + + const inlet = shapes.find( + (shape) => shape.sourcePartId === 'suction' && shape.kind === 'hollow-cylinder', + ) + const outlet = shapes.find( + (shape) => shape.sourcePartId === 'discharge' && shape.kind === 'hollow-cylinder', + ) + + expect(inlet?.position?.[0]).toBeCloseTo(0.24) + expect(inlet?.position?.[2]).toBeGreaterThan(0.2) + expect(inlet?.axis).toBe('z') + expect(outlet?.position?.[0]).toBeGreaterThan(0.4) + expect(outlet?.position?.[1]).toBeGreaterThan(0.5) + expect(outlet?.axis).toBe('x') + }) + + test('composes common conveyor, tank, and valve equipment parts', () => { + const shapes = composePartPrimitives({ + name: 'Factory line', + detail: 'low', + parts: [ + { kind: 'conveyor_frame', length: 1.1, width: 0.36 }, + { kind: 'roller_array', count: 5, length: 1.0, width: 0.36 }, + { kind: 'belt_surface', length: 1.1, width: 0.36 }, + { kind: 'cylindrical_tank', position: [1, 0.55, 0], length: 0.8, radius: 0.18 }, + { kind: 'valve_body', position: [1.8, 0.32, 0], radius: 0.09 }, + { kind: 'handwheel', position: [1.8, 0.52, 0], radius: 0.1 }, + ], + }) + + expect(shapes.some((shape) => shape.name?.includes('conveyor left rail'))).toBe(true) + expect(shapes.filter((shape) => shape.name?.includes('conveyor roller'))).toHaveLength(5) + expect(shapes.some((shape) => shape.name?.includes('conveyor belt surface'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('cylindrical tank shell'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('tank top nozzle'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('valve body barrel'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('handwheel rim'))).toBe(true) + }) + + test('auto-completes valve validation roles for gate valve blueprints', () => { + const shapes = composePartPrimitives({ + name: 'Gate valve', + parts: [{ kind: 'valve_body' }], + }) + + expect(shapes.some((shape) => shape.semanticRole === 'flange_inlet')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'flange_outlet')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'bonnet')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'stem')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'gate_wedge')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'bonnet_bolts')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'yoke')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'handwheel')).toBe(true) + }) + + test('parameterizes ball valves without requiring users to list internal parts', () => { + const shapes = composePartPrimitives({ + name: 'Ball Valve', + parts: [{ kind: 'valve_body' }], + }) + + expect(shapes.some((shape) => shape.semanticRole === 'valve_ball')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'valve_bore')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'seat_ring')).toHaveLength(2) + expect(shapes.some((shape) => shape.semanticRole === 'gate_wedge')).toBe(false) + expect(shapes.some((shape) => shape.name?.includes('lever handle'))).toBe(true) + + const inletFlange = shapes.find((shape) => shape.semanticRole === 'flange_inlet') + const outletFlange = shapes.find((shape) => shape.semanticRole === 'flange_outlet') + expect(inletFlange?.position?.[1]).toBeCloseTo(0.38) + expect(outletFlange?.position?.[1]).toBeCloseTo(0.38) + expect(inletFlange?.position?.[0]).toBeLessThan(0) + expect(outletFlange?.position?.[0]).toBeGreaterThan(0) + }) + + test('composes bicycle and vehicle equipment families', () => { + const bicycle = composePartPrimitives({ + name: 'Bike', + parts: [{ kind: 'bicycle_frame' }], + }) + expect( + bicycle.filter((shape) => shape.name?.includes('bicycle') && shape.name?.includes('tire')), + ).toHaveLength(2) + expect(bicycle.filter((shape) => shape.semanticRole === 'bicycle_tire')).toHaveLength(2) + expect(bicycle.some((shape) => shape.name?.includes('bicycle top tube'))).toBe(true) + expect(bicycle.some((shape) => shape.name?.includes('bicycle left fork blade'))).toBe(true) + expect(bicycle.some((shape) => shape.name?.includes('handlebar crossbar'))).toBe(true) + expect(bicycle.some((shape) => shape.name?.includes('saddle cushion'))).toBe(true) + expect(bicycle.some((shape) => shape.name?.includes('chain elongated loop'))).toBe(true) + const chain = bicycle.find((shape) => shape.name?.includes('chain elongated loop')) + expect(chain?.kind).toBe('sweep') + expect(chain?.closed).toBe(true) + expect(chain?.path?.length).toBeGreaterThan(6) + expect(bicycle.some((shape) => shape.name?.includes('front chainring'))).toBe(true) + expect(bicycle.some((shape) => shape.name?.includes('rear sprocket'))).toBe(true) + expect(bicycle.some((shape) => shape.semanticRole === 'crank')).toBe(true) + expect(bicycle.some((shape) => shape.semanticRole === 'chainring')).toBe(true) + expect(bicycle.filter((shape) => shape.semanticRole === 'pedal')).toHaveLength(2) + + const duplicateWheelsetBicycle = composePartPrimitives({ + name: 'Duplicate wheelset bike', + parts: [{ kind: 'bicycle_wheels' }, { kind: 'bike_wheelset' }, { kind: 'bicycle_frame' }], + }) + expect( + duplicateWheelsetBicycle.filter( + (shape) => shape.name?.includes('bicycle') && shape.name?.includes('tire'), + ), + ).toHaveLength(2) + + const singleBicycleWheel = composePartPrimitives({ + name: 'single bicycle wheel', + parts: [ + { + id: 'bicycle_wheel', + kind: 'wheel_set', + semanticRole: 'bicycle_wheel', + radius: 0.35, + }, + ], + }) + expect( + singleBicycleWheel.filter((shape) => shape.semanticRole === 'bicycle_tire'), + ).toHaveLength(1) + expect(singleBicycleWheel.filter((shape) => shape.semanticRole === 'bicycle_rim')).toHaveLength( + 1, + ) + expect(singleBicycleWheel.filter((shape) => shape.semanticRole === 'bicycle_hub')).toHaveLength( + 1, + ) + expect( + singleBicycleWheel.filter((shape) => shape.semanticRole === 'bicycle_spoke'), + ).toHaveLength(8) + + const twoExplicitBicycleWheels = composePartPrimitives({ + name: 'two bicycle wheels', + parts: [ + { + id: 'bicycle_wheels', + kind: 'wheel_set', + semanticRole: 'bicycle_wheel', + count: 2, + radius: 0.35, + }, + ], + }) + expect( + twoExplicitBicycleWheels.filter((shape) => shape.semanticRole === 'bicycle_tire'), + ).toHaveLength(2) + expect( + twoExplicitBicycleWheels.filter((shape) => shape.semanticRole === 'bicycle_rim'), + ).toHaveLength(2) + expect( + twoExplicitBicycleWheels.filter((shape) => shape.semanticRole === 'bicycle_hub'), + ).toHaveLength(2) + expect( + twoExplicitBicycleWheels.filter((shape) => shape.semanticRole === 'bicycle_spoke'), + ).toHaveLength(16) + + const llmAliasBicycle = composePartPrimitives({ + name: 'red bicycle', + primaryColor: '#CC0000', + length: 2, + parts: [ + { id: 'frame', kind: 'bicycle_frame', semanticRole: 'frame' }, + { id: 'fork', kind: 'bicycle_fork', semanticRole: 'fork' }, + { id: 'wheel_front', kind: 'bicycle_wheel', semanticRole: 'wheel', axis: 'x' }, + { id: 'wheel_rear', kind: 'bicycle_wheel', semanticRole: 'wheel', axis: 'x' }, + { id: 'handlebar', kind: 'bicycle_handlebar', semanticRole: 'handlebar' }, + { id: 'seat', kind: 'bicycle_seat', semanticRole: 'seat' }, + { id: 'crank', kind: 'bicycle_crank', semanticRole: 'crank' }, + { id: 'chainring', kind: 'bicycle_chainring', semanticRole: 'chainring' }, + { id: 'pedals', kind: 'bicycle_pedals', semanticRole: 'pedal' }, + { id: 'chain', kind: 'bicycle_chain', semanticRole: 'chain' }, + ], + }) + expect(llmAliasBicycle.filter((shape) => shape.semanticRole === 'bicycle_tire')).toHaveLength(2) + const llmAliasBicycleTires = llmAliasBicycle.filter( + (shape) => shape.semanticRole === 'bicycle_tire', + ) + expect(llmAliasBicycleTires.every((shape) => shape.axis === 'z')).toBe(true) + expect( + llmAliasBicycleTires.every( + (shape) => (shape.tubeRadius ?? 1) < (shape.majorRadius ?? 0) * 0.1, + ), + ).toBe(true) + expect(llmAliasBicycle.filter((shape) => shape.semanticRole === 'bicycle_spoke')).toHaveLength( + 16, + ) + expect(llmAliasBicycle.some((shape) => shape.semanticRole === 'bicycle_frame')).toBe(true) + expect(llmAliasBicycle.some((shape) => shape.semanticRole === 'bicycle_fork')).toBe(true) + expect(llmAliasBicycle.some((shape) => shape.semanticRole === 'saddle')).toBe(true) + expect(llmAliasBicycle.some((shape) => shape.semanticRole === 'chain_loop')).toBe(true) + + const relationshipHeavyBicycle = composePartPrimitives({ + name: 'blue bicycle', + primaryColor: '#2563EB', + length: 1.8, + width: 0.5, + height: 1, + geometryBrief: { + category: 'complete_bicycle', + requiredRoles: [ + 'bicycle_tire', + 'bicycle_frame', + 'bicycle_fork', + 'handlebar', + 'saddle', + 'chain_loop', + ], + }, + parts: [ + { id: 'rear_wheel', kind: 'wheel_set', semanticRole: 'bicycle_tire', radius: 0.35 }, + { + id: 'front_wheel', + kind: 'wheel_set', + semanticRole: 'bicycle_tire', + alignBeside: 'rear_wheel', + side: 'front', + radius: 0.35, + }, + { + id: 'frame', + kind: 'tube_frame', + semanticRole: 'bicycle_frame', + alignAbove: 'rear_wheel', + }, + { + id: 'fork', + kind: 'fork', + semanticRole: 'bicycle_fork', + connectTo: 'frame', + connectPoint: 'head_tube', + }, + { + id: 'handlebar', + kind: 'handlebar', + semanticRole: 'handlebar', + connectTo: 'fork', + connectPoint: 'steerer_top', + }, + { + id: 'saddle', + kind: 'saddle', + semanticRole: 'saddle', + connectTo: 'frame', + connectPoint: 'seat_tube_top', + }, + { id: 'chain', kind: 'chain_loop', semanticRole: 'chain_loop' }, + ], + }) + const tires = relationshipHeavyBicycle.filter((shape) => shape.semanticRole === 'bicycle_tire') + expect(tires).toHaveLength(2) + expect(tires.every((shape) => shape.axis === 'z')).toBe(true) + expect(tires.every((shape) => (shape.tubeRadius ?? 1) < (shape.majorRadius ?? 0) * 0.1)).toBe( + true, + ) + expect( + relationshipHeavyBicycle.filter((shape) => shape.semanticRole === 'bicycle_spoke'), + ).toHaveLength(16) + expect(tires[0]?.majorRadius).toBeCloseTo(0.32) + expect(tires.map((shape) => shape.position?.[0]).sort()).toEqual([ + -0.5800000000000001, 0.5800000000000001, + ]) + expect(tires.every((shape) => shape.position?.[1] === 0.32)).toBe(true) + const tireTop = (tires[0]?.position?.[1] ?? 0) + (tires[0]?.majorRadius ?? 0) + const topTube = relationshipHeavyBicycle.find((shape) => shape.name?.includes('top tube')) + const handlebar = relationshipHeavyBicycle.find((shape) => + shape.name?.includes('handlebar crossbar'), + ) + const handlebarStem = relationshipHeavyBicycle.find((shape) => + shape.name?.includes('handlebar stem'), + ) + const frontForkBlade = relationshipHeavyBicycle.find((shape) => + shape.name?.includes('bicycle left fork blade'), + ) + const steererTube = relationshipHeavyBicycle.find((shape) => + shape.name?.includes('bicycle steerer tube'), + ) + const saddle = relationshipHeavyBicycle.find((shape) => shape.name?.includes('saddle cushion')) + expect(topTube?.position?.[1]).toBeGreaterThan(tireTop + 0.2) + expect(handlebar?.position?.[1]).toBeGreaterThan(tireTop + 0.3) + expect(saddle?.position?.[1]).toBeGreaterThan(tireTop + 0.38) + expect(handlebar?.position?.[2]).toBe(0) + expect(saddle?.position?.[2]).toBe(0) + const frontTire = [...tires].sort( + (a, b) => (b.position?.[0] ?? Number.NEGATIVE_INFINITY) - (a.position?.[0] ?? 0), + )[0] + const forkAxle = cylinderEndpointByAxis(frontForkBlade ?? {}, 0, 'max') + expect(forkAxle[0]).toBeCloseTo(frontTire?.position?.[0] ?? 0, 4) + expect(forkAxle[1]).toBeCloseTo(frontTire?.position?.[1] ?? 0, 4) + const steererTop = cylinderEndpointByAxis(steererTube ?? {}, 1, 'max') + const stemBase = cylinderEndpointByAxis(handlebarStem ?? {}, 1, 'min') + expect(stemBase[0]).toBeCloseTo(steererTop[0], 4) + expect(stemBase[1]).toBeCloseTo(steererTop[1], 4) + expect(handlebar?.position?.[0]).toBeLessThan(frontTire?.position?.[0] ?? 0) + + const car = composePartPrimitives({ + name: 'Car', + primaryColor: '#cc0000', + parts: [{ kind: 'vehicle_body' }], + }) + const bodyShell = car.find((shape) => shape.name?.includes('vehicle body shell')) + expect(bodyShell?.kind).toBe('trapezoid-prism') + expect(bodyShell?.material?.properties?.color).toBe('#cc0000') + expect(bodyShell?.semanticRole).toBe('vehicle_body') + expect(car.some((shape) => shape.name?.includes('vehicle rounded front nose'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('vehicle tapered rear quarter'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('vehicle front deck'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('vehicle rear deck'))).toBe(true) + expect(car.filter((shape) => shape.name?.includes('vehicle tire'))).toHaveLength(4) + const vehicleTires = car.filter((shape) => shape.semanticRole === 'vehicle_tire') + expect(vehicleTires).toHaveLength(4) + expect( + vehicleTires.every((shape) => (shape.tubeRadius ?? 0) > (shape.majorRadius ?? 1) * 0.18), + ).toBe(true) + expect(car.some((shape) => shape.name?.includes('windshield'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('rear window'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('rear quarter window'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('headlight'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('front bumper bar'))).toBe(true) + expect(car.some((shape) => shape.name?.includes('rear bumper bar'))).toBe(true) + }) + + test('normalizes vehicle part aliases and derives a coherent car layout from the body', () => { + const car = composePartPrimitives({ + partName: 'purple-sedan', + primaryColor: '#8B5CF6', + enhanceVisualDetails: true, + parts: [ + { + partType: 'vehicle_body', + id: 'body', + length: 4.6, + width: 1.8, + height: 1.4, + position: [0, 0.85, 0], + cornerRadius: 0.15, + }, + { + partType: 'vehicle_wheels', + id: 'wheels', + wheelRadius: 0.35, + wheelWidth: 0.22, + frontZ: 1.5, + rearZ: -1.5, + position: [0, 0.35, 0], + }, + { partType: 'vehicle_windows', id: 'windows', position: [0, 1.55, 0.3] }, + { + partType: 'headlights', + id: 'front-lights', + position: [2.1, 0.75, 0], + rotation: [0, Math.PI / 2, 0], + }, + { + partType: 'bumper', + id: 'front-bumper', + length: 1.7, + height: 0.3, + position: [2.3, 0.45, 0], + rotation: [0, Math.PI / 2, 0], + }, + { + partType: 'bumper', + id: 'rear-bumper', + length: 1.7, + height: 0.3, + position: [-2.3, 0.45, 0], + rotation: [0, Math.PI / 2, 0], + }, + ], + }) + + const bodyShell = car.find((shape) => shape.name?.includes('vehicle body shell')) + expect(bodyShell?.material?.properties?.color).toBe('#8B5CF6') + expect(bodyShell?.height).toBeLessThan(0.8) + + const tires = car.filter((shape) => shape.name?.includes('vehicle tire')) + expect(tires).toHaveLength(4) + expect(Math.min(...tires.map((shape) => shape.position?.[0] ?? 0))).toBeLessThan(-1.4) + expect(Math.max(...tires.map((shape) => shape.position?.[0] ?? 0))).toBeGreaterThan(1.4) + expect(Math.max(...tires.map((shape) => Math.abs(shape.position?.[2] ?? 0)))).toBeGreaterThan( + 0.7, + ) + + const windshield = car.find((shape) => shape.name?.includes('windshield')) + expect(windshield?.rotation?.[2]).toBeLessThan(Math.PI / 2) + expect(windshield?.position?.[2]).toBeCloseTo(0) + + const rearWindow = car.find((shape) => shape.name?.includes('rear window')) + expect(rearWindow?.rotation?.[2]).toBeGreaterThan(Math.PI / 2) + + const sideWindow = car.find((shape) => shape.name?.includes('side window left')) + expect(sideWindow?.rotation?.[0]).toBeCloseTo(Math.PI / 2) + expect( + car.filter( + (shape) => shape.name?.includes('vehicle wheel arch lip') && shape.kind === 'torus', + ), + ).toHaveLength(4) + expect(car.filter((shape) => shape.name?.includes('vehicle wheel well shadow'))).toHaveLength(4) + + const frontBumper = car.find((shape) => shape.name?.includes('front bumper bar')) + expect(frontBumper?.rotation).toBeUndefined() + expect(frontBumper?.position?.[0]).toBeGreaterThan(2.2) + }) + + test('honors compact vehicle size and body-local color aliases', () => { + const car = composePartPrimitives({ + name: 'small red car', + parts: [ + { + kind: 'vehicle_body', + primaryColor: '#cc0000', + vehicleStyle: 'sedan', + sizeScale: 0.8, + }, + { kind: 'vehicle_wheels' }, + { kind: 'vehicle_windows' }, + { kind: 'headlights' }, + { kind: 'bumper' }, + ], + }) + + const bodyShell = car.find((shape) => shape.name?.includes('vehicle body shell')) + expect(bodyShell?.length).toBeCloseTo(3.52) + expect(bodyShell?.width).toBeCloseTo(1.44) + expect(bodyShell?.material?.properties?.color).toBe('#cc0000') + + const cabin = car.find((shape) => shape.name?.includes('vehicle cabin frame')) + expect(cabin?.kind).toBe('trapezoid-prism') + expect(cabin?.length).toBeLessThan((bodyShell?.length ?? 0) * 0.45) + expect(cabin?.length).toBeGreaterThan((bodyShell?.length ?? 0) * 0.4) + expect(cabin?.height).toBeLessThan(0.1) + + const roof = car.find((shape) => shape.name?.includes('vehicle roof cap')) + expect(roof?.semanticRole).toBe('vehicle_roof') + expect(roof?.kind).toBe('rounded-panel') + expect(roof?.thickness).toBeGreaterThan(0.02) + expect(roof?.thickness).toBeLessThan(0.05) + expect(roof?.width).toBeLessThan(cabin?.width ?? Number.POSITIVE_INFINITY) + expect(roof?.position?.[1]).toBeGreaterThan(cabin?.position?.[1] ?? 0) + + const hood = car.find((shape) => shape.name?.includes('front deck hood surface')) + expect(hood?.kind).toBe('wedge') + expect(hood?.height).toBeLessThan((bodyShell?.height ?? 1) * 0.12) + + const glasshouse = car.find((shape) => shape.name?.includes('integrated vehicle glasshouse')) + expect(glasshouse?.kind).toBe('trapezoid-prism') + expect(glasshouse?.semanticRole).toBe('vehicle_window') + expect(glasshouse?.material?.properties?.transparent).toBe(true) + expect(glasshouse?.topLengthScale).toBeLessThan(1) + + const pillars = car.filter((shape) => shape.semanticRole === 'vehicle_pillar') + expect(pillars).toHaveLength(6) + expect(pillars.every((shape) => shape.material?.properties?.color === '#cc0000')).toBe(true) + + const roofRails = car.filter((shape) => shape.name?.includes('vehicle roof rail')) + expect(roofRails).toHaveLength(2) + + const windshield = car.find((shape) => shape.name?.includes('windshield')) + expect(windshield?.material?.properties?.transparent).toBe(true) + expect(windshield?.material?.properties?.color).toBe('#1e3a8a') + expect(windshield?.rotation?.[2]).toBeLessThan(Math.PI / 2) + + const rearWindow = car.find((shape) => shape.name?.includes('rear window')) + expect(rearWindow?.rotation?.[2]).toBeGreaterThan(Math.PI / 2) + + const sideWindows = car.filter( + (shape) => shape.name?.includes('side window left') && shape.kind === 'rounded-panel', + ) + expect(sideWindows).toHaveLength(2) + expect(sideWindows.every((shape) => (shape.width ?? 0) > (windshield?.length ?? 0) * 0.7)).toBe( + true, + ) + + expect(car.some((shape) => shape.name?.includes('vehicle wheel arch lip'))).toBe(false) + expect(car.some((shape) => shape.name?.includes('vehicle wheel well shadow'))).toBe(false) + }) + + test('uses a tapered trapezoid cabin when vehicle roof corners are requested below 90 degrees', () => { + const car = composePartPrimitives({ + name: 'Red streamlined car', + primaryColor: '#cc0000', + autoComplete: false, + parts: [ + { + kind: 'vehicle_body', + length: 4, + width: 1.6, + height: 1.25, + roofCornerAngle: 85, + cornerRadius: 0.14, + cornerSegments: 10, + }, + ], + }) + + const cabin = car.find((shape) => shape.name?.includes('vehicle cabin frame')) + expect(cabin?.kind).toBe('trapezoid-prism') + expect(cabin?.topLengthScale).toBeCloseTo(0.9) + expect(cabin?.topWidthScale).toBeCloseTo(0.9) + expect(cabin?.semanticRole).toBe('vehicle_cabin') + }) + + test('derives distinct vehicle style proportions from intent', () => { + const sedan = composePartPrimitives({ + name: 'family sedan', + parts: [{ kind: 'vehicle_body' }], + }) + const suv = composePartPrimitives({ + name: 'offroad SUV', + parts: [{ kind: 'vehicle_body' }], + }) + const sports = composePartPrimitives({ + name: 'red sports car', + parts: [{ kind: 'vehicle_body' }], + }) + const truck = composePartPrimitives({ + name: 'pickup truck', + parts: [{ kind: 'vehicle_body' }], + }) + + const body = (shapes: ReturnType) => + shapes.find((shape) => shape.name?.includes('vehicle body shell')) + const cabin = (shapes: ReturnType) => + shapes.find((shape) => shape.name?.includes('vehicle cabin frame')) + const tireRadius = (shapes: ReturnType) => + shapes.find((shape) => shape.name?.includes('vehicle tire'))?.majorRadius ?? 0 + + expect(body(suv)?.position?.[1]).toBeGreaterThan(body(sedan)?.position?.[1] ?? 0) + expect(body(sports)?.position?.[1]).toBeLessThan(body(sedan)?.position?.[1] ?? 0) + expect(tireRadius(sports)).toBeGreaterThan(tireRadius(sedan)) + expect(cabin(sports)?.topLengthScale).toBeLessThan(cabin(sedan)?.topLengthScale ?? 1) + expect(cabin(suv)?.length).toBeGreaterThan(cabin(sedan)?.length ?? 0) + expect(truck.some((shape) => shape.name?.includes('truck cargo bed'))).toBe(true) + }) + + test('composes expanded factory equipment families and visual details', () => { + const shapes = composePartPrimitives({ + name: 'Plant equipment', + autoComplete: false, + parts: [ + { kind: 'gearbox_body' }, + { kind: 'filter_vessel', position: [0.8, 0.62, 0] }, + { kind: 'heat_exchanger', position: [1.6, 0.52, 0] }, + { kind: 'agitator_tank', position: [2.6, 0.58, 0] }, + { kind: 'pipe_rack', position: [3.7, 0.45, 0], count: 2 }, + { kind: 'platform_ladder', position: [4.8, 0.75, 0] }, + { kind: 'nameplate', position: [0, 0.45, 0.2] }, + { kind: 'warning_label', position: [0.1, 0.52, 0.21] }, + { kind: 'seam_ring', position: [0.8, 0.98, 0], axis: 'y', radius: 0.18 }, + ], + }) + + expect(shapes.some((shape) => shape.name?.includes('gearbox housing'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('filter vessel shell'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('heat exchanger shell'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('agitator tank shell'))).toBe(true) + expect(shapes.filter((shape) => shape.name?.includes('rack pipe'))).toHaveLength(2) + expect(shapes.some((shape) => shape.name?.includes('access platform deck'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('nameplate'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('warning label'))).toBe(true) + expect(shapes.some((shape) => shape.name?.includes('seam ring'))).toBe(true) + }) + + test('supports part connections and blueprint assessment', () => { + const shapes = composePartPrimitives({ + name: 'Connected pump port', + autoComplete: false, + parts: [ + { + id: 'port', + kind: 'pipe_port', + position: [0, 0, 0], + axis: 'z', + length: 0.4, + radius: 0.08, + }, + { + kind: 'flange_ring', + connectTo: 'port', + anchor: 'front', + childAnchor: 'back', + axis: 'z', + radius: 0.12, + }, + ], + }) + const flange = shapes.find((shape) => shape.name?.includes('flange ring')) + expect(flange?.position?.[2]).toBeCloseTo(0.2175) + + const assessment = assessPartBlueprint({ + parts: [{ kind: 'volute_casing' }, { kind: 'inlet_port' }], + }) + expect(assessment.family).toBe('pump') + expect(assessment.score).toBeLessThan(1) + expect(assessment.missing).toContain('outlet_port') + expect(assessment.recommendations.length).toBeGreaterThan(0) + }) + + test('supports semantic part connection points', () => { + const shapes = composePartPrimitives({ + name: 'Semantic pump connections', + autoComplete: false, + parts: [ + { + id: 'casing', + kind: 'volute_casing', + position: [0.2, 0.4, 0.05], + radius: 0.2, + depth: 0.1, + outletAngle: 0, + }, + { + id: 'suction-flange', + name: 'suction-flange', + kind: 'flange_ring', + connectTo: 'casing', + connectPoint: 'inlet', + childPoint: 'back', + axis: 'z', + radius: 0.1, + includeBolts: false, + }, + { + id: 'outlet-pipe', + name: 'outlet-pipe', + kind: 'outlet_port', + connectTo: 'casing', + connectPoint: 'outlet', + childPoint: 'base', + axis: 'x', + radius: 0.05, + length: 0.24, + }, + ], + }) + + const suctionFlange = shapes.find((shape) => shape.name?.includes('suction-flange flange ring')) + const outletPipe = shapes.find((shape) => shape.name?.includes('outlet-pipe outlet port')) + expect(suctionFlange?.position?.[2]).toBeCloseTo(0.1215) + expect(outletPipe?.position?.[0]).toBeCloseTo(0.532) + }) + + test('resolves compose_parts spatial relationship fields before manual coordinates', () => { + const shapes = composePartPrimitives({ + name: 'Relation layout', + autoComplete: false, + parts: [ + { + id: 'base', + name: 'base', + kind: 'skid_base', + length: 1, + width: 0.4, + height: 0.08, + position: [0, 0.04, 0], + }, + { + id: 'motor', + name: 'motor', + kind: 'ribbed_motor_body', + centeredOn: 'base', + axis: 'x', + length: 0.4, + radius: 0.1, + }, + { + id: 'controls', + name: 'controls', + kind: 'control_box', + alignAbove: 'base', + relationGap: 0.02, + width: 0.2, + depth: 0.08, + height: 0.16, + }, + { + id: 'side-module', + name: 'side-module', + kind: 'control_box', + alignBeside: 'base', + side: 'right', + relationGap: 0.03, + width: 0.2, + depth: 0.08, + height: 0.16, + }, + ], + }) + + const motor = shapes.find((shape) => shape.name?.includes('motor ribbed motor body')) + expect(motor?.position).toEqual([0, 0.42, 0]) + + const controls = shapes.find((shape) => shape.name?.includes('controls control box')) + expect(controls?.position?.[0]).toBeCloseTo(0) + expect(controls?.position?.[1]).toBeCloseTo(0.18) + expect(controls?.position?.[2]).toBeCloseTo(0) + + const sideModule = shapes.find((shape) => shape.name?.includes('side-module control box')) + expect(sideModule?.position?.[0]).toBeCloseTo(0.63) + expect(sideModule?.position?.[1]).toBeCloseTo(0.04) + expect(sideModule?.position?.[2]).toBeCloseTo(0) + }) + + test('prefers relationship placement over conflicting manual coordinates', () => { + const shapes = composePartPrimitives({ + name: 'Relation conflict layout', + autoComplete: false, + parts: [ + { + id: 'left_leg', + name: 'left leg', + kind: 'generic_body', + semanticRole: 'support_column', + length: 0.4, + width: 0.4, + height: 4, + position: [-2, 2, 0], + }, + { + id: 'right_leg', + name: 'right leg', + kind: 'generic_body', + semanticRole: 'support_column', + length: 0.4, + width: 0.4, + height: 4, + alignBeside: 'left_leg', + side: 'right', + relationGap: 3.6, + position: [-2, 2, 0], + }, + { + id: 'girder', + name: 'top girder', + kind: 'generic_body', + semanticRole: 'spanning_beam', + length: 4.8, + width: 0.5, + height: 0.4, + alignAbove: 'left_leg', + position: [0, 2, 0], + }, + ], + }) + + const leftLeg = shapes.find((shape) => shape.sourcePartId === 'left_leg') + const rightLeg = shapes.find((shape) => shape.sourcePartId === 'right_leg') + const girder = shapes.find((shape) => shape.semanticRole === 'spanning_beam') + expect(rightLeg?.position?.[0]).toBeGreaterThan(leftLeg?.position?.[0] ?? 0) + expect(girder?.position?.[1]).toBeGreaterThan(leftLeg?.position?.[1] ?? 0) + expect(girder?.position?.[1]).toBeCloseTo(4.2) + }) + + test('treats alignAbove with bottom side as bottom attachment', () => { + const shapes = composePartPrimitives({ + name: 'Bottom side relation layout', + autoComplete: false, + parts: [ + { + id: 'body', + name: 'body', + kind: 'generic_body', + length: 1, + width: 0.6, + height: 0.8, + position: [0, 0.4, 0], + }, + { + id: 'feet', + name: 'feet', + kind: 'generic_foot_set', + alignAbove: 'body', + side: 'bottom', + radius: 0.04, + height: 0.12, + }, + ], + }) + + const body = shapes.find((shape) => shape.name?.includes('body generic body')) + const foot = shapes.find((shape) => shape.semanticRole === 'support_foot') + expect(foot?.position?.[1]).toBeLessThan(body?.position?.[1] ?? 0) + }) + + test('expands around relationship into evenly distributed circular parts', () => { + const shapes = composePartPrimitives({ + name: 'Around layout', + autoComplete: false, + parts: [ + { + id: 'tank', + name: 'tank', + kind: 'cylindrical_tank', + axis: 'y', + radius: 0.4, + length: 1, + position: [0, 0.5, 0], + }, + { + id: 'foot', + name: 'support foot', + kind: 'control_box', + around: 'tank', + aroundCount: 4, + aroundRadius: 0.55, + width: 0.12, + depth: 0.08, + height: 0.16, + }, + ], + }) + + const feet = shapes.filter((shape) => shape.name?.match(/support foot \d control box$/)) + expect(feet).toHaveLength(4) + expect(feet[0]?.position?.[0]).toBeCloseTo(0.55) + expect(feet[0]?.position?.[2]).toBeCloseTo(0) + expect(feet[1]?.position?.[0]).toBeCloseTo(0) + expect(feet[1]?.position?.[2]).toBeCloseTo(0.55) + expect(feet[2]?.position?.[0]).toBeCloseTo(-0.55) + expect(feet[2]?.position?.[2]).toBeCloseTo(0) + expect(feet[3]?.position?.[0]).toBeCloseTo(0) + expect(feet[3]?.position?.[2]).toBeCloseTo(-0.55) + }) + + test('expands linear part arrays after relationship placement', () => { + const shapes = composePartPrimitives({ + name: 'Array layout', + autoComplete: false, + parts: [ + { + id: 'block', + name: 'block', + kind: 'rounded_machine_body', + length: 1, + width: 0.4, + height: 0.24, + position: [0, 0.12, 0], + }, + { + id: 'cylinder', + name: 'cylinder head', + kind: 'control_box', + alignAbove: 'block', + width: 0.08, + depth: 0.08, + height: 0.1, + array: { count: 3, axis: 'x', spacing: 0.2 }, + }, + ], + }) + + const heads = shapes.filter((shape) => shape.name?.match(/cylinder head \d control box$/)) + expect(heads).toHaveLength(3) + expect(heads[0]?.position?.[0]).toBeCloseTo(-0.2) + expect(heads[1]?.position?.[0]).toBeCloseTo(0) + expect(heads[2]?.position?.[0]).toBeCloseTo(0.2) + for (const head of heads) { + expect(head.position?.[1]).toBeCloseTo(0.29) + expect(head.position?.[2]).toBeCloseTo(0) + } + }) + + test('places around corner patterns on rectangular parent corners', () => { + const shapes = composePartPrimitives({ + name: 'Corner layout', + autoComplete: false, + parts: [ + { + id: 'base', + name: 'base', + kind: 'skid_base', + length: 1, + width: 0.6, + height: 0.08, + position: [0, 0.04, 0], + }, + { + id: 'foot', + name: 'corner foot', + kind: 'control_box', + around: 'base', + cornerPattern: true, + width: 0.1, + depth: 0.1, + height: 0.12, + }, + ], + }) + + const feet = shapes.filter((shape) => shape.name?.match(/corner foot \d control box$/)) + expect(feet).toHaveLength(4) + expect(feet[0]?.position?.[0]).toBeCloseTo(-0.45) + expect(feet[0]?.position?.[2]).toBeCloseTo(-0.25) + expect(feet[1]?.position?.[0]).toBeCloseTo(0.45) + expect(feet[1]?.position?.[2]).toBeCloseTo(-0.25) + expect(feet[2]?.position?.[0]).toBeCloseTo(0.45) + expect(feet[2]?.position?.[2]).toBeCloseTo(0.25) + expect(feet[3]?.position?.[0]).toBeCloseTo(-0.45) + expect(feet[3]?.position?.[2]).toBeCloseTo(0.25) + }) + + test('links common mechanical parts with contextual defaults', () => { + const fan = composePartPrimitives({ + name: 'Context fan', + autoComplete: false, + parts: [ + { + id: 'blades', + name: 'blades', + kind: 'radial_blades', + radius: 0.3, + position: [0, 1.2, 0.04], + }, + { + id: 'grill', + name: 'grill', + kind: 'protective_grill', + }, + ], + }) + const grillRing = fan.find((shape) => shape.name?.includes('grill front ring 4')) + expect(grillRing?.position?.[0]).toBeCloseTo(0) + expect(grillRing?.position?.[1]).toBeCloseTo(1.18) + expect(grillRing?.position?.[2]).toBeCloseTo(0.04) + expect(grillRing?.majorRadius).toBeCloseTo(0.354) + + const pump = composePartPrimitives({ + name: 'Context pump', + autoComplete: false, + parts: [ + { + id: 'casing', + name: 'casing', + kind: 'volute_casing', + radius: 0.32, + depth: 0.16, + position: [0, 0.55, 0.2], + }, + { id: 'suction', name: 'suction', kind: 'inlet_port', radius: 0.06, length: 0.2 }, + { id: 'discharge', name: 'discharge', kind: 'outlet_port', radius: 0.05, length: 0.24 }, + ], + }) + const suction = pump.find((shape) => shape.name?.includes('suction inlet port')) + const discharge = pump.find((shape) => shape.name?.includes('discharge outlet port')) + expect(suction?.position?.[2]).toBeCloseTo(0.3864) + expect(discharge?.position?.[0]).toBeCloseTo(0.4267) + }) + + test('treats fan blade parts as mixer blades in shaft agitator context', () => { + const shapes = composePartPrimitives({ + name: 'shaft mixer impeller', + autoComplete: false, + parts: [ + { id: 'shaft', kind: 'vertical_pole', semanticRole: 'mixer_shaft', height: 0.8 }, + { id: 'hub', kind: 'circular_base', semanticRole: 'mixer_hub', radius: 0.05 }, + { + id: 'blades', + kind: 'fan_blade', + semanticRole: 'mixer_blade', + count: 3, + length: 0.32, + width: 0.16, + }, + ], + }) + + const bladeShapes = shapes.filter((shape) => shape.sourcePartKind === 'mixer_blades') + const fanShapes = shapes.filter((shape) => shape.sourcePartKind === 'fan_blade') + + expect(bladeShapes.filter((shape) => shape.semanticRole === 'mixer_blade')).toHaveLength(3) + expect(fanShapes).toHaveLength(0) + expect(shapes.some((shape) => shape.semanticRole === 'mixer_shaft')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'mixer_hub')).toBe(true) + }) + + test('composes propeller blade sets as reusable taiji-half paddles', () => { + const shapes = composePartPrimitives({ + name: 'Generic agitator', + autoComplete: false, + parts: [ + { + kind: 'propeller_blade_set', + count: 3, + bladeRadius: 0.42, + bladeWidth: 0.16, + depth: 0.032, + bladePitch: 0.52, + bladeShape: 'taiji_half', + verticalCurve: 0.07, + wireRadius: 0.055, + }, + ], + }) + + const blades = shapes.filter((shape) => shape.semanticRole === 'propeller_blade') + expect(blades).toHaveLength(3) + expect(blades.every((shape) => shape.sourcePartKind === 'propeller_blade_set')).toBe(true) + expect(blades.every((shape) => shape.name?.includes('taiji half propeller blade'))).toBe(true) + const profile = blades[0]?.profile ?? [] + expect(profile.length).toBeGreaterThanOrEqual(26) + const maxY = Math.max(...profile.map(([, y]) => y)) + const minY = Math.min(...profile.map(([, y]) => y)) + expect(maxY).toBeGreaterThan(Math.abs(minY) * 0.8) + expect(blades.every((shape) => Math.abs((shape.rotation?.[0] ?? 0) + Math.PI / 2) > 0.18)).toBe( + true, + ) + blades.forEach(expectBladeRotationMatchesRadialPlacement) + }) + + test('composes mixer blades through the reusable taiji-half blade set kernel', () => { + const shapes = composePartPrimitives({ + name: 'Mud mixer', + autoComplete: false, + parts: [ + { + kind: 'mixer_blades', + count: 3, + bladeRadius: 0.42, + bladeWidth: 0.16, + depth: 0.032, + bladePitch: 0.52, + wireRadius: 0.055, + }, + ], + }) + + const blades = shapes.filter((shape) => shape.semanticRole === 'mixer_blade') + expect(blades).toHaveLength(3) + expect(blades.every((shape) => shape.kind === 'extrude')).toBe(true) + expect(blades.every((shape) => shape.name?.includes('taiji half mixer propeller blade'))).toBe( + true, + ) + expect(blades.every((shape) => (shape.profile?.length ?? 0) >= 20)).toBe(true) + expect(blades.every((shape) => shape.rotation?.[0] === -Math.PI / 2)).toBe(true) + expect(blades.every((shape) => shape.rotation?.[1] === 0)).toBe(true) + expect(blades.every((shape) => shape.rotation?.[2] === 0)).toBe(true) + expect(new Set(blades.map((shape) => JSON.stringify(shape.profile?.slice(0, 3)))).size).toBe(3) + const profile = blades[0]?.profile ?? [] + const minX = Math.min(...profile.map(([x]) => x)) + const maxX = Math.max(...profile.map(([x]) => x)) + const widestX = profile.reduce((best, [x]) => { + const widthAtBest = profile.filter(([px]) => px === best).map(([, y]) => y) + const widthAtX = profile.filter(([px]) => px === x).map(([, y]) => y) + return Math.max(...widthAtX) - Math.min(...widthAtX) > + Math.max(...widthAtBest) - Math.min(...widthAtBest) + ? x + : best + }, minX) + const widthAt = (xValue: number) => { + const ys = profile.filter(([x]) => x === xValue).map(([, y]) => y) + return Math.max(...ys) - Math.min(...ys) + } + expect(widthAt(widestX)).toBeGreaterThan(widthAt(minX) * 1.8) + expect(widthAt(widestX)).toBeGreaterThan(widthAt(maxX) * 1.8) + }) + + test('composes curved organic and aerodynamic reusable parts', () => { + const propeller = composePartPrimitives({ + name: 'Curved propeller', + autoComplete: false, + parts: [ + { + kind: 'airfoil_blade', + name: 'propeller', + count: 3, + length: 0.6, + rootWidth: 0.18, + tipWidth: 0.06, + thickness: 0.025, + pitch: 0.42, + camber: 0.04, + }, + ], + }) + const blades = propeller.filter((shape) => shape.semanticRole === 'airfoil_blade') + expect(blades).toHaveLength(3) + expect(blades.every((shape) => shape.kind === 'extrude')).toBe(true) + expect(blades.every((shape) => (shape.profile?.length ?? 0) >= 20)).toBe(true) + blades.forEach(expectBladeRotationMatchesRadialPlacement) + expect(propeller.some((shape) => shape.semanticRole === 'airfoil_hub')).toBe(true) + + const lens = composePartPrimitives({ + name: 'Frog sunglasses', + autoComplete: false, + parts: [ + { + kind: 'curved_lens_panel', + name: 'frog lens', + lensShape: 'frog', + width: 0.34, + height: 0.2, + curvature: 0.12, + color: '#111827', + }, + ], + }) + const lensPanel = lens.find((shape) => shape.semanticRole === 'curved_lens') + expect(lensPanel?.kind).toBe('extrude') + expect(lensPanel?.material?.properties?.transparent).toBe(true) + expect(lens.some((shape) => shape.semanticRole === 'lens_rim')).toBe(true) + + const mouse = composePartPrimitives({ + name: 'Mouse', + autoComplete: false, + parts: [{ kind: 'ergonomic_shell', name: 'mouse shell', style: 'mouse' }], + }) + expect(mouse.some((shape) => shape.semanticRole === 'ergonomic_shell')).toBe(true) + expect(mouse.filter((shape) => shape.semanticRole === 'mouse_button')).toHaveLength(2) + expect(mouse.some((shape) => shape.semanticRole === 'scroll_wheel')).toBe(true) + + const helmet = composePartPrimitives({ + name: 'Helmet shell', + autoComplete: false, + parts: [ + { + kind: 'ellipsoid_shell', + name: 'helmet', + length: 0.34, + width: 0.24, + height: 0.18, + shellThickness: 0.012, + }, + ], + }) + expect(helmet.some((shape) => shape.semanticRole === 'ellipsoid_shell')).toBe(true) + expect(helmet.some((shape) => shape.semanticRole === 'ellipsoid_shell_rim')).toBe(true) + expect(helmet.some((shape) => shape.semanticRole === 'ellipsoid_shell_opening')).toBe(true) + + const curvedPanel = composePartPrimitives({ + name: 'Curved machine panel', + autoComplete: false, + parts: [{ kind: 'curved_panel', width: 0.4, height: 0.22, curvature: 0.16 }], + }) + expect(curvedPanel.some((shape) => shape.sourcePartKind === 'curved_lens_panel')).toBe(true) + }) + + test('composes streamlined bodies and lofted panels from reusable curved parts', () => { + const aircraft = composePartPrimitives({ + name: 'Aircraft body', + autoComplete: false, + parts: [ + { + kind: 'streamlined_body', + name: 'fuselage', + length: 1.6, + width: 0.32, + height: 0.22, + noseRoundness: 0.75, + tailTaper: 0.45, + roofArc: 0.18, + }, + ], + }) + expect(aircraft.some((shape) => shape.semanticRole === 'streamlined_body')).toBe(true) + expect(aircraft.some((shape) => shape.semanticRole === 'streamlined_nose')).toBe(true) + expect(aircraft.some((shape) => shape.semanticRole === 'streamlined_tail')).toBe(true) + expect(aircraft.some((shape) => shape.semanticRole === 'streamlined_roof_arc')).toBe(true) + + const loft = composePartPrimitives({ + name: 'Transition fairing', + autoComplete: false, + parts: [ + { + kind: 'lofted_shell', + name: 'fairing', + length: 0.9, + width: 0.34, + height: 0.16, + thickness: 0.018, + sections: [ + { x: -0.45, width: 0.4, height: 0.12 }, + { x: 0, width: 0.3, height: 0.18, y: 0.02 }, + { x: 0.45, width: 0.12, height: 0.08 }, + ], + }, + ], + }) + expect(loft.filter((shape) => shape.semanticRole === 'lofted_panel_segment')).toHaveLength(2) + expect(loft.some((shape) => shape.semanticRole === 'lofted_panel_root')).toBe(true) + expect(loft.some((shape) => shape.semanticRole === 'lofted_panel_tip')).toBe(true) + }) + + test('auto-completes a Boeing 717 style airliner from aircraft intent', () => { + const shapes = composePartPrimitives({ + name: '生成一架波音717客机', + detail: 'medium', + parts: [], + }) + + const fuselage = shapes.find((shape) => shape.semanticRole === 'aircraft_fuselage') + expect(fuselage).toBeDefined() + const wings = shapes.filter((shape) => shape.semanticRole === 'aircraft_wing') + expect(wings).toHaveLength(2) + expect(wings.every((shape) => shape.kind === 'extrude')).toBe(true) + expect(wings.every((shape) => (shape.profile?.length ?? 0) >= 4)).toBe(true) + expect(wings.map((shape) => Math.sign(shape.position?.[2] ?? 0)).sort()).toEqual([-1, 1]) + expect(shapes.filter((shape) => shape.semanticRole === 'aircraft_winglet')).toHaveLength(2) + expect(shapes.filter((shape) => shape.semanticRole === 'engine_nacelle_left')).toHaveLength(1) + expect(shapes.filter((shape) => shape.semanticRole === 'engine_nacelle_right')).toHaveLength(1) + expect(shapes.some((shape) => shape.semanticRole === 'vertical_stabilizer')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'horizontal_stabilizer')).toHaveLength(2) + expect( + shapes.filter((shape) => shape.semanticRole === 'aircraft_landing_gear_nose'), + ).toHaveLength(2) + expect( + shapes.filter((shape) => shape.semanticRole === 'aircraft_landing_gear_main'), + ).toHaveLength(4) + const inferredAircraftLength = ((fuselage?.scale?.[0] as number | undefined) ?? 1) / 0.78 + const cabinWindows = shapes.filter((shape) => shape.semanticRole === 'cabin_window') + expect(cabinWindows.length).toBeGreaterThan(20) + expect( + cabinWindows.every( + (shape) => + shape.kind === 'conformal-strip' && + shape.xStart != null && + shape.xEnd != null && + shape.xEnd - shape.xStart < inferredAircraftLength * 0.03 && + shape.xEnd / inferredAircraftLength < 0.22 && + (shape.width ?? 0) < inferredAircraftLength * 0.035 && + (shape.material?.properties?.opacity ?? 0) >= 0.85, + ), + ).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'cockpit_window')).toBe(true) + const cockpitWindows = shapes.filter((shape) => shape.semanticRole === 'cockpit_window') + expect(cockpitWindows).toHaveLength(4) + expect( + cockpitWindows.every( + (shape) => + shape.kind === 'conformal-strip' && + shape.surface === 'ellipsoid-cylinder' && + shape.xStart != null && + shape.xEnd != null && + shape.xStart / inferredAircraftLength > 0.26 && + shape.xEnd / inferredAircraftLength < 0.35 && + shape.verticalOffset != null && + shape.verticalOffset > 0 && + shape.surfaceRadiusY != null && + shape.surfaceRadiusZ != null && + shape.rotation == null, + ), + ).toBe(true) + expect( + cockpitWindows.every((shape) => (shape.material?.properties?.opacity ?? 0) >= 0.85), + ).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'aircraft_nose')).toBe(false) + expect(shapes.some((shape) => shape.semanticRole === 'streamlined_roof_arc')).toBe(false) + const stripes = shapes.filter((shape) => shape.semanticRole === 'aircraft_livery_stripe') + expect(stripes).toHaveLength(2) + expect(stripes.every((shape) => shape.kind === 'conformal-strip')).toBe(true) + expect(stripes.map((shape) => shape.side).sort()).toEqual(['left', 'right']) + expect(stripes.every((shape) => shape.surfaceRadiusY && shape.surfaceRadiusZ)).toBe(true) + expect(stripes.every((shape) => shape.surfaceLength)).toBe(true) + expect(stripes.every((shape) => shape.endTaper === 0.42 && shape.widthSegments === 4)).toBe( + true, + ) + + const assessment = assessPartBlueprint({ + name: 'Boeing 717 airliner', + parts: [{ kind: 'fuselage_tube' }, { kind: 'low_mounted_wings' }], + }) + expect(assessment.family).toBe('aircraft') + expect(assessment.missing).toContain('aircraft_engine') + }) + + test('scales aircraft default blueprint from top-level length', () => { + const shapes = composePartPrimitives({ + name: 'Boeing 717 airliner', + length: 5, + geometryBrief: { category: 'aircraft', expectedDimensions: { length: 5 } }, + parts: [], + }) + + const fuselage = shapes.find((shape) => shape.semanticRole === 'aircraft_fuselage') + const wing = shapes.find((shape) => shape.semanticRole === 'aircraft_wing') + + expect(fuselage?.scale?.[0]).toBeCloseTo(3.9) + expect(wing?.kind).toBe('extrude') + expect(wing?.profile?.length).toBeGreaterThanOrEqual(4) + expect(Math.max(...(wing?.profile ?? []).map(([, y]) => Math.abs(y)))).toBeGreaterThan(1) + }) + + test('preserves 10 meter aircraft length in default blueprint', () => { + const shapes = composePartPrimitives({ + name: '10 meter aircraft', + length: 10, + geometryBrief: { category: 'aircraft', expectedDimensions: { length: 10 } }, + parts: [{ kind: 'aircraft_fuselage' }], + }) + + const fuselage = shapes.find((shape) => shape.semanticRole === 'aircraft_fuselage') + const wing = shapes.find((shape) => shape.semanticRole === 'aircraft_wing') + const engine = shapes.find((shape) => shape.semanticRole === 'engine_nacelle_left') + + expect(fuselage?.scale?.[0]).toBeCloseTo(7.8) + expect(shapes.some((shape) => shape.semanticRole === 'aircraft_nose')).toBe(false) + expect(wing?.kind).toBe('extrude') + expect(wing?.depth).toBeLessThanOrEqual(0.08) + expect(Math.max(...(wing?.profile ?? []).map(([, y]) => Math.abs(y)))).toBeGreaterThan(2) + expect(shapes.some((shape) => shape.semanticRole === 'aircraft_winglet')).toBe(true) + expect(engine?.radius).toBeLessThan(0.36) + }) + + test('applies compose_parts dimensions to the primary explicit part', () => { + const shapes = composePartPrimitives({ + name: 'Boeing 717 airliner', + geometryBrief: { category: 'aircraft', expectedDimensions: { length: 5 } }, + parts: [{ kind: 'aircraft_fuselage' }], + }) + + const fuselage = shapes.find((shape) => shape.semanticRole === 'aircraft_fuselage') + + expect(fuselage?.scale?.[0]).toBeCloseTo(3.9) + }) + + test('keeps aircraft default parts aligned when the LLM supplies only a fuselage part', () => { + const shapes = composePartPrimitives({ + name: 'Boeing 717 airliner', + length: 8, + geometryBrief: { category: 'aircraft', expectedDimensions: { length: 8 } }, + parts: [{ kind: 'aircraft_fuselage' }], + }) + + const fuselage = shapes.find((shape) => shape.semanticRole === 'aircraft_fuselage') + const wing = shapes.find((shape) => shape.semanticRole === 'aircraft_wing') + const engine = shapes.find((shape) => shape.semanticRole === 'engine_nacelle_left') + const verticalTail = shapes.find((shape) => shape.semanticRole === 'vertical_stabilizer') + const horizontalTail = shapes.find((shape) => shape.semanticRole === 'horizontal_stabilizer') + const horizontalTails = shapes.filter((shape) => shape.semanticRole === 'horizontal_stabilizer') + const noseGear = shapes.find((shape) => shape.semanticRole === 'aircraft_landing_gear_nose') + + expect(fuselage?.scale?.[0]).toBeCloseTo(6.24) + expect(wing?.position?.[1]).toBeLessThan(fuselage?.position?.[1] ?? 0) + expect(wing?.position?.[1]).toBeGreaterThan((fuselage?.position?.[1] ?? 0) - 0.35) + expect(engine?.position?.[1]).toBeLessThan(wing?.position?.[1] ?? 0) + expect(Math.abs(engine?.position?.[2] ?? 0)).toBeGreaterThan( + ((fuselage?.scale?.[2] as number | undefined) ?? 0) * 0.7, + ) + expect(verticalTail?.position?.[1]).toBeGreaterThan(fuselage?.position?.[1] ?? 0) + expect(horizontalTail?.position?.[1]).toBeGreaterThan(verticalTail?.position?.[1] ?? 0) + expect(horizontalTails.map((shape) => Math.sign(shape.position?.[2] ?? 0)).sort()).toEqual([ + -1, 1, + ]) + expect(noseGear?.position?.[1]).toBeLessThan((fuselage?.position?.[1] ?? 0) - 0.8) + expect(noseGear?.position?.[1]).toBeGreaterThan(0) + }) + + test('assesses alternative required parts and auto-completes stronger family structures', () => { + const pumpAssessment = assessPartBlueprint({ + parts: [ + { kind: 'skid_base' }, + { kind: 'rounded_machine_body' }, + { kind: 'volute_casing' }, + { kind: 'inlet_port' }, + { kind: 'outlet_port' }, + { kind: 'flange_ring' }, + ], + }) + expect(pumpAssessment.family).toBe('pump') + expect(pumpAssessment.missing).not.toContain('ribbed_motor_body') + expect(pumpAssessment.missingDetails).toContain('impeller_blades') + + const fan = composePartPrimitives({ + name: 'Auto fan', + parts: [{ kind: 'protective_grill' }], + }) + expect(fan.some((shape) => shape.name?.includes('circular base'))).toBe(true) + expect(fan.some((shape) => shape.name?.includes('vertical pole'))).toBe(true) + expect(fan.some((shape) => shape.name?.includes('bracket crossbar'))).toBe(true) + expect(fan.some((shape) => shape.name?.includes('motor housing'))).toBe(true) + expect(fan.filter((shape) => Boolean(shape.name?.match(/ blade \d+$/)))).toHaveLength(3) + }) + + test('controls protective grill complexity with detail levels', () => { + const low = composePartPrimitives({ + name: 'Low detail grill', + autoComplete: false, + parts: [{ kind: 'protective_grill', detailLevel: 'low' }], + }) + const medium = composePartPrimitives({ + name: 'Medium detail grill', + autoComplete: false, + parts: [{ kind: 'protective_grill', detailLevel: 'medium' }], + }) + const high = composePartPrimitives({ + name: 'High detail grill', + autoComplete: false, + parts: [{ kind: 'protective_grill', detailLevel: 'high' }], + }) + + expect(low.length).toBeLessThan(medium.length) + expect(medium.length).toBeLessThan(high.length) + expect(low.filter((shape) => shape.name?.includes('grill front ring'))).toHaveLength(3) + expect(low.filter((shape) => shape.name?.includes('grill spoke'))).toHaveLength(12) + expect(low.filter((shape) => shape.name?.includes('grill side rib'))).toHaveLength(6) + expect(high.filter((shape) => shape.name?.includes('grill spoke'))).toHaveLength(24) + }) + + test('applies detail levels to reusable industrial detail parts', () => { + const lowVent = composePartPrimitives({ + name: 'Low detail vent', + autoComplete: false, + parts: [{ kind: 'vent_slats', detailLevel: 'low' }], + }) + const highVent = composePartPrimitives({ + name: 'High detail vent', + autoComplete: false, + parts: [{ kind: 'vent_slats', detailLevel: 'high' }], + }) + const lowFlange = composePartPrimitives({ + name: 'Low detail flange', + autoComplete: false, + parts: [{ kind: 'flange_ring', detailLevel: 'low' }], + }) + const highFlange = composePartPrimitives({ + name: 'High detail flange', + autoComplete: false, + parts: [{ kind: 'flange_ring', detailLevel: 'high' }], + }) + const lowLadder = composePartPrimitives({ + name: 'Low detail ladder', + autoComplete: false, + parts: [{ kind: 'platform_ladder', detailLevel: 'low', height: 1.4 }], + }) + const highLadder = composePartPrimitives({ + name: 'High detail ladder', + autoComplete: false, + parts: [{ kind: 'platform_ladder', detailLevel: 'high', height: 1.4 }], + }) + + expect(lowVent.filter((shape) => shape.name?.includes('vent slat'))).toHaveLength(4) + expect(highVent.filter((shape) => shape.name?.includes('vent slat'))).toHaveLength(10) + expect(lowFlange.filter((shape) => shape.name?.includes('flange bolt'))).toHaveLength(4) + expect(highFlange.filter((shape) => shape.name?.includes('flange bolt'))).toHaveLength(10) + expect(lowLadder.filter((shape) => shape.name?.includes('ladder rung'))).toHaveLength(5) + expect(highLadder.filter((shape) => shape.name?.includes('ladder rung'))).toHaveLength(10) + }) + + test('scores visual details and can enhance detailed blueprints', () => { + const plainAssessment = assessPartVisualDetails({ + parts: [ + { kind: 'skid_base' }, + { kind: 'ribbed_motor_body' }, + { kind: 'volute_casing' }, + { kind: 'inlet_port' }, + { kind: 'outlet_port' }, + { kind: 'flange_ring' }, + ], + }) + expect(plainAssessment.family).toBe('pump') + expect(plainAssessment.score).toBeLessThan(1) + expect(plainAssessment.missingDetails).toContain('nameplate') + + const detailedPump = composePartPrimitives({ + name: 'Detailed realistic pump', + parts: [{ kind: 'volute_casing' }], + }) + expect(detailedPump.some((shape) => shape.name?.includes('impeller vane'))).toBe(true) + expect(detailedPump.some((shape) => shape.name?.includes('nameplate'))).toBe(true) + expect(detailedPump.some((shape) => shape.name?.includes('warning label'))).toBe(true) + + const explicitlyPlain = composePartPrimitives({ + name: 'Detailed pump but no auto visual details', + enhanceVisualDetails: false, + parts: [{ kind: 'volute_casing' }], + }) + expect(explicitlyPlain.some((shape) => shape.name?.includes('nameplate'))).toBe(false) + }) + + test('composes stage 5 desk, electrical cabinet, process pipe, and cable tray parts', () => { + const shapes = composePartPrimitives({ + name: 'Factory office and utilities', + autoComplete: false, + parts: [ + { kind: 'desk_top', length: 1.2, width: 0.6, height: 0.05 }, + { kind: 'leg_set', length: 1.2, width: 0.6, height: 0.7 }, + { kind: 'drawer_stack', count: 3 }, + { kind: 'electrical_cabinet', position: [1.1, 0.5, 0], slatCount: 4 }, + { kind: 'pipe_run', position: [2, 0.55, 0], axis: 'x', length: 1, radius: 0.05 }, + { kind: 'pipe_elbow', position: [2.6, 0.55, 0], radius: 0.05 }, + { kind: 'cable_tray', position: [3.4, 0.8, 0], length: 1, slatCount: 5 }, + ], + }) + + expect(shapes.some((shape) => shape.name?.includes('desk top'))).toBe(true) + expect(shapes.filter((shape) => shape.name?.includes('desk leg'))).toHaveLength(4) + expect(shapes.filter((shape) => shape.name?.includes('drawer front'))).toHaveLength(3) + expect(shapes.some((shape) => shape.name?.includes('electrical cabinet body'))).toBe(true) + expect( + shapes.filter((shape) => shape.name?.includes('electrical cabinet vent slat')), + ).toHaveLength(4) + expect( + shapes.some((shape) => shape.kind === 'hollow-cylinder' && shape.name?.includes('pipe run')), + ).toBe(true) + expect( + shapes.some((shape) => shape.kind === 'sweep' && shape.name?.includes('pipe elbow')), + ).toBe(true) + expect(shapes.filter((shape) => shape.name?.includes('cable tray rung'))).toHaveLength(5) + }) + + test('auto-completes and scores stage 5 part families', () => { + const desk = composePartPrimitives({ + name: 'Writing desk', + parts: [{ kind: 'desk_top' }], + }) + expect(desk.some((shape) => shape.name?.includes('desk top'))).toBe(true) + expect(desk.filter((shape) => shape.name?.includes('desk leg'))).toHaveLength(4) + + const electricalAssessment = assessPartVisualDetails({ + parts: [{ kind: 'electrical_cabinet' }], + }) + expect(electricalAssessment.family).toBe('electrical') + expect(electricalAssessment.missingDetails).toContain('cable_tray') + + const detailedPipe = composePartPrimitives({ + name: 'Detailed process pipe', + parts: [{ kind: 'pipe_run' }], + }) + expect(detailedPipe.some((shape) => shape.name?.includes('pipe elbow'))).toBe(true) + expect(detailedPipe.some((shape) => shape.name?.includes('flange ring'))).toBe(true) + }) + + test('keeps registry family plans isolated from legacy cross-family auto completion', () => { + const tank = composePartPrimitives({ + name: 'Detailed storage tank with inlet outlet and access platform', + family: 'tank', + registryPartPlan: true, + autoComplete: true, + enhanceVisualDetails: true, + parts: [ + { kind: 'cylindrical_tank', semanticRole: 'vessel_shell', height: 3, radius: 0.6 }, + { kind: 'inlet_port', semanticRole: 'inlet_port' }, + { kind: 'outlet_port', semanticRole: 'outlet_port' }, + { kind: 'platform_ladder', semanticRole: 'access_platform' }, + ], + }) + + expect(tank.some((shape) => shape.sourcePartKind === 'cylindrical_tank')).toBe(true) + expect(tank.some((shape) => shape.sourcePartKind === 'platform_ladder')).toBe(true) + expect(tank.some((shape) => shape.sourcePartKind === 'volute_casing')).toBe(false) + expect(tank.some((shape) => shape.sourcePartKind === 'impeller_blades')).toBe(false) + expect(tank.some((shape) => shape.sourcePartKind === 'ribbed_motor_body')).toBe(false) + + const machineTool = composePartPrimitives({ + name: 'Boeing style CNC machining center enclosure', + family: 'machine_tool', + registryPartPlan: true, + autoComplete: true, + enhanceVisualDetails: true, + parts: [ + { kind: 'generic_base', semanticRole: 'machine_base', length: 2.8, width: 1.1 }, + { kind: 'generic_body', semanticRole: 'machine_enclosure', length: 2.8, width: 1.1 }, + { kind: 'generic_panel', semanticRole: 'spindle_head' }, + { kind: 'control_box', semanticRole: 'control_panel' }, + ], + }) + + const sourceKinds = new Set(machineTool.map((shape) => shape.sourcePartKind)) + expect(sourceKinds.has('generic_base')).toBe(true) + expect(sourceKinds.has('generic_body')).toBe(true) + expect(sourceKinds.has('aircraft_fuselage')).toBe(false) + expect(sourceKinds.has('aircraft_wing')).toBe(false) + expect(sourceKinds.has('aircraft_engine')).toBe(false) + expect(sourceKinds.has('aircraft_landing_gear')).toBe(false) + }) +}) diff --git a/packages/core/src/lib/part-compose.ts b/packages/core/src/lib/part-compose.ts new file mode 100644 index 000000000..061680eb7 --- /dev/null +++ b/packages/core/src/lib/part-compose.ts @@ -0,0 +1,13663 @@ +import type { FamilyId, LayoutFamilyId } from './family-registry' +import { + angularStep, + radialExtrudeRotationInHorizontalPlane, + radialExtrudeRotationInLocalPlane, +} from './orientation-utils' +import type { + PrimitiveGeometryBrief, + PrimitiveMaterialInput, + PrimitiveShapeInput, + Vec3, +} from './primitive-compose' + +type PartAxis = 'x' | 'y' | 'z' +type PartSide = 'left' | 'right' | 'top' | 'bottom' | 'front' | 'back' +type VehicleStyle = 'sedan' | 'suv' | 'sports' | 'van' | 'truck' + +export type PartComposeKind = + | 'circular_base' + | 'vertical_pole' + | 'motor_housing' + | 'fan_blade' + | 'radial_blades' + | 'protective_grill' + | 'pyramid' + | 'hemisphere' + | 'wheel' + | 'wheel_set' + | 'window_panel' + | 'window_strip' + | 'body_shell' + | 'tube_frame' + | 'fork' + | 'light_pair' + | 'bar_pair' + | 'support_bracket' + | 'control_knob' + | 'vent_slats' + | 'vent_grill' + | 'skid_base' + | 'rounded_machine_body' + | 'volute_casing' + | 'impeller_blades' + | 'propeller_blade_set' + | 'mixer_blades' + | 'pipe_port' + | 'inlet_port' + | 'outlet_port' + | 'flange_ring' + | 'flanged_nozzle' + | 'manway_lid' + | 'inspection_hatch' + | 'sanitary_nozzle' + | 'jacket_shell' + | 'sight_glass' + | 'sample_valve' + | 'instrument_port' + | 'stainless_highlight_panel' + | 'bolt_pattern' + | 'control_box' + | 'ribbed_motor_body' + | 'conveyor_frame' + | 'roller_array' + | 'belt_surface' + | 'cylindrical_tank' + | 'storage_tank_shell' + | 'cooling_tower_shell' + | 'cooling_tower_rim' + | 'chimney_stack' + | 'liquid_volume' + | 'valve_body' + | 'handwheel' + | 'bicycle_wheels' + | 'bicycle_frame' + | 'bicycle_fork' + | 'handlebar' + | 'saddle' + | 'chain_loop' + | 'vehicle_body' + | 'vehicle_wheels' + | 'vehicle_windows' + | 'headlights' + | 'bumper' + | 'gearbox_body' + | 'filter_vessel' + | 'heat_exchanger' + | 'agitator_tank' + | 'pipe_rack' + | 'platform_ladder' + | 'desk_top' + | 'leg_set' + | 'drawer_stack' + | 'electrical_cabinet' + | 'pipe_run' + | 'pipe_elbow' + | 'cable_tray' + | 'nameplate' + | 'warning_label' + | 'seam_ring' + | 'airfoil_blade' + | 'ellipsoid_shell' + | 'curved_lens_panel' + | 'ergonomic_shell' + | 'streamlined_body' + | 'lofted_panel' + | 'aircraft_fuselage' + | 'aircraft_wing' + | 'aircraft_engine' + | 'aircraft_vertical_stabilizer' + | 'aircraft_horizontal_stabilizer' + | 'aircraft_landing_gear' + | 'generic_body' + | 'generic_base' + | 'generic_panel' + | 'generic_handle' + | 'generic_spout' + | 'generic_control_panel' + | 'generic_display' + | 'generic_foot_set' + | 'generic_opening' + | 'generic_detail_accent' + | 'mobile_platform_chassis' + | 'lidar_sensor' + | 'emergency_stop_button' + | 'status_light_strip' + | 'operator_panel' + | 'guard_fence' + | 'pallet_table' + | 'bearing_block' + | 'support_roller_pair' + | 'structural_tower_frame' + | 'helical_ladder' + | 'helical_stair' + | 'cyclone_separator_unit' + | 'coupling_guard' + | 'motor_gearbox_unit' + | 'pipe_manifold' + | 'hopper_body' + | 'conical_hopper' + | 'service_platform' + | 'platform_with_ladder' + | 'kiosk_body' + | 'kiosk_roof' + | 'kiosk_opening' + | 'kiosk_counter' + | 'kiosk_sign' + | 'kiosk_awning' + +export interface LoftedPanelSectionInput { + width?: number + height?: number + length?: number + x?: number + y?: number + z?: number + topScale?: [number, number] +} + +export type PartComposeDetail = 'low' | 'medium' | 'high' + +export interface PartComposePartInput { + kind?: PartComposeKind | string + partType?: PartComposeKind | string + type?: PartComposeKind | string + id?: string + name?: string + partName?: string + style?: string + variant?: string + detail?: PartComposeDetail | string + detailLevel?: PartComposeDetail | string + grillDetailLevel?: PartComposeDetail | string + bottomStyle?: string + legStyle?: string + valveStyle?: string + handleStyle?: string + state?: string + vehicleStyle?: VehicleStyle | string + position?: Vec3 + rotation?: Vec3 + connectTo?: string | number + attachToRole?: string + connectPoint?: string + childPoint?: string + centeredOn?: string | number + alignAbove?: string | number + alignBeside?: string | number + offsetFrom?: string | number + offsetDirection?: PartSide | string + offsetDistance?: number + around?: string | number + aroundIndex?: number + aroundCount?: number + aroundRadius?: number + aroundAngle?: number + aroundStartAngle?: number + aroundAxis?: PartAxis | string + cornerPattern?: boolean + cornerInset?: number + array?: { count?: number; axis?: PartAxis | string; spacing?: number } + arrayAlong?: PartAxis | 'length' | 'width' | 'height' | string + arrayAxis?: PartAxis | string + arrayOffset?: number + relationGap?: number + anchor?: string + childAnchor?: string + axis?: PartAxis | string + side?: PartSide | string + offset?: Vec3 | number + outletAngle?: number + radius?: number + baseRadius?: number + waistRadius?: number + majorRadius?: number + tubeRadius?: number + diameter?: number + scale?: Vec3 + radiusTop?: number + radiusBottom?: number + outletRadius?: number + flangeRadius?: number + flangeThickness?: number + bendRadius?: number + dimensions?: Record + params?: Record + height?: number + width?: number + depth?: number + domeDepth?: number + length?: number + thickness?: number + sizeScale?: number + cornerRadius?: number + cornerSegments?: number + count?: number + portCount?: number + doorCount?: number + legCount?: number + ringCount?: number + rungCount?: number + rollerLength?: number + radialSegments?: number + widthSegments?: number + heightSegments?: number + levelCount?: number + bayCount?: number + stageCount?: number + stepCount?: number + stairFlights?: number + stairSide?: PartSide | string + stairPlacement?: 'inside' | 'outside' | string + externalStairs?: boolean + innerRadius?: number + outerRadius?: number + sweepAngle?: number + startAngle?: number + railingHeight?: number + includeDiagonalBraces?: boolean + spokeCount?: number + wireRadius?: number + wheelRadius?: number + wheelWidth?: number + warningStripes?: boolean + stripeCount?: number + stripeHeight?: number + frontX?: number + rearX?: number + frontZ?: number + rearZ?: number + overallHeight?: number + bodyHeight?: number + cabinHeight?: number + roofCornerAngle?: number + cabinTopScale?: number + cabinTopLengthScale?: number + cabinTopWidthScale?: number + truncated?: boolean + topScale?: number | [number, number] + topLengthScale?: number + topWidthScale?: number + topRadius?: number + topLength?: number + topWidth?: number + bladeRadius?: number + hubRadius?: number + bladeWidth?: number + bladePitch?: number + bladeSweep?: number + bladeShape?: string + rootWidth?: number + tipWidth?: number + twist?: number + camber?: number + verticalCurve?: number + pitch?: number + lensShape?: string + curvature?: number + shellThickness?: number + openingRadius?: number + cutBottom?: boolean + noseSlope?: number + tailSlope?: number + sideTaper?: number + noseRoundness?: number + tailTaper?: number + roofArc?: number + sections?: LoftedPanelSectionInput[] + slatCount?: number + boltCount?: number + includeBolts?: boolean + includeSupportLegs?: boolean + includeHub?: boolean + material?: PrimitiveMaterialInput + materialPreset?: string + preset?: string + semanticRole?: string + semanticGroup?: string + sourcePartKind?: string + sourcePartId?: string + color?: string + primaryColor?: string + secondaryColor?: string + metalColor?: string + motorColor?: string + rollerColor?: string + darkColor?: string + accentColor?: string + opacity?: number +} + +export interface PartComposeInput { + name?: string + partName?: string + family?: string + geometryBrief?: PrimitiveGeometryBrief + position?: Vec3 + detail?: PartComposeDetail | string + length?: number + width?: number + depth?: number + height?: number + diameter?: number + radius?: number + thickness?: number + primaryColor?: string + secondaryColor?: string + metalColor?: string + darkColor?: string + accentColor?: string + autoComplete?: boolean + enhanceVisualDetails?: boolean + registryPartPlan?: boolean + __registryPartPlan?: boolean + parts?: PartComposePartInput[] +} + +export interface PartSpec { + kind: PartComposeKind | string + family?: string + semanticRole?: string + dimensions?: { + length?: number + width?: number + depth?: number + height?: number + diameter?: number + radius?: number + thickness?: number + } + transform?: { + position?: Vec3 + rotation?: Vec3 + } + material?: PrimitiveMaterialInput + color?: string + attachTo?: string | number + constraints?: Record +} + +export interface PartBlueprintAssessment { + family: + | 'fan' + | 'pump' + | 'conveyor' + | 'bicycle' + | 'vehicle' + | 'valve' + | 'desk' + | 'pipe_system' + | 'electrical' + | 'aircraft' + | 'unknown' + required: PartComposeKind[] + present: PartComposeKind[] + missing: PartComposeKind[] + optional: PartComposeKind[] + recommendedDetails: PartComposeKind[] + missingDetails: PartComposeKind[] + score: number + recommendations: string[] +} + +export interface PartVisualAssessment { + family: PartBlueprintAssessment['family'] + score: number + presentDetails: PartComposeKind[] + missingDetails: PartComposeKind[] + recommendations: string[] +} + +interface PartRequirementGroup { + label: string + anyOf: PartComposeKind[] + defaultPart: PartComposePartInput +} + +interface PartFamilySpec { + family: PartBlueprintAssessment['family'] + required: PartRequirementGroup[] + optional: PartComposeKind[] + recommendedDetails: PartRequirementGroup[] +} + +function clamp(value: unknown, fallback: number, min: number, max: number): number { + return Math.max( + min, + Math.min(max, typeof value === 'number' && Number.isFinite(value) ? value : fallback), + ) +} + +function clampInt(value: unknown, fallback: number, min: number, max: number): number { + return Math.round(clamp(value, fallback, min, max)) +} + +function add(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +function negate(v: Vec3): Vec3 { + return [-v[0], -v[1], -v[2]] +} + +function partAxis(axis: unknown, fallback: PartAxis): PartAxis { + return axis === 'x' || axis === 'y' || axis === 'z' ? axis : fallback +} + +function partSide(side: unknown): PartSide | undefined { + switch (side) { + case 'left': + case 'right': + case 'top': + case 'bottom': + case 'front': + case 'back': + return side + default: + return undefined + } +} + +function axisForSide(side: PartSide, fallback: PartAxis): PartAxis { + switch (side) { + case 'left': + case 'right': + return 'x' + case 'top': + case 'bottom': + return 'y' + case 'front': + case 'back': + return 'z' + default: + return fallback + } +} + +function signForSide(side: PartSide | undefined, axis: PartAxis): -1 | 1 { + if (side === 'left' || side === 'bottom' || side === 'back') return -1 + if (side === 'right' || side === 'top' || side === 'front') return 1 + return axis === 'z' ? 1 : 1 +} + +function offsetAlongAxis(center: Vec3, axis: PartAxis, distance: number): Vec3 { + switch (axis) { + case 'x': + return [center[0] + distance, center[1], center[2]] + case 'y': + return [center[0], center[1] + distance, center[2]] + default: + return [center[0], center[1], center[2] + distance] + } +} + +function axisNormal(axis: PartAxis, sign: -1 | 1 = 1): Vec3 { + switch (axis) { + case 'x': + return [sign, 0, 0] + case 'y': + return [0, sign, 0] + default: + return [0, 0, sign] + } +} + +function rotateVec(v: Vec3, euler: Vec3): Vec3 { + let [x, y, z] = v + + const cz = Math.cos(euler[2]) + const sz = Math.sin(euler[2]) + ;[x, y] = [x * cz - y * sz, x * sz + y * cz] + + const cy = Math.cos(euler[1]) + const sy = Math.sin(euler[1]) + ;[x, z] = [x * cy + z * sy, -x * sy + z * cy] + + const cx = Math.cos(euler[0]) + const sx = Math.sin(euler[0]) + ;[y, z] = [y * cx - z * sx, y * sx + z * cx] + + return [x, y, z] +} + +function applyPartRotation( + shapes: PrimitiveShapeInput[], + pivot: Vec3, + rotation: Vec3 | undefined, +): PrimitiveShapeInput[] { + if (!rotation) return shapes + return shapes.map((shape) => ({ + ...shape, + position: shape.position + ? add(pivot, rotateVec(sub(shape.position, pivot), rotation)) + : shape.position, + rotation: add(shape.rotation ?? [0, 0, 0], rotation), + cutouts: shape.cutouts?.map((cutout) => ({ + ...cutout, + position: cutout.position + ? add(pivot, rotateVec(sub(cutout.position, pivot), rotation)) + : cutout.position, + normal: cutout.normal ? rotateVec(cutout.normal, rotation) : cutout.normal, + })), + ports: shape.ports?.map((port) => ({ + ...port, + position: port.position + ? add(pivot, rotateVec(sub(port.position, pivot), rotation)) + : port.position, + normal: port.normal ? rotateVec(port.normal, rotation) : port.normal, + })), + })) +} + +function radialPoint(center: Vec3, angle: number, radius: number, zOffset = 0): Vec3 { + return [ + center[0] + Math.cos(angle) * radius, + center[1] + Math.sin(angle) * radius, + center[2] + zOffset, + ] +} + +function tubeBetween( + name: string, + start: Vec3, + end: Vec3, + radius: number, + mat: PrimitiveMaterialInput, +): PrimitiveShapeInput { + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const dz = end[2] - start[2] + const length = Math.hypot(dx, dy, dz) + const yaw = Math.atan2(dy, dx) + const pitch = -Math.atan2(dz, Math.hypot(dx, dy)) + return { + kind: 'cylinder', + name, + position: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2, (start[2] + end[2]) / 2], + rotation: [0, pitch, yaw], + axis: 'x', + radius, + height: Math.max(length, 0.001), + radialSegments: 12, + material: mat, + } +} + +function radialPointOnAxis(center: Vec3, axis: PartAxis, angle: number, radius: number): Vec3 { + const c = Math.cos(angle) * radius + const s = Math.sin(angle) * radius + switch (axis) { + case 'x': + return [center[0], center[1] + c, center[2] + s] + case 'y': + return [center[0] + c, center[1], center[2] + s] + default: + return [center[0] + c, center[1] + s, center[2]] + } +} + +function material( + color: string, + roughness = 0.55, + metalness = 0.05, + opacity = 1, +): PrimitiveMaterialInput { + return { + properties: { + color, + roughness, + metalness, + opacity, + transparent: opacity < 1, + }, + } +} + +function textOf(value: unknown): string { + if (typeof value === 'string') return value.toLowerCase() + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function partIntentText(input: PartComposeInput, part?: PartComposePartInput): string { + return [ + input.name, + input.geometryBrief, + part?.name, + part?.partName, + part?.style, + part?.variant, + part?.valveStyle, + part?.handleStyle, + part?.state, + ] + .map(textOf) + .join(' ') +} + +function isBallValveIntent(input: PartComposeInput, part?: PartComposePartInput): boolean { + return /(ball\s*valve|球阀|quarter[-\s]?turn|90\s*°|90\s*degree)/i.test( + partIntentText(input, part), + ) +} + +function partIdentityText(part: PartComposePartInput): string { + return [ + part.kind, + part.partType, + part.type, + part.id, + part.name, + part.partName, + part.style, + part.variant, + ] + .map(textOf) + .join(' ') +} + +function isMixerPartContext(input: PartComposeInput, parts: PartComposePartInput[]): boolean { + const text = [input.name, input.partName, input.geometryBrief, ...parts.map(partIdentityText)] + .map(textOf) + .join(' ') + const hasMixerLanguage = /mixer|agitator|impeller|mud|slurry|paddle|搅拌|泥浆|桨叶|叶轮/.test( + text, + ) + const hasPropellerSet = parts.some((part) => { + const kind = normalizedPartKind(part) + return kind === 'propeller_blade_set' || kind === 'mixer_blades' + }) + const hasBladePart = parts.some((part) => { + const kind = normalizedPartKind(part) + return ( + kind === 'propeller_blade_set' || + kind === 'mixer_blades' || + kind === 'fan_blade' || + kind === 'radial_blades' + ) + }) + const hasShaft = parts.some((part) => { + const kind = normalizedPartKind(part) + return kind === 'vertical_pole' || /shaft|rod|pole/.test(partIdentityText(part)) + }) + const hasHub = parts.some((part) => { + const kind = normalizedPartKind(part) + return kind === 'circular_base' || /hub|boss/.test(partIdentityText(part)) + }) + return ( + (hasMixerLanguage && hasPropellerSet && (hasShaft || hasHub)) || + (hasMixerLanguage && hasBladePart && hasShaft) || + (hasPropellerSet && hasShaft && hasHub) + ) +} + +function applyMixerPartDefaults( + parts: PartComposePartInput[], + input: PartComposeInput, +): PartComposePartInput[] { + if (!isMixerPartContext(input, parts)) return parts + const shaft = parts.find((part) => normalizedPartKind(part) === 'vertical_pole') + const hub = parts.find((part) => normalizedPartKind(part) === 'circular_base') + const shaftHeight = clamp(shaft?.height, 1.4, 0.25, 3) + const hubHeight = clamp(hub?.height, 0.1, 0.03, 0.35) + const hubY = hubHeight / 2 + + return parts.map((part) => { + const kind = normalizedPartKind(part) + const identity = partIdentityText(part) + if (kind === 'vertical_pole' && (/shaft|rod|pole/.test(identity) || part === shaft)) { + return { + ...part, + id: part.id ?? 'mixer_shaft', + position: part.position ?? [0, hubHeight + shaftHeight / 2, 0], + semanticRole: part.semanticRole ?? 'mixer_shaft', + semanticGroup: part.semanticGroup ?? 'mixer_shaft', + sourcePartKind: part.sourcePartKind ?? 'mixer_shaft', + } + } + if (kind === 'circular_base' && (/hub|boss/.test(identity) || part === hub)) { + return { + ...part, + id: part.id ?? 'mixer_hub', + alignAbove: undefined, + alignBeside: undefined, + centeredOn: undefined, + around: undefined, + position: part.position ?? [0, hubY, 0], + semanticRole: part.semanticRole ?? 'mixer_hub', + semanticGroup: part.semanticGroup ?? 'mixer_hub', + sourcePartKind: part.sourcePartKind ?? 'mixer_hub', + } + } + if ( + kind === 'propeller_blade_set' || + kind === 'mixer_blades' || + kind === 'fan_blade' || + kind === 'radial_blades' + ) { + return { + ...part, + kind: 'mixer_blades', + id: part.id ?? 'mixer_blades', + around: undefined, + aroundCount: undefined, + aroundIndex: undefined, + aroundAngle: undefined, + position: part.position ?? [0, hubY + hubHeight * 0.25, 0], + bladeShape: part.bladeShape ?? 'taiji_half', + count: part.count ?? 3, + semanticRole: part.semanticRole ?? 'mixer_blade', + semanticGroup: part.semanticGroup ?? 'mixer_blades', + sourcePartKind: part.sourcePartKind ?? 'mixer_blades', + } + } + return part + }) +} + +function partMaterial( + part: PartComposePartInput, + fallback: PrimitiveMaterialInput, +): PrimitiveMaterialInput { + if (part.material) return part.material + if (part.materialPreset) return { preset: part.materialPreset } + if (part.color) return material(part.color) + return fallback +} + +function ringSegments(detail: PartComposeInput['detail']): number { + switch (detail) { + case 'high': + return 64 + case 'low': + return 32 + default: + return 48 + } +} + +function normalizePartKind(kind: unknown): PartComposeKind | null { + const raw = + typeof kind === 'string' + ? kind + .trim() + .toLowerCase() + .replace(/[\s-]+/g, '_') + : '' + switch (raw) { + case 'base': + case 'round_base': + case 'circular_base': + return 'circular_base' + case 'pole': + case 'rod': + case 'vertical_pole': + return 'vertical_pole' + case 'motor': + case 'motor_housing': + case 'head_housing': + return 'motor_housing' + case 'blades': + case 'blade': + case 'fan_blade': + case 'fanblade': + case 'impeller_fan_blade': + return 'fan_blade' + case 'fan_blades': + case 'radial_blades': + return 'radial_blades' + case 'grill': + case 'grille': + case 'cage': + case 'protective_grill': + case 'protective_grille': + return 'protective_grill' + case 'pyramid': + case 'square_pyramid': + case 'four_sided_pyramid': + case 'tetra_pyramid': + return 'pyramid' + case 'hemisphere': + case 'half_sphere': + case 'semi_sphere': + case 'dome': + case 'half_dome': + case '\u534a\u7403': + case '\u534a\u7403\u4f53': + case '\u534a\u5706\u7403': + case '\u534a\u5706\u5f62\u7403': + return 'hemisphere' + case 'bracket': + case 'yoke': + case 'support_bracket': + return 'support_bracket' + case 'knob': + case 'control_knob': + return 'control_knob' + case 'vents': + case 'slats': + case 'louvers': + case 'louver': + case 'louver_panel': + case 'louvered_panel': + case 'louvered_vents': + case 'vent_slats': + return 'vent_slats' + case 'vent_grill': + case 'vent_grille': + case 'grille_panel': + case 'air_grille': + case 'louver_grill': + case 'louvered_grill': + case 'air_vent_grill': + return 'vent_grill' + case 'skid': + case 'skid_base': + case 'machine_base': + case 'base_frame': + return 'skid_base' + case 'body': + case 'machine_body': + case 'rounded_body': + case 'rounded_machine_body': + return 'rounded_machine_body' + case 'volute': + case 'volute_casing': + case 'pump_casing': + case 'blower_casing': + case 'scroll_casing': + return 'volute_casing' + case 'impeller': + case 'impeller_blades': + case 'pump_impeller': + case 'turbine_blades': + return 'impeller_blades' + case 'propeller_blade_set': + case 'propeller_blades': + case 'propeller_set': + case 'blade_set': + case 'paddle_set': + case 'agitator_blade_set': + case 'taiji_propeller': + case 'taiji_half_blades': + return 'propeller_blade_set' + case 'mixer_blades': + case 'mixer_impeller': + case 'mud_mixer_blades': + case 'agitator_blades': + case 'taiji_blades': + return 'mixer_blades' + case 'wheel': + case 'single_wheel': + case 'rubber_wheel': + case 'landing_wheel': + case 'bicycle_wheel': + case 'bike_wheel': + case 'cycle_wheel': + case 'front_bicycle_wheel': + case 'rear_bicycle_wheel': + return 'wheel' + case 'wheel_set': + case 'wheels': + case 'wheelset': + case 'wheel_pair': + case 'tire_set': + case 'tire_pair': + return 'wheel_set' + case 'window_panel': + case 'glass_panel': + case 'rounded_window': + case 'window': + return 'window_panel' + case 'window_strip': + case 'glass_strip': + case 'window_array': + case 'cabin_windows': + return 'window_strip' + case 'body_shell': + case 'shell_body': + case 'vehicle_body': + case 'car_body': + case 'auto_body': + return 'body_shell' + case 'tube_frame': + case 'frame_assembly': + case 'bicycle_frame': + case 'bike_frame': + case 'bicycle': + case 'bike': + case 'complete_bicycle': + case 'complete_bike': + return 'tube_frame' + case 'fork': + case 'bicycle_fork': + case 'front_fork': + case 'bike_fork': + return 'fork' + case 'light_pair': + case 'headlights': + case 'head_lights': + case 'lamps': + return 'light_pair' + case 'bar_pair': + case 'bumper': + case 'bumpers': + case 'car_bumper': + return 'bar_pair' + case 'generic_body': + case 'generic_main_body': + case 'main_body': + case 'equipment_body': + case 'building_body': + case 'furniture_body': + case 'cabinet_body': + case 'housing': + case 'shell': + return 'generic_body' + case 'generic_base': + case 'support_base': + case 'platform_base': + case 'base_slab': + case 'cup_platform': + case 'generic_platform': + return 'generic_base' + case 'generic_panel': + case 'panel': + case 'front_panel': + case 'side_panel': + case 'access_panel': + return 'generic_panel' + case 'generic_handle': + case 'handle': + case 'pull_handle': + case 'door_handle': + return 'generic_handle' + case 'generic_spout': + case 'spout': + case 'nozzle_spout': + case 'coffee_spout': + case 'dispense_spout': + return 'generic_spout' + case 'generic_control_panel': + case 'control_detail': + case 'buttons': + case 'button_panel': + return 'generic_control_panel' + case 'generic_display': + case 'display': + case 'screen': + case 'readout': + return 'generic_display' + case 'generic_foot_set': + case 'foot_set': + return 'generic_foot_set' + case 'generic_opening': + case 'opening': + case 'door_opening': + case 'window_opening': + return 'generic_opening' + case 'generic_detail_accent': + case 'detail_accent': + case 'accent': + case 'trim': + return 'generic_detail_accent' + case 'mobile_platform_chassis': + case 'mobile_chassis': + case 'agv_chassis': + case 'amr_chassis': + case 'robot_platform_chassis': + return 'mobile_platform_chassis' + case 'lidar_sensor': + case 'laser_scanner': + case 'navigation_sensor': + case 'safety_scanner': + case 'scanner': + return 'lidar_sensor' + case 'emergency_stop_button': + case 'e_stop': + case 'e_stop_button': + case 'emergency_button': + case 'stop_button': + return 'emergency_stop_button' + case 'status_light_strip': + case 'light_strip': + case 'led_strip': + case 'signal_light_strip': + case 'status_strip': + return 'status_light_strip' + case 'operator_panel': + case 'hmi_panel': + case 'control_pendant': + case 'operator_console': + return 'operator_panel' + case 'guard_fence': + case 'safety_fence': + case 'safety_guard': + case 'guard_rail': + case 'barrier_fence': + return 'guard_fence' + case 'pallet_table': + case 'pallet_station': + case 'pallet_deck': + case 'fixture_table': + return 'pallet_table' + case 'bearing_block': + case 'pillow_block': + case 'pillow_block_bearing': + case 'bearing_housing': + case 'mounted_bearing': + return 'bearing_block' + case 'coupling_guard': + case 'shaft_guard': + case 'coupling_cover': + case 'rotating_shaft_guard': + return 'coupling_guard' + case 'motor_gearbox_unit': + case 'motor_reducer_unit': + case 'drive_unit': + case 'gearmotor': + case 'motor_gearbox': + return 'motor_gearbox_unit' + case 'pipe_manifold': + case 'manifold': + case 'header_pipe': + case 'branch_manifold': + return 'pipe_manifold' + case 'hopper_body': + case 'feed_hopper': + case 'material_hopper': + case 'inlet_hopper': + return 'hopper_body' + case 'conical_hopper': + case 'cone_hopper': + case 'cone_discharge_hopper': + return 'conical_hopper' + case 'service_platform': + case 'maintenance_platform': + case 'inspection_platform': + case 'access_deck': + return 'service_platform' + case 'platform_with_ladder': + case 'maintenance_platform_ladder': + case 'access_platform_ladder': + return 'platform_with_ladder' + case 'kiosk_body': + case 'booth_body': + case 'small_building_body': + case 'stall_body': + case 'newsstand_body': + return 'kiosk_body' + case 'kiosk_roof': + case 'booth_roof': + case 'small_building_roof': + case 'pavilion_roof': + case 'shed_roof': + return 'kiosk_roof' + case 'kiosk_opening': + case 'service_window': + case 'serving_window': + case 'ticket_window': + case 'booth_window': + case 'kiosk_door': + return 'kiosk_opening' + case 'kiosk_counter': + case 'service_counter': + case 'serving_counter': + case 'sales_counter': + case 'vendor_counter': + return 'kiosk_counter' + case 'kiosk_sign': + case 'sign_panel': + case 'shop_sign': + case 'booth_sign': + case 'name_sign': + return 'kiosk_sign' + case 'kiosk_awning': + case 'awning': + case 'canopy': + case 'sunshade': + return 'kiosk_awning' + case 'airfoil_blade': + case 'airfoil_blades': + case 'propeller_blade': + case 'turbine_blade': + case 'curved_blade': + case 'curved_blades': + return 'airfoil_blade' + case 'ellipsoid_shell': + case 'ellipsoidal_shell': + case 'elliptical_shell': + case 'oval_shell': + case 'dome_shell': + case 'equipment_dome': + case 'helmet_shell': + case 'mouse_dome': + case 'tank_head': + case 'vessel_head': + return 'ellipsoid_shell' + case 'curved_lens': + case 'curved_panel': + case 'arc_panel': + case 'bent_panel': + case 'curved_lens_panel': + case 'lens_panel': + case 'sunglasses_lens': + case 'goggles_lens': + case 'visor': + return 'curved_lens_panel' + case 'ergonomic_shell': + case 'mouse_shell': + case 'smooth_shell': + case 'organic_shell': + return 'ergonomic_shell' + case 'aircraft_fuselage': + case 'fuselage': + case 'fuselage_tube': + case 'airliner_fuselage': + case 'airplane_body': + return 'aircraft_fuselage' + case 'streamlined_body': + case 'streamlined_shell': + case 'aero_body': + case 'aerodynamic_body': + case 'train_nose': + case 'bullet_train_nose': + return 'streamlined_body' + case 'lofted_panel': + case 'loft_panel': + case 'lofted_shell': + case 'sectioned_panel': + case 'transition_panel': + return 'lofted_panel' + case 'aircraft_wing': + case 'main_wing': + case 'main_wings': + case 'wing': + case 'wings': + case 'low_mounted_wing': + case 'low_mounted_wings': + case 'swept_wing': + case 'swept_wings': + return 'aircraft_wing' + case 'aircraft_engine': + case 'jet_engine': + case 'jet_engines': + case 'engine_nacelle': + case 'engine_nacelles': + case 'nacelle': + case 'nacelles': + case 'aft_mounted_engine': + case 'aft_mounted_engines': + return 'aircraft_engine' + case 'vertical_stabilizer': + case 'vertical_stabiliser': + case 'vertical_fin': + case 'tail_fin': + case 'aircraft_vertical_stabilizer': + return 'aircraft_vertical_stabilizer' + case 'horizontal_stabilizer': + case 'horizontal_stabiliser': + case 'horizontal_tail': + case 't_tail': + case 't_tail_stabilizer': + case 'aircraft_horizontal_stabilizer': + return 'aircraft_horizontal_stabilizer' + case 'landing_gear': + case 'aircraft_landing_gear': + case 'nose_gear': + case 'main_gear': + return 'aircraft_landing_gear' + case 'pipe': + case 'pipe_stub': + case 'pipe_port': + case 'pipe_nozzle': + case 'hose_port': + case 'service_port': + case 'connector_port': + case 'nozzle': + return 'pipe_port' + case 'inlet': + case 'inlet_port': + case 'suction_port': + return 'inlet_port' + case 'outlet': + case 'outlet_port': + case 'discharge_port': + return 'outlet_port' + case 'flange': + case 'flange_ring': + case 'mounting_flange': + case 'pipe_flange': + return 'flange_ring' + case 'flanged_nozzle': + case 'flanged_pipe_nozzle': + case 'nozzle_with_flange': + case 'process_nozzle': + return 'flanged_nozzle' + case 'manway_lid': + case 'manway_cover': + case 'manway_hatch': + case 'access_lid': + case 'hatch_cover': + return 'manway_lid' + case 'inspection_hatch': + case 'access_hatch': + case 'round_hatch': + return 'inspection_hatch' + case 'sanitary_nozzle': + case 'tri_clamp_nozzle': + case 'hygienic_nozzle': + case 'short_nozzle': + case 'feed_stub': + return 'sanitary_nozzle' + case 'jacket_shell': + case 'outer_jacket': + case 'thermal_jacket': + case 'cooling_jacket': + case 'heating_jacket': + return 'jacket_shell' + case 'sight_glass': + case 'sightglass': + case 'inspection_glass': + case 'view_glass': + case 'viewing_glass': + return 'sight_glass' + case 'sample_valve': + case 'sampling_valve': + case 'sampling_port': + case 'sample_cock': + return 'sample_valve' + case 'instrument_port': + case 'gauge_port': + case 'thermowell': + case 'pressure_gauge': + case 'temperature_probe': + case 'sensor_port': + return 'instrument_port' + case 'stainless_highlight_panel': + case 'metal_highlight_panel': + case 'polished_highlight': + case 'vertical_highlight': + case 'stainless_reflection': + return 'stainless_highlight_panel' + case 'bolts': + case 'bolt_pattern': + case 'bolt_circle': + case 'screw': + case 'screws': + case 'screw_pattern': + case 'fasteners': + case 'fastener_pattern': + return 'bolt_pattern' + case 'control_box': + case 'control_panel': + case 'electrical_box': + return 'control_box' + case 'ribbed_motor': + case 'ribbed_motor_body': + case 'electric_motor': + case 'industrial_motor': + return 'ribbed_motor_body' + case 'conveyor': + case 'conveyor_frame': + case 'belt_conveyor': + return 'conveyor_frame' + case 'rollers': + case 'roller_array': + case 'conveyor_rollers': + return 'roller_array' + case 'support_roller_pair': + case 'support_roller_station': + case 'trunnion_roller_pair': + case 'kiln_support_roller': + return 'support_roller_pair' + case 'structural_tower_frame': + case 'tower_frame': + case 'steel_tower_frame': + case 'preheater_tower_frame': + case 'multi_level_tower_frame': + return 'structural_tower_frame' + case 'helical_stair': + case 'helical_stairs': + case 'spiral_stair': + case 'spiral_stairs': + case 'external_spiral_stair': + case 'tower_spiral_stair': + case 'wraparound_stair': + case 'wraparound_stairs': + case 'ring_stair': + case 'annular_stair': + return 'helical_stair' + case 'helical_ladder': + case 'helical_ladders': + case 'spiral_ladder': + case 'spiral_ladders': + case 'spiral_access_ladder': + case 'spiral_access_stair': + case 'tower_helical_ladder': + case 'column_spiral_ladder': + case 'wraparound_ladder': + return 'helical_ladder' + case 'cyclone_separator_unit': + case 'cyclone_unit': + case 'preheater_cyclone': + case 'cyclone_stage': + case 'cyclone_separator': + return 'cyclone_separator_unit' + case 'belt': + case 'belt_surface': + case 'conveyor_belt': + return 'belt_surface' + case 'storage_tank_shell': + case 'atmospheric_storage_tank': + case 'flat_roof_tank': + case 'floating_roof_tank': + return 'storage_tank_shell' + case 'tank': + case 'vessel': + case 'cylindrical_tank': + case 'pressure_vessel': + return 'cylindrical_tank' + case 'liquid_volume': + case 'liquid_level': + case 'tank_liquid': + case 'fluid_volume': + return 'liquid_volume' + case 'cooling_tower_shell': + case 'natural_draft_cooling_tower': + case 'hyperboloid_cooling_tower': + return 'cooling_tower_shell' + case 'cooling_tower_rim': + case 'cooling_tower_opening': + case 'cooling_tower_top_rim': + return 'cooling_tower_rim' + case 'chimney': + case 'chimney_stack': + case 'smokestack': + case 'smoke_stack': + case 'industrial_chimney': + case 'flue_stack': + return 'chimney_stack' + case 'valve': + case 'valve_body': + return 'valve_body' + case 'handwheel': + case 'hand_wheel': + case 'valve_wheel': + return 'handwheel' + case 'bicycle_wheels': + case 'bike_wheels': + case 'bike_wheelset': + case 'bicycle_wheelset': + return 'wheel_set' + case 'handlebar': + case 'handlebars': + case 'bike_handlebar': + case 'bicycle_handlebar': + case 'bicycle_handlebars': + return 'handlebar' + case 'saddle': + case 'seat': + case 'bike_seat': + case 'bicycle_seat': + return 'saddle' + case 'chain': + case 'chain_loop': + case 'bike_chain': + case 'bicycle_chain': + case 'bicycle_crank': + case 'bike_crank': + case 'bicycle_chainring': + case 'bike_chainring': + case 'bicycle_pedals': + case 'bike_pedals': + return 'chain_loop' + case 'vehicle_wheels': + case 'car_wheels': + return 'wheel_set' + case 'car_windows': + case 'windows': + return 'window_strip' + case 'gearbox': + case 'gearbox_body': + case 'reducer': + case 'speed_reducer': + return 'gearbox_body' + case 'filter': + case 'filter_vessel': + case 'pressure_filter': + return 'filter_vessel' + case 'heat_exchanger': + case 'exchanger': + case 'shell_and_tube': + return 'heat_exchanger' + case 'agitator': + case 'agitator_tank': + case 'mixing_tank': + case 'mixer_tank': + return 'agitator_tank' + case 'pipe_rack': + case 'pipe_bridge': + case 'pipe_corridor': + return 'pipe_rack' + case 'platform': + case 'ladder': + case 'platform_ladder': + case 'access_platform': + return 'platform_ladder' + case 'desk_top': + case 'table_top': + case 'desktop': + case 'worktop': + return 'desk_top' + case 'legs': + case 'feet': + case 'support_feet': + case 'mounting_feet': + case 'rubber_feet': + case 'leveling_feet': + case 'leg_set': + case 'table_legs': + case 'desk_legs': + return 'leg_set' + case 'drawers': + case 'drawer_stack': + case 'drawer_unit': + case 'drawer_cabinet': + return 'drawer_stack' + case 'electrical_cabinet': + case 'electrical_panel': + case 'control_cabinet': + case 'power_cabinet': + case 'switchgear': + return 'electrical_cabinet' + case 'pipe_run': + case 'straight_pipe': + case 'pipeline': + case 'process_pipe': + return 'pipe_run' + case 'pipe_elbow': + case 'elbow': + case 'pipe_bend': + case 'bend': + return 'pipe_elbow' + case 'cable_tray': + case 'wire_tray': + case 'tray': + case 'cable_ladder': + return 'cable_tray' + case 'nameplate': + case 'name_plate': + case 'rating_plate': + case 'serial_plate': + case 'label_plate': + case 'data_plate': + return 'nameplate' + case 'warning_label': + case 'warning_sticker': + case 'label': + return 'warning_label' + case 'seam_ring': + case 'seam': + case 'joint_ring': + return 'seam_ring' + default: + return null + } +} + +function normalizedPartKind(part: PartComposePartInput): PartComposeKind | null { + return normalizePartKind(part.kind ?? part.partType ?? part.type) +} + +function normalizeVehicleStyle(value: unknown): VehicleStyle | undefined { + const text = textOf(value).replace(/[\s_-]+/g, '') + if (!text) return undefined + if (/sport|supercar|coupe|race|racing|跑车|赛车/.test(text)) return 'sports' + if (/suv|offroad|offroader|jeep/.test(text)) return 'suv' + if (/van|minivan|mpv|bus/.test(text)) return 'van' + if (/truck|pickup|ute|lorry|皮卡|卡车|货车/.test(text)) return 'truck' + if (/sedan|saloon|car|auto/.test(text)) return 'sedan' + return undefined +} + +function vehicleStyleFor(input: PartComposeInput, part?: PartComposePartInput): VehicleStyle { + return ( + normalizeVehicleStyle(part?.vehicleStyle) ?? + normalizeVehicleStyle(part?.style) ?? + normalizeVehicleStyle(part?.variant) ?? + normalizeVehicleStyle(partIntentText(input, part)) ?? + 'sedan' + ) +} + +function vehicleSizeScale(part: PartComposePartInput): number { + return clamp(part.sizeScale, 1, 0.2, 2) +} + +const VEHICLE_STYLE_DEFAULTS: Record< + VehicleStyle, + { + length: number + width: number + heightRatio: number + bodyHeightRatio: number + cabinHeightRatio: number + cabinLengthRatio: number + cabinWidthRatio: number + cabinXRatio: number + cabinTopScale: number + wheelRadiusRatio: number + wheelbaseRatio: number + trackRatio: number + groundClearanceRatio: number + } +> = { + sedan: { + length: 4.4, + width: 1.8, + heightRatio: 0.31, + bodyHeightRatio: 0.36, + cabinHeightRatio: 0.3, + cabinLengthRatio: 0.42, + cabinWidthRatio: 0.74, + cabinXRatio: -0.05, + cabinTopScale: 0.78, + wheelRadiusRatio: 0.078, + wheelbaseRatio: 0.72, + trackRatio: 0.9, + groundClearanceRatio: 0.15, + }, + suv: { + length: 4.65, + width: 1.95, + heightRatio: 0.38, + bodyHeightRatio: 0.42, + cabinHeightRatio: 0.44, + cabinLengthRatio: 0.46, + cabinWidthRatio: 0.82, + cabinXRatio: -0.04, + cabinTopScale: 0.9, + wheelRadiusRatio: 0.088, + wheelbaseRatio: 0.72, + trackRatio: 0.92, + groundClearanceRatio: 0.18, + }, + sports: { + length: 4.35, + width: 1.9, + heightRatio: 0.25, + bodyHeightRatio: 0.34, + cabinHeightRatio: 0.34, + cabinLengthRatio: 0.32, + cabinWidthRatio: 0.72, + cabinXRatio: -0.12, + cabinTopScale: 0.62, + wheelRadiusRatio: 0.095, + wheelbaseRatio: 0.76, + trackRatio: 0.94, + groundClearanceRatio: 0.11, + }, + van: { + length: 4.7, + width: 1.9, + heightRatio: 0.42, + bodyHeightRatio: 0.48, + cabinHeightRatio: 0.46, + cabinLengthRatio: 0.62, + cabinWidthRatio: 0.86, + cabinXRatio: -0.04, + cabinTopScale: 0.94, + wheelRadiusRatio: 0.075, + wheelbaseRatio: 0.7, + trackRatio: 0.88, + groundClearanceRatio: 0.14, + }, + truck: { + length: 5.2, + width: 1.95, + heightRatio: 0.36, + bodyHeightRatio: 0.38, + cabinHeightRatio: 0.42, + cabinLengthRatio: 0.32, + cabinWidthRatio: 0.8, + cabinXRatio: 0.18, + cabinTopScale: 0.86, + wheelRadiusRatio: 0.087, + wheelbaseRatio: 0.74, + trackRatio: 0.92, + groundClearanceRatio: 0.18, + }, +} + +function normalizePartInput(part: PartComposePartInput): PartComposePartInput { + const kind = normalizedPartKind(part) + const rawKind = `${part.kind ?? part.partType ?? part.type ?? ''}`.toLowerCase() + const rawParams = + typeof part.params === 'object' && part.params !== null && !Array.isArray(part.params) + ? part.params + : {} + const rawDimensions = + typeof part.dimensions === 'object' && + part.dimensions !== null && + !Array.isArray(part.dimensions) + ? part.dimensions + : {} + const dimensionDefaults: Partial = {} + for (const key of [ + 'length', + 'width', + 'depth', + 'height', + 'diameter', + 'radius', + 'thickness', + ] as const) { + const value = rawDimensions[key] ?? rawParams[key] + if (part[key] == null && typeof value === 'number' && Number.isFinite(value) && value > 0) { + dimensionDefaults[key] = value + } + } + const styleDefaults: Partial = {} + for (const key of [ + 'primaryColor', + 'metalColor', + 'darkColor', + 'accentColor', + 'color', + 'cornerRadius', + 'cornerSegments', + ] as const) { + const value = rawParams[key] + if (part[key] == null && value != null) { + const typedStyleDefaults = styleDefaults as Record + typedStyleDefaults[key] = value + } + } + const semanticRole = + part.semanticRole ?? + (kind === 'wheel_set' && /bicycle|bike/.test(rawKind) + ? 'bicycle_tire' + : kind === 'wheel_set' && /vehicle|car|auto/.test(rawKind) + ? 'vehicle_tire' + : kind === 'tube_frame' && /bicycle|bike/.test(rawKind) + ? 'bicycle_frame' + : kind === 'fork' && /bicycle|bike/.test(rawKind) + ? 'bicycle_fork' + : undefined) + return { + ...part, + ...dimensionDefaults, + ...styleDefaults, + ...(kind ? { kind } : {}), + ...(semanticRole ? { semanticRole } : {}), + name: part.name ?? part.partName, + } +} + +const PART_DIMENSION_KEYS = [ + 'length', + 'width', + 'depth', + 'height', + 'diameter', + 'radius', + 'thickness', +] as const + +type PartDimensionKey = (typeof PART_DIMENSION_KEYS)[number] + +function partInputDimensions(input: PartComposeInput): Partial> { + const expected = input.geometryBrief?.expectedDimensions ?? {} + const dimensions: Partial> = {} + for (const key of PART_DIMENSION_KEYS) { + const value = input[key] ?? expected[key] + if (typeof value === 'number' && Number.isFinite(value) && value > 0) dimensions[key] = value + } + return dimensions +} + +function primaryDimensionPartKinds( + input: PartComposeInput, + parts: PartComposePartInput[], +): PartComposeKind[] { + const present = partKinds(parts) + if (isAircraftIntent(input)) return ['aircraft_fuselage', 'streamlined_body'] + + switch (familySpecForParts(present).family) { + case 'vehicle': + return ['body_shell'] + case 'desk': + return ['desk_top'] + case 'conveyor': + return ['conveyor_frame'] + case 'pipe_system': + return ['pipe_run'] + case 'pump': + return ['skid_base', 'rounded_machine_body'] + case 'electrical': + return ['electrical_cabinet'] + case 'valve': + return ['valve_body'] + case 'bicycle': + return ['bicycle_frame', 'tube_frame'] + default: + return [] + } +} + +function applyPartDimensionDefaults(input: PartComposeInput): PartComposeInput { + const dimensions = partInputDimensions(input) + if (Object.keys(dimensions).length === 0 || !input.parts?.length) return input + + const primaryKinds = primaryDimensionPartKinds(input, input.parts) + const primaryIndex = + primaryKinds.length > 0 + ? input.parts.findIndex((part) => { + const kind = normalizedPartKind(part) + return kind != null && primaryKinds.includes(kind) + }) + : 0 + if (primaryIndex < 0) return input + + const parts = input.parts.map((part, index) => { + if (index !== primaryIndex) return part + const next = { ...part } + for (const key of PART_DIMENSION_KEYS) { + if (next[key] == null && dimensions[key] != null) next[key] = dimensions[key] + } + return next + }) + + return { ...input, parts } +} + +function normalizePartComposeInput(input: PartComposeInput): PartComposeInput { + return applyPartDimensionDefaults({ + ...input, + name: input.name ?? input.partName, + parts: input.parts?.map(normalizePartInput), + }) +} + +function isRegistryPartPlanInput(input: PartComposeInput): boolean { + return input.registryPartPlan === true || input.__registryPartPlan === true +} + +function vehicleLength(part: PartComposePartInput, style: VehicleStyle = 'sedan'): number { + return clamp( + part.length ?? part.depth, + VEHICLE_STYLE_DEFAULTS[style].length * vehicleSizeScale(part), + 0.3, + 6, + ) +} + +function vehicleWidth(part: PartComposePartInput, style: VehicleStyle = 'sedan'): number { + const defaults = VEHICLE_STYLE_DEFAULTS[style] + const derivedFromLength = + part.width == null && (part.length != null || part.depth != null) + ? vehicleLength(part, style) * (defaults.width / defaults.length) + : undefined + return clamp(part.width, derivedFromLength ?? defaults.width * vehicleSizeScale(part), 0.12, 2.8) +} + +function vehicleOverallHeight( + part: PartComposePartInput, + length = vehicleLength(part), + width = vehicleWidth(part), + style: VehicleStyle = 'sedan', +): number { + const defaults = VEHICLE_STYLE_DEFAULTS[style] + const scale = vehicleSizeScale(part) + const derivedFromLength = + part.overallHeight == null && part.height == null && (part.length != null || part.depth != null) + ? length * defaults.heightRatio + : undefined + return clamp( + part.overallHeight ?? part.height, + derivedFromLength ?? Math.max(width * 0.66, length * defaults.heightRatio, 0.46 * scale), + 0.22, + 2.4, + ) +} + +function vehicleWheelRadius( + part: PartComposePartInput, + length: number, + width: number, + overallHeight: number, + style: VehicleStyle = 'sedan', +): number { + const defaults = VEHICLE_STYLE_DEFAULTS[style] + const scale = vehicleSizeScale(part) + return clamp( + part.radius ?? part.wheelRadius, + Math.min(length * defaults.wheelRadiusRatio, width * 0.22, overallHeight * 0.28), + 0.04 * scale, + 0.6, + ) +} + +function numericDimension(input: PartComposeInput, key: PartDimensionKey): number | undefined { + const direct = input[key] + if (typeof direct === 'number' && Number.isFinite(direct) && direct > 0) return direct + const value = input.geometryBrief?.expectedDimensions?.[key] + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined +} + +function isCompleteBicycleParts(parts: PartComposePartInput[]): boolean { + const present = partKinds(parts) + return familySpecForParts(present).family === 'bicycle' +} + +function bicycleLayoutBase(part: PartComposePartInput): PartComposePartInput { + const { + position: _position, + rotation: _rotation, + connectTo: _connectTo, + connectPoint: _connectPoint, + childPoint: _childPoint, + centeredOn: _centeredOn, + alignAbove: _alignAbove, + alignBeside: _alignBeside, + offsetFrom: _offsetFrom, + offsetDirection: _offsetDirection, + offsetDistance: _offsetDistance, + around: _around, + aroundIndex: _aroundIndex, + aroundCount: _aroundCount, + aroundRadius: _aroundRadius, + aroundAngle: _aroundAngle, + aroundStartAngle: _aroundStartAngle, + aroundAxis: _aroundAxis, + array: _array, + arrayAxis: _arrayAxis, + arrayOffset: _arrayOffset, + relationGap: _relationGap, + anchor: _anchor, + childAnchor: _childAnchor, + side: _side, + ...rest + } = part + return rest +} + +function firstBicyclePart( + parts: PartComposePartInput[], + kinds: PartComposeKind[], +): PartComposePartInput | undefined { + return parts.find((part) => { + const kind = normalizedPartKind(part) + return kind != null && kinds.includes(kind) + }) +} + +const BICYCLE_FORK_AXLE_FORWARD_RATIO = 0.2 +const BICYCLE_FORK_CROWN_RISE_RATIO = 0.35 +const BICYCLE_FORK_AXLE_DROP_RATIO = 0.55 +const BICYCLE_STEERER_FORWARD_RATIO = 0.08 +const BICYCLE_STEERER_RISE_RATIO = 0.16 +const BICYCLE_HANDLEBAR_STEM_REACH_RATIO = 0.08 + +function applyBicycleLayoutDefaults( + parts: PartComposePartInput[], + input: PartComposeInput, +): PartComposePartInput[] { + if (!isCompleteBicycleParts(parts)) return parts + + const wheelPart = firstBicyclePart(parts, ['wheel_set', 'wheel']) + const framePart = firstBicyclePart(parts, ['tube_frame']) + const totalLength = numericDimension(input, 'length') + const totalHeight = numericDimension(input, 'height') + const totalWidth = numericDimension(input, 'width') + const requestedRadius = wheelPart?.radius ?? wheelPart?.wheelRadius ?? input.radius + const defaultWheelRadius = + totalHeight != null + ? Math.min(totalHeight * 0.3, totalLength != null ? totalLength * 0.18 : 0.3) + : totalLength != null + ? Math.min(totalLength * 0.17, 0.32) + : 0.22 + const maxWheelRadius = + totalHeight != null + ? Math.min(totalHeight * 0.32, totalLength != null ? totalLength * 0.19 : 0.32) + : totalLength != null + ? Math.min(totalLength * 0.17, 0.32) + : 0.32 + const wheelRadius = clamp(requestedRadius, defaultWheelRadius, 0.08, maxWheelRadius) + const fallbackWheelbase = + totalLength != null ? Math.max(totalLength - wheelRadius * 2, totalLength * 0.54) : 0.86 + const wheelbase = clamp( + wheelPart?.length ?? (totalLength == null ? framePart?.length : undefined), + fallbackWheelbase, + Math.max(wheelRadius * 2.2, 0.35), + 3, + ) + const frameHeight = clamp( + totalHeight == null ? framePart?.height : undefined, + totalHeight != null ? totalHeight * 0.68 : Math.max(0.42, wheelRadius * 1.9), + 0.18, + 1.2, + ) + const forkHeight = clamp(undefined, Math.max(frameHeight * 0.95, wheelRadius * 1.25), 0.18, 1.2) + const handlebarWidth = clamp(totalWidth, 0.42, 0.18, 1.2) + const forkSpread = clamp(totalWidth != null ? totalWidth * 0.18 : undefined, 0.08, 0.03, 0.22) + const wheelY = wheelRadius + const frameCenterY = wheelY + frameHeight * 0.52 + const saddleY = wheelY + frameHeight * 1.08 + const forkCenter: Vec3 = [ + wheelbase / 2 - forkHeight * BICYCLE_FORK_AXLE_FORWARD_RATIO, + wheelY + forkHeight * BICYCLE_FORK_AXLE_DROP_RATIO, + 0, + ] + const forkCrown: Vec3 = [ + forkCenter[0], + forkCenter[1] + forkHeight * BICYCLE_FORK_CROWN_RISE_RATIO, + 0, + ] + const steererTop: Vec3 = [ + forkCrown[0] + forkHeight * BICYCLE_STEERER_FORWARD_RATIO, + forkCrown[1] + forkHeight * BICYCLE_STEERER_RISE_RATIO, + 0, + ] + const handlebarStemDrop = clamp(undefined, forkHeight * 0.14, 0.055, 0.16) + const handlebarY = steererTop[1] + handlebarStemDrop + const handlebarX = steererTop[0] + handlebarWidth * BICYCLE_HANDLEBAR_STEM_REACH_RATIO + const chainSpan = clamp(undefined, wheelbase * 0.52, 0.28, 1.4) + const bottomBracketX = -wheelbase * 0.02 + + const laidOut: PartComposePartInput[] = [] + let hasWheelSet = false + for (const part of parts) { + const kind = normalizedPartKind(part) + if (!kind) { + laidOut.push(part) + continue + } + switch (kind) { + case 'wheel_set': + case 'wheel': + if (hasWheelSet) continue + hasWheelSet = true + laidOut.push({ + ...bicycleLayoutBase(part), + kind: 'wheel_set', + count: 2, + axis: 'z', + length: wheelbase, + radius: wheelRadius, + semanticRole: 'bicycle_tire', + sourcePartKind: 'bicycle_wheels', + position: [0, wheelY, 0] as Vec3, + }) + break + case 'tube_frame': + laidOut.push({ + ...bicycleLayoutBase(part), + kind: 'tube_frame', + length: wheelbase, + height: frameHeight, + semanticRole: 'bicycle_frame', + position: [0, frameCenterY, 0] as Vec3, + }) + break + case 'fork': + laidOut.push({ + ...bicycleLayoutBase(part), + kind: 'fork', + height: forkHeight, + width: forkSpread, + semanticRole: 'bicycle_fork', + position: forkCenter, + }) + break + case 'handlebar': + laidOut.push({ + ...bicycleLayoutBase(part), + kind: 'handlebar', + width: handlebarWidth, + height: handlebarStemDrop, + position: [handlebarX, handlebarY, 0] as Vec3, + }) + break + case 'saddle': + laidOut.push({ + ...bicycleLayoutBase(part), + kind: 'saddle', + position: [-wheelbase * 0.14, saddleY, 0] as Vec3, + }) + break + case 'chain_loop': + laidOut.push({ + ...bicycleLayoutBase(part), + kind: 'chain_loop', + length: chainSpan, + radius: wheelRadius * 0.3, + position: [bottomBracketX - chainSpan / 2, wheelY + frameHeight * 0.32, 0.018] as Vec3, + }) + break + default: + laidOut.push(part) + break + } + } + return laidOut +} + +function applyVehicleLayoutDefaults( + parts: PartComposePartInput[], + input: PartComposeInput, +): PartComposePartInput[] { + const body = parts.find((part) => normalizedPartKind(part) === 'body_shell') + if (!body) return parts + + const style = vehicleStyleFor(input, body) + const defaults = VEHICLE_STYLE_DEFAULTS[style] + const bodyLength = vehicleLength(body, style) + const bodyWidth = vehicleWidth(body, style) + const overallHeight = vehicleOverallHeight(body, bodyLength, bodyWidth, style) + const groundClearance = Math.min(overallHeight * defaults.groundClearanceRatio, bodyWidth * 0.22) + const bodyCenter = body.position ?? [0, groundClearance + overallHeight * 0.5, 0] + const baseY = bodyCenter[1] - overallHeight / 2 + const wheelRadius = vehicleWheelRadius(body, bodyLength, bodyWidth, overallHeight, style) + + return parts.map((part) => { + const kind = normalizedPartKind(part) + switch (kind) { + case 'body_shell': + return { + ...part, + vehicleStyle: style, + length: bodyLength, + width: bodyWidth, + height: overallHeight, + position: bodyCenter, + } + case 'wheel_set': { + const longitudinal = Math.abs( + Number(part.frontX ?? part.frontZ ?? bodyLength * 0.36) - + Number(part.rearX ?? part.rearZ ?? -bodyLength * 0.36), + ) + return { + ...part, + length: + Number.isFinite(longitudinal) && longitudinal > 0 + ? longitudinal + : bodyLength * defaults.wheelbaseRatio, + width: part.width ?? bodyWidth * defaults.trackRatio, + radius: part.radius ?? part.wheelRadius ?? wheelRadius, + wheelWidth: part.wheelWidth ?? part.depth ?? wheelRadius * 0.55, + semanticRole: part.semanticRole ?? 'vehicle_tire', + position: [bodyCenter[0], baseY + wheelRadius, bodyCenter[2]] as Vec3, + } + } + case 'window_strip': + return { + ...part, + vehicleStyle: style, + semanticRole: part.semanticRole ?? 'vehicle_window', + variant: part.variant ?? 'vehicle_glasshouse', + length: part.length ?? bodyLength * defaults.cabinLengthRatio, + width: part.width ?? bodyWidth * defaults.cabinWidthRatio, + height: part.height ?? overallHeight * 0.24, + position: [ + bodyCenter[0] + bodyLength * defaults.cabinXRatio, + baseY + overallHeight * 0.72, + bodyCenter[2], + ] as Vec3, + } + case 'light_pair': + return { + ...part, + width: part.width ?? bodyWidth, + semanticRole: part.semanticRole ?? 'headlight', + radius: part.radius ?? Math.min(bodyWidth * 0.045, overallHeight * 0.055), + position: [ + bodyCenter[0] + bodyLength * 0.49, + baseY + overallHeight * 0.36, + bodyCenter[2], + ] as Vec3, + } + case 'bar_pair': + return { + ...part, + width: part.width ?? part.length ?? bodyWidth * 0.96, + height: part.height ?? overallHeight * 0.055, + position: [ + bodyCenter[0] + bodyLength * 0.51, + baseY + overallHeight * 0.26, + bodyCenter[2], + ] as Vec3, + } + default: + return part + } + }) +} + +function hasExplicitPlacement(part: PartComposePartInput): boolean { + return ( + part.position != null || + part.connectTo != null || + part.alignAbove != null || + part.alignBeside != null || + part.centeredOn != null || + part.around != null + ) +} + +function hasExplicitSpatialPlacement(part: PartComposePartInput): boolean { + return ( + part.position != null || + part.alignAbove != null || + part.alignBeside != null || + part.centeredOn != null || + part.around != null + ) +} + +function partReference(part: PartComposePartInput, fallbackKind: PartComposeKind): string { + return part.id ?? part.name ?? part.partName ?? fallbackKind +} + +function applyContextualPartDefaults( + parts: PartComposePartInput[], + _input: PartComposeInput, +): PartComposePartInput[] { + const firstByKind = (kind: PartComposeKind) => + parts.find((part) => normalizedPartKind(part) === kind) + const fanBlades = firstByKind('radial_blades') + const volute = firstByKind('volute_casing') + const conveyorFrame = firstByKind('conveyor_frame') + + return parts.map((part) => { + const kind = normalizedPartKind(part) + if (!kind) return part + + if (kind === 'protective_grill' && fanBlades) { + const bladeRadius = clamp(fanBlades.bladeRadius ?? fanBlades.radius, 0.28, 0.05, 1.4) + return { + ...part, + radius: part.radius ?? bladeRadius * 1.18, + depth: part.depth ?? Math.max(0.05, bladeRadius * 0.24), + ...(hasExplicitPlacement(part) + ? {} + : { centeredOn: partReference(fanBlades, 'radial_blades') }), + } + } + + if (kind === 'inlet_port' && volute && !hasExplicitSpatialPlacement(part)) { + return { + ...part, + connectTo: partReference(volute, 'volute_casing'), + connectPoint: part.connectPoint ?? 'inlet', + childPoint: part.childPoint ?? 'base', + axis: part.axis ?? 'z', + } + } + + if (kind === 'outlet_port' && volute && !hasExplicitSpatialPlacement(part)) { + return { + ...part, + connectTo: partReference(volute, 'volute_casing'), + connectPoint: part.connectPoint ?? 'outlet', + childPoint: part.childPoint ?? 'base', + axis: part.axis ?? 'x', + } + } + + if ((kind === 'roller_array' || kind === 'belt_surface') && conveyorFrame) { + return { + ...part, + length: part.length ?? conveyorFrame.length, + width: part.width ?? conveyorFrame.width, + ...(hasExplicitPlacement(part) + ? {} + : { + alignAbove: partReference(conveyorFrame, 'conveyor_frame'), + relationGap: part.relationGap ?? (kind === 'belt_surface' ? 0.04 : 0.015), + }), + } + } + + return part + }) +} + +function composeCircularBase( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + index: number, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius, 0.28, 0.05, 2) + const height = clamp(part.height ?? part.depth, 0.08, 0.01, 0.4) + const center = add(origin, part.position ?? [0, height / 2, 0]) + return [ + { + kind: 'cylinder', + name: part.name ?? `${input.name ?? 'object'} circular base ${index + 1}`, + position: center, + axis: 'y', + radius, + height, + radialSegments: ringSegments(input.detail), + material: partMaterial(part, material(input.darkColor ?? '#24262b', 0.72, 0.18)), + }, + ] +} + +function composeVerticalPole( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + index: number, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius, 0.025, 0.005, 2) + const height = clamp(part.height ?? part.length, 1, 0.05, 50) + const center = add(origin, part.position ?? [0, height / 2 + 0.08, 0]) + return [ + { + kind: 'cylinder', + name: part.name ?? `${input.name ?? 'object'} vertical pole ${index + 1}`, + position: center, + axis: 'y', + radius, + height, + radialSegments: 24, + material: partMaterial(part, material(input.metalColor ?? '#b9bec7', 0.32, 0.68)), + }, + ] +} + +function composeMotorHousing( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + index: number, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius, 0.11, 0.03, 0.5) + const depth = clamp(part.depth ?? part.length ?? part.height, 0.16, 0.03, 0.8) + const center = add(origin, part.position ?? [0, 1.18, -depth * 0.15]) + const body = partMaterial(part, material(input.darkColor ?? '#30343b', 0.56, 0.25)) + return [ + { + kind: 'cylinder', + name: part.name ?? `${input.name ?? 'object'} motor housing ${index + 1}`, + position: center, + axis: 'z', + radius, + height: depth, + radialSegments: ringSegments(input.detail), + material: body, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} rear motor dome`, + position: [center[0], center[1], center[2] - depth * 0.48], + radius: 1, + scale: [radius * 0.95, radius * 0.95, depth * 0.32], + widthSegments: ringSegments(input.detail), + heightSegments: Math.max(16, Math.round(ringSegments(input.detail) * 0.5)), + material: body, + }, + ] +} + +function composeRadialBlades( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 1.18, 0.04]) + const count = clampInt(part.count, 3, 2, 8) + const radius = clamp(part.bladeRadius ?? part.radius, 0.28, 0.05, 1.4) + const bladeWidth = clamp( + part.bladeWidth ?? part.width, + radius * 0.26, + radius * 0.08, + radius * 0.55, + ) + const pitch = clamp(part.bladePitch, 0.24, -0.65, 0.65) + const sweep = clamp(part.bladeSweep, bladeWidth * 0.32, -bladeWidth, bladeWidth) + const bladeLength = radius * 0.78 + const rootRadius = radius * 0.18 + const bladeCenterRadius = rootRadius + bladeLength / 2 + const bladeDepth = clamp(part.depth ?? part.height, 0.018, 0.004, 0.08) + const rootWidth = bladeWidth * 0.42 + const bladeMat = partMaterial(part, material(input.accentColor ?? '#8ec5ff', 0.42, 0.02, 0.82)) + const rootMat = material(input.darkColor ?? '#25272c', 0.48, 0.35) + const shapes: PrimitiveShapeInput[] = [] + const profile: [number, number][] = [ + [0, -rootWidth * 0.5], + [bladeLength * 0.16, -bladeWidth * 0.42 + sweep * 0.12], + [bladeLength * 0.52, -bladeWidth * 0.55 + sweep * 0.38], + [bladeLength * 0.94, -bladeWidth * 0.25 + sweep], + [bladeLength, bladeWidth * 0.08 + sweep * 0.92], + [bladeLength * 0.72, bladeWidth * 0.44 + sweep * 0.46], + [bladeLength * 0.26, bladeWidth * 0.36 + sweep * 0.14], + [0, rootWidth * 0.5], + ] + + for (let i = 0; i < count; i += 1) { + const angle = angularStep(i, count, -Math.PI / 2) + shapes.push({ + kind: 'extrude', + name: `${part.name ?? input.name ?? 'object'} blade ${i + 1}`, + position: radialPoint( + center, + angle, + bladeCenterRadius, + Math.sin(i * 1.7) * bladeDepth * 0.25, + ), + rotation: radialExtrudeRotationInLocalPlane(angle, pitch), + profile, + depth: bladeDepth, + bevelSize: bladeDepth * 0.16, + bevelThickness: bladeDepth * 0.18, + bevelSegments: 2, + curveSegments: 10, + material: bladeMat, + }) + shapes.push({ + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} blade root ${i + 1}`, + position: radialPoint(center, angle, rootRadius * 0.82, -bladeDepth * 0.08), + rotation: [0, 0, angle], + axis: 'x', + radius: rootWidth * 0.22, + height: rootRadius * 1.2, + capSegments: 4, + radialSegments: 16, + material: rootMat, + }) + } + + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} blade hub`, + position: center, + axis: 'z', + radius: radius * 0.16, + height: clamp(part.depth, 0.055, 0.015, 0.2), + radialSegments: 32, + material: rootMat, + }) + + return shapes +} + +function composeFanBladeArray( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 1.18, 0.04]) + const count = clampInt(part.count ?? part.aroundCount, 1, 1, 16) + const bladeLength = clamp(part.length ?? part.bladeRadius ?? part.radius, 0.24, 0.04, 1.2) + const bladeWidth = clamp( + part.bladeWidth ?? part.width, + bladeLength * 0.26, + bladeLength * 0.06, + bladeLength * 0.55, + ) + const thickness = clamp(part.thickness ?? part.depth ?? part.height, 0.018, 0.003, 0.08) + const pitch = clamp(part.pitch ?? part.bladePitch, 0.24, -0.8, 0.8) + const sweep = clamp(part.bladeSweep, bladeWidth * 0.32, -bladeWidth, bladeWidth) + const hubRadius = clamp(part.wireRadius, bladeLength * 0.22, 0.01, bladeLength * 0.45) + const rootWidth = clamp(part.rootWidth, bladeWidth * 0.42, bladeWidth * 0.16, bladeWidth) + const profile: [number, number][] = [ + [0, -rootWidth * 0.5], + [bladeLength * 0.18, -bladeWidth * 0.42 + sweep * 0.12], + [bladeLength * 0.54, -bladeWidth * 0.55 + sweep * 0.38], + [bladeLength * 0.96, -bladeWidth * 0.25 + sweep], + [bladeLength, bladeWidth * 0.08 + sweep * 0.92], + [bladeLength * 0.72, bladeWidth * 0.44 + sweep * 0.46], + [bladeLength * 0.24, bladeWidth * 0.36 + sweep * 0.14], + [0, rootWidth * 0.5], + ] + const mat = partMaterial( + part, + material( + part.primaryColor ?? input.accentColor ?? input.primaryColor ?? '#8ec5ff', + 0.42, + 0.05, + 0.86, + ), + ) + const hubMat = material(input.darkColor ?? '#25272c', 0.48, 0.35) + const radialCenter = hubRadius + bladeLength * 0.5 + const baseId = part.id ?? part.name ?? part.partName ?? 'fan_blade' + const shapes: PrimitiveShapeInput[] = [] + + for (let index = 0; index < count; index += 1) { + const angle = part.aroundAngle ?? angularStep(index, count, -Math.PI / 2) + shapes.push({ + kind: 'extrude', + name: `${part.name ?? input.name ?? 'object'} fan blade ${index + 1}`, + semanticRole: part.semanticRole ?? 'fan_blade', + semanticGroup: part.semanticGroup ?? 'fan_blades', + sourcePartKind: part.sourcePartKind ?? 'fan_blade', + sourcePartId: count > 1 ? `${baseId}_${index + 1}` : baseId, + editableHints: { + primaryDimension: 'length', + canScale: ['primary', 'length', 'width', 'thickness'], + minFactor: 0.35, + maxFactor: 2.2, + }, + position: radialPoint(center, angle, radialCenter, Math.sin(index * 1.7) * thickness * 0.2), + rotation: radialExtrudeRotationInLocalPlane(angle, pitch), + profile, + depth: thickness, + bevelSize: thickness * 0.16, + bevelThickness: thickness * 0.18, + bevelSegments: 2, + curveSegments: 10, + material: mat, + }) + } + + if (part.includeHub !== false && count > 1) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} fan blade hub`, + semanticRole: 'fan_hub', + semanticGroup: part.semanticGroup ?? 'fan_blades', + sourcePartKind: part.sourcePartKind ?? 'fan_blade', + sourcePartId: `${baseId}_hub`, + position: center, + axis: 'z', + radius: hubRadius, + height: Math.max(thickness * 2.4, 0.045), + radialSegments: 32, + material: hubMat, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeProtectiveGrill( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const detailLevel = partDetailLevel(input, part) + const segmentDetail = + detailLevel === 'high' ? 'high' : detailLevel === 'low' ? 'low' : input.detail + const defaultRingCount = detailLevel === 'high' ? 5 : detailLevel === 'low' ? 3 : 4 + const defaultSpokeCount = detailLevel === 'high' ? 24 : detailLevel === 'low' ? 12 : 18 + const center = add(origin, part.position ?? [0, 1.18, 0.04]) + const radius = clamp(part.radius, 0.36, 0.08, 2) + const cageDepth = clamp(part.depth, 0.12, 0.005, 0.6) + const domeDepth = clamp(part.domeDepth, cageDepth * 0.72, 0.005, radius * 0.85) + const ringCount = clampInt(part.ringCount ?? part.count, defaultRingCount, 1, 8) + const spokeCount = clampInt(part.spokeCount, defaultSpokeCount, 6, 36) + const wireRadius = clamp(part.wireRadius, radius * 0.018, 0.002, 0.05) + const grillMat = partMaterial(part, material(input.metalColor ?? '#d1d5db', 0.38, 0.62)) + const shapes: PrimitiveShapeInput[] = [] + const frontZForRatio = (ratio: number) => center[2] + domeDepth * (1 - ratio * ratio) + const ringTubularSegments = ringSegments(segmentDetail) + const ringRadialSegments = Math.max(12, Math.round(ringTubularSegments * 0.35)) + + for (let i = 0; i < ringCount; i += 1) { + const ratio = ringCount === 1 ? 1 : 0.22 + (i / (ringCount - 1)) * 0.78 + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} grill front ring ${i + 1}`, + position: [center[0], center[1], frontZForRatio(ratio)], + axis: 'z', + majorRadius: radius * ratio, + tubeRadius: wireRadius, + radialSegments: ringRadialSegments, + tubularSegments: ringTubularSegments, + material: grillMat, + }) + } + + shapes.push( + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} grill rear outer ring`, + position: [center[0], center[1], center[2] - cageDepth], + axis: 'z', + majorRadius: radius, + tubeRadius: wireRadius, + radialSegments: ringRadialSegments, + tubularSegments: ringTubularSegments, + material: grillMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} grill center cap`, + position: [center[0], center[1], frontZForRatio(0.08) + wireRadius * 0.4], + axis: 'z', + radius: radius * 0.13, + height: wireRadius * 2.2, + radialSegments: Math.max(24, Math.round(ringTubularSegments * 0.6)), + material: grillMat, + }, + ) + + if (detailLevel !== 'low') { + shapes.splice(shapes.length - 1, 0, { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} grill rear inner support ring`, + position: [center[0], center[1], center[2] - cageDepth * 0.82], + axis: 'z', + majorRadius: radius * 0.42, + tubeRadius: wireRadius * 0.82, + radialSegments: ringRadialSegments, + tubularSegments: Math.max(24, Math.round(ringTubularSegments * 0.75)), + material: grillMat, + }) + } + + const innerRatio = 0.12 + const spokeStartZ = frontZForRatio(innerRatio) + const spokeEndZ = frontZForRatio(1) + const spokeRadialLength = radius * (1 - innerRatio) + const spokeDepth = spokeStartZ - spokeEndZ + const spokeLength = Math.hypot(spokeRadialLength, spokeDepth) + const spokeTilt = -Math.atan2(spokeDepth, spokeRadialLength) + const spokeMidRadius = radius * (innerRatio + (1 - innerRatio) / 2) + const spokeMidZ = (spokeStartZ + spokeEndZ) / 2 + + for (let i = 0; i < spokeCount; i += 1) { + const angle = angularStep(i, spokeCount) + const dx = Math.cos(angle) + const dy = Math.sin(angle) + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} grill spoke ${i + 1}`, + position: [center[0] + dx * spokeMidRadius, center[1] + dy * spokeMidRadius, spokeMidZ], + rotation: [0, spokeTilt, angle], + axis: 'x', + radius: wireRadius * 0.72, + height: spokeLength, + radialSegments: 8, + material: grillMat, + }) + } + + const sideRibCount = + detailLevel === 'high' + ? Math.max(12, Math.min(18, Math.round(spokeCount / 2))) + : Math.max(6, Math.min(12, Math.round(spokeCount / 2))) + for (let i = 0; i < sideRibCount; i += 1) { + const angle = angularStep(i, sideRibCount) + const dx = Math.cos(angle) + const dy = Math.sin(angle) + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} grill side rib ${i + 1}`, + position: [center[0] + dx * radius, center[1] + dy * radius, center[2] - cageDepth / 2], + axis: 'z', + radius: wireRadius * 0.75, + height: cageDepth, + radialSegments: 8, + material: grillMat, + }) + } + + return shapes +} + +function partDetailLevel(input: PartComposeInput, part: PartComposePartInput): PartComposeDetail { + const raw = + `${part.detailLevel ?? part.grillDetailLevel ?? part.detail ?? input.detail ?? ''}`.toLowerCase() + if (/(low|simple|coarse|light|\u4f4e|\u7b80)/i.test(raw)) return 'low' + if (/(high|fine|detailed|dense|\u9ad8|\u7ec6|\u5bc6)/i.test(raw)) return 'high' + return 'medium' +} + +function detailDefaultInt( + input: PartComposeInput, + part: PartComposePartInput, + values: Record, +): number { + return values[partDetailLevel(input, part)] +} + +function detailSegmentLevel( + input: PartComposeInput, + part: PartComposePartInput, +): PartComposeDetail { + const detailLevel = partDetailLevel(input, part) + if (detailLevel !== 'medium') return detailLevel + const raw = `${input.detail ?? ''}`.toLowerCase() + if (/(low|simple|coarse|light|\u4f4e|\u7b80)/i.test(raw)) return 'low' + if (/(high|fine|detailed|dense|\u9ad8|\u7ec6|\u5bc6)/i.test(raw)) return 'high' + return 'medium' +} + +function composePyramid( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length ?? part.width ?? part.diameter, 0.6, 0.02, 20) + const width = clamp(part.width ?? part.length ?? part.diameter, length, 0.02, 20) + const height = clamp(part.height ?? part.depth, 0.8, 0.02, 20) + const requestedRadius = part.radius ?? (part.diameter != null ? part.diameter / 2 : undefined) + const radius = clamp(requestedRadius, Math.max(length, width) / 2, 0.01, 20) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const scale: Vec3 = [length / (radius * 2), 1, width / (radius * 2)] + const topScale = pyramidTopScale(part, length, width) + const isTruncated = part.truncated === true || topScale != null || part.topRadius != null + const topRadius = isTruncated + ? clamp(part.topRadius, radius * (topScale ?? 0.35), 0.005, radius * 0.98) + : 0 + + // Three.js CylinderGeometry with 4 segments places the first vertex at +X, + // making edges face front/back/left/right (diamond orientation). + // Rotating 45° (π/4) around Y makes the flat faces front-facing (correct pyramid look). + const pyramidRotation: Vec3 = part.rotation + ? [part.rotation[0], (part.rotation[1] ?? 0) + Math.PI / 4, part.rotation[2]] + : [0, Math.PI / 4, 0] + + return [ + { + kind: isTruncated ? 'frustum' : 'cone', + name: `${part.name ?? input.name ?? 'object'} pyramid`, + semanticRole: part.semanticRole ?? 'pyramid', + sourcePartKind: part.sourcePartKind ?? 'pyramid', + position: center, + rotation: pyramidRotation, + axis: part.axis ?? 'y', + ...(isTruncated ? { radiusBottom: radius, radiusTop: topRadius } : { radius }), + height, + scale, + radialSegments: 4, + material: partMaterial(part, material(input.primaryColor ?? '#c08457', 0.56, 0.18)), + }, + ] +} + +function composeHemisphere( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const diameter = part.diameter ?? (part.radius != null ? part.radius * 2 : undefined) + const length = clamp(part.length ?? diameter, 1, 0.02, 20) + const width = clamp(part.width ?? part.depth ?? diameter, length, 0.02, 20) + const radius = clamp( + part.radius ?? (part.diameter != null ? part.diameter / 2 : undefined), + 0.5, + 0.01, + 10, + ) + const height = clamp(part.height, radius, 0.01, 10) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const scale: Vec3 = part.scale ?? [length / (radius * 2), height / radius, width / (radius * 2)] + + return applyPartRotation( + [ + { + kind: 'hemisphere', + name: `${part.name ?? input.name ?? 'object'} hemisphere`, + semanticRole: part.semanticRole ?? 'hemisphere', + sourcePartKind: part.sourcePartKind ?? 'hemisphere', + position: center, + radius, + scale, + widthSegments: clampInt(part.widthSegments, input.detail === 'high' ? 48 : 32, 8, 64), + heightSegments: clampInt(part.heightSegments, input.detail === 'high' ? 20 : 16, 4, 32), + material: partMaterial(part, material(input.primaryColor ?? '#94a3b8', 0.42, 0.2)), + }, + ], + center, + part.rotation, + ) +} + +function pyramidTopScale( + part: PartComposePartInput, + length: number, + width: number, +): number | undefined { + if (typeof part.topScale === 'number' && Number.isFinite(part.topScale)) { + return clamp(part.topScale, 0.35, 0.02, 0.95) + } + if (Array.isArray(part.topScale)) { + const [xScale, zScale] = part.topScale + const scales = [xScale, zScale].filter( + (value) => typeof value === 'number' && Number.isFinite(value), + ) + if (scales.length > 0) { + return clamp(scales.reduce((sum, value) => sum + value, 0) / scales.length, 0.35, 0.02, 0.95) + } + } + + const lengthScale = + typeof part.topLength === 'number' && Number.isFinite(part.topLength) + ? part.topLength / length + : undefined + const widthScale = + typeof part.topWidth === 'number' && Number.isFinite(part.topWidth) + ? part.topWidth / width + : undefined + const scales = [lengthScale, widthScale].filter((value): value is number => value != null) + if (scales.length === 0) return undefined + return clamp(scales.reduce((sum, value) => sum + value, 0) / scales.length, 0.35, 0.02, 0.95) +} + +function composeSupportBracket( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 1.08, -0.02]) + const width = clamp(part.width ?? part.length, 0.26, 0.04, 1) + const height = clamp(part.height, 0.18, 0.04, 0.8) + const depth = clamp(part.depth, 0.05, 0.01, 0.3) + const r = clamp(part.radius ?? part.wireRadius, 0.018, 0.004, 0.08) + const bracketMat = partMaterial(part, material(input.metalColor ?? '#9ca3af', 0.34, 0.72)) + return [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} bracket left arm`, + position: [center[0] - width / 2, center[1] + height / 2, center[2]], + axis: 'y', + radius: r, + height, + radialSegments: 16, + material: bracketMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} bracket right arm`, + position: [center[0] + width / 2, center[1] + height / 2, center[2]], + axis: 'y', + radius: r, + height, + radialSegments: 16, + material: bracketMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} bracket crossbar`, + position: [center[0], center[1], center[2]], + axis: 'x', + radius: r, + height: width, + radialSegments: 16, + material: bracketMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} bracket neck block`, + position: [center[0], center[1] - depth * 0.2, center[2]], + length: width * 0.32, + width: depth, + height: depth, + cornerRadius: depth * 0.2, + cornerSegments: 4, + material: bracketMat, + }, + ] +} + +function composeControlKnob( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + index: number, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius, 0.035, 0.005, 0.2) + const depth = clamp(part.depth ?? part.height, 0.025, 0.005, 0.15) + const center = add(origin, part.position ?? [0.12, 1.18, -0.04]) + return [ + { + kind: 'cylinder', + name: part.name ?? `${input.name ?? 'object'} control knob ${index + 1}`, + position: center, + axis: 'x', + radius, + height: depth, + radialSegments: 24, + material: partMaterial(part, material(input.darkColor ?? '#25272c', 0.5, 0.2)), + }, + ] +} + +function composeVentSlats( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + kind: 'vent_slats' | 'vent_grill' = 'vent_slats', +): PrimitiveShapeInput[] { + const detailLevel = partDetailLevel(input, part) + const center = add(origin, part.position ?? [0, 0.5, 0.02]) + const defaultCount = + kind === 'vent_grill' + ? detailDefaultInt(input, part, { low: 5, medium: 8, high: 12 }) + : detailDefaultInt(input, part, { low: 4, medium: 6, high: 10 }) + const count = clampInt(part.slatCount ?? part.count, defaultCount, 2, 20) + const width = clamp(part.width ?? part.length, 0.5, 0.05, 3) + const height = clamp(part.height, 0.018, 0.004, 0.08) + const spacing = clamp(part.depth, 0.055, 0.01, 0.3) + const panelHeight = Math.max(height * 2.4, spacing * (count - 1) + height * 2.4) + const frameWidth = clamp( + part.wireRadius ?? part.thickness, + Math.min(width, panelHeight) * 0.035, + 0.004, + 0.08, + ) + const panelDepth = clamp(part.thickness ?? part.wireRadius, height * 0.7, 0.003, 0.08) + const slatMat = partMaterial(part, material(input.darkColor ?? '#4b5563', 0.62, 0.08)) + const frameMat = material(input.metalColor ?? '#9ca3af', 0.42, 0.48) + const recessMat = material(input.darkColor ?? '#111827', 0.7, 0.04) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vent recess panel`, + position: [center[0], center[1], center[2] - panelDepth * 0.45], + length: width + frameWidth * 2.4, + width: panelHeight + frameWidth * 2.2, + thickness: panelDepth, + cornerRadius: frameWidth * 1.2, + cornerSegments: detailLevel === 'high' ? 4 : detailLevel === 'low' ? 1 : 3, + material: recessMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vent top frame`, + position: [center[0], center[1] + panelHeight / 2 + frameWidth / 2, center[2]], + length: width + frameWidth * 2, + width: frameWidth, + height: frameWidth, + cornerRadius: frameWidth * 0.25, + cornerSegments: detailLevel === 'low' ? 1 : 3, + material: frameMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vent bottom frame`, + position: [center[0], center[1] - panelHeight / 2 - frameWidth / 2, center[2]], + length: width + frameWidth * 2, + width: frameWidth, + height: frameWidth, + cornerRadius: frameWidth * 0.25, + cornerSegments: detailLevel === 'low' ? 1 : 3, + material: frameMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vent left frame`, + position: [center[0] - width / 2 - frameWidth / 2, center[1], center[2]], + length: frameWidth, + width: frameWidth, + height: panelHeight, + cornerRadius: frameWidth * 0.25, + cornerSegments: detailLevel === 'low' ? 1 : 3, + material: frameMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vent right frame`, + position: [center[0] + width / 2 + frameWidth / 2, center[1], center[2]], + length: frameWidth, + width: frameWidth, + height: panelHeight, + cornerRadius: frameWidth * 0.25, + cornerSegments: detailLevel === 'low' ? 1 : 3, + material: frameMat, + }, + ] + for (let i = 0; i < count; i += 1) { + const y = center[1] + (i - (count - 1) / 2) * spacing + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vent slat ${i + 1}`, + position: [center[0], y, center[2] + panelDepth * 0.15], + rotation: [0, 0, 0], + length: width, + width: height, + height, + cornerRadius: height * 0.3, + cornerSegments: detailLevel === 'low' ? 1 : 3, + material: slatMat, + }) + } + if (kind === 'vent_grill' && detailLevel !== 'low') { + for (const offset of [-0.25, 0.25]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vent vertical mullion`, + position: [center[0] + offset * width, center[1], center[2] + panelDepth * 0.2], + length: frameWidth * 0.8, + width: frameWidth * 0.7, + height: panelHeight * 0.92, + cornerRadius: frameWidth * 0.2, + cornerSegments: 3, + material: frameMat, + }) + } + } + return shapes +} + +function composeSkidBase( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.06, 0]) + const length = clamp(part.length ?? part.depth, 1.1, 0.25, 5) + const width = clamp(part.width, 0.46, 0.12, 2) + const railHeight = clamp(part.height, 0.08, 0.02, 0.35) + const railWidth = clamp(part.radius, Math.min(width, length) * 0.08, 0.015, 0.18) + const frameMat = partMaterial(part, material(input.darkColor ?? '#2f343b', 0.6, 0.42)) + const railZ = width / 2 - railWidth / 2 + return [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} skid left rail`, + position: [center[0], center[1], center[2] - railZ], + length, + width: railWidth, + height: railHeight, + cornerRadius: railHeight * 0.12, + cornerSegments: 3, + material: frameMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} skid right rail`, + position: [center[0], center[1], center[2] + railZ], + length, + width: railWidth, + height: railHeight, + cornerRadius: railHeight * 0.12, + cornerSegments: 3, + material: frameMat, + }, + ...[-0.36, 0, 0.36].map((offset, index) => ({ + kind: 'box' as const, + name: `${part.name ?? input.name ?? 'object'} skid cross member ${index + 1}`, + position: [center[0] + offset * length, center[1] + railHeight * 0.18, center[2]] as Vec3, + length: railWidth, + width: width, + height: railHeight * 0.72, + cornerRadius: railHeight * 0.08, + cornerSegments: 3, + material: frameMat, + })), + ] +} + +function composeRoundedMachineBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.45, 0]) + const length = clamp(part.length, 0.7, 0.1, 5) + const width = clamp(part.width ?? part.depth, 0.36, 0.08, 2) + const height = clamp(part.height, 0.36, 0.08, 2) + const cornerRadius = clamp(part.cornerRadius, Math.min(length, width, height) * 0.12, 0.004, 0.22) + const bodyMat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.48, 0.28)) + const secondaryMat = material(input.secondaryColor ?? '#334155', 0.55, 0.18) + const darkMat = material(input.darkColor ?? '#1f2937', 0.62, 0.18) + const metalMat = material(input.metalColor ?? '#cbd5e1', 0.34, 0.68) + const seamThickness = Math.min(length, width, height) * 0.012 + return [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} rounded machine body`, + position: center, + length, + width, + height, + cornerRadius, + cornerSegments: clampInt(part.cornerSegments, input.detail === 'high' ? 8 : 6, 3, 12), + material: bodyMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} recessed front access cover plate`, + position: [center[0], center[1] + height * 0.02, center[2] + width * 0.515], + length: length * 0.72, + width: width * 0.035, + height: height * 0.58, + cornerRadius: Math.min(length, height) * 0.025, + cornerSegments: 3, + material: secondaryMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} raised top service hatch`, + position: [center[0] - length * 0.06, center[1] + height * 0.51, center[2]], + length: length * 0.54, + width: width * 0.72, + thickness: seamThickness * 1.8, + cornerRadius: cornerRadius * 0.55, + cornerSegments: 4, + material: secondaryMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} lower shadow plinth`, + position: [center[0], center[1] - height * 0.52 - seamThickness, center[2]], + length: length * 0.94, + width: width * 0.92, + height: seamThickness * 2, + cornerRadius: cornerRadius * 0.35, + cornerSegments: 3, + material: darkMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} front horizontal seam`, + position: [center[0], center[1] + height * 0.22, center[2] + width * 0.535], + length: length * 0.8, + width: seamThickness, + height: seamThickness, + cornerRadius: seamThickness * 0.25, + cornerSegments: 2, + material: metalMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} side service seam`, + position: [center[0] - length * 0.18, center[1], center[2] + width * 0.536], + length: seamThickness, + width: seamThickness, + height: height * 0.62, + cornerRadius: seamThickness * 0.25, + cornerSegments: 2, + material: metalMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} rear foot pad left`, + position: [center[0] - length * 0.32, center[1] - height * 0.58, center[2] - width * 0.28], + length: length * 0.12, + width: width * 0.16, + height: seamThickness * 2.2, + cornerRadius: seamThickness * 0.4, + cornerSegments: 3, + material: darkMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} rear foot pad right`, + position: [center[0] + length * 0.32, center[1] - height * 0.58, center[2] - width * 0.28], + length: length * 0.12, + width: width * 0.16, + height: seamThickness * 2.2, + cornerRadius: seamThickness * 0.4, + cornerSegments: 3, + material: darkMat, + }, + ] +} + +function composeVoluteCasing( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.55, 0.18]) + const radius = clamp(part.radius, 0.28, 0.06, 2) + const depth = clamp(part.depth ?? part.width, radius * 0.48, 0.03, 1) + const outletAngle = clamp(part.outletAngle, Math.atan2(0.34, 0.72), -Math.PI, Math.PI) + const casingMat = partMaterial(part, material(input.primaryColor ?? '#6b7280', 0.5, 0.32)) + const darkMat = material(input.darkColor ?? '#1f2937', 0.58, 0.18) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} volute scroll casing`, + position: center, + axis: 'z', + majorRadius: radius * 0.5, + tubeRadius: radius * 0.24, + arc: Math.PI * 1.78, + radialSegments: Math.max(12, Math.round(ringSegments(input.detail) * 0.4)), + tubularSegments: ringSegments(input.detail), + material: casingMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} volute circular cover`, + position: [center[0], center[1], center[2] + depth * 0.04], + axis: 'z', + radius: radius * 0.72, + height: depth * 0.64, + radialSegments: ringSegments(input.detail), + wallThickness: radius * 0.08, + material: casingMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} volute inlet lip`, + position: [center[0], center[1], center[2] + depth * 0.42], + axis: 'z', + radius: radius * 0.34, + height: depth * 0.18, + radialSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.75)), + wallThickness: radius * 0.07, + material: darkMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} volute discharge neck`, + position: [ + center[0] + Math.cos(outletAngle) * radius * 0.8, + center[1] + Math.sin(outletAngle) * radius * 0.8, + center[2], + ], + rotation: [0, 0, outletAngle], + axis: 'x', + radius: radius * 0.18, + height: radius * 0.52, + radialSegments: Math.max(20, Math.round(ringSegments(input.detail) * 0.55)), + material: casingMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeImpellerBlades( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.55, 0.34]) + const count = clampInt(part.count, 7, 4, 16) + const radius = clamp(part.bladeRadius ?? part.radius, 0.18, 0.04, 1.2) + const bladeWidth = clamp( + part.bladeWidth ?? part.width, + radius * 0.18, + radius * 0.06, + radius * 0.36, + ) + const bladeDepth = clamp(part.depth ?? part.height, 0.025, 0.006, 0.12) + const sweep = clamp(part.bladeSweep, bladeWidth * 0.55, -bladeWidth, bladeWidth) + const mat = partMaterial(part, material(input.accentColor ?? '#94a3b8', 0.4, 0.45)) + const profile: [number, number][] = [ + [0, -bladeWidth * 0.32], + [radius * 0.38, -bladeWidth * 0.48 + sweep * 0.2], + [radius * 0.92, -bladeWidth * 0.22 + sweep], + [radius, bladeWidth * 0.18 + sweep * 0.8], + [radius * 0.45, bladeWidth * 0.44 + sweep * 0.24], + [0, bladeWidth * 0.28], + ] + const shapes: PrimitiveShapeInput[] = [] + + for (let i = 0; i < count; i += 1) { + const angle = angularStep(i, count) + shapes.push({ + kind: 'extrude', + name: `${part.name ?? input.name ?? 'object'} impeller vane ${i + 1}`, + position: radialPoint(center, angle, radius * 0.42, 0), + rotation: radialExtrudeRotationInLocalPlane(angle, 0), + profile, + depth: bladeDepth, + bevelSize: bladeDepth * 0.12, + bevelThickness: bladeDepth * 0.12, + bevelSegments: 1, + curveSegments: 8, + material: mat, + }) + } + + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} impeller hub`, + position: center, + axis: 'z', + radius: radius * 0.26, + height: bladeDepth * 1.45, + radialSegments: 32, + material: mat, + }) + + return shapes +} + +function normalizedBladeShape(value: unknown): 'taiji_half' | 'airfoil' { + const text = textOf(value).replace(/[\s-]+/g, '_') + if (/airfoil|wing|aero|翼型|飞机|航空/.test(text)) return 'airfoil' + return 'taiji_half' +} + +function taijiHalfBladeProfile( + length: number, + rootWidth: number, + bladeWidth: number, + longitudinalCurve: number, + steps: number, +): [number, number][] { + const profile: [number, number][] = [] + const halfRoot = rootWidth * 0.5 + const maxHalfWidth = bladeWidth * 0.78 + const halfWidthAt = (t: number) => { + const bulb = Math.sin(Math.PI * t) ** 0.48 + const outerWeight = 0.72 + t * 0.28 + const rootNeck = halfRoot * (1 - t) ** 2.2 + return rootNeck + maxHalfWidth * bulb * outerWeight + } + const spineOffset = (t: number) => + longitudinalCurve * (Math.sin(Math.PI * (t - 0.06)) + 0.28 * Math.sin(Math.PI * 2 * t)) + const innerCut = (t: number) => longitudinalCurve * 0.72 * Math.sin(Math.PI * t) * (1 - t * 0.35) + + for (let step = 0; step <= steps; step += 1) { + const t = step / steps + const x = length * (t - 0.5) + profile.push([x, spineOffset(t) + halfWidthAt(t) * (0.92 + t * 0.18)]) + } + for (let step = steps; step >= 0; step -= 1) { + const t = step / steps + const x = length * (t - 0.5) + const width = halfWidthAt(t) + profile.push([x, spineOffset(t) - width * (0.5 + 0.32 * (1 - t)) + innerCut(t)]) + } + return profile +} + +function rotateBladeProfile(profile: [number, number][], angle: number): [number, number][] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return profile.map(([x, y]) => [x * cos - y * sin, x * sin + y * cos]) +} + +function composePropellerBladeSet( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + options?: { + semanticRole?: string + semanticGroup?: string + sourcePartKind?: string + namePrefix?: string + }, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.12, 0]) + const count = clampInt(part.count, 3, 2, 8) + const bladeLength = clamp(part.bladeRadius ?? part.radius ?? part.length, 0.34, 0.08, 1.2) + const bladeWidth = clamp(part.bladeWidth ?? part.width, 0.13, 0.04, 0.45) + const bladeDepth = clamp(part.depth ?? part.height, 0.028, 0.01, 0.09) + const pitch = clamp(part.bladePitch, 0.52, 0, 1.05) + const hubRadius = clamp( + part.hubRadius ?? part.wireRadius, + bladeLength * 0.12, + 0.015, + bladeLength * 0.32, + ) + const rootWidth = Math.max(bladeDepth * 1.05, hubRadius * 0.36) + const tipWidth = Math.max(bladeDepth * 1.8, bladeWidth * 0.24) + const camber = clamp(part.camber, bladeWidth * 0.22, -bladeWidth * 0.8, bladeWidth * 0.8) + const sweep = clamp(part.bladeSweep, bladeWidth * 0.18, -bladeWidth, bladeWidth) + const longitudinalCurve = clamp( + part.verticalCurve ?? part.curvature, + bladeWidth * 0.38, + -bladeWidth, + bladeWidth, + ) + const bladeShape = normalizedBladeShape(part.bladeShape ?? part.style ?? part.variant) + const mat = partMaterial(part, material(input.accentColor ?? '#64748b', 0.5, 0.45)) + const shapes: PrimitiveShapeInput[] = [] + const profile = + bladeShape === 'airfoil' + ? airfoilProfile( + bladeLength, + rootWidth, + tipWidth, + camber, + sweep, + input.detail === 'low' ? 12 : input.detail === 'high' ? 30 : 22, + ) + : taijiHalfBladeProfile( + bladeLength, + rootWidth, + Math.max(bladeWidth, tipWidth * 1.8), + longitudinalCurve, + input.detail === 'low' ? 14 : input.detail === 'high' ? 36 : 28, + ) + const planarMixerBlades = options?.sourcePartKind === 'mixer_blades' + + for (let i = 0; i < count; i += 1) { + const angle = (i * Math.PI * 2) / count + const bladeProfile = planarMixerBlades ? rotateBladeProfile(profile, angle) : profile + shapes.push({ + kind: 'extrude', + name: `${part.name ?? input.name ?? 'object'} ${options?.namePrefix ?? bladeShape.replace('_', ' ')} propeller blade ${i + 1}`, + semanticRole: options?.semanticRole ?? part.semanticRole ?? 'propeller_blade', + semanticGroup: options?.semanticGroup ?? part.semanticGroup ?? 'propeller_blade_set', + sourcePartKind: options?.sourcePartKind ?? part.sourcePartKind ?? 'propeller_blade_set', + position: [ + center[0] + Math.cos(angle) * (hubRadius + bladeLength * 0.5), + center[1], + center[2] + Math.sin(angle) * (hubRadius + bladeLength * 0.5), + ], + rotation: planarMixerBlades + ? [-Math.PI / 2, 0, 0] + : radialExtrudeRotationInHorizontalPlane(angle, pitch * 0.55), + profile: bladeProfile, + depth: bladeDepth, + bevelSize: bladeDepth * 0.12, + bevelThickness: bladeDepth * 0.16, + bevelSegments: input.detail === 'high' ? 3 : 2, + curveSegments: input.detail === 'high' ? 24 : 18, + material: mat, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeMixerBlades( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + return composePropellerBladeSet(input, { bladeShape: 'taiji_half', ...part }, origin, { + semanticRole: 'mixer_blade', + semanticGroup: 'mixer_blades', + sourcePartKind: 'mixer_blades', + namePrefix: 'taiji half mixer', + }) +} + +function airfoilProfile( + length: number, + rootWidth: number, + tipWidth: number, + camber: number, + sweep: number, + steps: number, +): [number, number][] { + const profile: [number, number][] = [] + const halfRoot = rootWidth / 2 + const halfTip = tipWidth / 2 + const halfWidthAt = (t: number) => + halfTip + (halfRoot - halfTip) * Math.sqrt(Math.max(0, 1 - t * t)) + const centerOffset = (t: number) => + sweep * Math.sin(Math.PI * t) + camber * Math.sin(Math.PI * t) * (1 - t * 0.35) + + for (let step = 0; step <= steps; step += 1) { + const t = step / steps + profile.push([length * (t - 0.5), centerOffset(t) - halfWidthAt(t)]) + } + for (let step = steps; step >= 0; step -= 1) { + const t = step / steps + profile.push([length * (t - 0.5), centerOffset(t) + halfWidthAt(t)]) + } + return profile +} + +function composeAirfoilBlade( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.4, 0]) + const count = clampInt(part.count, 1, 1, 64) + const bladeLength = clamp(part.length ?? part.bladeRadius ?? part.radius, 0.46, 0.06, 2.5) + const rootWidth = clamp(part.rootWidth ?? part.bladeWidth ?? part.width, 0.13, 0.015, 0.8) + const tipWidth = clamp(part.tipWidth, rootWidth * 0.34, 0.006, rootWidth) + const thickness = clamp(part.thickness ?? part.depth ?? part.height, 0.025, 0.003, 0.16) + const pitch = clamp(part.pitch ?? part.bladePitch, 0.34, -1.2, 1.2) + const twist = clamp(part.twist, 0.18, -1.2, 1.2) + const camber = clamp(part.camber, rootWidth * 0.18, -rootWidth * 0.8, rootWidth * 0.8) + const sweep = clamp(part.bladeSweep, rootWidth * 0.18, -rootWidth, rootWidth) + const hubRadius = clamp(part.wireRadius, bladeLength * 0.12, 0.01, bladeLength * 0.35) + const profile = airfoilProfile( + bladeLength, + rootWidth, + tipWidth, + camber, + sweep, + input.detail === 'low' ? 10 : 18, + ) + const mat = partMaterial(part, material(input.accentColor ?? '#64748b', 0.45, 0.45)) + const shapes: PrimitiveShapeInput[] = [] + + for (let index = 0; index < count; index += 1) { + const angle = angularStep(index, count) + const radialCenter = hubRadius + bladeLength * 0.5 + shapes.push({ + kind: 'extrude', + name: `${part.name ?? input.name ?? 'object'} airfoil blade ${index + 1}`, + semanticRole: part.semanticRole ?? 'airfoil_blade', + semanticGroup: 'airfoil_blades', + sourcePartKind: 'airfoil_blade', + position: [ + center[0] + Math.cos(angle) * radialCenter, + center[1], + center[2] + Math.sin(angle) * radialCenter, + ], + rotation: radialExtrudeRotationInHorizontalPlane(angle, pitch + twist * 0.35), + profile, + depth: thickness, + bevelSize: thickness * 0.12, + bevelThickness: thickness * 0.16, + bevelSegments: 1, + curveSegments: 16, + material: mat, + }) + } + + if (count > 1) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} airfoil blade hub`, + semanticRole: 'airfoil_hub', + semanticGroup: 'airfoil_blades', + sourcePartKind: 'airfoil_blade', + position: center, + axis: 'y', + radius: hubRadius, + height: thickness * 1.8, + radialSegments: 32, + material: partMaterial(part, material(input.metalColor ?? '#94a3b8', 0.35, 0.7)), + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function lensProfile(shape: string | undefined, width: number, height: number): [number, number][] { + const normalized = + shape + ?.trim() + .toLowerCase() + .replace(/[\s-]+/g, '_') ?? '' + const steps = 24 + const profile: [number, number][] = [] + for (let index = 0; index < steps; index += 1) { + const angle = (index / steps) * Math.PI * 2 + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const aviatorDrop = normalized.includes('aviator') || normalized.includes('teardrop') + const frog = normalized.includes('frog') || normalized.includes('toad') + const lowerBulge = aviatorDrop ? (sin < 0 ? 1.26 : 0.92) : frog ? (sin < 0 ? 1.12 : 0.9) : 1 + const outerLift = frog ? 1 + Math.max(0, cos) * 0.16 : 1 + const innerPinch = frog ? 1 - Math.max(0, -cos) * 0.08 : 1 + profile.push([ + cos * width * 0.5 * outerLift * innerPinch, + sin * height * 0.5 * lowerBulge - (aviatorDrop ? height * 0.05 : 0), + ]) + } + return profile +} + +function composeCurvedLensPanel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.45, 0]) + const width = clamp(part.width ?? part.length, 0.32, 0.04, 2) + const height = clamp(part.height, 0.18, 0.025, 1.2) + const thickness = clamp(part.thickness ?? part.depth, 0.012, 0.002, 0.08) + const curvature = clamp(part.curvature, 0.05, -0.4, 0.4) + const profile = lensProfile(part.lensShape ?? part.style ?? part.variant, width, height) + const tint = part.color ?? part.primaryColor ?? input.primaryColor ?? '#1f2937' + const lensMat: PrimitiveMaterialInput = { + properties: { + color: tint, + roughness: 0.18, + metalness: 0.05, + opacity: 0.46, + transparent: true, + side: 'double', + }, + } + const rimMat = material(input.darkColor ?? '#111827', 0.42, 0.35) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'extrude', + name: `${part.name ?? input.name ?? 'object'} curved lens panel`, + semanticRole: 'curved_lens', + semanticGroup: 'curved_lens_panel', + sourcePartKind: 'curved_lens_panel', + position: center, + rotation: [0, curvature, 0], + profile, + depth: thickness, + bevelSize: thickness * 0.2, + bevelThickness: thickness * 0.24, + bevelSegments: 2, + curveSegments: 18, + material: lensMat, + }, + { + kind: 'extrude', + name: `${part.name ?? input.name ?? 'object'} curved lens rim`, + semanticRole: 'lens_rim', + semanticGroup: 'curved_lens_panel', + sourcePartKind: 'curved_lens_panel', + position: [center[0], center[1], center[2] - thickness * 0.7], + rotation: [0, curvature, 0], + profile: profile.map(([x, y]) => [x * 1.045, y * 1.06]), + holes: [profile.map(([x, y]) => [x * 0.94, y * 0.92])], + depth: thickness * 0.9, + bevelSize: thickness * 0.14, + bevelThickness: thickness * 0.18, + bevelSegments: 1, + curveSegments: 18, + material: rimMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeEllipsoidShell( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.48, 0.04, 6) + const width = clamp(part.width ?? part.depth, 0.28, 0.025, 4) + const height = clamp(part.height, 0.18, 0.02, 3) + const shellThickness = clamp(part.shellThickness ?? part.thickness, height * 0.05, 0.002, 0.18) + const openingRadius = clamp(part.openingRadius, Math.min(length, width) * 0.16, 0.01, 1) + const center = add(origin, part.position ?? [0, height * 0.56, 0]) + const role = part.semanticRole ?? 'ellipsoid_shell' + const group = part.semanticGroup ?? 'ellipsoid_shell' + const source = part.sourcePartKind ?? 'ellipsoid_shell' + const shellMat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.44, 0.22)) + const rimMat = material(input.darkColor ?? '#1f2937', 0.56, 0.2) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} ellipsoid shell body`, + semanticRole: role, + semanticGroup: group, + sourcePartKind: source, + position: center, + radius: 0.5, + scale: [length, height * 1.22, width], + widthSegments: part.cornerSegments ?? 40, + heightSegments: part.cornerSegments ?? 22, + material: shellMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} ellipsoid shell base rim`, + semanticRole: 'ellipsoid_shell_rim', + semanticGroup: group, + sourcePartKind: source, + position: [center[0], center[1] - height * 0.45, center[2]], + axis: 'y', + majorRadius: Math.min(length, width) * 0.34, + tubeRadius: shellThickness * 0.45, + radialSegments: 14, + tubularSegments: 44, + scale: [length / Math.max(width, 0.01), 1, 1], + material: rimMat, + }, + ] + + if (part.cutBottom !== false) { + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} flattened shell opening lip`, + semanticRole: 'ellipsoid_shell_opening', + semanticGroup: group, + sourcePartKind: source, + position: [center[0], center[1] - height * 0.5, center[2]], + length: length * 0.82, + width: width * 0.82, + thickness: shellThickness, + cornerRadius: Math.min(length, width) * 0.22, + cornerSegments: 8, + material: rimMat, + }) + } + + if (part.openingRadius != null || part.style === 'vessel_head' || part.variant === 'manway') { + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} top access opening rim`, + semanticRole: 'ellipsoid_shell_top_opening', + semanticGroup: group, + sourcePartKind: source, + position: [center[0], center[1] + height * 0.48, center[2]], + axis: 'y', + majorRadius: openingRadius, + tubeRadius: shellThickness * 0.42, + radialSegments: 12, + tubularSegments: 32, + material: rimMat, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeErgonomicShell( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.12, 0.04, 2) + const width = clamp(part.width ?? part.depth, 0.065, 0.02, 1) + const height = clamp(part.height, 0.036, 0.01, 0.6) + const center = add(origin, part.position ?? [0, height * 0.72, 0]) + const sideTaper = clamp(part.sideTaper, 0.18, 0, 0.6) + const noseSlope = clamp(part.noseSlope, 0.38, 0, 1) + const tailSlope = clamp(part.tailSlope, 0.22, 0, 1) + const shellMat = partMaterial(part, material(input.primaryColor ?? '#374151', 0.5, 0.2)) + const darkMat = material(input.darkColor ?? '#111827', 0.56, 0.18) + const panelWidth = width * (1 - sideTaper * 0.4) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} ergonomic shell base lip`, + semanticRole: 'ergonomic_shell_base', + semanticGroup: 'ergonomic_shell', + sourcePartKind: 'ergonomic_shell', + position: [center[0], center[1] - height * 0.44, center[2]], + length, + width, + thickness: Math.max(height * 0.08, 0.004), + cornerRadius: Math.min(length, width) * 0.24, + cornerSegments: 8, + material: darkMat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} ergonomic domed shell`, + semanticRole: 'ergonomic_shell', + semanticGroup: 'ergonomic_shell', + sourcePartKind: 'ergonomic_shell', + position: [center[0], center[1], center[2]], + radius: 0.5, + scale: [length, height * 1.55, panelWidth], + widthSegments: 32, + heightSegments: 20, + material: shellMat, + }, + { + kind: 'wedge', + name: `${part.name ?? input.name ?? 'object'} ergonomic low nose slope`, + semanticRole: 'ergonomic_shell_nose', + semanticGroup: 'ergonomic_shell', + sourcePartKind: 'ergonomic_shell', + position: [center[0] + length * 0.34, center[1] - height * 0.1, center[2]], + rotation: [0, 0, -noseSlope * 0.22], + length: length * 0.34, + width: panelWidth * 0.92, + height: height * 0.42, + material: shellMat, + }, + { + kind: 'wedge', + name: `${part.name ?? input.name ?? 'object'} ergonomic tail taper`, + semanticRole: 'ergonomic_shell_tail', + semanticGroup: 'ergonomic_shell', + sourcePartKind: 'ergonomic_shell', + position: [center[0] - length * 0.34, center[1] - height * 0.14, center[2]], + rotation: [0, 0, Math.PI + tailSlope * 0.18], + length: length * 0.32, + width: panelWidth * 0.9, + height: height * 0.36, + material: shellMat, + }, + ] + + if ( + part.style === 'mouse' || + part.variant === 'mouse' || + part.name?.toLowerCase().includes('mouse') + ) { + shapes.push( + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} left button panel`, + semanticRole: 'mouse_button', + semanticGroup: 'ergonomic_shell', + sourcePartKind: 'ergonomic_shell', + position: [center[0] + length * 0.18, center[1] + height * 0.42, center[2] - width * 0.18], + rotation: [0, 0, -0.12], + length: length * 0.32, + width: width * 0.28, + thickness: height * 0.04, + cornerRadius: width * 0.08, + cornerSegments: 5, + material: material(input.secondaryColor ?? '#4b5563', 0.48, 0.12), + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} right button panel`, + semanticRole: 'mouse_button', + semanticGroup: 'ergonomic_shell', + sourcePartKind: 'ergonomic_shell', + position: [center[0] + length * 0.18, center[1] + height * 0.42, center[2] + width * 0.18], + rotation: [0, 0, -0.12], + length: length * 0.32, + width: width * 0.28, + thickness: height * 0.04, + cornerRadius: width * 0.08, + cornerSegments: 5, + material: material(input.secondaryColor ?? '#4b5563', 0.48, 0.12), + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} scroll wheel`, + semanticRole: 'scroll_wheel', + semanticGroup: 'ergonomic_shell', + sourcePartKind: 'ergonomic_shell', + position: [center[0] + length * 0.24, center[1] + height * 0.47, center[2]], + rotation: [Math.PI / 2, 0, 0], + axis: 'z', + radius: Math.min(width, height) * 0.08, + height: width * 0.16, + radialSegments: 18, + material: darkMat, + }, + ) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeStreamlinedBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1.2, 0.08, 20) + const width = clamp(part.width ?? part.depth, 0.36, 0.03, 3) + const height = clamp(part.height, 0.22, 0.02, 2.5) + const center = add(origin, part.position ?? [0, height * 0.55, 0]) + const noseRoundness = clamp(part.noseRoundness, 0.56, 0, 1) + const tailTaper = clamp(part.tailTaper, 0.34, 0, 0.9) + const roofArc = clamp(part.roofArc, 0.22, 0, 0.8) + const shellMat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.45, 0.28)) + const darkMat = material(input.darkColor ?? '#1f2937', 0.52, 0.22) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} streamlined central body`, + semanticRole: 'streamlined_body', + semanticGroup: 'streamlined_body', + sourcePartKind: 'streamlined_body', + position: center, + radius: 0.5, + scale: [length * 0.78, height * 1.18, width], + widthSegments: 36, + heightSegments: 20, + material: shellMat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} rounded nose fairing`, + semanticRole: 'streamlined_nose', + semanticGroup: 'streamlined_body', + sourcePartKind: 'streamlined_body', + position: [ + center[0] + length * (0.4 + noseRoundness * 0.03), + center[1] - height * 0.035, + center[2], + ], + radius: 0.5, + scale: [ + length * 0.28 * Math.max(0.28, noseRoundness), + height * (0.68 + noseRoundness * 0.18), + width * (0.6 + noseRoundness * 0.2), + ], + widthSegments: 28, + heightSegments: 16, + material: shellMat, + }, + { + kind: 'trapezoid-prism', + name: `${part.name ?? input.name ?? 'object'} tapered tail fairing`, + semanticRole: 'streamlined_tail', + semanticGroup: 'streamlined_body', + sourcePartKind: 'streamlined_body', + position: [center[0] - length * 0.405, center[1] - height * 0.015, center[2]], + rotation: [0, 0, Math.PI], + length: length * 0.18, + width: width * 0.58, + height: height * 0.42, + topScale: [Math.max(0.18, 1 - tailTaper), Math.max(0.22, 1 - tailTaper * 0.8)], + cornerRadius: Math.min(width, height) * 0.12, + cornerSegments: 5, + material: shellMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} smooth roof highlight`, + semanticRole: 'streamlined_roof_arc', + semanticGroup: 'streamlined_body', + sourcePartKind: 'streamlined_body', + position: [ + center[0] - length * 0.03, + center[1] + height * (0.38 + roofArc * 0.25), + center[2], + ], + rotation: [0, 0, -roofArc * 0.18], + length: length * 0.42, + width: width * 0.48, + thickness: Math.max(height * 0.035, 0.006), + cornerRadius: Math.min(width, length) * 0.08, + cornerSegments: 6, + material: darkMat, + }, + ] + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeAircraftFuselage( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1.12, 0.4, 20) + const width = clamp(part.width ?? part.depth, 0.14, 0.05, 1.4) + const height = clamp(part.height, width * 1.08, 0.04, 1.2) + const center = add(origin, part.position ?? [0, height * 3.2, 0]) + const fuselageMat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#f8fafc', 0.5, 0.04), + ) + const stripeMat = material(part.accentColor ?? input.accentColor ?? '#0f8fb3', 0.36, 0.08) + const glassMat = material(input.darkColor ?? '#1e293b', 0.22, 0.02, 0.9) + const cockpitGlassMat = material(input.darkColor ?? '#020617', 0.24, 0.02, 0.94) + const base = composeStreamlinedBody( + input, + { + ...part, + kind: 'streamlined_body', + name: part.name ?? 'aircraft fuselage', + length, + width, + height, + position: center, + noseRoundness: part.noseRoundness ?? 0.62, + tailTaper: part.tailTaper ?? 0.56, + roofArc: part.roofArc ?? 0.08, + material: fuselageMat, + semanticGroup: 'aircraft_fuselage', + rotation: undefined, + }, + [0, 0, 0], + ) + .filter( + (shape) => + shape.semanticRole !== 'streamlined_roof_arc' && shape.semanticRole !== 'streamlined_nose', + ) + .map((shape) => ({ + ...shape, + sourcePartKind: 'aircraft_fuselage', + semanticGroup: 'aircraft_fuselage', + semanticRole: + shape.semanticRole === 'streamlined_body' + ? 'aircraft_fuselage' + : shape.semanticRole === 'streamlined_tail' + ? 'aircraft_tail' + : shape.semanticRole, + })) + const windowCount = clampInt(part.count, length > 1.6 ? 18 : 14, 6, 40) + const cabinWindowStart = -length * 0.31 + const cabinWindowEnd = length * 0.2 + const windowSpacing = (cabinWindowEnd - cabinWindowStart) / Math.max(windowCount - 1, 1) + const shapes: PrimitiveShapeInput[] = [ + ...base, + { + kind: 'conformal-strip', + name: `${part.name ?? input.name ?? 'aircraft'} left blue conformal cheatline stripe`, + semanticRole: 'aircraft_livery_stripe', + semanticGroup: 'aircraft_fuselage', + sourcePartKind: 'aircraft_fuselage', + position: center, + side: 'left', + surface: 'ellipsoid-cylinder', + xStart: -length * 0.38, + xEnd: length * 0.34, + verticalOffset: height * 0.04, + width: height * 0.075, + thickness: width * 0.025, + surfaceRadiusY: height * 0.5, + surfaceRadiusZ: width * 0.5, + surfaceLength: length, + endTaper: 0.42, + segments: 32, + widthSegments: 4, + material: stripeMat, + }, + { + kind: 'conformal-strip', + name: `${part.name ?? input.name ?? 'aircraft'} right blue conformal cheatline stripe`, + semanticRole: 'aircraft_livery_stripe', + semanticGroup: 'aircraft_fuselage', + sourcePartKind: 'aircraft_fuselage', + position: center, + side: 'right', + surface: 'ellipsoid-cylinder', + xStart: -length * 0.38, + xEnd: length * 0.34, + verticalOffset: height * 0.04, + width: height * 0.075, + thickness: width * 0.025, + surfaceRadiusY: height * 0.5, + surfaceRadiusZ: width * 0.5, + surfaceLength: length, + endTaper: 0.42, + segments: 32, + widthSegments: 4, + material: stripeMat, + }, + ] + + for (const sideName of ['left', 'right'] as const) { + for (let index = 0; index < windowCount; index += 1) { + const x = cabinWindowStart + index * windowSpacing + const windowDecalLength = clamp(undefined, length * 0.01, 0.025, 0.075) + shapes.push({ + kind: 'conformal-strip', + name: `${part.name ?? input.name ?? 'aircraft'} ${sideName} conformal cabin window ${index + 1}`, + semanticRole: 'cabin_window', + semanticGroup: 'aircraft_windows', + sourcePartKind: 'aircraft_fuselage', + position: center, + side: sideName, + surface: 'ellipsoid-cylinder', + xStart: x - windowDecalLength / 2, + xEnd: x + windowDecalLength / 2, + verticalOffset: height * 0.24, + width: clamp(undefined, height * 0.075, 0.025, 0.075), + thickness: width * 0.018, + surfaceRadiusY: height * 0.5, + surfaceRadiusZ: width * 0.5, + surfaceLength: length, + endTaper: 0.42, + segments: 2, + widthSegments: 2, + material: glassMat, + }) + } + } + + for (const sideName of ['left', 'right'] as const) { + shapes.push({ + kind: 'conformal-strip', + name: `${part.name ?? input.name ?? 'aircraft'} ${sideName} conformal cockpit side window`, + semanticRole: 'cockpit_window', + semanticGroup: 'aircraft_windows', + sourcePartKind: 'aircraft_fuselage', + position: center, + side: sideName, + surface: 'ellipsoid-cylinder', + xStart: length * 0.275, + xEnd: length * 0.305, + verticalOffset: height * 0.34, + width: clamp(undefined, height * 0.085, 0.035, 0.09), + thickness: width * 0.018, + surfaceRadiusY: height * 0.5, + surfaceRadiusZ: width * 0.5, + surfaceLength: length, + endTaper: 0.3, + segments: 3, + widthSegments: 2, + material: cockpitGlassMat, + }) + shapes.push({ + kind: 'conformal-strip', + name: `${part.name ?? input.name ?? 'aircraft'} ${sideName} conformal forward windshield pane`, + semanticRole: 'cockpit_window', + semanticGroup: 'aircraft_windows', + sourcePartKind: 'aircraft_fuselage', + position: center, + side: sideName, + surface: 'ellipsoid-cylinder', + xStart: length * 0.318, + xEnd: length * 0.34, + verticalOffset: height * 0.39, + width: clamp(undefined, height * 0.095, 0.035, 0.095), + thickness: width * 0.018, + surfaceRadiusY: height * 0.5, + surfaceRadiusZ: width * 0.5, + surfaceLength: length, + endTaper: 0.3, + segments: 3, + widthSegments: 2, + material: cockpitGlassMat, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeAircraftWing( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.04, 0.47, 0]) + const span = clamp(part.length, 0.95, 0.2, 16) + const chord = clamp(part.width ?? part.depth, 0.18, 0.04, 1.2) + const thickness = clamp(part.thickness ?? part.height, 0.014, 0.004, 0.08) + const dihedral = clamp(part.verticalCurve ?? part.pitch, 0.045, -0.25, 0.25) + const sweep = clamp(part.bladeSweep ?? part.twist, 0.12, -0.5, 0.5) + const mat = partMaterial(part, material(input.secondaryColor ?? '#cbd5e1', 0.48, 0.18)) + const includeWinglets = part.sourcePartKind !== 'aircraft_horizontal_stabilizer' + const side = partSide(part.side) + const sides = side === 'left' ? [-1] : side === 'right' ? [1] : [-1, 1] + const halfSpan = span / 2 + const shapes: PrimitiveShapeInput[] = [] + for (const wingSide of sides) { + const rootY = wingSide < 0 ? -halfSpan / 2 : halfSpan / 2 + const tipY = -rootY + const sweptTipX = sweep * halfSpan + const rootLeadingX = chord * 0.52 + const rootTrailingX = -chord * 0.48 + const tipLeadingX = chord * 0.18 + sweptTipX + const tipTrailingX = -chord * 0.22 + sweptTipX + const profile: [number, number][] = [ + [rootTrailingX, rootY], + [rootLeadingX, rootY], + [tipLeadingX, tipY], + [tipTrailingX, tipY], + ] + const wingCenter: Vec3 = [center[0], center[1], center[2] + wingSide * halfSpan * 0.5] + const tipPosition: Vec3 = [ + center[0] + (tipLeadingX + tipTrailingX) / 2, + center[1] + Math.abs(halfSpan * dihedral), + center[2] + wingSide * halfSpan, + ] + shapes.push({ + kind: 'extrude', + name: `${part.name ?? input.name ?? 'aircraft'} ${wingSide < 0 ? 'left' : 'right'} swept tapered airfoil wing`, + semanticRole: 'aircraft_wing', + semanticGroup: 'aircraft_wings', + sourcePartKind: 'aircraft_wing', + position: wingCenter, + rotation: [-Math.PI / 2 - wingSide * dihedral, 0, 0], + profile, + depth: thickness, + bevelSize: thickness * 0.12, + bevelThickness: thickness * 0.2, + bevelSegments: 1, + curveSegments: 8, + material: mat, + }) + if (includeWinglets) { + shapes.push({ + kind: 'trapezoid-prism', + name: `${part.name ?? input.name ?? 'aircraft'} ${wingSide < 0 ? 'left' : 'right'} upturned winglet`, + semanticRole: 'aircraft_winglet', + semanticGroup: 'aircraft_wings', + sourcePartKind: 'aircraft_wing', + position: tipPosition, + rotation: [0, 0, wingSide * 0.18], + length: chord * 0.18, + width: thickness * 1.8, + height: Math.max(thickness * 6, chord * 0.16), + topLengthScale: 0.55, + topWidthScale: 0.72, + cornerRadius: thickness * 0.4, + cornerSegments: 3, + material: mat, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeAircraftEngine( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [-0.38, 0.52, 0]) + const radius = clamp(part.radius, 0.05, 0.018, 0.36) + const length = clamp(part.length ?? part.depth, 0.2, 0.05, 1.25) + const spacing = clamp(part.width, 0.46, radius * 3, 4) + const count = clampInt(part.count, 2, 1, 4) + const nacelleMat = partMaterial(part, material(input.metalColor ?? '#64748b', 0.34, 0.56)) + const intakeMat = material(input.darkColor ?? '#111827', 0.42, 0.2) + const fanMat = material(input.secondaryColor ?? '#cbd5e1', 0.3, 0.65) + const offsets = + count === 1 + ? [0] + : Array.from({ length: count }, (_, index) => (index - (count - 1) / 2) * spacing) + const shapes: PrimitiveShapeInput[] = [] + offsets.forEach((zOffset, index) => { + const nacelleCenter: Vec3 = [center[0], center[1], center[2] + zOffset] + const sideRole = zOffset < 0 ? 'engine_nacelle_left' : 'engine_nacelle_right' + shapes.push( + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'aircraft'} engine nacelle ${index + 1}`, + semanticRole: count === 1 ? 'engine_nacelle' : sideRole, + semanticGroup: 'aircraft_engines', + sourcePartKind: 'aircraft_engine', + position: nacelleCenter, + axis: 'x', + radius, + height: length, + wallThickness: radius * 0.16, + radialSegments: ringSegments(input.detail), + material: nacelleMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'aircraft'} engine intake fan ${index + 1}`, + semanticRole: 'engine_fan', + semanticGroup: 'aircraft_engines', + sourcePartKind: 'aircraft_engine', + position: [nacelleCenter[0] + length * 0.48, nacelleCenter[1], nacelleCenter[2]], + axis: 'x', + radius: radius * 0.72, + height: length * 0.04, + radialSegments: 18, + material: fanMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'aircraft'} dark engine intake lip ${index + 1}`, + semanticRole: 'engine_intake', + semanticGroup: 'aircraft_engines', + sourcePartKind: 'aircraft_engine', + position: [nacelleCenter[0] + length * 0.52, nacelleCenter[1], nacelleCenter[2]], + axis: 'x', + majorRadius: radius * 0.88, + tubeRadius: radius * 0.08, + tubularSegments: ringSegments(input.detail), + radialSegments: 12, + material: intakeMat, + }, + ) + }) + return applyPartRotation(shapes, center, part.rotation) +} + +function composeAircraftVerticalStabilizer( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [-0.48, 0.73, 0]) + const length = clamp(part.length, 0.22, 0.04, 1.5) + const height = clamp(part.height, 0.28, 0.04, 1.4) + const width = clamp(part.width ?? part.thickness, 0.025, 0.004, 0.16) + return applyPartRotation( + [ + { + kind: 'trapezoid-prism', + name: `${part.name ?? input.name ?? 'aircraft'} swept vertical stabilizer`, + semanticRole: 'vertical_stabilizer', + semanticGroup: 'aircraft_tail', + sourcePartKind: 'aircraft_vertical_stabilizer', + position: center, + rotation: [0, 0, -0.12], + length, + width, + height, + topLengthScale: 0.42, + topWidthScale: 0.82, + cornerRadius: Math.min(length, height) * 0.04, + cornerSegments: 3, + material: partMaterial(part, material(input.secondaryColor ?? '#cbd5e1', 0.48, 0.18)), + }, + ], + center, + part.rotation, + ) +} + +function composeAircraftHorizontalStabilizer( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + return composeAircraftWing( + input, + { + ...part, + name: part.name ?? 'T-tail', + position: part.position ?? [-0.53, 0.84, 0], + length: part.length ?? 0.34, + width: part.width ?? 0.08, + thickness: part.thickness ?? part.height ?? 0.009, + verticalCurve: part.verticalCurve ?? 0.015, + sourcePartKind: 'aircraft_horizontal_stabilizer', + }, + origin, + ).map((shape) => ({ + ...shape, + name: shape.name?.replace('swept main wing', 'horizontal stabilizer'), + semanticRole: 'horizontal_stabilizer', + semanticGroup: 'aircraft_tail', + sourcePartKind: 'aircraft_horizontal_stabilizer', + })) +} + +function composeAircraftLandingGear( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.02, 0.12, 0]) + const radius = clamp(part.radius ?? part.wheelRadius, 0.035, 0.012, 0.2) + const track = clamp(part.width, 0.32, radius * 3, 1.4) + const wheelbase = clamp(part.length, 0.62, radius * 5, 2.5) + const tireMat = partMaterial(part, material(input.darkColor ?? '#111827', 0.72, 0.02)) + const strutMat = material(input.metalColor ?? '#94a3b8', 0.28, 0.72) + const positions: Vec3[] = [ + [center[0] + wheelbase * 0.42, center[1], center[2]], + [center[0] - wheelbase * 0.28, center[1], center[2] - track / 2], + [center[0] - wheelbase * 0.28, center[1], center[2] + track / 2], + ] + const shapes: PrimitiveShapeInput[] = [] + positions.forEach((wheelCenter, index) => { + const label = index === 0 ? 'nose' : `main ${index}` + const wheelRole = index === 0 ? 'aircraft_landing_gear_nose' : 'aircraft_landing_gear_main' + shapes.push( + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'aircraft'} landing gear wheel ${label}`, + semanticRole: wheelRole, + semanticGroup: 'aircraft_landing_gear', + sourcePartKind: 'aircraft_landing_gear', + position: wheelCenter, + axis: 'z', + majorRadius: radius, + tubeRadius: radius * 0.22, + radialSegments: 10, + tubularSegments: ringSegments(input.detail), + material: tireMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'aircraft'} landing gear strut ${label}`, + semanticRole: wheelRole, + semanticGroup: 'aircraft_landing_gear', + sourcePartKind: 'aircraft_landing_gear', + position: [wheelCenter[0], wheelCenter[1] + radius * 1.65, wheelCenter[2]], + axis: 'y', + radius: radius * 0.18, + height: radius * 2.4, + radialSegments: 10, + material: strutMat, + }, + ) + }) + return applyPartRotation(shapes, center, part.rotation) +} + +function normalizedLoftSections(part: PartComposePartInput) { + const length = clamp(part.length, 0.8, 0.08, 6) + const baseWidth = clamp(part.width ?? part.depth, 0.28, 0.02, 2) + const baseHeight = clamp(part.height, 0.12, 0.01, 1.8) + const provided = Array.isArray(part.sections) ? part.sections.filter(Boolean) : [] + if (provided.length >= 2) { + return provided.slice(0, 12).map((section, index) => ({ + x: + typeof section.x === 'number' && Number.isFinite(section.x) + ? section.x + : -length / 2 + (index * length) / Math.max(1, provided.length - 1), + width: clamp(section.width ?? section.length, baseWidth, 0.01, 3), + height: clamp(section.height, baseHeight, 0.005, 2), + y: clamp(section.y, 0, -2, 2), + z: clamp(section.z, 0, -2, 2), + topScale: section.topScale, + })) + } + return [ + { x: -length / 2, width: baseWidth * 1.05, height: baseHeight * 0.82, y: 0, z: 0 }, + { x: 0, width: baseWidth, height: baseHeight * 1.12, y: baseHeight * 0.12, z: 0 }, + { x: length / 2, width: baseWidth * 0.45, height: baseHeight * 0.62, y: 0, z: 0 }, + ] +} + +function composeLoftedPanel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, clamp(part.height, 0.12, 0.01, 1.8) * 0.7, 0]) + const thickness = clamp(part.thickness ?? part.depth, 0.024, 0.003, 0.18) + const sections = normalizedLoftSections(part) + const mat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.46, 0.24)) + const seamMat = material(input.darkColor ?? '#1f2937', 0.56, 0.2) + const shapes: PrimitiveShapeInput[] = [] + + for (let index = 0; index < sections.length - 1; index += 1) { + const a = sections[index] + const b = sections[index + 1] + if (!a || !b) continue + const segmentLength = Math.max(0.01, Math.abs(b.x - a.x)) + shapes.push({ + kind: 'trapezoid-prism', + name: `${part.name ?? input.name ?? 'object'} lofted panel segment ${index + 1}`, + semanticRole: part.semanticRole ?? 'lofted_panel_segment', + semanticGroup: 'lofted_panel', + sourcePartKind: 'lofted_panel', + position: [ + center[0] + (a.x + b.x) / 2, + center[1] + (a.y + b.y) / 2, + center[2] + (a.z + b.z) / 2, + ], + length: segmentLength, + width: Math.max(a.width, b.width), + height: Math.max(a.height, b.height), + topScale: [ + Math.max(0.05, b.width / Math.max(a.width, 0.01)), + Math.max(0.05, b.height / Math.max(a.height, 0.01)), + ], + cornerRadius: Math.min(a.width, b.width, a.height, b.height) * 0.08, + cornerSegments: 5, + material: mat, + }) + } + + sections.forEach((section, index) => { + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} loft section seam ${index + 1}`, + semanticRole: + index === 0 + ? 'lofted_panel_root' + : index === sections.length - 1 + ? 'lofted_panel_tip' + : 'lofted_panel_section', + semanticGroup: 'lofted_panel', + sourcePartKind: 'lofted_panel', + position: [center[0] + section.x, center[1] + section.y, center[2] + section.z], + rotation: [0, Math.PI / 2, 0], + length: section.width, + width: section.height, + thickness, + cornerRadius: Math.min(section.width, section.height) * 0.12, + cornerSegments: 5, + material: seamMat, + }) + }) + + return applyPartRotation(shapes, center, part.rotation) +} + +function composePipePort( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + label: string, +): PrimitiveShapeInput[] { + const side = partSide(part.side) + const axis = side + ? axisForSide(side, label === 'outlet_port' ? 'x' : 'z') + : partAxis(part.axis, label === 'outlet_port' ? 'x' : 'z') + const sign = signForSide(side, axis) + const center = add(origin, part.position ?? [0, 0.55, 0.45]) + const radius = clamp(part.radius, 0.08, 0.01, 0.8) + const length = clamp(part.length ?? part.depth ?? part.height, 0.26, 0.02, 2) + const rimCenter = offsetAlongAxis(center, axis, (length / 2) * sign) + const pipeMat = partMaterial(part, material(input.primaryColor ?? '#6b7280', 0.45, 0.35)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} ${label.replace('_', ' ')}`, + position: center, + axis, + radius, + height: length, + radialSegments: Math.max(20, Math.round(ringSegments(input.detail) * 0.55)), + wallThickness: radius * 0.18, + duct: { + crossSection: 'round', + radius, + wallThickness: radius * 0.18, + }, + ports: [ + { + id: label, + kind: label === 'inlet_port' ? 'inlet' : label === 'outlet_port' ? 'outlet' : 'generic', + semanticRole: label, + position: rimCenter, + normal: axisNormal(axis, sign), + axis, + radius, + direction: + label === 'inlet_port' ? 'in' : label === 'outlet_port' ? 'out' : 'bidirectional', + }, + ], + material: pipeMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} ${label.replace('_', ' ')} rim`, + position: rimCenter, + axis, + majorRadius: radius, + tubeRadius: radius * 0.08, + radialSegments: 12, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.65)), + material: pipeMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeFlangeRing( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const segmentDetail = detailSegmentLevel(input, part) + const side = partSide(part.side) + const axis = side ? axisForSide(side, 'z') : partAxis(part.axis, 'z') + const center = add(origin, part.position ?? [0, 0.55, 0.5]) + const radius = clamp(part.radius, 0.12, 0.02, 1) + const thickness = clamp(part.depth ?? part.height, 0.035, 0.006, 0.3) + const boltCount = clampInt( + part.boltCount ?? part.count, + detailDefaultInt(input, part, { low: 4, medium: 6, high: 10 }), + 3, + 20, + ) + const mat = partMaterial(part, material(input.metalColor ?? '#9ca3af', 0.34, 0.68)) + const gasketMat = material(input.darkColor ?? '#111827', 0.62, 0.08) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} flange ring`, + position: center, + axis, + radius, + height: thickness, + radialSegments: ringSegments(segmentDetail), + wallThickness: radius * 0.28, + material: mat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} flange gasket`, + position: offsetAlongAxis(center, axis, thickness * 0.54), + axis, + majorRadius: radius * 0.62, + tubeRadius: radius * 0.035, + radialSegments: 12, + tubularSegments: Math.max(24, Math.round(ringSegments(segmentDetail) * 0.65)), + material: gasketMat, + }, + ] + if (part.includeBolts !== false) { + shapes.push( + ...composeBoltPattern( + input, + { + ...part, + name: `${part.name ?? input.name ?? 'object'} flange`, + position: center, + rotation: undefined, + axis, + radius: radius * 0.76, + count: boltCount, + depth: thickness * 1.2, + }, + [0, 0, 0], + ), + ) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function panelRotationForSide(side: PartSide | undefined): Vec3 { + switch (side) { + case 'left': + case 'right': + return [0, Math.PI / 2, 0] + case 'top': + case 'bottom': + return [Math.PI / 2, 0, 0] + default: + return [0, 0, 0] + } +} + +function defaultSurfacePosition(input: PartComposeInput, side: PartSide | undefined): Vec3 { + const length = input.length ?? input.diameter ?? (input.radius ? input.radius * 2 : 1) + const width = + input.width ?? input.depth ?? input.diameter ?? (input.radius ? input.radius * 2 : 0.8) + const height = input.height ?? 1.2 + switch (side) { + case 'left': + return [-length * 0.51, height * 0.56, 0] + case 'right': + return [length * 0.51, height * 0.56, 0] + case 'back': + return [0, height * 0.56, -width * 0.51] + case 'top': + return [0, height * 1.02, 0] + case 'bottom': + return [0, -height * 0.02, 0] + default: + return [0, height * 0.56, width * 0.51] + } +} + +function composeManwayLid( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const segmentDetail = detailSegmentLevel(input, part) + const side = partSide(part.side) + const axis = side ? axisForSide(side, 'y') : partAxis(part.axis, 'y') + const center = add(origin, part.position ?? defaultSurfacePosition(input, side ?? 'top')) + const radius = clamp( + part.radius, + input.radius ?? (input.diameter ? input.diameter / 2 : 0.18), + 0.04, + 1, + ) + const thickness = clamp(part.thickness ?? part.depth ?? part.height, 0.035, 0.006, 0.25) + const boltCount = clampInt( + part.boltCount ?? part.count, + detailDefaultInt(input, part, { low: 4, medium: 8, high: 12 }), + 0, + 24, + ) + const lidMat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.28, 0.78), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.55, 0.2) + const role = genericPartRole(part, 'manway_lid') + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} manway lid`, + semanticRole: role, + position: center, + axis, + radius, + height: thickness, + radialSegments: ringSegments(segmentDetail), + material: lidMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} manway gasket`, + semanticRole: 'manway_gasket', + position: offsetAlongAxis(center, axis, thickness * 0.55), + axis, + majorRadius: radius * 0.72, + tubeRadius: radius * 0.035, + radialSegments: 12, + tubularSegments: Math.max(24, Math.round(ringSegments(segmentDetail) * 0.65)), + material: darkMat, + }, + { + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} manway handle`, + semanticRole: 'manway_handle', + position: offsetAlongAxis(center, axis, thickness * 0.9), + axis: axis === 'x' ? 'z' : 'x', + radius: Math.max(0.006, radius * 0.055), + height: radius * 0.78, + radialSegments: 10, + capSegments: 3, + material: darkMat, + }, + ] + if (boltCount > 0) { + shapes.push( + ...composeBoltPattern( + input, + { + ...part, + name: `${part.name ?? input.name ?? 'object'} manway`, + position: center, + rotation: undefined, + axis, + radius: radius * 0.82, + count: boltCount, + depth: thickness * 1.3, + wireRadius: radius * 0.045, + }, + [0, 0, 0], + ), + ) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeSanitaryNozzle( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const segmentDetail = detailSegmentLevel(input, part) + const side = partSide(part.side) + const axis = side ? axisForSide(side, 'y') : partAxis(part.axis, 'y') + const sign = signForSide(side, axis) + const center = add(origin, part.position ?? defaultSurfacePosition(input, side ?? 'top')) + const radius = clamp(part.radius, 0.08, 0.01, 0.5) + const length = clamp(part.length ?? part.depth ?? part.height, 0.18, 0.03, 1) + const mat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.24, 0.82), + ) + const role = genericPartRole(part, 'sanitary_nozzle') + const outer = offsetAlongAxis(center, axis, (length / 2) * sign) + return applyPartRotation( + [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} sanitary nozzle`, + semanticRole: role, + position: center, + axis, + radius, + height: length, + radialSegments: Math.max(20, Math.round(ringSegments(input.detail) * 0.55)), + wallThickness: radius * 0.16, + material: mat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} sanitary clamp bead`, + semanticRole: 'sanitary_clamp_bead', + position: outer, + axis, + majorRadius: radius * 1.08, + tubeRadius: radius * 0.08, + radialSegments: 12, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.65)), + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeFlangedNozzle( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const segmentDetail = detailSegmentLevel(input, part) + const side = partSide(part.side) + const axis = side ? axisForSide(side, 'y') : partAxis(part.axis, 'y') + const sign = signForSide(side, axis) + const center = add(origin, part.position ?? defaultSurfacePosition(input, side ?? 'front')) + const radius = clamp(part.radius, 0.09, 0.015, 0.8) + const length = clamp(part.length ?? part.depth ?? part.height, 0.26, 0.06, 1.8) + const flangeRadius = clamp(part.flangeRadius, radius * 1.75, radius * 1.1, radius * 3.2) + const flangeThickness = clamp(part.flangeThickness ?? part.thickness, radius * 0.28, 0.008, 0.22) + const mat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.24, 0.82), + ) + const role = genericPartRole(part, 'flanged_nozzle') + const nozzleCenter = offsetAlongAxis(center, axis, length * 0.22 * sign) + const flangeCenter = offsetAlongAxis(center, axis, length * 0.58 * sign) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} flanged nozzle neck`, + semanticRole: role, + sourcePartKind: 'flanged_nozzle', + position: nozzleCenter, + axis, + radius, + height: length, + radialSegments: Math.max(24, Math.round(ringSegments(segmentDetail) * 0.65)), + wallThickness: radius * 0.14, + material: mat, + }, + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} nozzle flange`, + semanticRole: 'nozzle_flange', + sourcePartKind: 'flanged_nozzle', + position: flangeCenter, + axis, + radius: flangeRadius, + height: flangeThickness, + wallThickness: Math.max(radius * 0.22, flangeRadius - radius * 1.05), + radialSegments: ringSegments(segmentDetail), + material: mat, + }, + ] + if (part.includeBolts !== false) { + shapes.push( + ...composeBoltPattern( + input, + { + ...part, + name: `${part.name ?? input.name ?? 'object'} nozzle flange`, + position: flangeCenter, + rotation: undefined, + axis, + radius: flangeRadius * 0.76, + count: clampInt( + part.boltCount ?? part.count, + detailDefaultInt(input, part, { low: 4, medium: 8, high: 12 }), + 0, + 24, + ), + depth: flangeThickness * 1.4, + wireRadius: flangeRadius * 0.035, + }, + [0, 0, 0], + ), + ) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeInspectionHatch( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const side = partSide(part.side) + const axis = side ? axisForSide(side, 'z') : partAxis(part.axis, 'z') + const center = add(origin, part.position ?? defaultSurfacePosition(input, side ?? 'front')) + const radius = clamp(part.radius, 0.18, 0.04, 1.2) + const thickness = clamp(part.thickness ?? part.depth ?? part.height, 0.035, 0.006, 0.28) + const hatchMat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.3, 0.75), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.55, 0.18) + const role = genericPartRole(part, 'inspection_hatch') + const handleAxis = axis === 'x' ? 'z' : 'x' + return applyPartRotation( + [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} inspection hatch cover`, + semanticRole: role, + sourcePartKind: 'inspection_hatch', + position: center, + axis, + radius, + height: thickness, + radialSegments: ringSegments(input.detail), + material: hatchMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} hatch hinge block`, + semanticRole: 'hatch_hinge', + sourcePartKind: 'inspection_hatch', + position: add(offsetAlongAxis(center, axis, thickness * 0.7), [radius * 0.78, 0, 0]), + length: radius * 0.18, + width: thickness * 1.2, + height: radius * 0.42, + material: darkMat, + }, + { + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} hatch handle`, + semanticRole: 'hatch_handle', + sourcePartKind: 'inspection_hatch', + position: offsetAlongAxis(center, axis, thickness * 0.95), + axis: handleAxis, + radius: Math.max(0.006, radius * 0.045), + height: radius * 0.72, + radialSegments: 10, + capSegments: 3, + material: darkMat, + }, + ], + center, + part.rotation, + ) +} + +function composeJacketShell( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'y') + const radius = clamp( + part.radius, + input.radius ? input.radius * 1.06 : (input.diameter ?? 1) * 0.53, + 0.08, + 3, + ) + const height = clamp(part.height, (input.height ?? 1.4) * 0.74, 0.12, 8) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const opacity = clamp(part.opacity, 0.28, 0.08, 1) + const mat = partMaterial( + part, + material(part.primaryColor ?? input.secondaryColor ?? '#dbe3ea', 0.3, 0.56, opacity), + ) + const seamMat = material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.24, 0.82) + const role = genericPartRole(part, 'jacket_shell') + return applyPartRotation( + [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} jacket shell`, + semanticRole: role, + position: center, + axis, + radius, + height, + radialSegments: ringSegments(input.detail), + wallThickness: clamp(part.thickness, radius * 0.025, 0.004, 0.12), + material: mat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} jacket upper seam`, + semanticRole: 'jacket_seam', + position: offsetAlongAxis(center, axis, height * 0.5), + axis, + majorRadius: radius, + tubeRadius: radius * 0.018, + radialSegments: 12, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.65)), + material: seamMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} jacket lower seam`, + semanticRole: 'jacket_seam', + position: offsetAlongAxis(center, axis, -height * 0.5), + axis, + majorRadius: radius, + tubeRadius: radius * 0.018, + radialSegments: 12, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.65)), + material: seamMat, + }, + ], + center, + part.rotation, + ) +} + +function composeSightGlass( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const side = partSide(part.side) ?? 'front' + const center = add(origin, part.position ?? defaultSurfacePosition(input, side)) + const length = clamp(part.length, 0.18, 0.03, 1.4) + const height = clamp(part.height ?? part.width, 0.24, 0.03, 1.6) + const thickness = clamp(part.thickness ?? part.depth, 0.012, 0.002, 0.08) + const rotation = part.rotation ?? panelRotationForSide(side) + const glass = material( + part.color ?? input.accentColor ?? '#93c5fd', + 0.06, + 0.02, + clamp(part.opacity, 0.42, 0.12, 0.9), + ) + const rim = material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.25, 0.82) + const role = genericPartRole(part, 'sight_glass') + return [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} sight glass rim`, + semanticRole: 'sight_glass_rim', + position: center, + rotation, + length: length * 1.18, + width: height * 1.14, + thickness: thickness * 1.25, + cornerRadius: Math.min(length, height) * 0.16, + cornerSegments: 5, + material: rim, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} sight glass`, + semanticRole: role, + position: offsetAlongAxis( + center, + axisForSide(side, 'z'), + signForSide(side, axisForSide(side, 'z')) * thickness * 0.8, + ), + rotation, + length, + width: height, + thickness, + cornerRadius: Math.min(length, height) * 0.14, + cornerSegments: 5, + material: glass, + }, + ] +} + +function composeSampleValve( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const side = partSide(part.side) ?? 'front' + const axis = axisForSide(side, 'z') + const sign = signForSide(side, axis) + const center = add(origin, part.position ?? defaultSurfacePosition(input, side)) + const radius = clamp(part.radius, 0.045, 0.008, 0.25) + const length = clamp(part.length ?? part.depth ?? part.height, 0.22, 0.04, 0.8) + const mat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.27, 0.78), + ) + const dark = material(part.darkColor ?? input.darkColor ?? '#111827', 0.48, 0.35) + const role = genericPartRole(part, 'sample_valve') + const knobCenter = offsetAlongAxis(center, axis, length * 0.42 * sign) + return applyPartRotation( + [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} sample valve nozzle`, + semanticRole: role, + position: center, + axis, + radius, + height: length, + radialSegments: 20, + wallThickness: radius * 0.18, + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} sample valve body`, + semanticRole: 'sample_valve_body', + position: knobCenter, + radius: radius * 1.25, + widthSegments: 20, + heightSegments: 12, + material: mat, + }, + { + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} sample valve handle`, + semanticRole: 'sample_valve_handle', + position: offsetAlongAxis(knobCenter, 'y', radius * 1.35), + axis: 'x', + radius: radius * 0.18, + height: radius * 3.2, + radialSegments: 10, + capSegments: 3, + material: dark, + }, + ], + center, + part.rotation, + ) +} + +function composeInstrumentPort( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const side = partSide(part.side) + const axis = side ? axisForSide(side, 'y') : partAxis(part.axis, 'y') + const sign = signForSide(side, axis) + const center = add(origin, part.position ?? defaultSurfacePosition(input, side ?? 'top')) + const radius = clamp(part.radius, 0.035, 0.006, 0.22) + const length = clamp(part.length ?? part.depth ?? part.height, 0.16, 0.03, 0.7) + const mat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#cbd5e1', 0.24, 0.8), + ) + const dark = material(part.darkColor ?? input.darkColor ?? '#0f172a', 0.48, 0.3) + const role = genericPartRole(part, 'instrument_port') + const head = offsetAlongAxis(center, axis, length * 0.62 * sign) + return applyPartRotation( + [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} instrument stem`, + semanticRole: role, + position: center, + axis, + radius, + height: length, + radialSegments: 18, + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} instrument gauge`, + semanticRole: 'instrument_gauge', + position: head, + axis, + radius: radius * 1.8, + height: radius * 0.65, + radialSegments: 24, + material: dark, + }, + ], + center, + part.rotation, + ) +} + +function composeStainlessHighlightPanel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const side = partSide(part.side) ?? 'front' + const center = add(origin, part.position ?? defaultSurfacePosition(input, side)) + const length = clamp(part.length, (input.length ?? input.diameter ?? 1) * 0.16, 0.02, 1.2) + const height = clamp(part.height ?? part.width, (input.height ?? 1.2) * 0.55, 0.04, 4) + const thickness = clamp(part.thickness ?? part.depth, 0.006, 0.001, 0.05) + const mat = partMaterial( + part, + material(part.color ?? '#f8fafc', 0.18, 0.68, clamp(part.opacity, 0.5, 0.12, 0.95)), + ) + return [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} stainless highlight panel`, + semanticRole: genericPartRole(part, 'stainless_highlight_panel'), + position: center, + rotation: part.rotation ?? panelRotationForSide(side), + length, + width: height, + thickness, + cornerRadius: Math.min(length, height) * 0.35, + cornerSegments: 6, + material: mat, + }, + ] +} + +function composeBoltPattern( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const side = partSide(part.side) + const axis = side ? axisForSide(side, 'z') : partAxis(part.axis, 'z') + const center = add(origin, part.position ?? [0, 0.55, 0.5]) + const radius = clamp(part.radius, 0.12, 0.01, 2) + const count = clampInt(part.boltCount ?? part.count, 6, 3, 32) + const boltRadius = clamp(part.wireRadius ?? part.width, radius * 0.08, 0.003, 0.08) + const boltDepth = clamp(part.depth ?? part.height, boltRadius * 1.5, 0.004, 0.2) + const boltMat = partMaterial(part, material(input.darkColor ?? '#1f2937', 0.42, 0.5)) + const pattern = { + id: `${part.id ?? part.sourcePartId ?? part.name ?? 'bolt_pattern'}_radial`, + kind: 'radial' as const, + semanticRole: part.semanticRole ?? 'bolt_pattern', + count, + axis, + radius, + startAngle: 0, + endAngle: Math.PI * 2, + mode: 'expanded' as const, + } + const shapes = Array.from({ length: count }, (_, i) => { + const angle = angularStep(i, count) + return { + kind: 'cylinder' as const, + name: `${part.name ?? input.name ?? 'object'} bolt ${i + 1}`, + position: radialPointOnAxis(center, axis, angle, radius), + axis, + radius: boltRadius, + height: boltDepth, + radialSegments: 12, + pattern, + material: boltMat, + } + }) + return applyPartRotation(shapes, center, part.rotation) +} + +function composeControlBox( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.32, 0.62, 0.24]) + const width = clamp(part.width ?? part.length, 0.24, 0.04, 1.5) + const height = clamp(part.height, 0.32, 0.06, 1.5) + const depth = clamp(part.depth, 0.11, 0.025, 0.7) + const boxMat = partMaterial(part, material(input.secondaryColor ?? '#334155', 0.55, 0.18)) + return [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} control box`, + position: center, + length: width, + width: depth, + height, + cornerRadius: Math.min(width, depth, height) * 0.08, + cornerSegments: 4, + material: boxMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} control face plate`, + position: [center[0], center[1], center[2] + depth * 0.52], + length: width * 0.78, + width: height * 0.62, + thickness: depth * 0.08, + cornerRadius: Math.min(width, height) * 0.025, + cornerSegments: 4, + material: material(input.darkColor ?? '#0f172a', 0.5, 0.08), + }, + ] +} + +function composeRibbedMotorBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'x') + const center = add(origin, part.position ?? [-0.24, 0.42, 0]) + const radius = clamp(part.radius, 0.18, 0.04, 1) + const length = clamp(part.length ?? part.depth, 0.48, 0.12, 3) + const finCount = clampInt(part.slatCount ?? part.count, 8, 3, 20) + const bodyMat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.46, 0.42)) + const darkMat = material(input.darkColor ?? '#1f2937', 0.5, 0.28) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} ribbed motor body`, + position: center, + axis, + radius, + height: length, + radialSegments: ringSegments(input.detail), + material: bodyMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} motor front end cap`, + position: offsetAlongAxis(center, axis, length * 0.53), + axis, + radius: radius * 0.92, + height: length * 0.08, + radialSegments: ringSegments(input.detail), + material: darkMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} motor rear fan cover`, + position: offsetAlongAxis(center, axis, -length * 0.53), + axis, + radius: radius * 0.98, + height: length * 0.1, + radialSegments: ringSegments(input.detail), + material: darkMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} motor shaft`, + position: offsetAlongAxis(center, axis, length * 0.72), + axis, + radius: radius * 0.18, + height: length * 0.32, + radialSegments: 20, + material: material(input.metalColor ?? '#cbd5e1', 0.28, 0.78), + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} motor terminal box`, + position: [center[0], center[1] + radius * 1.05, center[2]], + length: length * 0.34, + width: radius * 0.72, + height: radius * 0.38, + cornerRadius: radius * 0.05, + cornerSegments: 3, + material: darkMat, + }, + ] + + for (let i = 0; i < finCount; i += 1) { + const z = center[2] + (i - (finCount - 1) / 2) * ((radius * 1.55) / Math.max(1, finCount - 1)) + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} motor cooling fin ${i + 1}`, + position: [center[0], center[1] + radius * 0.98, z], + length: length * 0.82, + width: radius * 0.025, + height: radius * 0.18, + cornerRadius: radius * 0.01, + cornerSegments: 2, + material: bodyMat, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeConveyorFrame( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.38, 0]) + const length = clamp(part.length, 1.4, 0.3, 6) + const width = clamp(part.width, 0.42, 0.12, 2) + const height = clamp(part.height, 0.42, 0.12, 2) + const railSize = clamp(part.radius, 0.025, 0.006, 0.12) + const mat = partMaterial(part, material(input.metalColor ?? '#94a3b8', 0.34, 0.72)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} conveyor left rail`, + position: [center[0], center[1] + height * 0.2, center[2] - width / 2], + length, + width: railSize, + height: railSize, + material: mat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} conveyor right rail`, + position: [center[0], center[1] + height * 0.2, center[2] + width / 2], + length, + width: railSize, + height: railSize, + material: mat, + }, + ] + + const legPairs = clampInt( + part.legCount != null ? Math.ceil(part.legCount / 2) : undefined, + 2, + 1, + 8, + ) + const legOffsets = Array.from({ length: legPairs }, (_, index) => + legPairs === 1 ? 0 : -0.44 + (0.88 * index) / (legPairs - 1), + ) + for (const x of legOffsets) { + for (const z of [-0.5, 0.5]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} conveyor support leg`, + position: [center[0] + x * length, center[1] - height * 0.26, center[2] + z * width], + axis: 'y', + radius: railSize * 0.58, + height, + radialSegments: 12, + material: mat, + }) + } + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeRollerArray( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.52, 0]) + const count = clampInt(part.count, 7, 2, 32) + const length = clamp(part.length, 1.2, 0.2, 6) + const width = clamp(part.width, 0.46, 0.08, 2) + const radius = clamp(part.radius, 0.035, 0.008, 0.18) + const mat = partMaterial(part, material(input.metalColor ?? '#cbd5e1', 0.26, 0.82)) + const pattern = { + id: `${part.id ?? part.sourcePartId ?? part.name ?? 'roller_array'}_linear`, + kind: 'linear' as const, + semanticRole: part.semanticRole ?? 'roller_array', + count, + axis: 'x' as const, + spacing: count > 1 ? length / (count - 1) : 0, + mode: 'expanded' as const, + } + const shapes = Array.from({ length: count }, (_, i) => ({ + kind: 'cylinder' as const, + name: `${part.name ?? input.name ?? 'object'} conveyor roller ${i + 1}`, + position: [ + center[0] + (i - (count - 1) / 2) * (length / Math.max(1, count - 1)), + center[1], + center[2], + ] as Vec3, + axis: 'z', + radius, + height: width, + radialSegments: 20, + pattern, + material: mat, + })) + return applyPartRotation(shapes, center, part.rotation) +} + +function composeBeltSurface( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.56, 0]) + const length = clamp(part.length, 1.35, 0.2, 6) + const width = clamp(part.width, 0.46, 0.08, 2) + const thickness = clamp(part.height ?? part.depth, 0.025, 0.004, 0.12) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} conveyor belt surface`, + position: center, + length, + width, + height: thickness, + cornerRadius: thickness * 0.4, + cornerSegments: 3, + material: partMaterial(part, material(input.darkColor ?? '#111827', 0.64, 0.02)), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeCylindricalTank( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'x') + const center = add(origin, part.position ?? [0, 0.55, 0]) + const radius = clamp(part.radius, 0.24, 0.05, 2) + const length = clamp(part.length ?? part.height, 0.9, 0.16, 24) + const wallThickness = clamp( + part.thickness ?? part.shellThickness, + radius * 0.075, + radius * 0.02, + radius * 0.28, + ) + const mat = partMaterial(part, material(input.primaryColor ?? '#94a3b8', 0.42, 0.48)) + const metal = material(input.metalColor ?? '#cbd5e1', 0.28, 0.76) + const dark = material(input.darkColor ?? '#1f2937', 0.56, 0.24) + const supportMat = material(input.darkColor ?? '#334155', 0.58, 0.36) + const headScale = axis === 'x' ? [radius * 0.36, radius, radius] : [radius, radius * 0.36, radius] + const leftEnd = offsetAlongAxis(center, axis, -length * 0.52) + const rightEnd = offsetAlongAxis(center, axis, length * 0.52) + const topNozzleCenter: Vec3 = [center[0], center[1] + radius * 1.08, center[2]] + const manwayCenter: Vec3 = + axis === 'x' + ? [center[0] - length * 0.18, center[1], center[2] + radius * 1.04] + : [center[0] + radius * 1.04, center[1] + length * 0.16, center[2]] + const manwayAxis = axis === 'x' ? 'z' : 'x' + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} cylindrical tank shell`, + semanticRole: part.semanticRole ?? 'vessel_shell', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: center, + axis, + radius, + height: length, + wallThickness, + radialSegments: ringSegments(input.detail), + duct: { + crossSection: 'round', + radius, + wallThickness, + }, + ports: [ + { + id: 'vessel_left_head', + kind: 'support', + semanticRole: 'vessel_head', + position: leftEnd, + normal: axisNormal(axis, -1), + axis, + radius, + direction: 'bidirectional', + }, + { + id: 'vessel_right_head', + kind: 'support', + semanticRole: 'vessel_head', + position: rightEnd, + normal: axisNormal(axis, 1), + axis, + radius, + direction: 'bidirectional', + }, + { + id: 'top_nozzle', + kind: 'generic', + semanticRole: 'top_nozzle', + position: topNozzleCenter, + normal: axisNormal('y', 1), + axis: 'y', + radius: radius * 0.16, + direction: 'bidirectional', + }, + { + id: 'manway', + kind: 'access', + semanticRole: 'manway_flange', + position: manwayCenter, + normal: axisNormal(manwayAxis, 1), + axis: manwayAxis, + radius: radius * 0.22, + direction: 'bidirectional', + }, + ], + cutouts: [ + { + id: 'top_nozzle_opening', + kind: 'round', + semanticRole: 'top_nozzle', + position: topNozzleCenter, + normal: axisNormal('y', 1), + axis: 'y', + radius: radius * 0.16, + through: true, + bevelRadius: wallThickness * 0.5, + }, + { + id: 'manway_opening', + kind: 'round', + semanticRole: 'manway_flange', + position: manwayCenter, + normal: axisNormal(manwayAxis, 1), + axis: manwayAxis, + radius: radius * 0.22, + through: true, + bevelRadius: wallThickness * 0.5, + }, + ], + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} tank left dished end`, + semanticRole: 'vessel_head', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: leftEnd, + radius: 1, + scale: headScale as Vec3, + widthSegments: ringSegments(input.detail), + heightSegments: Math.max(16, Math.round(ringSegments(input.detail) * 0.5)), + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} tank right dished end`, + semanticRole: 'vessel_head', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: rightEnd, + radius: 1, + scale: headScale as Vec3, + widthSegments: ringSegments(input.detail), + heightSegments: Math.max(16, Math.round(ringSegments(input.detail) * 0.5)), + material: mat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} tank left seam ring`, + semanticRole: 'vessel_seam', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: leftEnd, + axis, + majorRadius: radius * 1.01, + tubeRadius: wallThickness * 0.48, + radialSegments: 10, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.7)), + material: metal, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} tank right seam ring`, + semanticRole: 'vessel_seam', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: rightEnd, + axis, + majorRadius: radius * 1.01, + tubeRadius: wallThickness * 0.48, + radialSegments: 10, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.7)), + material: metal, + }, + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} tank top nozzle`, + semanticRole: 'top_nozzle', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: topNozzleCenter, + axis: 'y', + radius: radius * 0.16, + height: radius * 0.35, + wallThickness: wallThickness * 0.65, + radialSegments: 20, + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} tank manway flange`, + semanticRole: 'manway_flange', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: manwayCenter, + axis: manwayAxis, + radius: radius * 0.22, + height: wallThickness * 3, + radialSegments: 28, + material: dark, + }, + ] + if (axis === 'x') { + for (const x of [-length * 0.28, length * 0.28]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} tank saddle support`, + semanticRole: 'saddle_support', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: [center[0] + x, center[1] - radius * 0.86, center[2]], + length: radius * 0.48, + width: radius * 1.72, + height: radius * 0.32, + cornerRadius: radius * 0.08, + cornerSegments: 3, + material: supportMat, + }) + } + } else { + for (const [x, z] of [ + [radius * 0.72, radius * 0.72], + [radius * 0.72, -radius * 0.72], + [-radius * 0.72, radius * 0.72], + [-radius * 0.72, -radius * 0.72], + ] as const) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} tank support leg`, + semanticRole: 'support_leg', + sourcePartKind: part.sourcePartKind ?? 'cylindrical_tank', + position: [center[0] + x, center[1] - length * 0.5 - radius * 0.28, center[2] + z], + axis: 'y', + radius: radius * 0.045, + height: radius * 0.56, + radialSegments: 12, + material: supportMat, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeStorageTankShell( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'y') + if (axis !== 'y') return composeCylindricalTank(input, part, origin) + + const center = add(origin, part.position ?? [0, 1.2, 0]) + const radius = clamp( + part.radius ?? (part.diameter != null ? part.diameter / 2 : undefined), + 0.8, + 0.12, + 6, + ) + const height = clamp(part.height ?? part.length, 2.4, 0.6, 24) + const wallThickness = clamp( + part.thickness ?? part.shellThickness, + radius * 0.045, + radius * 0.012, + radius * 0.12, + ) + const roofThickness = clamp( + part.domeDepth ?? part.thickness, + Math.max(0.035, radius * 0.035), + 0.015, + 0.24, + ) + const foundationHeight = clamp(part.baseRadius, Math.max(0.06, radius * 0.12), 0.03, 0.8) + const topY = center[1] + height / 2 + const bottomY = center[1] - height / 2 + const mat = partMaterial(part, material(input.primaryColor ?? '#cbd5e1', 0.42, 0.48)) + const roofMat = material(part.primaryColor ?? input.primaryColor ?? '#cbd5e1', 0.36, 0.58, 0.88) + const metal = material(input.metalColor ?? '#cbd5e1', 0.28, 0.76) + const foundationMat = material(input.darkColor ?? '#475569', 0.62, 0.22) + + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} straight storage tank shell`, + semanticRole: part.semanticRole ?? 'vessel_shell', + sourcePartKind: part.sourcePartKind ?? 'storage_tank_shell', + position: center, + axis: 'y', + radius, + height, + wallThickness, + radialSegments: ringSegments(input.detail), + duct: { + crossSection: 'round', + radius, + wallThickness, + }, + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} flat storage tank roof`, + semanticRole: 'vessel_roof', + sourcePartKind: part.sourcePartKind ?? 'storage_tank_shell', + position: [center[0], topY + roofThickness / 2, center[2]], + axis: 'y', + radius: radius * 1.01, + height: roofThickness, + radialSegments: ringSegments(input.detail), + material: roofMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} storage tank bottom plate`, + semanticRole: 'tank_bottom', + sourcePartKind: part.sourcePartKind ?? 'storage_tank_shell', + position: [center[0], bottomY + roofThickness / 2, center[2]], + axis: 'y', + radius: radius * 0.99, + height: roofThickness, + radialSegments: ringSegments(input.detail), + material: roofMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} storage tank top rim`, + semanticRole: 'top_rim', + sourcePartKind: part.sourcePartKind ?? 'storage_tank_shell', + position: [center[0], topY + roofThickness * 0.9, center[2]], + axis: 'y', + majorRadius: radius * 1.015, + tubeRadius: Math.max(0.012, wallThickness * 0.58), + radialSegments: 10, + tubularSegments: Math.max(28, Math.round(ringSegments(input.detail) * 0.75)), + material: metal, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} storage tank bottom rim`, + semanticRole: 'bottom_rim', + sourcePartKind: part.sourcePartKind ?? 'storage_tank_shell', + position: [center[0], bottomY + roofThickness * 0.45, center[2]], + axis: 'y', + majorRadius: radius * 1.015, + tubeRadius: Math.max(0.012, wallThickness * 0.52), + radialSegments: 10, + tubularSegments: Math.max(28, Math.round(ringSegments(input.detail) * 0.75)), + material: metal, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} storage tank ring foundation`, + semanticRole: 'foundation_ring', + sourcePartKind: part.sourcePartKind ?? 'storage_tank_shell', + position: [center[0], bottomY - foundationHeight / 2, center[2]], + axis: 'y', + radius: radius * 1.13, + height: foundationHeight, + radialSegments: ringSegments(input.detail), + material: foundationMat, + }, + ] + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeLiquidVolume( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'y') + const radius = clamp(part.radius, 0.5, 0.04, 6) + const height = clamp(part.height ?? part.length, 1, 0.02, 24) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const opacity = clamp(part.opacity, 0.58, 0.08, 0.92) + const mat = + part.material ?? + (part.materialPreset + ? { preset: part.materialPreset } + : material(part.color ?? input.secondaryColor ?? '#38bdf8', 0.24, 0.04, opacity)) + return applyPartRotation( + [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} liquid volume`, + semanticRole: part.semanticRole ?? 'liquid_volume', + sourcePartKind: part.sourcePartKind ?? 'liquid_volume', + position: center, + axis, + radius, + height, + radialSegments: ringSegments(input.detail), + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeCoolingTowerShell( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const height = clamp(part.height, 7.2, 1.8, 24) + const baseRadius = clamp(part.radius ?? part.baseRadius, 1.15, 0.25, 6) + const waistRadius = clamp(part.waistRadius, baseRadius * 0.62, 0.16, baseRadius * 0.96) + const topRadius = clamp(part.topRadius, baseRadius * 0.94, waistRadius * 1.05, baseRadius * 1.35) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const mat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#f8fafc', 0.54, 0.12), + ) + const profile: Array<[number, number]> = [ + [baseRadius, -height / 2], + [baseRadius * 0.9, -height * 0.4], + [waistRadius, -height * 0.07], + [waistRadius * 1.08, height * 0.22], + [topRadius * 0.92, height * 0.42], + [topRadius, height / 2], + ] + return applyPartRotation( + [ + { + kind: 'lathe', + name: `${part.name ?? input.name ?? 'cooling tower'} hyperboloid shell`, + semanticRole: part.semanticRole ?? 'cooling_tower_shell', + sourcePartKind: part.sourcePartKind ?? 'cooling_tower_shell', + position: center, + profile, + segments: ringSegments(input.detail), + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeCoolingTowerRim( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius ?? part.majorRadius, 1.1, 0.16, 7) + const tubeRadius = clamp(part.tubeRadius, Math.max(radius * 0.045, 0.025), 0.006, 0.28) + const center = add(origin, part.position ?? [0, 7.2, 0]) + const mat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#ffffff', 0.5, 0.14), + ) + return applyPartRotation( + [ + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'cooling tower'} open top rim`, + semanticRole: part.semanticRole ?? 'top_steam_opening', + sourcePartKind: part.sourcePartKind ?? 'cooling_tower_rim', + position: center, + axis: 'y', + majorRadius: radius, + tubeRadius, + radialSegments: ringSegments(input.detail), + tubularSegments: 12, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeChimneyStack( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const height = clamp(part.height ?? part.length, 6, 0.6, 80) + const baseRadius = clamp(part.radius ?? part.width ?? part.diameter, height * 0.055, 0.05, 6) + const topRadius = clamp(part.topRadius, baseRadius * 0.72, baseRadius * 0.28, baseRadius) + const rawCenter = add(origin, part.position ?? [0, height / 2, 0]) + const center: Vec3 = [rawCenter[0], Math.max(rawCenter[1], origin[1] + height / 2), rawCenter[2]] + const shaftMaterial = partMaterial(part, material(input.primaryColor ?? '#d8d4ca', 0.58, 0.18)) + const concreteMaterial = material('#d8d4ca', 0.62, 0.12) + const redMaterial = material(part.secondaryColor ?? input.secondaryColor ?? '#b91c1c', 0.46, 0.22) + const whiteMaterial = material('#f8fafc', 0.5, 0.1) + const darkMaterial = material(input.darkColor ?? '#111827', 0.5, 0.25) + const radiusAt = (y: number) => { + const t = Math.max(0, Math.min(1, y / height)) + return baseRadius + (topRadius - baseRadius) * t + } + const makeBand = ( + name: string, + yMin: number, + yMax: number, + bandMaterial: PrimitiveMaterialInput, + semanticRole: string, + oversize = 1.018, + ): PrimitiveShapeInput => ({ + kind: 'frustum', + name, + semanticRole, + sourcePartKind: part.sourcePartKind ?? 'chimney_stack', + position: [center[0], center[1] - height / 2 + (yMin + yMax) / 2, center[2]], + axis: 'y', + radiusBottom: radiusAt(yMin) * oversize, + radiusTop: radiusAt(yMax) * oversize, + height: yMax - yMin, + radialSegments: ringSegments(input.detail), + material: bandMaterial, + }) + + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'frustum', + name: `${part.name ?? input.name ?? 'chimney'} tapered chimney shell`, + semanticRole: part.semanticRole ?? 'chimney_body', + sourcePartKind: part.sourcePartKind ?? 'chimney_stack', + position: center, + axis: 'y', + radiusBottom: baseRadius, + radiusTop: topRadius, + height, + radialSegments: ringSegments(input.detail), + material: shaftMaterial, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'chimney'} reinforced base plinth`, + semanticRole: 'chimney_base', + sourcePartKind: part.sourcePartKind ?? 'chimney_stack', + position: [center[0], center[1] - height / 2 + height * 0.025, center[2]], + axis: 'y', + radius: baseRadius * 1.42, + height: height * 0.05, + radialSegments: ringSegments(input.detail), + material: concreteMaterial, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'chimney'} top rim`, + semanticRole: 'chimney_top_rim', + sourcePartKind: part.sourcePartKind ?? 'chimney_stack', + position: [center[0], center[1] + height / 2, center[2]], + axis: 'y', + majorRadius: topRadius * 1.03, + tubeRadius: Math.max(topRadius * 0.045, 0.012), + radialSegments: ringSegments(input.detail), + tubularSegments: 12, + material: darkMaterial, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'chimney'} lower access door`, + semanticRole: 'access_door', + sourcePartKind: part.sourcePartKind ?? 'chimney_stack', + position: [center[0], center[1] - height * 0.42, center[2] + baseRadius * 1.025], + rotation: [Math.PI / 2, 0, 0], + length: baseRadius * 0.46, + width: height * 0.11, + thickness: Math.max(baseRadius * 0.025, 0.006), + cornerRadius: baseRadius * 0.03, + cornerSegments: 3, + material: darkMaterial, + }, + ] + + const seamCount = clampInt(part.ringCount, Math.max(5, Math.round(height / 1.1)), 3, 24) + for (let i = 1; i < seamCount; i += 1) { + const y = (height * i) / seamCount + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'chimney'} concrete lift seam`, + semanticRole: 'chimney_seam_ring', + sourcePartKind: part.sourcePartKind ?? 'chimney_stack', + position: [center[0], center[1] - height / 2 + y, center[2]], + axis: 'y', + majorRadius: radiusAt(y) * 1.012, + tubeRadius: Math.max(baseRadius * 0.006, 0.004), + radialSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.6)), + tubularSegments: 8, + material: material('#b8b4aa', 0.64, 0.08), + }) + } + + const stripeIntent = `${part.variant ?? ''} ${part.style ?? ''} ${input.name ?? ''}`.toLowerCase() + const warningStripes = + part.warningStripes === true || /red.?white|stripe|striped|warning|红白|紅白/.test(stripeIntent) + if (warningStripes) { + const stripeCount = clampInt(part.stripeCount ?? part.count, 5, 2, 12) + const stripeZoneHeight = clamp(part.stripeHeight, height * 0.36, height * 0.12, height * 0.7) + const yStart = height - stripeZoneHeight + const stripeStep = stripeZoneHeight / stripeCount + for (let i = 0; i < stripeCount; i += 1) { + const yMin = yStart + i * stripeStep + const yMax = yStart + (i + 1) * stripeStep + shapes.push( + makeBand( + `${part.name ?? input.name ?? 'chimney'} ${i % 2 === 0 ? 'red' : 'white'} warning band`, + yMin, + yMax, + i % 2 === 0 ? redMaterial : whiteMaterial, + i % 2 === 0 ? 'chimney_warning_red_band' : 'chimney_warning_white_band', + ), + ) + } + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeValveBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const ballValve = isBallValveIntent(input, part) + const axis = partAxis(part.axis, 'x') + const center = add(origin, part.position ?? [0, 0.38, 0]) + const radius = clamp(part.radius, 0.12, 0.03, 0.8) + const length = clamp(part.length ?? part.depth, 0.46, 0.12, 2) + const mat = partMaterial(part, material(input.primaryColor ?? '#475569', 0.45, 0.45)) + const metalMat = material(input.metalColor ?? '#cbd5e1', 0.28, 0.78) + const darkMat = material(input.darkColor ?? '#1f2937', 0.42, 0.5) + const bonnetY = center[1] + radius * 1.16 + const yokeBaseY = center[1] + radius * 1.72 + const ballValveDetails: PrimitiveShapeInput[] = [ + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} valve ball`, + position: center, + radius: 1, + scale: [radius * 0.68, radius * 0.68, radius * 0.68], + widthSegments: ringSegments(input.detail), + heightSegments: Math.max(16, Math.round(ringSegments(input.detail) * 0.5)), + material: metalMat, + semanticRole: 'valve_ball', + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} valve ball bore`, + position: center, + axis, + radius: radius * 0.24, + height: length * 0.72, + radialSegments: 20, + material: darkMat, + semanticRole: 'valve_bore', + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} inlet seat ring`, + position: offsetAlongAxis(center, axis, -length * 0.28), + axis, + majorRadius: radius * 0.38, + tubeRadius: radius * 0.035, + radialSegments: 8, + tubularSegments: 32, + material: darkMat, + semanticRole: 'seat_ring', + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} outlet seat ring`, + position: offsetAlongAxis(center, axis, length * 0.28), + axis, + majorRadius: radius * 0.38, + tubeRadius: radius * 0.035, + radialSegments: 8, + tubularSegments: 32, + material: darkMat, + semanticRole: 'seat_ring', + }, + ] + const gateValveDetails: PrimitiveShapeInput[] = [ + { + kind: 'wedge', + name: `${part.name ?? input.name ?? 'object'} valve gate wedge`, + position: [center[0], center[1] - radius * 0.1, center[2]], + length: radius * 0.8, + width: radius * 0.42, + height: radius * 0.72, + slopeAxis: 'x', + slopeDirection: 'positive', + material: material(input.secondaryColor ?? '#334155', 0.48, 0.35), + semanticRole: 'gate_wedge', + }, + ] + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} valve body barrel`, + position: center, + axis, + radius, + height: length, + radialSegments: ringSegments(input.detail), + material: mat, + semanticRole: 'valve_body', + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} valve bulb chamber`, + position: center, + radius: 1, + scale: [radius * 1.08, radius * 1.2, radius * 1.08], + widthSegments: ringSegments(input.detail), + heightSegments: Math.max(16, Math.round(ringSegments(input.detail) * 0.5)), + material: mat, + semanticRole: 'valve_body', + }, + ...(ballValve ? ballValveDetails : gateValveDetails), + { + kind: 'frustum', + name: `${part.name ?? input.name ?? 'object'} valve bonnet`, + position: [center[0], bonnetY, center[2]], + axis: 'y', + radiusBottom: radius * 0.62, + radiusTop: radius * 0.42, + height: radius * 0.42, + radialSegments: Math.max(20, Math.round(ringSegments(input.detail) * 0.55)), + material: mat, + semanticRole: 'bonnet', + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} valve stem`, + position: [center[0], center[1] + radius * 1.35, center[2]], + axis: 'y', + radius: radius * 0.18, + height: radius * 0.9, + radialSegments: 16, + material: metalMat, + semanticRole: 'stem', + }, + ] + if (!ballValve) { + for (const z of [-radius * 0.48, radius * 0.48]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} valve yoke post`, + position: [center[0], yokeBaseY, center[2] + z], + axis: 'y', + radius: radius * 0.08, + height: radius * 0.95, + radialSegments: 12, + material: metalMat, + semanticRole: 'yoke', + }) + } + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} valve yoke bridge`, + position: [center[0], yokeBaseY + radius * 0.48, center[2]], + axis: 'z', + radius: radius * 0.07, + height: radius * 1.15, + radialSegments: 12, + material: metalMat, + semanticRole: 'yoke', + }) + } + for (let i = 0; i < 6; i += 1) { + const angle = (i * Math.PI * 2) / 6 + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} valve bonnet bolt ${i + 1}`, + position: [ + center[0] + Math.cos(angle) * radius * 0.54, + bonnetY - radius * 0.22, + center[2] + Math.sin(angle) * radius * 0.54, + ], + axis: 'y', + radius: radius * 0.045, + height: radius * 0.12, + radialSegments: 8, + material: darkMat, + semanticRole: 'bonnet_bolts', + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeHandwheel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const leverHandle = /lever|handle|bar|手柄|把手/i.test(partIntentText(input, part)) + const axis = partAxis(part.axis, 'y') + const center = add(origin, part.position ?? [0, 0.62, 0]) + const radius = clamp(part.radius, 0.11, 0.025, 0.6) + const wire = clamp(part.wireRadius, radius * 0.08, 0.002, 0.04) + const spokeCount = clampInt(part.spokeCount ?? part.count, 4, 3, 8) + const mat = partMaterial(part, material(input.darkColor ?? '#1f2937', 0.45, 0.45)) + if (leverHandle) { + const leverLength = clamp(part.length, radius * 2.6, radius * 1.1, radius * 5) + return applyPartRotation( + [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} handwheel hub`, + position: center, + axis: 'y', + radius: radius * 0.22, + height: wire * 3.2, + radialSegments: 16, + material: mat, + }, + { + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} lever handle`, + position: [center[0], center[1], center[2] + leverLength * 0.42] as Vec3, + axis: 'z', + radius: wire, + height: leverLength, + radialSegments: 12, + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} lever end knob`, + position: [center[0], center[1], center[2] + leverLength * 0.92] as Vec3, + radius: wire * 2.3, + material: mat, + }, + ], + center, + part.rotation, + ) + } + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} handwheel rim`, + position: center, + axis, + majorRadius: radius, + tubeRadius: wire, + radialSegments: 12, + tubularSegments: ringSegments(input.detail), + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} handwheel hub`, + position: center, + axis, + radius: radius * 0.22, + height: wire * 2.6, + radialSegments: 16, + material: mat, + }, + ] + for (let i = 0; i < spokeCount; i += 1) { + const angle = angularStep(i, spokeCount) + const position = radialPointOnAxis(center, axis, angle, radius * 0.5) + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} handwheel spoke ${i + 1}`, + position, + rotation: axis === 'y' ? [0, 0, angle] : [0, angle, 0], + axis: axis === 'y' ? 'x' : 'z', + radius: wire * 0.5, + height: radius, + radialSegments: 8, + material: mat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeBicycleWheels( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.32, 0]) + const wheelRadius = clamp(part.radius, 0.22, 0.06, 1) + const length = clamp(part.length, 0.86, 0.25, 3) + const count = clampInt(part.count, 2, 1, 2) + const tube = clamp(part.wireRadius, wheelRadius * 0.045, 0.004, 0.04) + const tireMat = material(input.darkColor ?? '#111827', 0.68, 0.02) + const metalMat = material(input.metalColor ?? '#cbd5e1', 0.28, 0.78) + const shapes: PrimitiveShapeInput[] = [] + const positions = wheelSetPositions(count, length, 0) + positions.forEach((offset, index) => { + const label = count === 1 ? 'single' : index === 0 ? 'rear' : 'front' + const wheelCenter: Vec3 = [center[0] + offset[0], center[1] + offset[1], center[2] + offset[2]] + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} bicycle ${label} tire`, + semanticRole: 'bicycle_tire', + sourcePartKind: part.sourcePartKind ?? 'bicycle_wheels', + position: wheelCenter, + axis: 'z', + majorRadius: wheelRadius, + tubeRadius: tube * 1.7, + radialSegments: 12, + tubularSegments: ringSegments(input.detail), + material: tireMat, + }) + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} bicycle ${label} rim`, + semanticRole: 'bicycle_rim', + sourcePartKind: part.sourcePartKind ?? 'bicycle_wheels', + position: wheelCenter, + axis: 'z', + majorRadius: wheelRadius * 0.76, + tubeRadius: tube * 0.55, + radialSegments: 8, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.65)), + material: metalMat, + }) + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} bicycle ${label} hub`, + semanticRole: 'bicycle_hub', + sourcePartKind: part.sourcePartKind ?? 'bicycle_wheels', + position: wheelCenter, + axis: 'z', + radius: wheelRadius * 0.08, + height: tube * 5, + radialSegments: 16, + material: metalMat, + }) + for (let i = 0; i < 8; i += 1) { + const angle = (i * Math.PI * 2) / 8 + shapes.push({ + ...tubeBetween( + `${part.name ?? input.name ?? 'object'} bicycle ${label} spoke ${i + 1}`, + wheelCenter, + [ + wheelCenter[0] + Math.cos(angle) * wheelRadius * 0.72, + wheelCenter[1] + Math.sin(angle) * wheelRadius * 0.72, + wheelCenter[2], + ], + tube * 0.22, + metalMat, + ), + semanticRole: 'bicycle_spoke', + sourcePartKind: part.sourcePartKind ?? 'bicycle_wheels', + }) + } + }) + return applyPartRotation(shapes, center, part.rotation) +} + +function composeBicycleFrame( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.52, 0]) + const length = clamp(part.length, 0.86, 0.25, 3) + const height = clamp(part.height, 0.36, 0.12, 1.5) + const r = clamp(part.radius ?? part.wireRadius, 0.018, 0.004, 0.08) + const mat = partMaterial(part, material(input.primaryColor ?? '#2563eb', 0.42, 0.18)) + const rear: Vec3 = [center[0] - length / 2, center[1] - height * 0.55, center[2]] + const front: Vec3 = [center[0] + length / 2, center[1] - height * 0.55, center[2]] + const seat: Vec3 = [center[0] - length * 0.12, center[1] + height * 0.35, center[2]] + const head: Vec3 = [center[0] + length * 0.34, center[1] + height * 0.28, center[2]] + const bottom: Vec3 = [center[0] - length * 0.02, center[1] - height * 0.2, center[2]] + const pedalOffset = Math.max(r * 4.5, 0.08) + const pedalLength = Math.max(r * 3.5, 0.07) + const metalMat = material(input.darkColor ?? '#111827', 0.48, 0.35) + const shapes: PrimitiveShapeInput[] = [ + tubeBetween(`${part.name ?? input.name ?? 'object'} bicycle top tube`, seat, head, r, mat), + tubeBetween(`${part.name ?? input.name ?? 'object'} bicycle down tube`, head, bottom, r, mat), + tubeBetween(`${part.name ?? input.name ?? 'object'} bicycle seat tube`, seat, bottom, r, mat), + tubeBetween(`${part.name ?? input.name ?? 'object'} bicycle chain stay`, bottom, rear, r, mat), + tubeBetween(`${part.name ?? input.name ?? 'object'} bicycle seat stay`, seat, rear, r, mat), + tubeBetween(`${part.name ?? input.name ?? 'object'} bicycle front stay`, head, front, r, mat), + { + kind: 'cylinder' as const, + name: `${part.name ?? input.name ?? 'object'} bicycle crank`, + position: bottom, + axis: 'z', + radius: r * 2.2, + height: r * 3, + radialSegments: 18, + semanticRole: 'crank', + material: metalMat, + }, + { + ...tubeBetween( + `${part.name ?? input.name ?? 'object'} bicycle left crank arm`, + bottom, + [bottom[0], bottom[1] - r * 2.5, bottom[2] - pedalOffset], + r * 0.38, + metalMat, + ), + semanticRole: 'crank', + }, + { + ...tubeBetween( + `${part.name ?? input.name ?? 'object'} bicycle right crank arm`, + bottom, + [bottom[0], bottom[1] + r * 2.5, bottom[2] + pedalOffset], + r * 0.38, + metalMat, + ), + semanticRole: 'crank', + }, + { + kind: 'box' as const, + name: `${part.name ?? input.name ?? 'object'} bicycle left pedal`, + position: [bottom[0], bottom[1] - r * 2.5, bottom[2] - pedalOffset - pedalLength * 0.4], + length: pedalLength, + width: r * 1.2, + height: r * 0.75, + semanticRole: 'pedal', + material: metalMat, + }, + { + kind: 'box' as const, + name: `${part.name ?? input.name ?? 'object'} bicycle right pedal`, + position: [bottom[0], bottom[1] + r * 2.5, bottom[2] + pedalOffset + pedalLength * 0.4], + length: pedalLength, + width: r * 1.2, + height: r * 0.75, + semanticRole: 'pedal', + material: metalMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeBicycleFork( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.37, 0.5, 0]) + const height = clamp(part.height, 0.42, 0.12, 1.5) + const spread = clamp(part.width, 0.08, 0.02, 0.4) + const r = clamp(part.radius ?? part.wireRadius, 0.014, 0.003, 0.06) + const mat = partMaterial(part, material(input.metalColor ?? '#cbd5e1', 0.3, 0.75)) + const crown: Vec3 = [center[0], center[1] + height * BICYCLE_FORK_CROWN_RISE_RATIO, center[2]] + const axle: Vec3 = [ + center[0] + height * BICYCLE_FORK_AXLE_FORWARD_RATIO, + center[1] - height * BICYCLE_FORK_AXLE_DROP_RATIO, + center[2], + ] + const shapes = [ + tubeBetween( + `${part.name ?? input.name ?? 'object'} bicycle left fork blade`, + [crown[0], crown[1], crown[2] - spread / 2], + [axle[0], axle[1], axle[2] - spread / 2], + r, + mat, + ), + tubeBetween( + `${part.name ?? input.name ?? 'object'} bicycle right fork blade`, + [crown[0], crown[1], crown[2] + spread / 2], + [axle[0], axle[1], axle[2] + spread / 2], + r, + mat, + ), + tubeBetween( + `${part.name ?? input.name ?? 'object'} bicycle steerer tube`, + crown, + [ + crown[0] + height * BICYCLE_STEERER_FORWARD_RATIO, + crown[1] + height * BICYCLE_STEERER_RISE_RATIO, + crown[2], + ], + r, + mat, + ), + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeHandlebar( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.44, 0.78, 0]) + const width = clamp(part.width ?? part.length, 0.32, 0.06, 1.2) + const stemDrop = clamp(part.height, width * 0.22, 0.025, 0.45) + const r = clamp(part.radius ?? part.wireRadius, 0.014, 0.003, 0.06) + const mat = partMaterial(part, material(input.metalColor ?? '#cbd5e1', 0.28, 0.78)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} handlebar crossbar`, + position: center, + axis: 'z', + radius: r, + height: width, + radialSegments: 12, + material: mat, + }, + tubeBetween( + `${part.name ?? input.name ?? 'object'} handlebar stem`, + [center[0] - width * BICYCLE_HANDLEBAR_STEM_REACH_RATIO, center[1] - stemDrop, center[2]], + center, + r, + mat, + ), + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeSaddle( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [-0.12, 0.76, 0]) + const length = clamp(part.length, 0.18, 0.04, 0.6) + const width = clamp(part.width, 0.12, 0.03, 0.4) + const height = clamp(part.height, 0.035, 0.01, 0.16) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} saddle cushion`, + position: center, + length, + width, + height, + cornerRadius: height * 0.55, + cornerSegments: 5, + material: partMaterial(part, material(input.darkColor ?? '#111827', 0.62, 0.02)), + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} saddle post`, + position: [center[0], center[1] - height * 2.4, center[2]], + axis: 'y', + radius: height * 0.22, + height: height * 3.6, + radialSegments: 12, + material: material(input.metalColor ?? '#cbd5e1', 0.3, 0.75), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeChainLoop( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [-0.23, 0.36, 0.018]) + const chainringRadius = clamp(part.radius, 0.105, 0.04, 0.24) + const rearCogRadius = clamp(part.depth, chainringRadius * 0.52, 0.025, chainringRadius * 0.8) + const span = clamp(part.length, 0.46, 0.22, 1.4) + const tubeRadius = clamp(part.wireRadius, chainringRadius * 0.045, 0.002, 0.018) + const chainHalfHeight = Math.max(chainringRadius * 0.62, rearCogRadius * 1.15) + const frontX = span / 2 + const rearX = -span / 2 + const chainPath: Vec3[] = [ + [rearX, chainHalfHeight * 0.72, 0], + [-span * 0.18, chainHalfHeight, 0], + [frontX, chainHalfHeight, 0], + [frontX + chainHalfHeight * 0.34, chainHalfHeight * 0.45, 0], + [frontX + chainHalfHeight * 0.34, -chainHalfHeight * 0.45, 0], + [frontX, -chainHalfHeight, 0], + [-span * 0.18, -chainHalfHeight * 0.82, 0], + [rearX, -chainHalfHeight * 0.58, 0], + [rearX - chainHalfHeight * 0.28, -chainHalfHeight * 0.18, 0], + [rearX - chainHalfHeight * 0.24, chainHalfHeight * 0.36, 0], + ] + const chainMat = partMaterial(part, material(input.darkColor ?? '#111827', 0.48, 0.35)) + const metalMat = material(input.metalColor ?? '#cbd5e1', 0.3, 0.7) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'sweep', + name: `${part.name ?? input.name ?? 'object'} chain elongated loop`, + position: center, + path: chainPath, + radius: tubeRadius, + radialSegments: 6, + tubularSegments: Math.max(32, Math.round(ringSegments(input.detail) * 0.7)), + closed: true, + semanticRole: 'chain_loop', + material: chainMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} front chainring`, + position: [center[0] + frontX, center[1], center[2] - tubeRadius * 1.6], + axis: 'z', + majorRadius: chainringRadius, + tubeRadius: tubeRadius * 0.75, + radialSegments: 8, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.55)), + semanticRole: 'chainring', + material: metalMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} rear sprocket`, + position: [ + center[0] + rearX, + center[1] - chainHalfHeight * 0.1, + center[2] - tubeRadius * 1.6, + ], + axis: 'z', + majorRadius: rearCogRadius, + tubeRadius: tubeRadius * 0.65, + radialSegments: 8, + tubularSegments: 24, + semanticRole: 'rear_sprocket', + material: metalMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeVehicleBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const style = vehicleStyleFor(input, part) + const defaults = VEHICLE_STYLE_DEFAULTS[style] + const length = vehicleLength(part, style) + const width = vehicleWidth(part, style) + const overallHeight = vehicleOverallHeight(part, length, width, style) + const center = add(origin, part.position ?? [0, Math.max(0.34, overallHeight * 0.58), 0]) + const baseY = center[1] - overallHeight / 2 + const bodyHeight = clamp( + part.bodyHeight, + overallHeight * defaults.bodyHeightRatio, + 0.08, + overallHeight * 0.65, + ) + const cabinHeight = clamp( + part.cabinHeight, + overallHeight * defaults.cabinHeightRatio, + 0.06, + overallHeight * 0.7, + ) + const bodyY = baseY + overallHeight * 0.38 + const deckY = bodyY + bodyHeight * 0.48 + const cabinY = baseY + overallHeight * 0.72 + const bodyColor = part.primaryColor ?? part.color ?? input.primaryColor ?? '#ef4444' + const mat = partMaterial(part, material(bodyColor, 0.42, 0.18)) + const shadowMat = material(part.darkColor ?? input.darkColor ?? '#1f2937', 0.58, 0.16) + const bodyCornerRadius = clamp( + part.cornerRadius, + Math.min(length, width, bodyHeight) * 0.12, + 0, + Math.min(length, width, bodyHeight) * 0.45, + ) + const bodyCornerSegments = clampInt(part.cornerSegments, 6, 1, 12) + const roofCornerAngle = clamp(part.roofCornerAngle, 90, 65, 90) + const angleTopScale = roofCornerAngle < 90 ? 1 - (90 - roofCornerAngle) * 0.02 : undefined + const cabinTopLengthScale = clamp( + part.cabinTopLengthScale ?? part.cabinTopScale ?? angleTopScale, + defaults.cabinTopScale, + 0.55, + 1, + ) + const cabinTopWidthScale = clamp( + part.cabinTopWidthScale ?? part.cabinTopScale ?? angleTopScale, + defaults.cabinTopScale, + 0.55, + 1, + ) + const useTaperedCabin = cabinTopLengthScale < 0.995 || cabinTopWidthScale < 0.995 + const cabinLength = length * defaults.cabinLengthRatio + const cabinWidth = width * defaults.cabinWidthRatio + const cabinX = center[0] + length * defaults.cabinXRatio + const roofHeight = Math.max(bodyHeight * 0.06, 0.024) + const cabinFrameHeight = Math.max(roofHeight * 1.4, cabinHeight * 0.14) + const roofLength = cabinLength * cabinTopLengthScale * 0.96 + const roofWidth = cabinWidth * cabinTopWidthScale * 0.94 + const noseLength = style === 'truck' || style === 'van' ? length * 0.18 : length * 0.24 + const tailLength = style === 'sports' ? length * 0.22 : length * 0.18 + const fenderRadius = Math.min(overallHeight * 0.22, width * 0.2) + const wheelWellRadius = vehicleWheelRadius(part, length, width, overallHeight, style) * 1.08 + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'trapezoid-prism', + name: `${part.name ?? input.name ?? 'object'} vehicle body shell`, + position: [center[0], bodyY, center[2]], + length, + width, + height: bodyHeight, + topLengthScale: style === 'van' ? 0.98 : 0.94, + topWidthScale: style === 'truck' || style === 'suv' ? 0.93 : 0.88, + cornerRadius: bodyCornerRadius, + cornerSegments: bodyCornerSegments, + material: mat, + }, + { + kind: 'wedge', + name: `${part.name ?? input.name ?? 'object'} vehicle rounded front nose`, + position: [center[0] + length * 0.43, bodyY + bodyHeight * 0.04, center[2]], + length: noseLength, + width: width * 0.86, + height: bodyHeight * 0.46, + slopeAxis: 'x', + slopeDirection: 'negative', + cornerRadius: Math.min(bodyCornerRadius, bodyHeight * 0.18), + cornerSegments: Math.max(4, bodyCornerSegments - 1), + material: mat, + }, + { + kind: 'wedge', + name: `${part.name ?? input.name ?? 'object'} vehicle tapered rear quarter`, + position: [center[0] - length * 0.43, bodyY + bodyHeight * 0.02, center[2]], + length: tailLength, + width: width * 0.84, + height: bodyHeight * 0.4, + slopeAxis: 'x', + slopeDirection: 'positive', + cornerRadius: Math.min(bodyCornerRadius, bodyHeight * 0.15), + cornerSegments: Math.max(4, bodyCornerSegments - 1), + material: mat, + }, + { + kind: 'wedge', + name: `${part.name ?? input.name ?? 'object'} vehicle front deck hood surface`, + position: [center[0] + length * 0.25, deckY, center[2]], + length: length * 0.36, + width: width * 0.82, + height: bodyHeight * 0.1, + slopeAxis: 'x', + slopeDirection: 'negative', + cornerRadius: Math.min(bodyCornerRadius, bodyHeight * 0.08), + cornerSegments: Math.max(3, bodyCornerSegments - 1), + material: mat, + }, + { + kind: 'wedge', + name: `${part.name ?? input.name ?? 'object'} vehicle rear deck trunk surface`, + position: [center[0] - length * 0.35, deckY - bodyHeight * 0.02, center[2]], + length: length * 0.24, + width: width * 0.82, + height: bodyHeight * 0.085, + slopeAxis: 'x', + slopeDirection: 'positive', + cornerRadius: Math.min(bodyCornerRadius, bodyHeight * 0.08), + cornerSegments: Math.max(3, bodyCornerSegments - 1), + material: mat, + }, + { + kind: useTaperedCabin ? 'trapezoid-prism' : 'box', + name: `${part.name ?? input.name ?? 'object'} vehicle cabin frame`, + position: [cabinX, cabinY - cabinHeight * 0.42, center[2]], + length: cabinLength, + width: cabinWidth, + height: cabinFrameHeight, + cornerRadius: Math.min(bodyCornerRadius, cabinHeight * 0.12), + cornerSegments: Math.max(3, bodyCornerSegments - 1), + topLengthScale: cabinTopLengthScale, + topWidthScale: cabinTopWidthScale, + material: mat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vehicle roof cap`, + position: [cabinX, cabinY + cabinHeight * 0.5 + roofHeight * 0.18, center[2]], + length: roofLength, + width: roofWidth, + thickness: roofHeight, + cornerRadius: Math.min(bodyCornerRadius, roofHeight * 0.65), + cornerSegments: Math.max(3, bodyCornerSegments - 1), + material: mat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vehicle rocker shadow`, + position: [center[0], baseY + overallHeight * 0.2, center[2]], + length: length * 0.9, + width: width * 0.86, + height: bodyHeight * 0.16, + cornerRadius: bodyHeight * 0.04, + cornerSegments: 3, + material: shadowMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vehicle lower front intake`, + position: [center[0] + length * 0.47, baseY + overallHeight * 0.24, center[2]], + rotation: [0, Math.PI / 2, 0], + length: width * 0.52, + width: bodyHeight * 0.16, + thickness: Math.max(length * 0.008, 0.016), + cornerRadius: bodyHeight * 0.055, + cornerSegments: 4, + material: shadowMat, + }, + ] + const pillarHeight = Math.max(cabinHeight * 0.72, 0.08) + const pillarY = cabinY + cabinHeight * 0.08 + const pillarWidth = Math.max(width * 0.022, 0.028) + const pillarLength = Math.max(length * 0.012, 0.032) + for (const [label, x] of [ + ['A', cabinX + cabinLength * 0.43], + ['B', cabinX], + ['C', cabinX - cabinLength * 0.43], + ] as const) { + for (const [side, z] of [ + ['left', center[2] - cabinWidth * 0.49], + ['right', center[2] + cabinWidth * 0.49], + ] as const) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vehicle ${label} pillar ${side}`, + position: [x, pillarY, z], + length: pillarLength, + width: pillarWidth, + height: pillarHeight, + cornerRadius: pillarWidth * 0.35, + cornerSegments: 3, + material: mat, + }) + } + } + for (const [side, z] of [ + ['left', center[2] - roofWidth * 0.52], + ['right', center[2] + roofWidth * 0.52], + ] as const) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} vehicle roof rail ${side}`, + position: [cabinX, cabinY + cabinHeight * 0.44, z], + length: roofLength, + width: pillarWidth, + height: roofHeight * 0.9, + cornerRadius: pillarWidth * 0.35, + cornerSegments: 3, + material: mat, + }) + } + for (const [side, z] of [ + ['left', center[2] - width * 0.515], + ['right', center[2] + width * 0.515], + ] as const) { + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vehicle side character line ${side}`, + position: [center[0], bodyY + bodyHeight * 0.22, z], + rotation: [Math.PI / 2, 0, 0], + length: length * 0.78, + width: Math.max(bodyHeight * 0.055, 0.02), + thickness: width * 0.012, + cornerRadius: bodyHeight * 0.03, + cornerSegments: 3, + material: shadowMat, + }) + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vehicle lower sill ${side}`, + position: [center[0], baseY + overallHeight * 0.28, z], + rotation: [Math.PI / 2, 0, 0], + length: length * 0.74, + width: Math.max(bodyHeight * 0.08, 0.026), + thickness: width * 0.014, + cornerRadius: bodyHeight * 0.04, + cornerSegments: 3, + material: mat, + }) + for (const x of [cabinX + cabinLength * 0.43, cabinX - cabinLength * 0.43]) { + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vehicle door cutline ${side}`, + position: [x, bodyY + bodyHeight * 0.16, z], + rotation: [Math.PI / 2, 0, Math.PI / 2], + length: bodyHeight * 0.48, + width: Math.max(length * 0.006, 0.012), + thickness: width * 0.01, + cornerRadius: bodyHeight * 0.018, + cornerSegments: 2, + material: shadowMat, + }) + } + } + if (style === 'truck') { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} truck cargo bed`, + position: [center[0] - length * 0.24, deckY - bodyHeight * 0.02, center[2]], + length: length * 0.42, + width: width * 0.88, + height: bodyHeight * 0.2, + cornerRadius: Math.min(bodyCornerRadius, bodyHeight * 0.06), + cornerSegments: Math.max(3, bodyCornerSegments - 1), + material: mat, + }) + } + if (input.enhanceVisualDetails === true || input.detail === 'high') { + for (const x of [-length * 0.36, length * 0.36]) { + for (const z of [-width * 0.515, width * 0.515]) { + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vehicle wheel well shadow`, + position: [center[0] + x, baseY + wheelWellRadius * 1.05, center[2] + z], + rotation: [Math.PI / 2, 0, 0], + length: wheelWellRadius * 2.35, + width: wheelWellRadius * 1.75, + thickness: width * 0.012, + cornerRadius: wheelWellRadius * 0.42, + cornerSegments: 5, + material: shadowMat, + }) + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} vehicle wheel arch lip`, + position: [center[0] + x, baseY + wheelWellRadius * 1.16, center[2] + z], + axis: 'z', + majorRadius: wheelWellRadius, + tubeRadius: Math.max(width * 0.01, 0.012), + radialSegments: 8, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.62)), + scale: [1.16, 0.72, 1], + material: mat, + }) + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} vehicle fender crown`, + position: [center[0] + x, baseY + wheelWellRadius * 1.82, center[2] + z], + rotation: [Math.PI / 2, 0, 0], + length: fenderRadius * 2.1, + width: Math.max(fenderRadius * 0.28, 0.028), + thickness: width * 0.014, + cornerRadius: fenderRadius * 0.16, + cornerSegments: 4, + material: mat, + }) + } + } + } + return applyPartRotation(shapes, center, part.rotation) +} +function wheelSetPositions(count: number, length: number, width: number): Vec3[] { + if (count <= 1) return [[0, 0, 0]] + if (count === 2) + return [ + [-length / 2, 0, 0], + [length / 2, 0, 0], + ] + if (count === 3) { + return [ + [length / 2, 0, 0], + [-length / 2, 0, -width / 2], + [-length / 2, 0, width / 2], + ] + } + return [ + [-length / 2, 0, -width / 2], + [-length / 2, 0, width / 2], + [length / 2, 0, -width / 2], + [length / 2, 0, width / 2], + ] +} + +function isBicycleWheelContext(input: PartComposeInput, part: PartComposePartInput): boolean { + const text = [ + input.name, + input.geometryBrief, + part.kind, + part.partType, + part.type, + part.id, + part.name, + part.partName, + part.semanticRole, + ...(input.parts ?? []).map(partIdentityText), + ] + .map(textOf) + .join(' ') + return /bicycle|bike/.test(text) +} + +function isVehicleWheelContext(input: PartComposeInput, part: PartComposePartInput): boolean { + const text = [ + input.name, + input.geometryBrief, + part.kind, + part.partType, + part.type, + part.id, + part.name, + part.partName, + part.semanticRole, + ...(input.parts ?? []).map(partIdentityText), + ] + .map(textOf) + .join(' ') + return /vehicle|car|auto|automobile|sedan|suv|truck|van/.test(text) +} + +function wheelTireRole(input: PartComposeInput, part: PartComposePartInput, partName: string) { + const role = normalizedRoleToken(part.semanticRole) + if ( + role === 'bicycle_tire' || + ((role === '' || role === 'wheel' || role === 'wheels' || role === 'bicycle_wheel') && + isBicycleWheelContext(input, part)) + ) { + return 'bicycle_tire' + } + if ( + role === 'vehicle_tire' || + role === 'vehicle_tires' || + role === 'car_tire' || + role === 'car_tires' || + role === 'vehicle_tyre' || + role === 'car_tyre' || + ((role === '' || role === 'wheel' || role === 'wheels' || role === 'vehicle_wheel') && + isVehicleWheelContext(input, part)) + ) { + return 'vehicle_tire' + } + if (/bicycle|bike/.test(partName)) return 'bicycle_tire' + if (/vehicle|car|auto/.test(partName)) return 'vehicle_tire' + return role || 'wheel_tire' +} + +function composeWheelSet( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.16, 0]) + const partName = `${part.name ?? part.partName ?? part.kind ?? ''}`.toLowerCase() + const tireRole = wheelTireRole(input, part, partName) + const defaultCount = tireRole === 'bicycle_tire' ? 2 : tireRole === 'vehicle_tire' ? 4 : 4 + const count = clampInt(part.count, defaultCount, 1, 8) + const length = clamp(part.length, count === 2 ? 0.86 : 0.95, 0, 8) + const width = clamp(part.width, count >= 4 ? 0.54 : 0, 0, 4) + const radius = clamp(part.radius ?? part.wheelRadius, count === 2 ? 0.22 : 0.14, 0.025, 1.2) + const wheelWidth = clamp(part.wheelWidth ?? part.depth, radius * 0.42, 0.012, 0.6) + const tireMat = partMaterial(part, material(input.darkColor ?? '#111827', 0.72, 0.02)) + const rimMat = material(input.metalColor ?? '#d1d5db', 0.25, 0.75) + const hubDarkMat = material(input.darkColor ?? '#1f2937', 0.48, 0.36) + const axis = + tireRole === 'bicycle_tire' || tireRole === 'vehicle_tire' ? 'z' : partAxis(part.axis, 'z') + const shapes: PrimitiveShapeInput[] = [] + const tireNamePrefix = + tireRole === 'vehicle_tire' ? 'vehicle' : tireRole === 'bicycle_tire' ? 'bicycle' : 'wheel' + const positions = wheelSetPositions(count, length, width) + positions.forEach((offset, index) => { + const wheelCenter: Vec3 = [center[0] + offset[0], center[1] + offset[1], center[2] + offset[2]] + const label = + count === 2 + ? index === 0 + ? 'rear' + : 'front' + : count === 3 + ? index === 0 + ? 'nose' + : `main ${index}` + : `${index + 1}` + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} ${tireNamePrefix} tire ${label}`, + semanticRole: tireRole, + sourcePartKind: + part.sourcePartKind ?? (tireRole === 'bicycle_tire' ? 'bicycle_wheels' : 'wheel_set'), + position: wheelCenter, + axis, + majorRadius: radius, + tubeRadius: Math.min(radius * 0.22, wheelWidth * 0.42), + radialSegments: 12, + tubularSegments: ringSegments(input.detail), + material: tireMat, + }) + shapes.push({ + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} ${label} wheel rim ring`, + semanticRole: tireRole === 'bicycle_tire' ? 'bicycle_rim' : 'wheel_rim', + sourcePartKind: + part.sourcePartKind ?? (tireRole === 'bicycle_tire' ? 'bicycle_wheels' : 'wheel_set'), + position: wheelCenter, + axis, + majorRadius: radius * 0.55, + tubeRadius: Math.max(radius * 0.045, wheelWidth * 0.06), + radialSegments: 8, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.55)), + material: rimMat, + }) + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} ${label} wheel hub`, + semanticRole: tireRole === 'bicycle_tire' ? 'bicycle_hub' : 'wheel_hub', + sourcePartKind: + part.sourcePartKind ?? (tireRole === 'bicycle_tire' ? 'bicycle_wheels' : 'wheel_set'), + position: wheelCenter, + axis, + radius: radius * 0.45, + height: wheelWidth * 0.35, + radialSegments: 20, + material: rimMat, + }) + if (input.detail === 'high' || input.enhanceVisualDetails === true) { + const spokeCount = tireRole === 'bicycle_tire' ? 8 : 5 + for (let spoke = 0; spoke < spokeCount; spoke += 1) { + const angle = angularStep(spoke, spokeCount) + const spokeLength = radius * 0.58 + shapes.push({ + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} ${label} wheel spoke ${spoke + 1}`, + semanticRole: tireRole === 'bicycle_tire' ? 'bicycle_spoke' : 'wheel_spoke', + sourcePartKind: + part.sourcePartKind ?? (tireRole === 'bicycle_tire' ? 'bicycle_wheels' : 'wheel_set'), + position: [ + wheelCenter[0] + Math.cos(angle) * radius * 0.28, + wheelCenter[1] + Math.sin(angle) * radius * 0.28, + wheelCenter[2], + ], + rotation: [0, 0, angle], + axis: 'x', + radius: Math.max(radius * 0.018, 0.004), + height: spokeLength, + radialSegments: 8, + capSegments: 2, + material: hubDarkMat, + }) + } + } + }) + if (axis === 'z' && count >= 4) { + const axleXs = Array.from(new Set(positions.map((position) => Number(position[0].toFixed(4))))) + for (const x of axleXs) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} wheel axle`, + semanticRole: 'wheel_axle', + sourcePartKind: part.sourcePartKind ?? 'wheel_set', + position: [center[0] + x, center[1], center[2]], + axis: 'z', + radius: radius * 0.12, + height: width + wheelWidth * 1.2, + radialSegments: 16, + material: hubDarkMat, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeWindowPanel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.55, 0]) + const length = clamp(part.length ?? part.width, 0.32, 0.02, 4) + const height = clamp(part.height, 0.18, 0.015, 2) + const thickness = clamp(part.thickness ?? part.depth, 0.01, 0.002, 0.12) + const glass = partMaterial( + part, + material(part.accentColor ?? input.accentColor ?? '#38bdf8', 0.12, 0.02, 0.58), + ) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: part.name ?? `${input.name ?? 'object'} glass panel`, + semanticRole: part.semanticRole ?? 'window_panel', + sourcePartKind: part.sourcePartKind ?? 'window_panel', + position: center, + rotation: part.rotation, + length, + width: height, + thickness, + cornerRadius: clamp( + part.cornerRadius, + Math.min(length, height) * 0.16, + 0, + Math.min(length, height) * 0.45, + ), + cornerSegments: clampInt(part.cornerSegments, 4, 1, 12), + material: glass, + }, + ] + return shapes +} + +function composeWindowStrip( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + if ( + part.vehicleStyle || + part.style === 'vehicle_glasshouse' || + part.variant === 'vehicle_glasshouse' + ) { + return composeVehicleWindows(input, part, origin) + } + const center = add(origin, part.position ?? [0, 0.65, 0.02]) + const count = clampInt(part.count, 8, 2, 60) + const length = clamp(part.length, 1.2, 0.08, 12) + const panelWidth = clamp(part.width, Math.min(0.12, length / Math.max(count * 1.6, 1)), 0.01, 0.8) + const height = clamp(part.height, panelWidth * 0.72, 0.01, 0.5) + const spacing = count <= 1 ? 0 : length / Math.max(count - 1, 1) + const shapes: PrimitiveShapeInput[] = [] + for (let i = 0; i < count; i += 1) { + shapes.push( + ...composeWindowPanel( + input, + { + ...part, + name: `${part.name ?? input.name ?? 'object'} window ${i + 1}`, + semanticRole: part.semanticRole ?? 'window_panel', + sourcePartKind: part.sourcePartKind ?? 'window_strip', + position: [center[0] - length / 2 + i * spacing, center[1], center[2]], + length: panelWidth, + height, + }, + origin, + ), + ) + } + return shapes +} + +function composeVehicleWheels( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + return composeWheelSet(input, { semanticRole: 'vehicle_tire', ...part }, origin) +} + +function composeVehicleWindows( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const style = vehicleStyleFor(input, part) + const defaults = VEHICLE_STYLE_DEFAULTS[style] + const center = add(origin, part.position ?? [0, 0.55, 0]) + const length = clamp(part.length, 0.5, 0.1, 2) + const width = clamp(part.width, 0.52, 0.08, 1.8) + const height = clamp(part.height, 0.12, 0.03, 0.6) + const glass = partMaterial( + part, + material(part.accentColor ?? input.accentColor ?? '#1e3a8a', 0.18, 0.02, 0.68), + ) + const trim = material(input.darkColor ?? '#0f172a', 0.42, 0.12) + const glasshouseTopScale = clamp(part.cabinTopScale, defaults.cabinTopScale, 0.55, 1) + const sideWindowLength = length * (style === 'sports' ? 0.58 : 0.66) + const sidePanelLength = sideWindowLength * 0.43 + const quarterPanelLength = length * 0.16 + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'trapezoid-prism', + name: `${part.name ?? input.name ?? 'object'} integrated vehicle glasshouse`, + semanticRole: part.semanticRole ?? 'vehicle_window', + sourcePartKind: part.sourcePartKind ?? 'window_strip', + position: [center[0], center[1] + height * 0.1, center[2]], + length: length * 0.72, + width: width * 0.76, + height: height * 0.82, + topLengthScale: glasshouseTopScale, + topWidthScale: Math.min(0.92, glasshouseTopScale + 0.04), + material: glass, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} windshield`, + semanticRole: part.semanticRole ?? 'vehicle_window', + sourcePartKind: part.sourcePartKind ?? 'window_strip', + position: [center[0] + length * 0.42, center[1] + height * 0.1, center[2]], + rotation: [0, 0, Math.PI / 2 - 0.22], + length: height * 1.02, + width: width * 0.46, + thickness: 0.01, + cornerRadius: height * 0.12, + cornerSegments: 4, + material: glass, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} rear window`, + semanticRole: part.semanticRole ?? 'vehicle_window', + sourcePartKind: part.sourcePartKind ?? 'window_strip', + position: [center[0] - length * 0.42, center[1] + height * 0.1, center[2]], + rotation: [0, 0, Math.PI / 2 + 0.18], + length: height * 0.96, + width: width * 0.44, + thickness: 0.01, + cornerRadius: height * 0.12, + cornerSegments: 4, + material: glass, + }, + ] + for (const side of [-1, 1]) { + for (const [label, x] of [ + ['front', center[0] + length * 0.14], + ['rear', center[0] - length * 0.15], + ] as const) { + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} ${label} side window ${ + side < 0 ? 'left' : 'right' + }`, + semanticRole: part.semanticRole ?? 'vehicle_window', + sourcePartKind: part.sourcePartKind ?? 'window_strip', + position: [x, center[1] + height * 0.1, center[2] + side * width * 0.505], + rotation: [Math.PI / 2, 0, 0], + length: sidePanelLength, + width: height * 0.82, + thickness: 0.01, + cornerRadius: height * 0.12, + cornerSegments: 4, + material: glass, + }) + } + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} rear quarter window ${side < 0 ? 'left' : 'right'}`, + semanticRole: part.semanticRole ?? 'vehicle_window', + sourcePartKind: part.sourcePartKind ?? 'window_strip', + position: [ + center[0] - length * 0.26, + center[1] + height * 0.06, + center[2] + side * width * 0.51, + ], + rotation: [Math.PI / 2, 0, 0], + length: quarterPanelLength, + width: height * 0.72, + thickness: 0.01, + cornerRadius: height * 0.11, + cornerSegments: 4, + material: glass, + }) + for (const x of [center[0] - length * 0.01, center[0] - length * 0.3]) { + shapes.push({ + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} side window divider ${ + side < 0 ? 'left' : 'right' + }`, + sourcePartKind: part.sourcePartKind ?? 'window_strip', + position: [x, center[1] + height * 0.1, center[2] + side * width * 0.512], + rotation: [Math.PI / 2, 0, Math.PI / 2], + length: height * 0.86, + width: Math.max(length * 0.01, 0.012), + thickness: 0.012, + cornerRadius: height * 0.04, + cornerSegments: 2, + material: trim, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeHeadlights( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.62, 0.34, 0]) + const width = clamp(part.width, 0.5, 0.08, 2) + const radius = clamp(part.radius, 0.035, 0.008, 0.16) + const lightMat = partMaterial(part, material('#fde68a', 0.2, 0.02, 0.86)) + return [-1, 1].map( + (side): PrimitiveShapeInput => ({ + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} ${side < 0 ? 'left' : 'right'} headlight`, + semanticRole: part.semanticRole ?? 'headlight', + sourcePartKind: part.sourcePartKind ?? 'light_pair', + position: [center[0], center[1], center[2] + side * width * 0.34], + radius, + scale: [0.55, 0.75, 1], + material: lightMat, + }), + ) +} + +function composeBumper( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.66, 0.24, 0]) + const width = clamp(part.width ?? part.length, 0.56, 0.08, 2.5) + const height = clamp(part.height, 0.045, 0.01, 0.2) + const bumperMat = partMaterial(part, material(input.darkColor ?? '#1f2937', 0.48, 0.25)) + const makeBar = (name: string, position: Vec3): PrimitiveShapeInput => ({ + kind: 'box', + name, + position, + length: height, + width, + height, + cornerRadius: height * 0.35, + cornerSegments: 4, + material: bumperMat, + }) + const side = partSide(part.side) + const shapes: PrimitiveShapeInput[] = [] + + if (!side || side === 'front') { + shapes.push(makeBar(`${part.name ?? input.name ?? 'object'} front bumper bar`, center)) + } + if (!side || side === 'back') { + shapes.push( + makeBar(`${part.name ?? input.name ?? 'object'} rear bumper bar`, [ + center[0] - Math.abs(center[0]) * 2, + center[1], + center[2], + ]), + ) + } + + return shapes +} +function genericPartRole(part: PartComposePartInput, fallback: string): string { + return normalizedRoleToken(part.semanticRole) || fallback +} + +function composeGenericBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, input.length ?? 1, 0.08, 8) + const width = clamp(part.width ?? part.depth, input.width ?? input.depth ?? 0.65, 0.05, 5) + const height = clamp(part.height, input.height ?? 0.8, 0.05, 5) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const mat = partMaterial(part, material(part.primaryColor ?? input.primaryColor ?? '#8b9aae')) + return applyPartRotation( + [ + { + kind: 'box', + name: part.name ?? `${input.name ?? 'generic object'} body`, + semanticRole: genericPartRole(part, 'main_body'), + semanticGroup: part.semanticGroup ?? 'generic_parts', + sourcePartKind: 'generic_body', + position: center, + length, + width, + height, + cornerRadius: clamp(part.cornerRadius, Math.min(length, width, height) * 0.06, 0, 0.5), + cornerSegments: part.cornerSegments ?? 5, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeGenericBase( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, (input.length ?? 1) * 1.08, 0.08, 8) + const width = clamp( + part.width ?? part.depth, + (input.width ?? input.depth ?? 0.65) * 1.08, + 0.05, + 5, + ) + const thickness = clamp(part.thickness ?? part.height, (input.height ?? 0.8) * 0.08, 0.01, 0.8) + const center = add(origin, part.position ?? [0, thickness * 0.5, 0]) + const mat = partMaterial(part, material(part.darkColor ?? input.darkColor ?? '#1f2937', 0.66)) + return applyPartRotation( + [ + { + kind: 'rounded-panel', + name: part.name ?? `${input.name ?? 'generic object'} base`, + semanticRole: genericPartRole(part, 'support_base'), + semanticGroup: part.semanticGroup ?? 'generic_parts', + sourcePartKind: 'generic_base', + position: center, + length, + width, + thickness, + cornerRadius: clamp(part.cornerRadius, Math.min(length, width) * 0.04, 0, 0.35), + cornerSegments: part.cornerSegments ?? 5, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeGenericPanel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, + kind: + | 'generic_panel' + | 'generic_control_panel' + | 'generic_display' + | 'generic_opening' + | 'generic_detail_accent', +): PrimitiveShapeInput[] { + const objectLength = input.length ?? 1 + const objectWidth = input.width ?? input.depth ?? 0.65 + const objectHeight = input.height ?? 0.8 + const length = clamp( + part.length, + kind === 'generic_detail_accent' ? objectLength * 0.24 : objectLength * 0.3, + 0.02, + 4, + ) + const panelHeight = clamp( + part.height ?? part.width, + kind === 'generic_opening' ? objectHeight * 0.34 : objectHeight * 0.22, + 0.02, + 3, + ) + const thickness = clamp(part.thickness ?? part.depth, 0.025, 0.002, 0.4) + const fallbackZ = objectWidth * 0.51 + const fallbackY = + kind === 'generic_opening' + ? objectHeight * 0.36 + : kind === 'generic_detail_accent' + ? objectHeight * 0.68 + : objectHeight * 0.62 + const center = add( + origin, + part.position ?? [ + kind === 'generic_detail_accent' ? objectLength * 0.18 : 0, + fallbackY, + fallbackZ, + ], + ) + const fallbackColor = + kind === 'generic_display' + ? '#0f172a' + : kind === 'generic_opening' + ? '#111827' + : kind === 'generic_control_panel' + ? '#38bdf8' + : '#94a3b8' + const mat = partMaterial( + part, + material(part.color ?? part.accentColor ?? input.accentColor ?? fallbackColor, 0.4), + ) + return applyPartRotation( + [ + { + kind: 'rounded-panel', + name: + part.name ?? + `${input.name ?? 'generic object'} ${kind.replace(/^generic_/, '').replace(/_/g, ' ')}`, + semanticRole: genericPartRole( + part, + kind === 'generic_control_panel' + ? 'control_detail' + : kind === 'generic_display' + ? 'display' + : kind === 'generic_opening' + ? 'opening' + : kind === 'generic_detail_accent' + ? 'detail_accent' + : 'panel', + ), + semanticGroup: part.semanticGroup ?? 'generic_parts', + sourcePartKind: kind, + position: center, + length, + width: panelHeight, + thickness, + cornerRadius: clamp(part.cornerRadius, Math.min(length, panelHeight) * 0.06, 0, 0.2), + cornerSegments: part.cornerSegments ?? 4, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeGenericHandle( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const objectWidth = input.width ?? input.depth ?? 0.65 + const objectHeight = input.height ?? 0.8 + const length = clamp(part.length, 0.22, 0.03, 2) + const radius = clamp(part.radius ?? part.wireRadius, 0.018, 0.004, 0.12) + const center = add(origin, part.position ?? [0, objectHeight * 0.46, objectWidth * 0.56]) + return applyPartRotation( + [ + { + kind: 'capsule', + name: part.name ?? `${input.name ?? 'generic object'} handle`, + semanticRole: genericPartRole(part, 'handle'), + semanticGroup: part.semanticGroup ?? 'generic_parts', + sourcePartKind: 'generic_handle', + position: center, + axis: 'x', + radius, + height: length, + radialSegments: 12, + capSegments: 4, + material: partMaterial(part, material(input.darkColor ?? '#111827', 0.62, 0.12)), + }, + ], + center, + part.rotation, + ) +} + +function composeGenericSpout( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const objectLength = input.length ?? 1 + const objectWidth = input.width ?? input.depth ?? 0.65 + const objectHeight = input.height ?? 0.8 + const radius = clamp(part.radius, Math.min(objectLength, objectWidth) * 0.035, 0.004, 0.2) + const length = clamp(part.length ?? part.depth ?? part.height, objectWidth * 0.22, 0.02, 1.2) + const center = add(origin, part.position ?? [0, objectHeight * 0.52, objectWidth * 0.58]) + return applyPartRotation( + [ + { + kind: 'cylinder', + name: part.name ?? `${input.name ?? 'generic object'} spout`, + semanticRole: genericPartRole(part, 'spout'), + semanticGroup: part.semanticGroup ?? 'generic_parts', + sourcePartKind: 'generic_spout', + position: center, + axis: partAxis(part.axis, 'z'), + radius, + height: length, + radialSegments: 16, + material: partMaterial(part, material(input.darkColor ?? '#111827', 0.5, 0.24)), + }, + ], + center, + part.rotation, + ) +} + +function composeGenericFootSet( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const objectLength = input.length ?? 1 + const objectWidth = input.width ?? input.depth ?? 0.65 + const radius = clamp(part.radius, 0.035, 0.006, 0.18) + const height = clamp(part.height, 0.08, 0.02, 0.8) + const inset = clamp(part.cornerInset, radius * 2.2, 0, Math.min(objectLength, objectWidth) * 0.45) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const xs = [-objectLength / 2 + inset, objectLength / 2 - inset] + const zs = [-objectWidth / 2 + inset, objectWidth / 2 - inset] + const mat = partMaterial(part, material(input.darkColor ?? '#111827', 0.66, 0.08)) + const shapes: PrimitiveShapeInput[] = [] + for (const x of xs) { + for (const z of zs) { + shapes.push({ + kind: 'cylinder', + name: part.name ?? `${input.name ?? 'generic object'} foot`, + semanticRole: genericPartRole(part, 'support_foot'), + semanticGroup: part.semanticGroup ?? 'generic_parts', + sourcePartKind: 'generic_foot_set', + position: [center[0] + x, center[1], center[2] + z], + axis: 'y', + radius, + height, + radialSegments: 10, + material: mat, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeMobilePlatformChassis( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, input.length ?? 1.45, 0.3, 4) + const width = clamp(part.width ?? part.depth, input.width ?? input.depth ?? 0.9, 0.24, 2.4) + const height = clamp(part.height, input.height ?? 0.28, 0.08, 1.2) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const cornerRadius = clamp( + part.cornerRadius, + Math.min(length, width) * 0.18, + 0, + Math.min(length, width) * 0.35, + ) + const bodyMat = material(part.primaryColor ?? input.primaryColor ?? '#e5e7eb', 0.48, 0.08) + const skirtMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.62, 0.12) + const deckMat = material(part.secondaryColor ?? input.secondaryColor ?? '#334155', 0.55, 0.12) + const seamMat = material(part.accentColor ?? input.accentColor ?? '#38bdf8', 0.35, 0.02, 0.9) + const skirtHeight = Math.max(0.035, height * 0.32) + const deckThickness = Math.max(0.024, height * 0.16) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'mobile platform'} lower bumper skirt`, + semanticRole: 'lower_bumper_skirt', + sourcePartKind: 'mobile_platform_chassis', + position: [center[0], center[1] - height * 0.35, center[2]], + length: length * 1.04, + width: width * 1.04, + thickness: skirtHeight, + cornerRadius: cornerRadius * 1.02, + cornerSegments: part.cornerSegments ?? 8, + material: skirtMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'mobile platform'} rounded chassis body`, + semanticRole: part.semanticRole ?? 'vehicle_body', + sourcePartKind: 'mobile_platform_chassis', + position: center, + length, + width, + height, + cornerRadius, + cornerSegments: part.cornerSegments ?? 8, + material: bodyMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'mobile platform'} top load deck`, + semanticRole: 'cargo_platform', + sourcePartKind: 'mobile_platform_chassis', + position: [center[0], center[1] + height * 0.52, center[2]], + length: length * 0.74, + width: width * 0.68, + thickness: deckThickness, + cornerRadius: Math.min(length, width) * 0.07, + cornerSegments: 5, + material: deckMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'mobile platform'} left side status seam`, + semanticRole: 'status_light_strip', + sourcePartKind: 'mobile_platform_chassis', + position: [center[0], center[1] + height * 0.02, center[2] + width * 0.53], + length: length * 0.62, + width: Math.max(0.018, height * 0.08), + thickness: Math.max(0.006, width * 0.01), + cornerRadius: Math.max(0.01, height * 0.05), + cornerSegments: 3, + material: seamMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'mobile platform'} right side status seam`, + semanticRole: 'status_light_strip', + sourcePartKind: 'mobile_platform_chassis', + position: [center[0], center[1] + height * 0.02, center[2] - width * 0.53], + length: length * 0.62, + width: Math.max(0.018, height * 0.08), + thickness: Math.max(0.006, width * 0.01), + cornerRadius: Math.max(0.01, height * 0.05), + cornerSegments: 3, + material: seamMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeLidarSensor( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius, 0.045, 0.012, 0.18) + const height = clamp(part.height ?? part.length, radius * 0.8, radius * 0.25, radius * 2.5) + const center = add(origin, part.position ?? [0, 0.24, 0]) + const bodyMat = partMaterial( + part, + material(part.darkColor ?? input.darkColor ?? '#0f172a', 0.42, 0.3), + ) + const lensMat = material(part.accentColor ?? input.accentColor ?? '#38bdf8', 0.18, 0.02, 0.72) + const axis = partAxis(part.axis, 'x') + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'mobile platform'} lidar housing`, + semanticRole: part.semanticRole ?? 'navigation_sensor', + sourcePartKind: 'lidar_sensor', + position: center, + axis, + radius, + height, + radialSegments: 24, + material: bodyMat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'mobile platform'} lidar lens`, + semanticRole: 'sensor_lens', + sourcePartKind: 'lidar_sensor', + position: offsetAlongAxis(center, axis, height * 0.52), + radius: radius * 0.55, + scale: axis === 'x' ? [0.35, 0.68, 1] : axis === 'z' ? [1, 0.68, 0.35] : [0.8, 0.35, 0.8], + material: lensMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeEmergencyStopButton( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius, 0.04, 0.012, 0.16) + const height = clamp(part.height ?? part.length, radius * 0.55, radius * 0.2, radius * 1.8) + const center = add(origin, part.position ?? [0.35, 0.38, 0.2]) + const axis = partAxis(part.axis, 'y') + const baseMat = material(input.darkColor ?? '#111827', 0.55, 0.25) + const redMat = partMaterial(part, material(part.color ?? '#ef4444', 0.38, 0.02)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'mobile platform'} emergency stop base`, + semanticRole: 'emergency_stop_base', + sourcePartKind: 'emergency_stop_button', + position: offsetAlongAxis(center, axis, -height * 0.35), + axis, + radius: radius * 1.12, + height: height * 0.38, + radialSegments: 24, + material: baseMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'mobile platform'} emergency stop button`, + semanticRole: part.semanticRole ?? 'emergency_stop_button', + sourcePartKind: 'emergency_stop_button', + position: center, + axis, + radius, + height, + radialSegments: 28, + material: redMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'mobile platform'} emergency stop guard ring`, + semanticRole: 'emergency_stop_guard', + sourcePartKind: 'emergency_stop_button', + position: offsetAlongAxis(center, axis, height * 0.08), + axis, + majorRadius: radius * 1.1, + tubeRadius: radius * 0.08, + radialSegments: 8, + tubularSegments: 28, + material: baseMat, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeStatusLightStrip( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const objectLength = input.length ?? 1.4 + const objectWidth = input.width ?? input.depth ?? 0.85 + const length = clamp(part.length, objectLength * 0.5, 0.04, 4) + const stripHeight = clamp(part.height ?? part.width, 0.035, 0.008, 0.25) + const thickness = clamp(part.thickness ?? part.depth, 0.012, 0.002, 0.08) + const side = partSide(part.side) + const defaultZ = + side === 'left' + ? objectWidth * 0.52 + : side === 'right' + ? -objectWidth * 0.52 + : objectWidth * 0.52 + const center = add(origin, part.position ?? [0, (input.height ?? 0.45) * 0.45, defaultZ]) + const lightMat = partMaterial( + part, + material(part.color ?? part.accentColor ?? input.accentColor ?? '#38bdf8', 0.18, 0.02, 0.82), + ) + return applyPartRotation( + [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'mobile platform'} status light strip`, + semanticRole: part.semanticRole ?? 'status_light_strip', + semanticGroup: part.semanticGroup ?? 'generic_parts', + sourcePartKind: 'status_light_strip', + position: center, + length, + width: stripHeight, + thickness, + cornerRadius: Math.min(length, stripHeight) * 0.3, + cornerSegments: 4, + material: lightMat, + }, + ], + center, + part.rotation, + ) +} + +function composeOperatorPanel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const height = clamp(part.height, 0.62, 0.18, 2) + const width = clamp(part.width ?? part.length, 0.32, 0.12, 1.2) + const depth = clamp(part.depth ?? part.thickness, 0.12, 0.03, 0.5) + const center = add( + origin, + part.position ?? [ + (input.length ?? 1.6) * 0.42, + height * 0.55, + (input.width ?? input.depth ?? 0.8) * 0.52, + ], + ) + const bodyMat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#e5e7eb', 0.48, 0.08), + ) + const screenMat = material(part.darkColor ?? '#0f172a', 0.24, 0.02) + const buttonMat = material(part.accentColor ?? input.accentColor ?? '#22c55e', 0.3, 0.02) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} operator panel body`, + semanticRole: part.semanticRole ?? 'control_panel', + sourcePartKind: 'operator_panel', + position: center, + length: width, + width: depth, + height, + cornerRadius: Math.min(width, depth, height) * 0.08, + cornerSegments: 4, + material: bodyMat, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'machine'} operator panel screen`, + semanticRole: 'display_screen', + sourcePartKind: 'operator_panel', + position: [center[0], center[1] + height * 0.16, center[2] + depth * 0.54], + length: width * 0.62, + width: height * 0.22, + thickness: 0.012, + cornerRadius: width * 0.04, + cornerSegments: 3, + material: screenMat, + }, + ] + for (const x of [-0.18, 0, 0.18]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} operator panel button`, + semanticRole: 'control_button', + sourcePartKind: 'operator_panel', + position: [center[0] + x * width, center[1] - height * 0.16, center[2] + depth * 0.55], + axis: 'z', + radius: Math.max(0.012, width * 0.035), + height: 0.012, + radialSegments: 14, + material: buttonMat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeGuardFence( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, input.length ?? 1.8, 0.25, 8) + const height = clamp(part.height, 0.9, 0.2, 3) + const width = clamp(part.width ?? part.depth, 0.08, 0.02, 0.5) + const postRadius = clamp(part.radius ?? part.wireRadius, 0.018, 0.006, 0.08) + const count = clampInt(part.count, 4, 2, 12) + const center = add( + origin, + part.position ?? [0, height * 0.5, -(input.width ?? input.depth ?? 1) * 0.55], + ) + const postMat = partMaterial( + part, + material(part.color ?? input.accentColor ?? '#facc15', 0.42, 0.16), + ) + const railMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.5, 0.12) + const shapes: PrimitiveShapeInput[] = [] + for (let i = 0; i < count; i += 1) { + const x = center[0] - length / 2 + (length * i) / Math.max(1, count - 1) + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} guard fence post ${i + 1}`, + semanticRole: 'guard_fence_post', + sourcePartKind: 'guard_fence', + position: [x, center[1], center[2]], + axis: 'y', + radius: postRadius, + height, + radialSegments: 10, + material: postMat, + }) + } + for (const y of [center[1] + height * 0.28, center[1] - height * 0.12]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} guard fence rail`, + semanticRole: part.semanticRole ?? 'safety_barrier', + sourcePartKind: 'guard_fence', + position: [center[0], y, center[2]], + length, + width, + height: Math.max(0.025, postRadius * 1.8), + material: railMat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composePalletTable( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1, 0.25, 4) + const width = clamp(part.width ?? part.depth, 0.7, 0.2, 3) + const height = clamp(part.height, 0.28, 0.08, 1.2) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const deckMat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#475569', 0.55, 0.12), + ) + const legMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.55, 0.2) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'machine'} pallet table deck`, + semanticRole: part.semanticRole ?? 'pallet_table', + sourcePartKind: 'pallet_table', + position: [center[0], center[1] + height * 0.45, center[2]], + length, + width, + thickness: Math.max(0.045, height * 0.16), + cornerRadius: Math.min(length, width) * 0.04, + cornerSegments: 3, + material: deckMat, + }, + ] + for (const x of [-1, 1]) { + for (const z of [-1, 1]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} pallet table leg`, + semanticRole: 'support_leg', + sourcePartKind: 'pallet_table', + position: [center[0] + x * length * 0.38, center[1], center[2] + z * width * 0.36], + length: Math.max(0.04, length * 0.04), + width: Math.max(0.04, width * 0.05), + height, + material: legMat, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeBearingBlock( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.42, 0.12, 1.6) + const width = clamp(part.width ?? part.depth, 0.22, 0.08, 1) + const height = clamp(part.height, 0.26, 0.08, 1.2) + const radius = clamp(part.radius ?? part.diameter, Math.min(width, height) * 0.24, 0.015, 0.4) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const bodyMat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#64748b', 0.72, 0.22), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.5, 0.18) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} bearing block base`, + semanticRole: 'bearing_base', + sourcePartKind: 'bearing_block', + position: [center[0], center[1] - height * 0.34, center[2]], + length, + width, + height: height * 0.22, + cornerRadius: Math.min(length, width) * 0.06, + cornerSegments: 3, + material: bodyMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} bearing block housing`, + semanticRole: part.semanticRole ?? 'bearing_block', + sourcePartKind: 'bearing_block', + position: [center[0], center[1], center[2]], + length: length * 0.55, + width: width * 0.86, + height: height * 0.72, + cornerRadius: Math.min(width, height) * 0.12, + cornerSegments: 5, + material: bodyMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'machine'} bearing ring`, + semanticRole: 'bearing_ring', + sourcePartKind: 'bearing_block', + position: [center[0], center[1] + height * 0.04, center[2] + width * 0.45], + axis: 'z', + majorRadius: radius, + tubeRadius: Math.max(0.008, radius * 0.18), + radialSegments: 10, + tubularSegments: 32, + material: darkMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} bearing bore`, + semanticRole: 'bearing_bore', + sourcePartKind: 'bearing_block', + position: [center[0], center[1] + height * 0.04, center[2] + width * 0.46], + axis: 'z', + radius: radius * 0.58, + height: Math.max(0.018, width * 0.08), + radialSegments: 24, + material: darkMat, + }, + ] + for (const x of [-1, 1]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} bearing mounting bolt`, + semanticRole: 'mounting_bolt', + sourcePartKind: 'bearing_block', + position: [ + center[0] + x * length * 0.34, + center[1] - height * 0.21, + center[2] + width * 0.18, + ], + axis: 'y', + radius: Math.max(0.008, radius * 0.16), + height: height * 0.05, + radialSegments: 12, + material: darkMat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeSupportRollerPair( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.9, 0.28, 3) + const width = clamp(part.width ?? part.depth, 1.18, 0.28, 4) + const height = clamp(part.height, 0.34, 0.12, 1.4) + const rollerRadius = clamp(part.radius ?? part.wheelRadius, height * 0.28, 0.035, 0.5) + const rollerLength = clamp(part.rollerLength ?? part.thickness, width * 0.24, 0.08, width * 0.48) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const bodyMat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#64748b', 0.68, 0.28), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.54, 0.28) + const rollerMat = material(part.rollerColor ?? part.metalColor ?? '#374151', 0.38, 0.62) + const role = part.semanticRole ?? 'support_roller' + const rollerY = center[1] + height * 0.16 + const rollerZ = Math.max(width * 0.22, rollerRadius * 1.7) + const blockLength = Math.max(length * 0.18, rollerRadius * 1.2) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'kiln'} support roller foundation`, + semanticRole: 'support_roller_base', + sourcePartKind: 'support_roller_pair', + position: [center[0], center[1] - height * 0.32, center[2]], + length, + width, + height: height * 0.26, + cornerRadius: Math.min(length, width) * 0.035, + cornerSegments: 3, + material: bodyMat, + }, + ] + for (const side of [-1, 1]) { + const z = center[2] + side * rollerZ + shapes.push( + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'kiln'} ${side < 0 ? 'left' : 'right'} support roller`, + semanticRole: role, + sourcePartKind: 'support_roller_pair', + position: [center[0], rollerY, z], + axis: 'x', + radius: rollerRadius, + height: rollerLength, + radialSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.5)), + material: rollerMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'kiln'} ${side < 0 ? 'left' : 'right'} roller pedestal`, + semanticRole: 'support_roller_pedestal', + sourcePartKind: 'support_roller_pair', + position: [center[0], center[1] - height * 0.05, z], + length: blockLength, + width: rollerLength * 1.18, + height: height * 0.28, + cornerRadius: Math.min(blockLength, rollerLength) * 0.05, + cornerSegments: 3, + material: bodyMat, + }, + ) + } + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'kiln'} thrust roller`, + semanticRole: 'thrust_roller', + sourcePartKind: 'support_roller_pair', + position: [center[0] + length * 0.32, rollerY + rollerRadius * 0.2, center[2]], + axis: 'z', + radius: rollerRadius * 0.48, + height: Math.max(0.04, width * 0.08), + radialSegments: 20, + material: darkMat, + }) + return applyPartRotation(shapes, center, part.rotation) +} + +function composeStructuralTowerFrame( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 2.2, 0.8, 12) + const width = clamp(part.width ?? part.depth, 1.6, 0.6, 8) + const height = clamp(part.height, 6, 1.4, 18) + const levels = Math.max(2, Math.min(9, Math.round(part.levelCount ?? part.count ?? 5))) + const bayCount = Math.max(1, Math.min(5, Math.round(part.bayCount ?? 2))) + const columnSize = clamp(part.thickness, Math.min(length, width) * 0.035, 0.025, 0.18) + const deckThickness = Math.max(0.018, columnSize * 0.45) + const includeDiagonalBraces = part.includeDiagonalBraces !== false + const includeExternalStairs = part.externalStairs !== false + const stairFlights = Math.max(2, Math.min(levels, Math.round(part.stairFlights ?? levels))) + const stairSide = part.stairSide === 'left' ? -1 : 1 + const stairPlacement = part.stairPlacement === 'inside' ? 'inside' : 'outside' + const center = add(origin, part.position ?? [0, height / 2, 0]) + const bottomY = center[1] - height / 2 + const frameMat = partMaterial( + part, + material(part.darkColor ?? input.darkColor ?? '#111827', 0.58, 0.42), + ) + const deckMat = material(part.metalColor ?? input.metalColor ?? '#475569', 0.72, 0.3, 0.28) + const railMat = material(part.accentColor ?? input.accentColor ?? '#1f2937', 0.54, 0.34) + const shapes: PrimitiveShapeInput[] = [] + const cornerXs = [-length / 2, length / 2] + const cornerZs = [-width / 2, width / 2] + + for (const x of cornerXs) { + for (const z of cornerZs) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} corner column`, + semanticRole: 'tower_column', + sourcePartKind: 'structural_tower_frame', + position: [center[0] + x, center[1], center[2] + z], + length: columnSize, + width: columnSize, + height, + material: frameMat, + }) + } + } + + for (let bay = 1; bay < bayCount; bay += 1) { + const x = -length / 2 + (length * bay) / bayCount + for (const z of cornerZs) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} intermediate column`, + semanticRole: 'tower_column', + sourcePartKind: 'structural_tower_frame', + position: [center[0] + x, center[1], center[2] + z], + length: columnSize * 0.85, + width: columnSize * 0.85, + height, + material: frameMat, + }) + } + } + + for (let level = 0; level <= levels; level += 1) { + const y = bottomY + (height * level) / levels + for (const z of cornerZs) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} level ${level} longitudinal beam`, + semanticRole: level === 0 ? (part.semanticRole ?? 'preheater_tower_body') : 'tower_beam', + sourcePartKind: 'structural_tower_frame', + position: [center[0], y, center[2] + z], + length, + width: columnSize, + height: columnSize, + material: frameMat, + }) + } + for (const x of cornerXs) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} level ${level} transverse beam`, + semanticRole: 'tower_beam', + sourcePartKind: 'structural_tower_frame', + position: [center[0] + x, y, center[2]], + length: columnSize, + width, + height: columnSize, + material: frameMat, + }) + } + if (level > 0) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} level ${level} grated platform`, + semanticRole: 'multi_level_platform', + sourcePartKind: 'structural_tower_frame', + position: [center[0], y + deckThickness * 0.55, center[2]], + length: length * 0.95, + width: width * 0.95, + height: deckThickness, + material: deckMat, + }) + shapes.push( + { + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} level ${level} front guard rail`, + semanticRole: 'platform_guard_rail', + sourcePartKind: 'structural_tower_frame', + position: [center[0], y + columnSize * 2.2, center[2] + width * 0.5], + length: length, + width: columnSize * 0.55, + height: columnSize * 0.55, + material: railMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} level ${level} rear guard rail`, + semanticRole: 'platform_guard_rail', + sourcePartKind: 'structural_tower_frame', + position: [center[0], y + columnSize * 2.2, center[2] - width * 0.5], + length, + width: columnSize * 0.55, + height: columnSize * 0.55, + material: railMat, + }, + ) + } + } + + if (includeDiagonalBraces) { + const bayHeight = height / levels + const braceLength = Math.hypot(length, bayHeight) + const braceAngle = Math.atan2(bayHeight, length) + for (let level = 0; level < levels; level += 1) { + const y = bottomY + bayHeight * (level + 0.5) + for (const z of cornerZs) { + shapes.push( + { + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} level ${level + 1} diagonal brace`, + semanticRole: 'tower_diagonal_brace', + sourcePartKind: 'structural_tower_frame', + position: [center[0], y, center[2] + z], + length: braceLength, + width: columnSize * 0.42, + height: columnSize * 0.42, + rotation: [0, 0, braceAngle], + material: railMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} level ${level + 1} cross brace`, + semanticRole: 'tower_diagonal_brace', + sourcePartKind: 'structural_tower_frame', + position: [center[0], y, center[2] + z], + length: braceLength, + width: columnSize * 0.38, + height: columnSize * 0.38, + rotation: [0, 0, -braceAngle], + material: railMat, + }, + ) + } + } + } + + if (includeExternalStairs) { + const stairDepth = Math.max(columnSize * 5, width * (stairPlacement === 'inside' ? 0.2 : 0.24)) + const stairWidth = Math.max( + columnSize * 2.4, + length * (stairPlacement === 'inside' ? 0.07 : 0.08), + ) + const sideX = + stairPlacement === 'inside' + ? center[0] + stairSide * (length * 0.5 - stairWidth * 0.8 - columnSize * 2.2) + : center[0] + stairSide * (length * 0.5 + columnSize * 3.2) + const stairCenterZ = + stairPlacement === 'inside' + ? center[2] + width * 0.18 + : center[2] + width * 0.5 + columnSize * 2.4 + const flightHeight = height / stairFlights + const flightRun = Math.max(stairDepth * 0.72, columnSize * 5) + const flightLength = Math.hypot(flightRun, flightHeight * 0.72) + const flightAngle = Math.atan2(flightHeight * 0.72, flightRun) + + for (let flight = 0; flight < stairFlights; flight += 1) { + const y = bottomY + flightHeight * (flight + 0.5) + const direction = flight % 2 === 0 ? 1 : -1 + shapes.push( + { + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} ${stairPlacement} stair flight ${flight + 1}`, + semanticRole: + stairPlacement === 'inside' ? 'internal_stair_flight' : 'external_stair_flight', + sourcePartKind: 'structural_tower_frame', + position: [sideX, y, stairCenterZ + direction * stairDepth * 0.18], + length: stairWidth, + width: flightLength, + height: columnSize * 0.48, + rotation: [direction * flightAngle, 0, 0], + material: deckMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} ${stairPlacement} stair landing ${flight + 1}`, + semanticRole: + stairPlacement === 'inside' ? 'internal_stair_landing' : 'external_stair_landing', + sourcePartKind: 'structural_tower_frame', + position: [ + sideX, + bottomY + flightHeight * (flight + 1), + stairCenterZ - direction * stairDepth * 0.34, + ], + length: stairWidth * 1.35, + width: stairDepth * 0.45, + height: deckThickness, + material: deckMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} ${stairPlacement} stair guard rail ${flight + 1}`, + semanticRole: + stairPlacement === 'inside' ? 'internal_stair_guard_rail' : 'external_stair_guard_rail', + sourcePartKind: 'structural_tower_frame', + position: [ + sideX + stairSide * stairWidth * 0.58, + y + columnSize * 1.8, + stairCenterZ + direction * stairDepth * 0.18, + ], + length: columnSize * 0.52, + width: flightLength, + height: columnSize * 0.55, + rotation: [direction * flightAngle, 0, 0], + material: railMat, + }, + ) + } + } else { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} vertical access ladder`, + semanticRole: 'access_ladder', + sourcePartKind: 'structural_tower_frame', + position: [center[0] - length * 0.56, center[1], center[2] + width * 0.56], + length: columnSize * 1.1, + width: columnSize * 2.4, + height: height * 0.86, + material: railMat, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeHelicalStair( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const height = clamp(part.height ?? part.overallHeight, 6, 0.8, 18) + const treadWidth = clamp(part.width, 0.32, 0.12, 1.2) + const innerRadius = clamp(part.innerRadius ?? part.radius, 0.9, 0.08, 6) + const outerRadius = clamp(part.outerRadius, innerRadius + treadWidth, innerRadius + 0.08, 7) + const stairWidth = outerRadius - innerRadius + const centerRadius = innerRadius + stairWidth / 2 + const detail = partDetailLevel(input, part) + const defaultTurns = clamp(height / 3.2, 2, 1.15, 4.5) + const sweepAngle = clamp( + part.sweepAngle, + defaultTurns * Math.PI * 2, + Math.PI * 0.75, + Math.PI * 10, + ) + const minimumStepCount = clampInt( + undefined, + Math.max( + Math.ceil(height / (detail === 'high' ? 0.24 : detail === 'medium' ? 0.28 : 0.32)), + Math.ceil((Math.abs(sweepAngle) * centerRadius) / 0.58), + 8, + ), + 8, + 72, + ) + const defaultStepCount = + detail === 'high' + ? Math.max(28, Math.round(height * 3.2)) + : detail === 'medium' + ? Math.max(22, Math.round(height * 2.4)) + : Math.max(16, Math.round(height * 1.6)) + const stepCount = clampInt( + part.stepCount ?? part.count, + Math.max(defaultStepCount, minimumStepCount), + minimumStepCount, + 96, + ) + const startAngle = part.startAngle ?? part.aroundStartAngle ?? 0 + const treadArc = Math.abs(sweepAngle / stepCount) * centerRadius + const treadDepth = clamp(part.depth, Math.min(0.72, treadArc * 0.82), 0.04, 0.9) + const treadThickness = clamp(part.thickness, 0.035, 0.012, 0.16) + const railHeight = clamp(part.railingHeight, 0.42, 0.18, 1.1) + const wireRadius = clamp(part.wireRadius, 0.018, 0.004, 0.08) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const bottomY = center[1] - height / 2 + const steel = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#64748b', 0.4, 0.66), + ) + const treadMat = material( + part.color ?? part.metalColor ?? input.metalColor ?? '#94a3b8', + 0.48, + 0.5, + ) + const sourcePartKind = + part.sourcePartKind ?? (part.kind === 'helical_ladder' ? 'helical_ladder' : 'helical_stair') + const accessLabel = sourcePartKind === 'helical_ladder' ? 'helical ladder' : 'helical stair' + const shapes: PrimitiveShapeInput[] = [] + const pointAt = (radius: number, angle: number, y: number): Vec3 => [ + center[0] + Math.cos(angle) * radius, + y, + center[2] + Math.sin(angle) * radius, + ] + const localHelixPath = (radius: number, pointCount: number): Vec3[] => + Array.from({ length: pointCount + 1 }, (_, i) => { + const t = i / pointCount + const angle = startAngle + sweepAngle * t + return [Math.cos(angle) * radius, -height / 2 + height * t, Math.sin(angle) * radius] + }) + + for (let i = 0; i < stepCount; i += 1) { + const t = (i + 0.5) / stepCount + const angle = startAngle + sweepAngle * t + const y = bottomY + height * t + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} ${accessLabel} tread ${i + 1}`, + semanticRole: + sourcePartKind === 'helical_ladder' + ? 'helical_ladder_tread' + : (part.semanticRole ?? 'helical_stair_tread'), + sourcePartKind, + position: pointAt(centerRadius, angle, y), + rotation: [0, -angle, 0], + length: stairWidth, + width: treadDepth, + height: treadThickness, + material: treadMat, + }) + } + + const railPathPointCount = clampInt( + part.ringCount, + Math.max(detail === 'high' ? 32 : detail === 'medium' ? 24 : 18, Math.ceil(stepCount / 2)), + 8, + 72, + ) + const innerStringerRadius = innerRadius + wireRadius * 1.5 + const outerStringerRadius = outerRadius - wireRadius * 1.5 + const helixPath = localHelixPath(outerStringerRadius, railPathPointCount) + const innerHelixPath = localHelixPath(innerStringerRadius, railPathPointCount) + const railSegments = Math.max(24, railPathPointCount * 4) + shapes.push( + { + kind: 'sweep', + name: `${part.name ?? input.name ?? 'tower'} continuous outer guard rail`, + semanticRole: + sourcePartKind === 'helical_ladder' + ? 'helical_ladder_guard_rail' + : 'helical_stair_guard_rail', + sourcePartKind, + position: [center[0], center[1] + railHeight, center[2]], + path: helixPath, + radius: wireRadius, + tubularSegments: railSegments, + radialSegments: 8, + material: steel, + }, + { + kind: 'sweep', + name: `${part.name ?? input.name ?? 'tower'} continuous outer mid rail`, + semanticRole: + sourcePartKind === 'helical_ladder' ? 'helical_ladder_mid_rail' : 'helical_stair_mid_rail', + sourcePartKind, + position: [center[0], center[1] + railHeight * 0.55, center[2]], + path: helixPath, + radius: wireRadius * 0.78, + tubularSegments: railSegments, + radialSegments: 8, + material: steel, + }, + { + kind: 'sweep', + name: `${part.name ?? input.name ?? 'tower'} outer tread stringer`, + semanticRole: + sourcePartKind === 'helical_ladder' ? 'helical_ladder_stringer' : 'helical_stair_stringer', + sourcePartKind, + position: [center[0], center[1] - treadThickness * 0.4, center[2]], + path: helixPath, + radius: wireRadius * 0.92, + tubularSegments: railSegments, + radialSegments: 8, + material: steel, + }, + { + kind: 'sweep', + name: `${part.name ?? input.name ?? 'tower'} inner tread stringer`, + semanticRole: + sourcePartKind === 'helical_ladder' ? 'helical_ladder_stringer' : 'helical_stair_stringer', + sourcePartKind, + position: [center[0], center[1] - treadThickness * 0.4, center[2]], + path: innerHelixPath, + radius: wireRadius * 0.92, + tubularSegments: railSegments, + radialSegments: 8, + material: steel, + }, + ) + + const landingWidth = Math.min(0.95, treadDepth * 1.65) + for (const [index, t] of [0, 1].entries()) { + const angle = startAngle + sweepAngle * t + const y = bottomY + height * t + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'tower'} ${accessLabel} ${index === 0 ? 'bottom' : 'top'} landing`, + semanticRole: + sourcePartKind === 'helical_ladder' ? 'helical_ladder_landing' : 'helical_stair_landing', + sourcePartKind, + position: pointAt(centerRadius, angle, y), + rotation: [0, -angle, 0], + length: stairWidth * 1.18, + width: landingWidth, + height: treadThickness * 1.15, + material: treadMat, + }) + } + + const postEvery = detail === 'high' ? 3 : detail === 'medium' ? 4 : 5 + for (let i = 0; i <= stepCount; i += postEvery) { + const t = i / stepCount + const angle = startAngle + sweepAngle * t + const y = bottomY + height * t + shapes.push({ + ...tubeBetween( + `${part.name ?? input.name ?? 'tower'} ${accessLabel} post ${i + 1}`, + pointAt(outerStringerRadius, angle, y), + pointAt(outerStringerRadius, angle, y + railHeight), + wireRadius, + steel, + ), + semanticRole: + sourcePartKind === 'helical_ladder' ? 'helical_ladder_post' : 'helical_stair_post', + sourcePartKind, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeCycloneSeparatorUnit( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const height = clamp(part.height, 1.2, 0.45, 4) + const radius = clamp(part.radius ?? part.diameter, 0.26, 0.08, 1.4) + const coneHeight = clamp(part.depth, height * 0.28, height * 0.16, height * 0.42) + const bodyHeight = clamp(part.bodyHeight, height * 0.48, height * 0.28, height * 0.68) + const outletHeight = clamp(part.length, height * 0.2, height * 0.08, height * 0.34) + const ductRadius = clamp(part.thickness, radius * 0.26, 0.025, radius * 0.5) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const bottomY = center[1] - height / 2 + const coneCenterY = bottomY + coneHeight / 2 + const bodyCenterY = bottomY + coneHeight + bodyHeight / 2 + const topY = bottomY + coneHeight + bodyHeight + const sideSign = part.side === 'left' ? -1 : 1 + const shellMat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#9ca3af', 0.38, 0.44), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#1f2937', 0.56, 0.28) + const ductMat = material(part.metalColor ?? input.metalColor ?? '#64748b', 0.48, 0.42) + const segments = Math.max(24, Math.round(ringSegments(input.detail) * 0.75)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'cyclone'} cylindrical cyclone body`, + semanticRole: part.semanticRole ?? 'preheater_cyclone', + sourcePartKind: 'cyclone_separator_unit', + position: [center[0], bodyCenterY, center[2]], + axis: 'y', + radius, + height: bodyHeight, + radialSegments: segments, + material: shellMat, + }, + { + kind: 'frustum', + name: `${part.name ?? input.name ?? 'cyclone'} conical lower hopper`, + semanticRole: 'cyclone_cone', + sourcePartKind: 'cyclone_separator_unit', + position: [center[0], coneCenterY, center[2]], + axis: 'y', + radiusTop: radius, + radiusBottom: radius * 0.22, + height: coneHeight, + radialSegments: segments, + material: shellMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'cyclone'} top outlet riser`, + semanticRole: 'cyclone_top_outlet', + sourcePartKind: 'cyclone_separator_unit', + position: [center[0], topY + outletHeight / 2, center[2]], + axis: 'y', + radius: radius * 0.46, + height: outletHeight, + radialSegments: Math.max(20, Math.round(segments * 0.66)), + material: ductMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'cyclone'} tangential gas inlet`, + semanticRole: 'preheater_gas_duct', + sourcePartKind: 'cyclone_separator_unit', + position: [center[0] + sideSign * radius * 1.12, bodyCenterY + bodyHeight * 0.22, center[2]], + axis: 'x', + radius: ductRadius, + height: radius * 1.2, + radialSegments: 16, + material: ductMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'cyclone'} meal drop pipe`, + semanticRole: 'meal_drop_pipe', + sourcePartKind: 'cyclone_separator_unit', + position: [center[0], bottomY - height * 0.18, center[2]], + axis: 'y', + radius: radius * 0.14, + height: height * 0.36, + radialSegments: 14, + material: darkMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'cyclone'} body flange band`, + semanticRole: 'cyclone_connection_band', + sourcePartKind: 'cyclone_separator_unit', + position: [center[0], topY - bodyHeight * 0.08, center[2]], + axis: 'y', + majorRadius: radius * 1.01, + tubeRadius: Math.max(0.006, radius * 0.035), + radialSegments: 8, + tubularSegments: segments, + material: darkMat, + }, + ] + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeCouplingGuard( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.58, 0.16, 2.4) + const radius = clamp(part.radius ?? part.diameter, 0.16, 0.04, 0.7) + const thickness = clamp(part.thickness, 0.028, 0.006, 0.16) + const center = add(origin, part.position ?? [0, radius, 0]) + const guardMat = partMaterial( + part, + material(part.color ?? input.accentColor ?? '#facc15', 0.42, 0.16), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.5, 0.16) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'half-cylinder', + name: `${part.name ?? input.name ?? 'machine'} coupling guard cover`, + semanticRole: part.semanticRole ?? 'coupling_guard', + sourcePartKind: 'coupling_guard', + position: center, + axis: 'x', + radius, + height: length, + thickness, + radialSegments: 24, + material: guardMat, + }, + ] + for (const x of [-1, 1]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} coupling guard end flange`, + semanticRole: 'guard_end_flange', + sourcePartKind: 'coupling_guard', + position: [center[0] + x * length * 0.5, center[1] - radius * 0.12, center[2]], + length: thickness, + width: radius * 2.05, + height: radius * 0.18, + material: darkMat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeMotorGearboxUnit( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1.05, 0.3, 4) + const radius = clamp(part.radius ?? part.diameter, 0.18, 0.05, 0.8) + const height = clamp(part.height, radius * 2.1, 0.12, 1.8) + const center = add(origin, part.position ?? [0, height * 0.52, 0]) + const motorMat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#64748b', 0.68, 0.22), + ) + const gearboxMat = material(part.secondaryColor ?? input.secondaryColor ?? '#475569', 0.72, 0.2) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#111827', 0.5, 0.18) + const motorLength = length * 0.55 + const gearboxLength = length * 0.26 + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} drive motor`, + semanticRole: 'drive_motor', + sourcePartKind: 'motor_gearbox_unit', + position: [center[0] - length * 0.16, center[1], center[2]], + axis: 'x', + radius, + height: motorLength, + radialSegments: 32, + material: motorMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} gearbox housing`, + semanticRole: part.semanticRole ?? 'gearbox_body', + sourcePartKind: 'motor_gearbox_unit', + position: [center[0] + length * 0.32, center[1], center[2]], + length: gearboxLength, + width: radius * 1.85, + height: height, + cornerRadius: radius * 0.12, + cornerSegments: 4, + material: gearboxMat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} output shaft`, + semanticRole: 'output_shaft', + sourcePartKind: 'motor_gearbox_unit', + position: [center[0] + length * 0.52, center[1], center[2]], + axis: 'x', + radius: radius * 0.18, + height: length * 0.18, + radialSegments: 20, + material: darkMat, + }, + ] + for (let i = 0; i < 6; i += 1) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} motor cooling rib`, + semanticRole: 'motor_cooling_rib', + sourcePartKind: 'motor_gearbox_unit', + position: [ + center[0] - length * 0.16 - motorLength * 0.32 + i * motorLength * 0.13, + center[1] + radius, + center[2], + ], + length: motorLength * 0.05, + width: radius * 1.55, + height: Math.max(0.015, radius * 0.08), + material: darkMat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composePipeManifold( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1.2, 0.25, 6) + const radius = clamp(part.radius ?? part.diameter, 0.065, 0.012, 0.4) + const count = clampInt(part.count ?? part.portCount, 4, 2, 10) + const center = add(origin, part.position ?? [0, radius * 2.2, 0]) + const pipeMat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#94a3b8', 0.8, 0.22), + ) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'machine'} manifold header`, + semanticRole: part.semanticRole ?? 'pipe_manifold', + sourcePartKind: 'pipe_manifold', + position: center, + axis: 'x', + radius, + height: length, + wallThickness: Math.max(0.004, radius * 0.16), + radialSegments: 28, + material: pipeMat, + }, + ] + for (let i = 0; i < count; i += 1) { + const x = center[0] - length * 0.38 + (length * 0.76 * i) / Math.max(1, count - 1) + shapes.push({ + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'machine'} manifold branch ${i + 1}`, + semanticRole: 'manifold_branch', + sourcePartKind: 'pipe_manifold', + position: [x, center[1] + radius * 1.9, center[2]], + axis: 'y', + radius: radius * 0.62, + height: radius * 3.2, + wallThickness: Math.max(0.003, radius * 0.12), + radialSegments: 20, + material: pipeMat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeHopperBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.9, 0.25, 4) + const width = clamp(part.width ?? part.depth, 0.7, 0.2, 3) + const height = clamp(part.height, 0.8, 0.25, 3.5) + const center = add(origin, part.position ?? [0, height * 0.62, 0]) + const topLengthScale = Array.isArray(part.topScale) + ? clamp(part.topScale[0], 1.65, 0.2, 3) + : clamp(typeof part.topScale === 'number' ? part.topScale : part.topLengthScale, 1.65, 0.2, 3) + const topWidthScale = Array.isArray(part.topScale) + ? clamp(part.topScale[1], 1.45, 0.2, 3) + : clamp(typeof part.topScale === 'number' ? part.topScale : part.topWidthScale, 1.45, 0.2, 3) + const bodyMat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#94a3b8', 0.55, 0.14), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#374151', 0.5, 0.16) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'trapezoid-prism', + name: `${part.name ?? input.name ?? 'machine'} tapered hopper body`, + semanticRole: part.semanticRole ?? 'hopper_body', + sourcePartKind: 'hopper_body', + position: center, + length, + width, + height, + topScale: [topLengthScale, topWidthScale], + topLengthScale, + topWidthScale, + material: bodyMat, + }, + { + kind: 'frustum', + name: `${part.name ?? input.name ?? 'machine'} hopper outlet throat`, + semanticRole: 'hopper_outlet', + sourcePartKind: 'hopper_body', + position: [center[0], center[1] - height * 0.58, center[2]], + axis: 'y', + radiusTop: Math.min(length, width) * 0.22, + radiusBottom: Math.min(length, width) * 0.1, + height: height * 0.25, + radialSegments: 4, + material: darkMat, + }, + ] + for (const x of [-1, 1]) { + for (const z of [-1, 1]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} hopper support leg`, + semanticRole: 'hopper_support_leg', + sourcePartKind: 'hopper_body', + position: [center[0] + x * length * 0.38, height * 0.28, center[2] + z * width * 0.36], + length: Math.max(0.035, length * 0.035), + width: Math.max(0.035, width * 0.04), + height: height * 0.56, + material: darkMat, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeConicalHopper( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const radiusTop = clamp(part.radiusTop ?? part.radius ?? part.width, 0.42, 0.08, 3) + const radiusBottom = clamp( + part.radiusBottom ?? part.outletRadius, + radiusTop * 0.18, + 0.02, + radiusTop, + ) + const height = clamp(part.height, 0.82, 0.18, 5) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const mat = partMaterial( + part, + material(part.primaryColor ?? input.primaryColor ?? '#94a3b8', 0.52, 0.16), + ) + const darkMat = material(part.darkColor ?? input.darkColor ?? '#374151', 0.5, 0.16) + const role = genericPartRole(part, 'conical_hopper') + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'frustum', + name: `${part.name ?? input.name ?? 'machine'} conical hopper`, + semanticRole: role, + sourcePartKind: 'conical_hopper', + position: center, + axis: 'y', + radiusTop, + radiusBottom, + height, + radialSegments: clampInt(part.radialSegments, 32, 4, 64), + material: mat, + }, + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'machine'} hopper outlet collar`, + semanticRole: 'hopper_outlet_collar', + sourcePartKind: 'conical_hopper', + position: [center[0], center[1] - height * 0.52, center[2]], + axis: 'y', + radius: radiusBottom * 1.08, + height: Math.max(0.04, height * 0.08), + wallThickness: Math.max(0.004, radiusBottom * 0.12), + radialSegments: 24, + material: darkMat, + }, + ] + if (part.includeSupportLegs !== false) { + for (const angle of [Math.PI / 4, (Math.PI * 3) / 4, (Math.PI * 5) / 4, (Math.PI * 7) / 4]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} hopper support leg`, + semanticRole: 'support_leg', + sourcePartKind: 'conical_hopper', + position: [ + center[0] + Math.cos(angle) * radiusTop * 0.72, + height * 0.25, + center[2] + Math.sin(angle) * radiusTop * 0.72, + ], + axis: 'y', + radius: Math.max(0.014, radiusTop * 0.035), + height: height * 0.5, + radialSegments: 8, + material: darkMat, + }) + } + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeServicePlatform( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1.2, 0.3, 6) + const width = clamp(part.width ?? part.depth, 0.65, 0.2, 3) + const height = clamp(part.height, 0.9, 0.2, 3.5) + const railHeight = clamp(part.overallHeight, height * 0.42, 0.18, 1.4) + const center = add(origin, part.position ?? [0, height, 0]) + const deckMat = partMaterial( + part, + material(part.metalColor ?? input.metalColor ?? '#64748b', 0.65, 0.18), + ) + const railMat = material(part.color ?? input.accentColor ?? '#facc15', 0.42, 0.12) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'machine'} service platform deck`, + semanticRole: part.semanticRole ?? 'service_platform', + sourcePartKind: 'service_platform', + position: center, + length, + width, + thickness: Math.max(0.04, height * 0.06), + cornerRadius: Math.min(length, width) * 0.025, + cornerSegments: 2, + material: deckMat, + }, + ] + for (const x of [-1, 1]) { + for (const z of [-1, 1]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} platform post`, + semanticRole: 'platform_post', + sourcePartKind: 'service_platform', + position: [ + center[0] + x * length * 0.46, + center[1] + railHeight * 0.5, + center[2] + z * width * 0.44, + ], + axis: 'y', + radius: 0.018, + height: railHeight, + radialSegments: 10, + material: railMat, + }) + } + } + for (const z of [-1, 1]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} platform guard rail`, + semanticRole: 'guard_rail', + sourcePartKind: 'service_platform', + position: [center[0], center[1] + railHeight * 0.85, center[2] + z * width * 0.44], + length, + width: 0.035, + height: 0.035, + material: railMat, + }) + } + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'machine'} access ladder`, + semanticRole: 'access_ladder', + sourcePartKind: 'service_platform', + position: [center[0] - length * 0.48, center[1] - height * 0.35, center[2]], + length: 0.04, + width: width * 0.35, + height, + material: railMat, + }) + return applyPartRotation(shapes, center, part.rotation) +} + +function composePlatformWithLadder( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const shapes = composeServicePlatform(input, part, origin) + const center = add(origin, part.position ?? [0, clamp(part.height, 0.9, 0.2, 3.5), 0]) + const length = clamp(part.length, 1.2, 0.3, 6) + const width = clamp(part.width ?? part.depth, 0.65, 0.2, 3) + const height = clamp(part.height, 0.9, 0.2, 3.5) + const railMat = material(part.color ?? input.accentColor ?? '#facc15', 0.42, 0.12) + const rungCount = clampInt( + part.rungCount ?? part.count, + detailDefaultInt(input, part, { low: 4, medium: 6, high: 10 }), + 3, + 16, + ) + const ladderX = center[0] - length * 0.52 + const ladderZ = center[2] - width * 0.18 + for (const zOffset of [-0.08, 0.08]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} ladder side rail`, + semanticRole: 'ladder_side_rail', + sourcePartKind: 'platform_with_ladder', + position: [ladderX, center[1] - height * 0.45, ladderZ + zOffset], + axis: 'y', + radius: 0.014, + height, + radialSegments: 8, + material: railMat, + }) + } + for (let index = 0; index < rungCount; index += 1) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'machine'} ladder rung ${index + 1}`, + semanticRole: 'ladder_rung', + sourcePartKind: 'platform_with_ladder', + position: [ + ladderX, + center[1] - height * 0.88 + (height * 0.78 * index) / Math.max(1, rungCount - 1), + ladderZ, + ], + axis: 'z', + radius: 0.012, + height: 0.22, + radialSegments: 8, + material: railMat, + }) + } + return shapes.map((shape) => + shape.sourcePartKind === 'service_platform' + ? { ...shape, sourcePartKind: 'platform_with_ladder' } + : shape, + ) +} + +function kioskTotalDimensions(input: PartComposeInput) { + const length = clamp(input.length, 1.8, 0.4, 8) + const width = clamp(input.width ?? input.depth, 1.2, 0.3, 5) + const height = clamp(input.height, 2.1, 0.7, 5) + return { length, width, height } +} + +function composeKioskBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const total = kioskTotalDimensions(input) + const length = clamp(part.length, total.length, 0.4, 8) + const width = clamp(part.width ?? part.depth, total.width, 0.3, 5) + const height = clamp(part.height, total.height * 0.78, 0.4, 5) + const center = add(origin, part.position ?? [0, height * 0.5, 0]) + const mat = partMaterial(part, material(part.primaryColor ?? input.primaryColor ?? '#d1d5db')) + return applyPartRotation( + [ + { + kind: 'box', + name: part.name ?? `${input.name ?? 'kiosk'} body`, + semanticRole: part.semanticRole ?? 'kiosk_body', + semanticGroup: part.semanticGroup ?? 'kiosk', + sourcePartKind: 'kiosk_body', + position: center, + length, + width, + height, + cornerRadius: clamp(part.cornerRadius, Math.min(length, width, height) * 0.025, 0, 0.3), + cornerSegments: part.cornerSegments ?? 3, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeKioskRoof( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const total = kioskTotalDimensions(input) + const bodyHeight = total.height * 0.78 + const length = clamp(part.length, total.length * 1.16, 0.4, 9) + const width = clamp(part.width ?? part.depth, total.width * 1.18, 0.3, 6) + const height = clamp(part.height ?? part.thickness, total.height * 0.16, 0.04, 1.2) + const center = add(origin, part.position ?? [0, bodyHeight + height * 0.5, 0]) + const mat = partMaterial(part, material(part.color ?? input.secondaryColor ?? '#7f1d1d')) + return applyPartRotation( + [ + { + kind: part.variant === 'flat' ? 'box' : 'wedge', + name: part.name ?? `${input.name ?? 'kiosk'} roof`, + semanticRole: part.semanticRole ?? 'roof', + semanticGroup: part.semanticGroup ?? 'kiosk', + sourcePartKind: 'kiosk_roof', + position: center, + length, + width, + height, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeKioskOpening( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const total = kioskTotalDimensions(input) + const length = clamp(part.length, total.length * 0.42, 0.08, 5) + const panelHeight = clamp(part.height ?? part.width, total.height * 0.34, 0.08, 4) + const thickness = clamp(part.thickness ?? part.depth, 0.035, 0.004, 0.5) + const center = add(origin, part.position ?? [0, total.height * 0.42, total.width * 0.515]) + const mat = partMaterial(part, material(part.color ?? input.darkColor ?? '#111827', 0.58, 0.04)) + return applyPartRotation( + [ + { + kind: 'rounded-panel', + name: part.name ?? `${input.name ?? 'kiosk'} service opening`, + semanticRole: part.semanticRole ?? 'opening', + semanticGroup: part.semanticGroup ?? 'kiosk', + sourcePartKind: 'kiosk_opening', + position: center, + length, + width: panelHeight, + thickness, + cornerRadius: clamp(part.cornerRadius, Math.min(length, panelHeight) * 0.05, 0, 0.25), + cornerSegments: part.cornerSegments ?? 4, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeKioskCounter( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const total = kioskTotalDimensions(input) + const length = clamp(part.length, total.length * 0.62, 0.08, 6) + const width = clamp(part.width ?? part.depth, total.width * 0.2, 0.04, 2) + const thickness = clamp(part.thickness ?? part.height, total.height * 0.04, 0.02, 0.6) + const center = add(origin, part.position ?? [0, total.height * 0.27, total.width * 0.62]) + const mat = partMaterial(part, material(part.color ?? input.metalColor ?? '#9ca3af', 0.45, 0.18)) + return applyPartRotation( + [ + { + kind: 'rounded-panel', + name: part.name ?? `${input.name ?? 'kiosk'} service counter`, + semanticRole: part.semanticRole ?? 'service_counter', + semanticGroup: part.semanticGroup ?? 'kiosk', + sourcePartKind: 'kiosk_counter', + position: center, + length, + width, + thickness, + cornerRadius: clamp(part.cornerRadius, Math.min(length, width) * 0.04, 0, 0.2), + cornerSegments: part.cornerSegments ?? 4, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeKioskSign( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const total = kioskTotalDimensions(input) + const length = clamp(part.length, total.length * 0.64, 0.08, 6) + const panelHeight = clamp(part.height ?? part.width, total.height * 0.12, 0.04, 1.5) + const thickness = clamp(part.thickness ?? part.depth, 0.035, 0.004, 0.4) + const center = add(origin, part.position ?? [0, total.height * 0.72, total.width * 0.54]) + const mat = partMaterial(part, material(part.accentColor ?? input.accentColor ?? '#facc15', 0.32)) + return applyPartRotation( + [ + { + kind: 'rounded-panel', + name: part.name ?? `${input.name ?? 'kiosk'} sign panel`, + semanticRole: part.semanticRole ?? 'sign_panel', + semanticGroup: part.semanticGroup ?? 'kiosk', + sourcePartKind: 'kiosk_sign', + position: center, + length, + width: panelHeight, + thickness, + cornerRadius: clamp(part.cornerRadius, Math.min(length, panelHeight) * 0.08, 0, 0.2), + cornerSegments: part.cornerSegments ?? 4, + material: mat, + }, + ], + center, + part.rotation, + ) +} + +function composeKioskAwning( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const total = kioskTotalDimensions(input) + const length = clamp(part.length, total.length * 0.72, 0.08, 7) + const width = clamp(part.width ?? part.depth, total.width * 0.32, 0.04, 2.4) + const thickness = clamp(part.thickness ?? part.height, total.height * 0.04, 0.02, 0.8) + const center = add(origin, part.position ?? [0, total.height * 0.58, total.width * 0.64]) + const mat = partMaterial(part, material(part.color ?? input.secondaryColor ?? '#ef4444', 0.48)) + return applyPartRotation( + [ + { + kind: 'wedge', + name: part.name ?? `${input.name ?? 'kiosk'} front awning`, + semanticRole: part.semanticRole ?? 'awning', + semanticGroup: part.semanticGroup ?? 'kiosk', + sourcePartKind: 'kiosk_awning', + position: center, + rotation: part.rotation ?? [0, 0, 0], + length, + width, + height: thickness, + material: mat, + }, + ], + center, + undefined, + ) +} + +function composeGearboxBody( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.34, 0]) + const length = clamp(part.length, 0.46, 0.12, 2) + const width = clamp(part.width, 0.34, 0.08, 1.4) + const height = clamp(part.height, 0.34, 0.08, 1.4) + const mat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.46, 0.38)) + const metal = material(input.metalColor ?? '#cbd5e1', 0.28, 0.78) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} gearbox housing`, + position: center, + length, + width, + height, + cornerRadius: Math.min(length, width, height) * 0.1, + cornerSegments: 5, + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} gearbox output shaft`, + position: [center[0] + length * 0.68, center[1], center[2]], + axis: 'x', + radius: height * 0.14, + height: length * 0.34, + radialSegments: 20, + material: metal, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} gearbox input shaft`, + position: [center[0] - length * 0.62, center[1] + height * 0.18, center[2]], + axis: 'x', + radius: height * 0.1, + height: length * 0.24, + radialSegments: 18, + material: metal, + }, + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} gearbox nameplate`, + position: [center[0], center[1] + height * 0.04, center[2] + width * 0.51], + length: length * 0.36, + width: height * 0.18, + thickness: width * 0.025, + cornerRadius: height * 0.015, + cornerSegments: 3, + material: material(input.metalColor ?? '#facc15', 0.24, 0.65), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeFilterVessel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.62, 0]) + const radius = clamp(part.radius, 0.18, 0.05, 1.2) + const height = clamp(part.height ?? part.length, 0.72, 0.18, 3) + const mat = partMaterial(part, material(input.primaryColor ?? '#94a3b8', 0.42, 0.45)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} filter vessel shell`, + position: center, + axis: 'y', + radius, + height, + radialSegments: ringSegments(input.detail), + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} filter top cap`, + position: [center[0], center[1] + height * 0.53, center[2]], + radius: 1, + scale: [radius, radius * 0.32, radius], + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} filter bottom cap`, + position: [center[0], center[1] - height * 0.53, center[2]], + radius: 1, + scale: [radius, radius * 0.32, radius], + material: mat, + }, + ...composePipePort( + input, + { + kind: 'inlet_port', + name: `${part.name ?? input.name ?? 'object'} filter inlet`, + position: [center[0] - radius * 0.95, center[1] + height * 0.18, center[2]], + axis: 'x', + side: 'left', + radius: radius * 0.18, + length: radius * 0.7, + }, + [0, 0, 0], + 'inlet_port', + ), + ...composePipePort( + input, + { + kind: 'outlet_port', + name: `${part.name ?? input.name ?? 'object'} filter outlet`, + position: [center[0] + radius * 0.95, center[1] - height * 0.18, center[2]], + axis: 'x', + side: 'right', + radius: radius * 0.18, + length: radius * 0.7, + }, + [0, 0, 0], + 'outlet_port', + ), + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeHeatExchanger( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'x') + const center = add(origin, part.position ?? [0, 0.52, 0]) + const radius = clamp(part.radius, 0.18, 0.05, 1.2) + const length = clamp(part.length ?? part.height, 1.0, 0.24, 5) + const mat = partMaterial(part, material(input.primaryColor ?? '#9ca3af', 0.42, 0.5)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} heat exchanger shell`, + position: center, + axis, + radius, + height: length, + radialSegments: ringSegments(input.detail), + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} heat exchanger left channel head`, + position: offsetAlongAxis(center, axis, -length * 0.55), + axis, + radius: radius * 1.04, + height: length * 0.08, + radialSegments: ringSegments(input.detail), + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} heat exchanger right channel head`, + position: offsetAlongAxis(center, axis, length * 0.55), + axis, + radius: radius * 1.04, + height: length * 0.08, + radialSegments: ringSegments(input.detail), + material: mat, + }, + ...[-0.36, -0.12, 0.12, 0.36].map( + (offset): PrimitiveShapeInput => ({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} heat exchanger tube bundle`, + position: + axis === 'x' + ? [center[0], center[1] + radius * offset, center[2] + radius * 0.18] + : [center[0] + radius * offset, center[1], center[2] + radius * 0.18], + axis, + radius: radius * 0.035, + height: length * 0.86, + radialSegments: 10, + material: material(input.metalColor ?? '#cbd5e1', 0.3, 0.75), + }), + ), + ...composePipePort( + input, + { + kind: 'inlet_port', + name: `${part.name ?? input.name ?? 'object'} heat exchanger top nozzle`, + position: [center[0] - length * 0.25, center[1] + radius * 1.15, center[2]], + axis: 'y', + side: 'top', + radius: radius * 0.14, + length: radius * 0.45, + }, + [0, 0, 0], + 'inlet_port', + ), + ...composePipePort( + input, + { + kind: 'outlet_port', + name: `${part.name ?? input.name ?? 'object'} heat exchanger bottom nozzle`, + position: [center[0] + length * 0.25, center[1] - radius * 1.15, center[2]], + axis: 'y', + side: 'bottom', + radius: radius * 0.14, + length: radius * 0.45, + }, + [0, 0, 0], + 'outlet_port', + ), + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeAgitatorTank( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.58, 0]) + const radius = clamp(part.radius, 0.24, 0.06, 1.5) + const height = clamp(part.height ?? part.length, 0.7, 0.2, 3) + const wallThickness = clamp( + part.thickness ?? part.shellThickness, + radius * 0.075, + radius * 0.02, + radius * 0.28, + ) + const mat = partMaterial(part, material(input.primaryColor ?? '#94a3b8', 0.42, 0.46)) + const metal = material(input.metalColor ?? '#cbd5e1', 0.28, 0.78) + const dark = material(part.motorColor ?? input.darkColor ?? '#1f2937', 0.56, 0.24) + const legStyle = String(part.legStyle ?? '').toLowerCase() + const bottomStyle = String(part.bottomStyle ?? '').toLowerCase() + const legCount = clampInt(part.legCount ?? part.count, legStyle === 'splayed' ? 3 : 4, 3, 4) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} agitator tank shell`, + semanticRole: part.semanticRole ?? 'reactor_vessel_shell', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: center, + axis: 'y', + radius, + height, + wallThickness, + radialSegments: ringSegments(input.detail), + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} agitator top dished head`, + semanticRole: 'vessel_head', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0], center[1] + height * 0.52, center[2]], + radius: 1, + scale: [radius, radius * 0.32, radius], + widthSegments: ringSegments(input.detail), + heightSegments: Math.max(16, Math.round(ringSegments(input.detail) * 0.5)), + material: mat, + }, + { + kind: 'sphere', + name: `${part.name ?? input.name ?? 'object'} agitator bottom dished head`, + semanticRole: 'vessel_head', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0], center[1] - height * 0.52, center[2]], + radius: 1, + scale: [radius, radius * 0.26, radius], + widthSegments: ringSegments(input.detail), + heightSegments: Math.max(16, Math.round(ringSegments(input.detail) * 0.5)), + material: mat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} agitator top seam ring`, + semanticRole: 'vessel_seam', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0], center[1] + height * 0.5, center[2]], + axis: 'y', + majorRadius: radius * 1.01, + tubeRadius: wallThickness * 0.45, + radialSegments: 10, + tubularSegments: Math.max(24, Math.round(ringSegments(input.detail) * 0.7)), + material: metal, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} agitator motor`, + semanticRole: 'agitator_motor', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0], center[1] + height * 0.66, center[2]], + axis: 'y', + radius: radius * 0.22, + height: radius * 0.38, + radialSegments: 24, + material: dark, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} agitator shaft`, + semanticRole: 'agitator_shaft', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0], center[1] + height * 0.05, center[2]], + axis: 'y', + radius: radius * 0.035, + height: height * 0.9, + radialSegments: 12, + material: metal, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} agitator hub`, + semanticRole: 'agitator_hub', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0], center[1] - height * 0.22, center[2]], + axis: 'y', + radius: radius * 0.12, + height: radius * 0.16, + radialSegments: 18, + material: metal, + }, + ] + if (bottomStyle === 'conical') { + shapes.push({ + kind: 'frustum', + name: `${part.name ?? input.name ?? 'object'} conical discharge bottom`, + semanticRole: 'conical_discharge_bottom', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0], center[1] - height * 0.52 - radius * 0.18, center[2]], + axis: 'y', + radiusTop: radius * 0.42, + radiusBottom: radius * 0.12, + height: radius * 0.36, + radialSegments: ringSegments(input.detail), + material: mat, + }) + } + for (let i = 0; i < 3; i += 1) { + const angle = (i * Math.PI * 2) / 3 + shapes.push({ + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} agitator blade ${i + 1}`, + semanticRole: 'reactor_impeller', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [ + center[0] + Math.cos(angle) * radius * 0.22, + center[1] - height * 0.22, + center[2] + Math.sin(angle) * radius * 0.22, + ], + rotation: [0, 0, angle], + axis: 'x', + radius: radius * 0.035, + height: radius * 0.55, + radialSegments: 10, + capSegments: 3, + material: metal, + }) + } + shapes.push( + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} agitator side inlet nozzle`, + semanticRole: 'feed_nozzle', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0] - radius * 1.06, center[1] + height * 0.16, center[2]], + axis: 'x', + radius: radius * 0.13, + height: radius * 0.48, + wallThickness: wallThickness * 0.65, + radialSegments: 20, + material: mat, + }, + { + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} agitator manway flange`, + semanticRole: 'manway_flange', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + position: [center[0] + radius * 1.04, center[1] + height * 0.1, center[2]], + axis: 'x', + radius: radius * 0.2, + height: wallThickness * 3, + radialSegments: 28, + material: dark, + }, + ) + for (let i = 0; i < legCount; i += 1) { + const angle = + legCount === 3 ? -Math.PI / 2 + (i * Math.PI * 2) / 3 : Math.PI / 4 + (i * Math.PI * 2) / 4 + const topRadius = radius * 0.58 + const footRadius = legStyle === 'splayed' ? radius * 0.86 : radius * 0.62 + const topY = center[1] - height * 0.5 - radius * 0.02 + const bottomY = center[1] - height * 0.5 - radius * 0.5 + const start: Vec3 = [ + center[0] + Math.cos(angle) * topRadius, + topY, + center[2] + Math.sin(angle) * topRadius, + ] + const end: Vec3 = [ + center[0] + Math.cos(angle) * footRadius, + bottomY, + center[2] + Math.sin(angle) * footRadius, + ] + shapes.push({ + ...tubeBetween( + `${part.name ?? input.name ?? 'object'} agitator support leg`, + start, + end, + radius * 0.04, + dark, + ), + semanticRole: 'support_leg', + sourcePartKind: part.sourcePartKind ?? 'agitator_tank', + radialSegments: 12, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composePipeRack( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.45, 0]) + const length = clamp(part.length, 1.4, 0.3, 6) + const width = clamp(part.width, 0.5, 0.12, 2.5) + const height = clamp(part.height, 0.7, 0.2, 3) + const pipeCount = clampInt(part.count, 3, 1, 8) + const r = clamp(part.radius ?? part.wireRadius, 0.025, 0.006, 0.12) + const steel = partMaterial(part, material(input.metalColor ?? '#94a3b8', 0.34, 0.72)) + const pipeMat = material(input.primaryColor ?? '#64748b', 0.45, 0.42) + const shapes: PrimitiveShapeInput[] = [] + for (const x of [-length / 2, length / 2]) { + for (const z of [-width / 2, width / 2]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} pipe rack column`, + position: [center[0] + x, center[1], center[2] + z], + axis: 'y', + radius: r, + height, + radialSegments: 12, + material: steel, + }) + } + } + for (const z of [-width / 2, width / 2]) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} pipe rack beam`, + position: [center[0], center[1] + height / 2, center[2] + z], + length, + width: r * 1.6, + height: r * 1.6, + material: steel, + }) + } + for (let i = 0; i < pipeCount; i += 1) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} rack pipe ${i + 1}`, + position: [ + center[0], + center[1] + height * 0.55, + center[2] + (i - (pipeCount - 1) / 2) * ((width * 0.72) / Math.max(1, pipeCount - 1)), + ], + axis: 'x', + radius: r * 0.85, + height: length * 1.08, + radialSegments: 16, + material: pipeMat, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composePlatformLadder( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.75, 0]) + const length = clamp(part.length, 0.72, 0.2, 3) + const width = clamp(part.width, 0.48, 0.12, 2) + const height = clamp(part.height, 0.9, 0.25, 4) + const r = clamp(part.radius ?? part.wireRadius, 0.018, 0.004, 0.08) + const steel = partMaterial(part, material(input.metalColor ?? '#94a3b8', 0.34, 0.72)) + const defaultRungCount = detailDefaultInt(input, part, { + low: Math.max(4, Math.round(height / 0.26)), + medium: Math.max(5, Math.round(height / 0.18)), + high: Math.max(7, Math.round(height / 0.14)), + }) + const rungCount = clampInt(part.rungCount ?? part.count, defaultRungCount, 4, 16) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} access platform deck`, + semanticRole: part.semanticRole ?? 'access_platform', + sourcePartKind: part.sourcePartKind ?? 'platform_ladder', + position: [center[0], center[1] + height * 0.18, center[2]], + length, + width, + height: r * 0.8, + material: steel, + }, + ] + for (let i = 1; i < 4; i += 1) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} platform deck grating ${i}`, + semanticRole: 'platform_grating', + sourcePartKind: part.sourcePartKind ?? 'platform_ladder', + position: [ + center[0], + center[1] + height * 0.185, + center[2] - width * 0.35 + i * width * 0.18, + ], + length: length * 0.92, + width: r * 0.36, + height: r * 0.9, + material: steel, + }) + } + for (const x of [-length / 2, length / 2]) { + for (const z of [-width / 2, width / 2]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} platform support post`, + semanticRole: 'platform_post', + sourcePartKind: part.sourcePartKind ?? 'platform_ladder', + position: [center[0] + x, center[1] - height * 0.25, center[2] + z], + axis: 'y', + radius: r, + height, + radialSegments: 12, + material: steel, + }) + } + } + for (const [name, z] of [ + ['front', width / 2], + ['back', -width / 2], + ] as const) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} platform guard rail ${name}`, + semanticRole: 'guard_rail', + sourcePartKind: part.sourcePartKind ?? 'platform_ladder', + position: [center[0], center[1] + height * 0.42, center[2] + z], + axis: 'x', + radius: r, + height: length, + radialSegments: 12, + material: steel, + }) + } + for (const [name, x] of [ + ['left', -length / 2], + ['right', length / 2], + ] as const) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} platform side guard rail ${name}`, + semanticRole: 'guard_rail', + sourcePartKind: part.sourcePartKind ?? 'platform_ladder', + position: [center[0] + x, center[1] + height * 0.42, center[2]], + axis: 'z', + radius: r, + height: width, + radialSegments: 12, + material: steel, + }) + } + for (const z of [center[2] - width * 0.68, center[2] - width * 0.48]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} ladder side rail`, + semanticRole: 'ladder_side_rail', + sourcePartKind: part.sourcePartKind ?? 'platform_ladder', + position: [center[0] - length * 0.62, center[1] - height * 0.1, z], + axis: 'y', + radius: r, + height: height * 0.92, + radialSegments: 10, + material: steel, + }) + } + for (let i = 0; i < rungCount; i += 1) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} ladder rung ${i + 1}`, + semanticRole: 'ladder_rung', + sourcePartKind: part.sourcePartKind ?? 'platform_ladder', + position: [ + center[0] - length * 0.62, + center[1] - height * 0.55 + ((i + 1) * (height * 0.82)) / (rungCount + 1), + center[2] - width * 0.58, + ], + axis: 'z', + radius: r * 0.65, + height: width * 0.42, + radialSegments: 10, + material: steel, + }) + } + return applyPartRotation(shapes, center, part.rotation) +} + +function composeNameplate( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.45, 0.21]) + const length = clamp(part.length, 0.18, 0.04, 0.8) + const width = clamp(part.width ?? part.height, 0.08, 0.02, 0.4) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} nameplate`, + position: center, + length, + width, + thickness: clamp(part.depth, 0.008, 0.002, 0.04), + cornerRadius: Math.min(length, width) * 0.08, + cornerSegments: 3, + material: partMaterial(part, material(input.metalColor ?? '#facc15', 0.24, 0.65)), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeWarningLabel( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0.08, 0.5, 0.215]) + const length = clamp(part.length, 0.14, 0.04, 0.6) + const width = clamp(part.width ?? part.height, 0.07, 0.02, 0.3) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} warning label`, + position: center, + length, + width, + thickness: clamp(part.depth, 0.006, 0.001, 0.03), + cornerRadius: Math.min(length, width) * 0.06, + cornerSegments: 3, + material: partMaterial(part, material('#f59e0b', 0.5, 0.02)), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeSeamRing( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'z') + const center = add(origin, part.position ?? [0, 0.5, 0]) + const radius = clamp(part.radius, 0.2, 0.02, 2) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} seam ring`, + position: center, + axis, + majorRadius: radius, + tubeRadius: clamp(part.wireRadius, radius * 0.018, 0.002, 0.03), + radialSegments: 8, + tubularSegments: ringSegments(input.detail), + material: partMaterial(part, material(input.darkColor ?? '#334155', 0.5, 0.18)), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeDeskTop( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1.2, 0.35, 4) + const width = clamp(part.width ?? part.depth, 0.6, 0.2, 2) + const thickness = clamp(part.height ?? part.depth, 0.055, 0.02, 0.18) + const center = add(origin, part.position ?? [0, 0.74, 0]) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'rounded-panel', + name: `${part.name ?? input.name ?? 'object'} desk top`, + position: center, + length, + width, + thickness, + cornerRadius: Math.min(length, width) * 0.035, + cornerSegments: 5, + material: partMaterial(part, material(input.primaryColor ?? '#b7794b', 0.62, 0.02)), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeLegSet( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 1.08, 0.25, 4) + const width = clamp(part.width ?? part.depth, 0.5, 0.15, 2) + const height = clamp(part.height, 0.7, 0.12, 1.4) + const radius = clamp(part.radius, 0.025, 0.008, 0.09) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const insetX = Math.max(radius * 2.2, length * 0.08) + const insetZ = Math.max(radius * 2.2, width * 0.1) + const legMat = partMaterial(part, material(input.metalColor ?? '#9ca3af', 0.36, 0.68)) + const shapes: PrimitiveShapeInput[] = [] + + for (const x of [-length / 2 + insetX, length / 2 - insetX]) { + for (const z of [-width / 2 + insetZ, width / 2 - insetZ]) { + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} desk leg`, + position: [center[0] + x, center[1], center[2] + z], + axis: 'y', + radius, + height, + radialSegments: 16, + material: legMat, + }) + } + } + + shapes.push({ + kind: 'cylinder', + name: `${part.name ?? input.name ?? 'object'} rear stretcher`, + position: [center[0], center[1] + height * 0.2, center[2] - width / 2 + insetZ], + axis: 'x', + radius: radius * 0.6, + height: length - insetX * 2, + radialSegments: 10, + material: legMat, + }) + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeDrawerStack( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.34, 0.14, 1.2) + const width = clamp(part.width ?? part.depth, 0.44, 0.12, 1) + const height = clamp(part.height, 0.52, 0.16, 1.1) + const drawerCount = clampInt(part.count, 3, 1, 6) + const center = add(origin, part.position ?? [0.38, 0.46, 0]) + const mat = partMaterial(part, material(input.primaryColor ?? '#a16207', 0.58, 0.03)) + const faceMat = material(input.secondaryColor ?? '#c08457', 0.56, 0.02) + const metal = material(input.metalColor ?? '#d1d5db', 0.26, 0.72) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} drawer stack cabinet`, + position: center, + length, + width, + height, + cornerRadius: Math.min(length, width, height) * 0.045, + cornerSegments: 4, + material: mat, + }, + ] + + for (let i = 0; i < drawerCount; i += 1) { + const y = center[1] + height / 2 - ((i + 0.5) * height) / drawerCount + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} drawer front ${i + 1}`, + position: [center[0], y, center[2] + width * 0.51], + length: length * 0.88, + width: width * 0.035, + height: (height / drawerCount) * 0.72, + cornerRadius: Math.min(length, height / drawerCount) * 0.035, + cornerSegments: 3, + material: faceMat, + }) + shapes.push({ + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} drawer handle ${i + 1}`, + position: [center[0], y, center[2] + width * 0.545], + axis: 'x', + radius: length * 0.018, + height: length * 0.34, + radialSegments: 10, + capSegments: 3, + material: metal, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composeElectricalCabinet( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const length = clamp(part.length, 0.55, 0.18, 2) + const width = clamp(part.width ?? part.depth, 0.22, 0.08, 1) + const height = clamp(part.height, 0.95, 0.32, 3) + const center = add(origin, part.position ?? [0, height / 2, 0]) + const bodyMat = partMaterial(part, material(input.primaryColor ?? '#d1d5db', 0.48, 0.22)) + const dark = material(input.darkColor ?? '#334155', 0.48, 0.22) + const warning = material('#f59e0b', 0.5, 0.02) + const metal = material(input.metalColor ?? '#cbd5e1', 0.28, 0.74) + const doorCount = clampInt(part.doorCount, 1, 1, 4) + const frontNormal: Vec3 = [0, 0, 1] + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} electrical cabinet body`, + position: center, + length, + width, + height, + cornerRadius: Math.min(length, width, height) * 0.035, + bevelRadius: Math.min(length, width, height) * 0.035, + cornerSegments: 4, + cutouts: [ + { + id: 'cabinet_door_recess', + kind: 'rectangular', + semanticRole: 'access_door', + position: [center[0], center[1], center[2] + width * 0.51], + normal: frontNormal, + axis: 'z', + length: length * 0.92, + height: height * 0.86, + depth: width * 0.035, + bevelRadius: Math.min(length, height) * 0.02, + }, + { + id: 'cabinet_nameplate_recess', + kind: 'rectangular', + semanticRole: 'nameplate', + position: [ + center[0] - length * 0.22, + center[1] - height * 0.22, + center[2] + width * 0.56, + ], + normal: frontNormal, + axis: 'z', + length: length * 0.24, + height: height * 0.055, + depth: width * 0.02, + bevelRadius: length * 0.008, + }, + { + id: 'cabinet_vent_opening', + kind: 'slot', + semanticRole: 'vent', + position: [center[0], center[1] - height * 0.28, center[2] + width * 0.56], + normal: frontNormal, + axis: 'z', + length: length * 0.42, + height: height * 0.16, + depth: width * 0.02, + bevelRadius: height * 0.006, + }, + ], + ports: [ + { + id: 'cabinet_access_front', + kind: 'access', + semanticRole: 'access_door', + position: [center[0], center[1], center[2] + width * 0.56], + normal: frontNormal, + axis: 'z', + width: length * 0.92, + height: height * 0.86, + direction: 'bidirectional', + }, + ], + material: bodyMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} electrical cabinet door panel`, + position: [center[0], center[1], center[2] + width * 0.515], + length: length * 0.92, + width: width * 0.035, + height: height * 0.86, + cornerRadius: Math.min(length, height) * 0.02, + cornerSegments: 3, + material: bodyMat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} electrical cabinet warning label`, + position: [center[0] - length * 0.22, center[1] + height * 0.22, center[2] + width * 0.57], + length: length * 0.2, + width: width * 0.02, + height: height * 0.08, + cornerRadius: length * 0.01, + cornerSegments: 3, + material: warning, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} electrical cabinet nameplate`, + position: [center[0] - length * 0.22, center[1] - height * 0.22, center[2] + width * 0.57], + length: length * 0.24, + width: width * 0.02, + height: height * 0.055, + cornerRadius: length * 0.008, + cornerSegments: 3, + material: metal, + }, + ] + + for (let i = 1; i < doorCount; i += 1) { + const x = center[0] - length * 0.46 + (length * 0.92 * i) / doorCount + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} electrical cabinet door seam ${i}`, + position: [x, center[1], center[2] + width * 0.54], + length: length * 0.012, + width: width * 0.015, + height: height * 0.82, + material: dark, + }) + } + + for (let i = 0; i < doorCount; i += 1) { + const doorCenterX = center[0] - length * 0.46 + (length * 0.92 * (i + 0.5)) / doorCount + shapes.push({ + kind: 'capsule', + name: `${part.name ?? input.name ?? 'object'} electrical cabinet handle ${i + 1}`, + position: [ + doorCenterX + (length * 0.28) / doorCount, + center[1] + height * 0.03, + center[2] + width * 0.565, + ], + axis: 'y', + radius: length * 0.018, + height: height * 0.2, + radialSegments: 10, + capSegments: 3, + material: metal, + }) + } + + const slatCount = clampInt(part.slatCount ?? part.count, 5, 2, 10) + for (let i = 0; i < slatCount; i += 1) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} electrical cabinet vent slat ${i + 1}`, + position: [ + center[0], + center[1] - height * 0.34 + i * height * 0.028, + center[2] + width * 0.575, + ], + length: length * 0.42, + width: width * 0.018, + height: height * 0.008, + material: dark, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function composePipeRun( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const axis = partAxis(part.axis, 'x') + const length = clamp(part.length ?? part.height, 1, 0.08, 8) + const radius = clamp(part.radius, 0.055, 0.008, 0.45) + const center = add(origin, part.position ?? [0, 0.55, 0]) + const pipeMat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.42, 0.42)) + const metal = material(input.metalColor ?? '#cbd5e1', 0.28, 0.75) + const wallThickness = clamp(part.depth, radius * 0.18, radius * 0.05, radius * 0.45) + const start = offsetAlongAxis(center, axis, -length / 2) + const end = offsetAlongAxis(center, axis, length / 2) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${part.name ?? input.name ?? 'object'} pipe run`, + position: center, + axis, + radius, + height: length, + wallThickness, + radialSegments: 24, + duct: { + crossSection: 'round', + radius, + wallThickness, + }, + ports: [ + { + id: 'pipe_start', + kind: 'inlet', + semanticRole: 'pipe_start', + position: start, + normal: axisNormal(axis, -1), + axis, + radius, + direction: 'in', + }, + { + id: 'pipe_end', + kind: 'outlet', + semanticRole: 'pipe_end', + position: end, + normal: axisNormal(axis, 1), + axis, + radius, + direction: 'out', + }, + ], + material: pipeMat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} pipe run left coupling`, + position: start, + axis, + majorRadius: radius, + tubeRadius: radius * 0.12, + radialSegments: 8, + tubularSegments: 24, + material: metal, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} pipe run right coupling`, + position: end, + axis, + majorRadius: radius, + tubeRadius: radius * 0.12, + radialSegments: 8, + tubularSegments: 24, + material: metal, + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composePipeElbow( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const radius = clamp(part.radius, 0.055, 0.008, 0.45) + const bendRadius = clamp( + part.bendRadius ?? part.length ?? part.depth, + radius * 4.2, + radius * 1.4, + 2, + ) + const center = add(origin, part.position ?? [0, 0.55, 0]) + const mat = partMaterial(part, material(input.primaryColor ?? '#64748b', 0.42, 0.42)) + const start: Vec3 = [center[0] - bendRadius, center[1], center[2]] + const end: Vec3 = [center[0], center[1], center[2] + bendRadius] + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'sweep', + name: `${part.name ?? input.name ?? 'object'} pipe elbow`, + position: center, + path: [ + [-bendRadius, 0, 0], + [-bendRadius * 0.72, 0, bendRadius * 0.55], + [-bendRadius * 0.28, 0, bendRadius * 0.9], + [0, 0, bendRadius], + ], + radius, + radialSegments: 16, + tubularSegments: 32, + duct: { + crossSection: 'round', + radius, + wallThickness: radius * 0.18, + }, + ports: [ + { + id: 'elbow_start', + kind: 'inlet', + semanticRole: 'pipe_start', + position: start, + normal: [-1, 0, 0], + axis: 'x', + radius, + direction: 'in', + }, + { + id: 'elbow_end', + kind: 'outlet', + semanticRole: 'pipe_end', + position: end, + normal: [0, 0, 1], + axis: 'z', + radius, + direction: 'out', + }, + ], + material: mat, + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} elbow start rim`, + position: start, + axis: 'x', + majorRadius: radius, + tubeRadius: radius * 0.12, + radialSegments: 8, + tubularSegments: 24, + material: material(input.metalColor ?? '#cbd5e1', 0.28, 0.75), + }, + { + kind: 'torus', + name: `${part.name ?? input.name ?? 'object'} elbow end rim`, + position: end, + axis: 'z', + majorRadius: radius, + tubeRadius: radius * 0.12, + radialSegments: 8, + tubularSegments: 24, + material: material(input.metalColor ?? '#cbd5e1', 0.28, 0.75), + }, + ] + return applyPartRotation(shapes, center, part.rotation) +} + +function composeCableTray( + input: PartComposeInput, + part: PartComposePartInput, + origin: Vec3, +): PrimitiveShapeInput[] { + const center = add(origin, part.position ?? [0, 0.72, 0]) + const length = clamp(part.length, 1.2, 0.24, 6) + const width = clamp(part.width ?? part.depth, 0.26, 0.08, 1.2) + const railHeight = clamp(part.height, 0.08, 0.025, 0.4) + const thickness = clamp(part.radius ?? part.wireRadius, 0.018, 0.004, 0.08) + const slatCount = clampInt(part.slatCount ?? part.count, 7, 2, 18) + const mat = partMaterial(part, material(input.metalColor ?? '#94a3b8', 0.34, 0.72)) + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} cable tray left rail`, + position: [center[0], center[1], center[2] - width / 2], + length, + width: thickness, + height: railHeight, + material: mat, + }, + { + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} cable tray right rail`, + position: [center[0], center[1], center[2] + width / 2], + length, + width: thickness, + height: railHeight, + material: mat, + }, + ] + + for (let i = 0; i < slatCount; i += 1) { + shapes.push({ + kind: 'box', + name: `${part.name ?? input.name ?? 'object'} cable tray rung ${i + 1}`, + position: [ + center[0] - length / 2 + ((i + 0.5) * length) / slatCount, + center[1] - railHeight * 0.42, + center[2], + ], + length: thickness * 1.2, + width, + height: thickness * 0.65, + material: mat, + }) + } + + return applyPartRotation(shapes, center, part.rotation) +} + +function partCenter(part: PartComposePartInput, kind: PartComposeKind | null): Vec3 { + if (part.position) return part.position + switch (kind) { + case 'circular_base': { + const height = clamp(part.height ?? part.depth, 0.08, 0.01, 0.4) + return [0, height / 2, 0] + } + case 'vertical_pole': { + const height = clamp(part.height ?? part.length, 1, 0.05, 50) + return [0, height / 2 + 0.08, 0] + } + case 'motor_housing': + case 'fan_blade': + case 'radial_blades': + case 'protective_grill': + return [0, 1.18, kind === 'motor_housing' ? -0.024 : 0.04] + case 'wheel': + case 'wheel_set': + return [0, clamp(part.radius ?? part.wheelRadius, 0.14, 0.025, 1.2), 0] + case 'window_panel': + case 'window_strip': + return [0, 0.55, 0.02] + case 'propeller_blade_set': + case 'mixer_blades': + case 'airfoil_blade': + return [0, 0.4, 0] + case 'ellipsoid_shell': + return [0, clamp(part.height, 0.18, 0.02, 3) * 0.56, 0] + case 'hemisphere': { + const radius = clamp( + part.radius ?? (part.diameter != null ? part.diameter / 2 : undefined), + 0.5, + 0.01, + 10, + ) + const height = clamp(part.height, radius, 0.01, 10) + return [0, height / 2, 0] + } + case 'curved_lens_panel': + return [0, 0.45, 0] + case 'ergonomic_shell': + return [0, clamp(part.height, 0.036, 0.01, 0.6) * 0.72, 0] + case 'streamlined_body': + return [0, clamp(part.height, 0.22, 0.02, 2.5) * 0.55, 0] + case 'lofted_panel': + return [0, clamp(part.height, 0.12, 0.01, 1.8) * 0.7, 0] + case 'support_bracket': + return [0, 1.08, 0] + case 'control_knob': + return [0, 0.22, 0.2] + case 'skid_base': + return [0, 0.06, 0] + case 'support_roller_pair': + return [0, 0.22, 0] + case 'structural_tower_frame': + return [0, clamp(part.height, 5, 1, 16) / 2, 0] + case 'helical_ladder': + case 'helical_stair': + return [0, clamp(part.height ?? part.overallHeight, 6, 0.8, 18) / 2, 0] + case 'cyclone_separator_unit': + return [0, clamp(part.height, 1.2, 0.3, 4) / 2, 0] + case 'rounded_machine_body': + return [0, 0.45, 0] + case 'flange_ring': + case 'bolt_pattern': + return [0, 0.55, 0.5] + case 'inlet_port': + case 'outlet_port': + case 'pipe_port': + return [0, 0.55, 0.45] + case 'volute_casing': + return [0, 0.55, 0.18] + case 'control_box': + return [0.32, 0.62, 0.24] + case 'ribbed_motor_body': + case 'gearbox_body': + return [kind === 'ribbed_motor_body' ? -0.24 : 0, 0.42, 0] + case 'conveyor_frame': + return [0, 0.38, 0] + case 'roller_array': + return [0, 0.52, 0] + case 'belt_surface': + return [0, 0.56, 0] + case 'desk_top': + return [0, 0.74, 0] + case 'leg_set': + return [0, clamp(part.height, 0.7, 0.12, 1.4) / 2, 0] + case 'drawer_stack': + return [0.38, 0.46, 0] + case 'electrical_cabinet': + return [0, clamp(part.height, 0.95, 0.32, 3) / 2, 0] + case 'pipe_run': + case 'pipe_elbow': + return [0, 0.55, 0] + case 'valve_body': + return [0, 0.38, 0] + case 'handwheel': + return [0, 0.62, 0] + case 'cable_tray': + return [0, 0.72, 0] + default: + return [0, 0, 0] + } +} + +function partHalfExtents(part: PartComposePartInput, kind: PartComposeKind | null): Vec3 { + const axis = partAxis(part.axis, kind === 'outlet_port' ? 'x' : 'z') + const radius = clamp( + part.radius, + kind === 'flange_ring' ? 0.12 : kind === 'valve_body' ? 0.12 : 0.08, + 0.01, + 2, + ) + const length = clamp( + part.length ?? part.depth ?? part.height, + kind === 'flange_ring' ? 0.035 : kind === 'valve_body' ? 0.46 : 0.26, + 0.004, + 6, + ) + const axisExtents = (alongAxis: number, radial: number): Vec3 => { + switch (axis) { + case 'x': + return [alongAxis, radial, radial] + case 'y': + return [radial, alongAxis, radial] + default: + return [radial, radial, alongAxis] + } + } + + switch (kind) { + case 'circular_base': { + const baseRadius = clamp(part.radius, 0.28, 0.05, 2) + const baseHeight = clamp(part.height ?? part.depth, 0.08, 0.01, 0.4) + return [baseRadius, baseHeight / 2, baseRadius] + } + case 'vertical_pole': { + const poleRadius = clamp(part.radius, 0.025, 0.005, 2) + const poleHeight = clamp(part.height ?? part.length, 1, 0.05, 50) + return [poleRadius, poleHeight / 2, poleRadius] + } + case 'motor_housing': { + const motorRadius = clamp(part.radius, 0.11, 0.03, 0.5) + const motorDepth = clamp(part.depth ?? part.length ?? part.height, 0.16, 0.03, 0.8) + return [motorRadius, motorRadius, motorDepth / 2] + } + case 'fan_blade': { + const bladeLength = clamp(part.length ?? part.bladeRadius ?? part.radius, 0.24, 0.04, 1.2) + const bladeWidth = clamp(part.bladeWidth ?? part.width, bladeLength * 0.24, 0.012, 0.55) + const bladeDepth = clamp(part.thickness ?? part.depth ?? part.height, 0.018, 0.003, 0.08) + const hubRadius = clamp(part.wireRadius, bladeLength * 0.22, 0.01, bladeLength * 0.45) + return [hubRadius + bladeLength, bladeWidth / 2, bladeDepth / 2] + } + case 'radial_blades': { + const bladeRadius = clamp(part.bladeRadius ?? part.radius, 0.28, 0.05, 1.4) + const bladeThickness = clamp(part.height ?? part.thickness, 0.012, 0.003, 0.05) + return [bladeRadius, bladeRadius, bladeThickness / 2] + } + case 'propeller_blade_set': + case 'mixer_blades': { + const bladeLength = clamp(part.bladeRadius ?? part.radius ?? part.length, 0.34, 0.08, 1.2) + const bladeWidth = clamp(part.bladeWidth ?? part.width, 0.13, 0.04, 0.45) + const bladeDepth = clamp(part.depth ?? part.height, 0.028, 0.01, 0.09) + const hubRadius = clamp(part.wireRadius, bladeLength * 0.12, 0.015, bladeLength * 0.32) + return [hubRadius + bladeLength, bladeDepth / 2, hubRadius + bladeLength + bladeWidth / 2] + } + case 'wheel': + case 'wheel_set': { + const wheelRadius = clamp(part.radius ?? part.wheelRadius, 0.14, 0.025, 1.2) + const wheelWidth = clamp(part.wheelWidth ?? part.depth, wheelRadius * 0.42, 0.012, 0.6) + const length = clamp(part.length, 0.95, 0, 8) + const width = clamp(part.width, 0.54, 0, 4) + return [Math.max(length / 2, wheelRadius), wheelRadius, Math.max(width / 2, wheelWidth / 2)] + } + case 'window_panel': + return [ + clamp(part.length ?? part.width, 0.32, 0.02, 4) / 2, + clamp(part.height, 0.18, 0.015, 2) / 2, + clamp(part.thickness ?? part.depth, 0.01, 0.002, 0.12) / 2, + ] + case 'window_strip': + return [ + clamp(part.length, 1.2, 0.08, 12) / 2, + clamp(part.height, 0.09, 0.01, 0.5) / 2, + clamp(part.thickness ?? part.depth, 0.01, 0.002, 0.12) / 2, + ] + case 'airfoil_blade': { + const bladeLength = clamp(part.length ?? part.bladeRadius ?? part.radius, 0.46, 0.06, 2.5) + const rootWidth = clamp(part.rootWidth ?? part.bladeWidth ?? part.width, 0.13, 0.015, 0.8) + const thickness = clamp(part.thickness ?? part.depth ?? part.height, 0.025, 0.003, 0.16) + const hubRadius = clamp(part.wireRadius, bladeLength * 0.12, 0.01, bladeLength * 0.35) + return [hubRadius + bladeLength, rootWidth / 2, hubRadius + bladeLength] + } + case 'ellipsoid_shell': + return [ + clamp(part.length, 0.48, 0.04, 6) / 2, + clamp(part.height, 0.18, 0.02, 3) / 2, + clamp(part.width ?? part.depth, 0.28, 0.025, 4) / 2, + ] + case 'hemisphere': { + const diameter = part.diameter ?? (part.radius != null ? part.radius * 2 : undefined) + const length = clamp(part.length ?? diameter, 1, 0.02, 20) + const width = clamp(part.width ?? part.depth ?? diameter, length, 0.02, 20) + const radius = clamp( + part.radius ?? (part.diameter != null ? part.diameter / 2 : undefined), + 0.5, + 0.01, + 10, + ) + const height = clamp(part.height, radius, 0.01, 10) + return [length / 2, height / 2, width / 2] + } + case 'curved_lens_panel': + return [ + clamp(part.width ?? part.length, 0.32, 0.04, 2) / 2, + clamp(part.height, 0.18, 0.025, 1.2) / 2, + clamp(part.thickness ?? part.depth, 0.012, 0.002, 0.08) / 2, + ] + case 'ergonomic_shell': + return [ + clamp(part.length, 0.12, 0.04, 2) / 2, + clamp(part.height, 0.036, 0.01, 0.6) / 2, + clamp(part.width ?? part.depth, 0.065, 0.02, 1) / 2, + ] + case 'streamlined_body': + return [ + clamp(part.length, 1.2, 0.08, 8) / 2, + clamp(part.height, 0.22, 0.02, 2.5) / 2, + clamp(part.width ?? part.depth, 0.36, 0.03, 3) / 2, + ] + case 'aircraft_fuselage': + return [ + clamp(part.length, 1.12, 0.4, 8) / 2, + clamp(part.height, clamp(part.width ?? part.depth, 0.14, 0.05, 1.4) * 1.08, 0.04, 1.2) / 2, + clamp(part.width ?? part.depth, 0.14, 0.05, 1.4) / 2, + ] + case 'aircraft_wing': + return [ + clamp(part.width ?? part.depth, 0.18, 0.04, 1.2) / 2, + clamp(part.thickness ?? part.height, 0.018, 0.004, 0.12) / 2, + clamp(part.length, 0.95, 0.2, 5) / 2, + ] + case 'aircraft_engine': { + const engineRadius = clamp(part.radius, 0.065, 0.018, 0.5) + const engineLength = clamp(part.length ?? part.depth, 0.24, 0.05, 1.4) + const engineSpacing = clamp(part.width, 0.46, engineRadius * 3, 2) + return [engineLength / 2, engineRadius, engineSpacing / 2 + engineRadius] + } + case 'aircraft_vertical_stabilizer': + return [ + clamp(part.length, 0.22, 0.04, 1.5) / 2, + clamp(part.height, 0.28, 0.04, 1.4) / 2, + clamp(part.width ?? part.thickness, 0.025, 0.004, 0.16) / 2, + ] + case 'aircraft_horizontal_stabilizer': + return [ + clamp(part.width ?? part.depth, 0.1, 0.03, 0.8) / 2, + clamp(part.thickness ?? part.height, 0.014, 0.003, 0.08) / 2, + clamp(part.length, 0.42, 0.08, 2.5) / 2, + ] + case 'aircraft_landing_gear': { + const gearRadius = clamp(part.radius ?? part.wheelRadius, 0.035, 0.012, 0.2) + return [ + clamp(part.length, 0.62, gearRadius * 5, 2.5) / 2, + gearRadius * 2.8, + clamp(part.width, 0.32, gearRadius * 3, 1.4) / 2 + gearRadius, + ] + } + case 'generic_body': + return [ + clamp(part.length, 1, 0.08, 8) / 2, + clamp(part.height, 0.8, 0.05, 5) / 2, + clamp(part.width ?? part.depth, 0.65, 0.05, 5) / 2, + ] + case 'generic_base': + return [ + clamp(part.length, 1.08, 0.08, 8) / 2, + clamp(part.thickness ?? part.height, 0.08, 0.01, 0.8) / 2, + clamp(part.width ?? part.depth, 0.72, 0.05, 5) / 2, + ] + case 'generic_panel': + case 'generic_control_panel': + case 'generic_display': + case 'generic_opening': + case 'generic_detail_accent': + return [ + clamp(part.length, 0.3, 0.02, 4) / 2, + clamp(part.height ?? part.width, 0.22, 0.02, 3) / 2, + clamp(part.thickness ?? part.depth, 0.025, 0.002, 0.4) / 2, + ] + case 'generic_handle': + return [ + clamp(part.length, 0.22, 0.03, 2) / 2, + clamp(part.radius, 0.018, 0.004, 0.12), + clamp(part.depth ?? part.width, 0.05, 0.01, 0.5) / 2, + ] + case 'generic_spout': + return [ + clamp(part.radius, 0.035, 0.004, 0.2), + clamp(part.radius, 0.035, 0.004, 0.2), + clamp(part.length ?? part.depth ?? part.height, 0.2, 0.02, 1.2) / 2, + ] + case 'generic_foot_set': + return [ + clamp(part.length, 0.9, 0.08, 8) / 2, + clamp(part.height, 0.08, 0.02, 0.8) / 2, + clamp(part.width ?? part.depth, 0.55, 0.05, 5) / 2, + ] + case 'kiosk_body': + return [ + clamp(part.length, 1.8, 0.4, 8) / 2, + clamp(part.height, 1.7, 0.4, 5) / 2, + clamp(part.width ?? part.depth, 1.2, 0.3, 5) / 2, + ] + case 'kiosk_roof': + return [ + clamp(part.length, 2.1, 0.4, 9) / 2, + clamp(part.height ?? part.thickness, 0.28, 0.04, 1.2) / 2, + clamp(part.width ?? part.depth, 1.45, 0.3, 6) / 2, + ] + case 'kiosk_opening': + return [ + clamp(part.length, 0.8, 0.08, 5) / 2, + clamp(part.height ?? part.width, 0.75, 0.08, 4) / 2, + clamp(part.thickness ?? part.depth, 0.035, 0.004, 0.5) / 2, + ] + case 'kiosk_counter': + return [ + clamp(part.length, 1, 0.08, 6) / 2, + clamp(part.thickness ?? part.height, 0.08, 0.02, 0.6) / 2, + clamp(part.width ?? part.depth, 0.28, 0.04, 2) / 2, + ] + case 'kiosk_sign': + return [ + clamp(part.length, 1, 0.08, 6) / 2, + clamp(part.height ?? part.width, 0.26, 0.04, 1.5) / 2, + clamp(part.thickness ?? part.depth, 0.035, 0.004, 0.4) / 2, + ] + case 'kiosk_awning': + return [ + clamp(part.length, 1.25, 0.08, 7) / 2, + clamp(part.thickness ?? part.height, 0.08, 0.02, 0.8) / 2, + clamp(part.width ?? part.depth, 0.45, 0.04, 2.4) / 2, + ] + case 'lofted_panel': + return [ + clamp(part.length, 0.8, 0.08, 6) / 2, + clamp(part.height, 0.12, 0.01, 1.8) / 2, + clamp(part.width ?? part.depth, 0.28, 0.02, 2) / 2, + ] + case 'protective_grill': { + const grillRadius = clamp(part.radius, 0.36, 0.08, 2) + const grillDepth = clamp(part.depth, 0.12, 0.005, 0.6) + return [grillRadius, grillRadius, grillDepth / 2] + } + case 'support_bracket': + return [ + clamp(part.width ?? part.length, 0.22, 0.04, 1) / 2, + clamp(part.height, 0.16, 0.03, 0.8) / 2, + clamp(part.depth, 0.045, 0.01, 0.3) / 2, + ] + case 'control_knob': { + const knobRadius = clamp(part.radius, 0.045, 0.01, 0.2) + const knobDepth = clamp(part.depth ?? part.height, 0.025, 0.004, 0.12) + return [knobRadius, knobRadius, knobDepth / 2] + } + case 'pyramid': + return [ + clamp(part.length ?? part.width ?? part.diameter, 0.6, 0.02, 20) / 2, + clamp(part.height ?? part.depth, 0.8, 0.02, 20) / 2, + clamp(part.width ?? part.length ?? part.diameter, 0.6, 0.02, 20) / 2, + ] + case 'skid_base': + return [ + clamp(part.length ?? part.depth, 1.1, 0.25, 5) / 2, + clamp(part.height, 0.08, 0.02, 0.35) / 2, + clamp(part.width, 0.46, 0.12, 2) / 2, + ] + case 'flange_ring': + case 'bolt_pattern': + return axisExtents(length / 2, radius) + case 'pipe_port': + case 'inlet_port': + case 'outlet_port': + return axisExtents(length / 2, radius) + case 'valve_body': + return axisExtents(length / 2, radius) + case 'volute_casing': { + const r = clamp(part.radius, 0.28, 0.06, 2) + const d = clamp(part.depth ?? part.width, r * 0.48, 0.03, 1) + return [r, r, d / 2] + } + case 'rounded_machine_body': + case 'control_box': + case 'gearbox_body': + case 'ribbed_motor_body': + case 'body_shell': + case 'electrical_cabinet': + case 'conveyor_frame': + case 'roller_array': + case 'belt_surface': + if (kind === 'ribbed_motor_body') { + return axisExtents( + clamp(part.length ?? part.depth, 0.48, 0.12, 3) / 2, + clamp(part.radius, 0.18, 0.04, 1), + ) + } + if (kind === 'conveyor_frame') { + return [ + clamp(part.length, 1.4, 0.3, 6) / 2, + clamp(part.height, 0.42, 0.12, 2) / 2, + clamp(part.width, 0.42, 0.12, 2) / 2, + ] + } + if (kind === 'roller_array') { + return [ + clamp(part.length, 1.2, 0.2, 6) / 2, + clamp(part.radius, 0.035, 0.008, 0.18), + clamp(part.width, 0.46, 0.08, 2) / 2, + ] + } + if (kind === 'belt_surface') { + return [ + clamp(part.length, 1.35, 0.2, 6) / 2, + clamp(part.height ?? part.depth, 0.025, 0.004, 0.12) / 2, + clamp(part.width, 0.46, 0.08, 2) / 2, + ] + } + if (kind === 'control_box') { + return [ + clamp(part.width ?? part.length, 0.24, 0.04, 1.5) / 2, + clamp(part.height, 0.32, 0.06, 1.5) / 2, + clamp(part.depth, 0.11, 0.025, 0.7) / 2, + ] + } + return [ + clamp( + part.length, + kind === 'body_shell' ? 1.2 : kind === 'electrical_cabinet' ? 0.55 : 0.6, + 0.1, + 6, + ) / 2, + clamp(part.height, kind === 'electrical_cabinet' ? 0.95 : 0.34, 0.05, 3) / 2, + clamp(part.width ?? part.depth, kind === 'electrical_cabinet' ? 0.22 : 0.34, 0.05, 3) / 2, + ] + case 'desk_top': + return [ + clamp(part.length, 1.2, 0.35, 4) / 2, + clamp(part.height ?? part.depth, 0.055, 0.02, 0.18) / 2, + clamp(part.width ?? part.depth, 0.6, 0.2, 2) / 2, + ] + case 'leg_set': + return [ + clamp(part.length, 1.08, 0.25, 4) / 2, + clamp(part.height, 0.7, 0.12, 1.4) / 2, + clamp(part.width ?? part.depth, 0.5, 0.15, 2) / 2, + ] + case 'drawer_stack': + return [ + clamp(part.length, 0.34, 0.14, 1.2) / 2, + clamp(part.height, 0.52, 0.16, 1.1) / 2, + clamp(part.width ?? part.depth, 0.44, 0.12, 1) / 2, + ] + case 'cable_tray': + return [ + clamp(part.length, 1.2, 0.24, 6) / 2, + clamp(part.height, 0.08, 0.025, 0.4) / 2, + clamp(part.width ?? part.depth, 0.26, 0.08, 1.2) / 2, + ] + case 'pipe_run': { + const pipeAxis = partAxis(part.axis, 'x') + const pipeLength = clamp(part.length ?? part.height, 1, 0.08, 8) + const pipeRadius = clamp(part.radius, 0.055, 0.008, 0.45) + return pipeAxis === 'x' + ? [pipeLength / 2, pipeRadius, pipeRadius] + : pipeAxis === 'y' + ? [pipeRadius, pipeLength / 2, pipeRadius] + : [pipeRadius, pipeRadius, pipeLength / 2] + } + case 'pipe_elbow': { + const pipeRadius = clamp(part.radius, 0.055, 0.008, 0.45) + const bendRadius = clamp( + part.bendRadius ?? part.length ?? part.depth, + pipeRadius * 4.2, + pipeRadius * 1.4, + 2, + ) + return [bendRadius / 2 + pipeRadius, pipeRadius, bendRadius / 2 + pipeRadius] + } + case 'cylindrical_tank': + case 'chimney_stack': + case 'heat_exchanger': { + const r = clamp(part.radius, 0.2, 0.04, 2) + const l = clamp(part.length ?? part.height, 0.9, 0.1, 6) / 2 + if (kind === 'chimney_stack') { + const h = clamp(part.height ?? part.length, 6, 0.6, 80) + const chimneyRadius = clamp(part.radius ?? part.width ?? part.diameter, h * 0.055, 0.05, 6) + return [chimneyRadius * 1.42, h / 2, chimneyRadius * 1.42] + } + return partAxis(part.axis, 'x') === 'x' ? [l, r, r] : [r, l, r] + } + default: + return [0.1, 0.1, 0.1] + } +} + +function anchorOffset(anchor: unknown, extents: Vec3): Vec3 { + switch (anchor) { + case 'left': + return [-extents[0], 0, 0] + case 'right': + return [extents[0], 0, 0] + case 'top': + return [0, extents[1], 0] + case 'bottom': + return [0, -extents[1], 0] + case 'front': + return [0, 0, extents[2]] + case 'back': + return [0, 0, -extents[2]] + default: + return [0, 0, 0] + } +} + +function connectionPointOffset( + part: PartComposePartInput, + kind: PartComposeKind | null, + point: unknown, +): Vec3 { + const normalizedPoint = + typeof point === 'string' + ? point + .trim() + .toLowerCase() + .replace(/[\s-]+/g, '_') + : '' + const extents = partHalfExtents(part, kind) + const axis = partAxis(part.axis, kind === 'outlet_port' ? 'x' : 'z') + const radius = clamp(part.radius, 0.08, 0.01, 2) + const length = clamp( + part.length ?? part.depth ?? part.height, + kind === 'flange_ring' ? 0.035 : 0.26, + 0.004, + 6, + ) + const side = partSide(part.side) + const sideSign = signForSide(side, axis) + const axisOffset = (distance: number) => + sub(offsetAlongAxis([0, 0, 0], axis, distance), [0, 0, 0]) + + switch (kind) { + case 'circular_base': + if ( + normalizedPoint === 'top' || + normalizedPoint === 'mount' || + normalizedPoint === 'center' + ) { + return [0, extents[1], 0] + } + if (normalizedPoint === 'bottom' || normalizedPoint === 'floor') return [0, -extents[1], 0] + break + case 'vertical_pole': + if (normalizedPoint === 'top' || normalizedPoint === 'head' || normalizedPoint === 'mount') { + return [0, extents[1], 0] + } + if ( + normalizedPoint === 'bottom' || + normalizedPoint === 'foot' || + normalizedPoint === 'base' + ) { + return [0, -extents[1], 0] + } + if (normalizedPoint === 'shaft' || normalizedPoint === 'center') return [0, 0, 0] + break + case 'motor_housing': + case 'radial_blades': + case 'protective_grill': + if ( + normalizedPoint === 'front' || + normalizedPoint === 'face' || + normalizedPoint === 'blade_face' || + normalizedPoint === 'grill_face' + ) { + return [0, 0, extents[2]] + } + if ( + normalizedPoint === 'back' || + normalizedPoint === 'rear' || + normalizedPoint === 'motor_side' || + normalizedPoint === 'mount' + ) { + return [0, 0, -extents[2]] + } + if ( + normalizedPoint === 'hub' || + normalizedPoint === 'shaft' || + normalizedPoint === 'center' + ) { + return [0, 0, 0] + } + break + case 'pipe_port': + case 'inlet_port': + case 'outlet_port': + if ( + normalizedPoint === 'open' || + normalizedPoint === 'mouth' || + normalizedPoint === 'port' || + normalizedPoint === 'nozzle' || + normalizedPoint === 'front' || + normalizedPoint === 'outlet' || + normalizedPoint === 'inlet' + ) { + return axisOffset((length / 2) * sideSign) + } + if (normalizedPoint === 'base' || normalizedPoint === 'back' || normalizedPoint === 'rear') { + return axisOffset((-length / 2) * sideSign) + } + break + case 'pipe_run': { + const pipeAxis = partAxis(part.axis, 'x') + const pipeLength = clamp(part.length ?? part.height, 1, 0.08, 8) + const pipeSideSign = signForSide(side, pipeAxis) + const pipeAxisOffset = (distance: number) => + sub(offsetAlongAxis([0, 0, 0], pipeAxis, distance), [0, 0, 0]) + if ( + normalizedPoint === 'open' || + normalizedPoint === 'mouth' || + normalizedPoint === 'port' || + normalizedPoint === 'nozzle' || + normalizedPoint === 'front' || + normalizedPoint === 'outlet' || + normalizedPoint === 'end' || + normalizedPoint === 'right' + ) { + return pipeAxisOffset((pipeLength / 2) * pipeSideSign) + } + if ( + normalizedPoint === 'base' || + normalizedPoint === 'back' || + normalizedPoint === 'rear' || + normalizedPoint === 'start' || + normalizedPoint === 'left' || + normalizedPoint === 'inlet' + ) { + return pipeAxisOffset((-pipeLength / 2) * pipeSideSign) + } + break + } + case 'pipe_elbow': { + const elbowRadius = clamp(part.radius, 0.055, 0.008, 0.45) + const bendRadius = clamp( + part.bendRadius ?? part.length ?? part.depth, + elbowRadius * 4.2, + elbowRadius * 1.4, + 2, + ) + if ( + normalizedPoint === 'start' || + normalizedPoint === 'inlet' || + normalizedPoint === 'left' + ) { + return [-bendRadius, 0, 0] + } + if ( + normalizedPoint === 'end' || + normalizedPoint === 'outlet' || + normalizedPoint === 'front' || + normalizedPoint === 'open' + ) { + return [0, 0, bendRadius] + } + break + } + case 'desk_top': + if ( + normalizedPoint === 'leg_mount' || + normalizedPoint === 'under' || + normalizedPoint === 'underside' + ) { + return [0, -extents[1], 0] + } + break + case 'electrical_cabinet': + if (normalizedPoint === 'front' || normalizedPoint === 'door') return [0, 0, extents[2]] + if (normalizedPoint === 'cable_entry' || normalizedPoint === 'top') return [0, extents[1], 0] + if (normalizedPoint === 'bottom' || normalizedPoint === 'base') return [0, -extents[1], 0] + break + case 'cable_tray': + if (normalizedPoint === 'left' || normalizedPoint === 'start') return [-extents[0], 0, 0] + if (normalizedPoint === 'right' || normalizedPoint === 'end') return [extents[0], 0, 0] + if (normalizedPoint === 'bottom') return [0, -extents[1], 0] + break + case 'flange_ring': + case 'bolt_pattern': + case 'seam_ring': + if (normalizedPoint === 'front' || normalizedPoint === 'face' || normalizedPoint === 'open') { + return axisOffset(length / 2) + } + if (normalizedPoint === 'back' || normalizedPoint === 'rear' || normalizedPoint === 'mount') { + return axisOffset(-length / 2) + } + break + case 'volute_casing': { + const r = clamp(part.radius, 0.28, 0.06, 2) + const depth = clamp(part.depth ?? part.width, r * 0.48, 0.03, 1) + const outletAngle = clamp(part.outletAngle, Math.atan2(0.34, 0.72), -Math.PI, Math.PI) + if ( + normalizedPoint === 'inlet' || + normalizedPoint === 'suction' || + normalizedPoint === 'front' + ) { + return [0, 0, depth * 0.54] + } + if (normalizedPoint === 'outlet' || normalizedPoint === 'discharge') { + return [Math.cos(outletAngle) * r * 1.06, Math.sin(outletAngle) * r * 1.06, 0] + } + break + } + case 'ribbed_motor_body': + case 'gearbox_body': { + const motorAxis = partAxis(part.axis, 'x') + const bodyLength = clamp( + part.length ?? part.depth, + kind === 'gearbox_body' ? 0.46 : 0.48, + 0.12, + 3, + ) + if ( + normalizedPoint === 'shaft' || + normalizedPoint === 'output' || + normalizedPoint === 'front' + ) { + return sub(offsetAlongAxis([0, 0, 0], motorAxis, bodyLength * 0.72), [0, 0, 0]) + } + if (normalizedPoint === 'input' || normalizedPoint === 'back' || normalizedPoint === 'rear') { + return sub(offsetAlongAxis([0, 0, 0], motorAxis, -bodyLength * 0.62), [0, 0, 0]) + } + break + } + case 'valve_body': + if (normalizedPoint === 'inlet' || normalizedPoint === 'left') return axisOffset(-length / 2) + if (normalizedPoint === 'outlet' || normalizedPoint === 'right') return axisOffset(length / 2) + if (normalizedPoint === 'stem' || normalizedPoint === 'top') return [0, radius * 1.8, 0] + break + case 'cylindrical_tank': + case 'heat_exchanger': { + const vesselAxis = partAxis(part.axis, 'x') + const vesselLength = clamp( + part.length ?? part.height, + kind === 'heat_exchanger' ? 1 : 0.9, + 0.1, + 6, + ) + const vesselRadius = clamp(part.radius, 0.2, 0.04, 2) + if (normalizedPoint === 'left' || normalizedPoint === 'inlet') { + return sub(offsetAlongAxis([0, 0, 0], vesselAxis, -vesselLength / 2), [0, 0, 0]) + } + if (normalizedPoint === 'right' || normalizedPoint === 'outlet') { + return sub(offsetAlongAxis([0, 0, 0], vesselAxis, vesselLength / 2), [0, 0, 0]) + } + if (normalizedPoint === 'top' || normalizedPoint === 'nozzle') return [0, vesselRadius, 0] + break + } + } + + return anchorOffset(point, extents) +} + +function alignAbovePosition( + parent: PartComposePartInput, + parentKind: PartComposeKind | null, + child: PartComposePartInput, + childKind: PartComposeKind | null, +): Vec3 { + const parentCenter = partCenter(parent, parentKind) + const parentExtents = partHalfExtents(parent, parentKind) + const childExtents = partHalfExtents(child, childKind) + const gap = clamp(child.relationGap, 0, 0, 2) + return [ + parentCenter[0], + parentCenter[1] + parentExtents[1] + childExtents[1] + gap, + parentCenter[2], + ] +} + +function centeredOnPosition( + parent: PartComposePartInput, + parentKind: PartComposeKind | null, + child: PartComposePartInput, + childKind: PartComposeKind | null, +): Vec3 { + const parentCenter = partCenter(parent, parentKind) + const childCenter = partCenter(child, childKind) + return [parentCenter[0], childCenter[1], parentCenter[2]] +} + +function alignBesidePosition( + parent: PartComposePartInput, + parentKind: PartComposeKind | null, + child: PartComposePartInput, + childKind: PartComposeKind | null, +): Vec3 { + const parentCenter = partCenter(parent, parentKind) + const parentExtents = partHalfExtents(parent, parentKind) + const childExtents = partHalfExtents(child, childKind) + const side = partSide(child.side) ?? partSide(child.anchor) ?? 'right' + const gap = clamp(child.relationGap, 0, 0, 2) + switch (side) { + case 'left': + return [ + parentCenter[0] - parentExtents[0] - childExtents[0] - gap, + parentCenter[1], + parentCenter[2], + ] + case 'front': + return [ + parentCenter[0], + parentCenter[1], + parentCenter[2] + parentExtents[2] + childExtents[2] + gap, + ] + case 'back': + return [ + parentCenter[0], + parentCenter[1], + parentCenter[2] - parentExtents[2] - childExtents[2] - gap, + ] + case 'top': + return alignAbovePosition(parent, parentKind, child, childKind) + case 'bottom': + return [ + parentCenter[0], + parentCenter[1] - parentExtents[1] - childExtents[1] - gap, + parentCenter[2], + ] + default: + return [ + parentCenter[0] + parentExtents[0] + childExtents[0] + gap, + parentCenter[1], + parentCenter[2], + ] + } +} + +function aroundPosition( + parent: PartComposePartInput, + parentKind: PartComposeKind | null, + child: PartComposePartInput, + childKind: PartComposeKind | null, +): Vec3 { + const parentCenter = partCenter(parent, parentKind) + const parentExtents = partHalfExtents(parent, parentKind) + const childCenter = partCenter(child, childKind) + const childExtents = partHalfExtents(child, childKind) + const gap = clamp(child.relationGap, 0, 0, 2) + if (child.cornerPattern) { + const count = clampInt(child.aroundCount ?? child.count, 4, 1, 128) + const index = clampInt(child.aroundIndex, 0, 0, Math.max(0, count - 1)) + const cornerIndex = index % 4 + const inset = clamp(child.cornerInset, 0, 0, 20) + const xSign = cornerIndex === 0 || cornerIndex === 3 ? -1 : 1 + const zSign = cornerIndex === 0 || cornerIndex === 1 ? -1 : 1 + return [ + parentCenter[0] + xSign * Math.max(0, parentExtents[0] - childExtents[0] - inset), + childCenter[1], + parentCenter[2] + zSign * Math.max(0, parentExtents[2] - childExtents[2] - inset), + ] + } + const radius = clamp( + child.aroundRadius, + Math.max(parentExtents[0], parentExtents[2]) + Math.max(childExtents[0], childExtents[2]) + gap, + 0, + 20, + ) + const count = clampInt(child.aroundCount ?? child.count, 1, 1, 128) + const index = clampInt(child.aroundIndex, 0, 0, Math.max(0, count - 1)) + const angle = + typeof child.aroundAngle === 'number' && Number.isFinite(child.aroundAngle) + ? child.aroundAngle + : clamp(child.aroundStartAngle, 0, -Math.PI * 2, Math.PI * 2) + (Math.PI * 2 * index) / count + const axis = partAxis(child.aroundAxis, 'y') + const cos = Math.cos(angle) * radius + const sin = Math.sin(angle) * radius + + switch (axis) { + case 'x': + return [childCenter[0], parentCenter[1] + cos, parentCenter[2] + sin] + case 'z': + return [parentCenter[0] + cos, parentCenter[1] + sin, childCenter[2]] + default: + return [parentCenter[0] + cos, childCenter[1], parentCenter[2] + sin] + } +} + +function positionWithArrayOffset(part: PartComposePartInput, position: Vec3): Vec3 { + const axis = partAxis(part.arrayAxis, 'x') + const offset = clamp(part.arrayOffset, 0, -50, 50) + if (offset === 0) return position + return offsetAlongAxis(position, axis, offset) +} + +function expandArrayParts(parts: PartComposePartInput[]): PartComposePartInput[] { + const expanded: PartComposePartInput[] = [] + for (const part of parts) { + const count = clampInt(part.array?.count, 1, 1, 128) + const spacing = clamp(part.array?.spacing, 0, 0, 20) + if (!part.array || count <= 1 || spacing === 0) { + expanded.push(part) + continue + } + const axis = partAxis(part.array.axis, 'x') + const centerOffset = ((count - 1) * spacing) / 2 + const originalId = part.id + for (let index = 0; index < count; index += 1) { + expanded.push({ + ...part, + id: originalId ? `${originalId}_${index + 1}` : undefined, + // Preserve original id as alias so findParent can resolve references to it + sourcePartId: part.sourcePartId ?? originalId, + name: part.name + ? `${part.name} ${index + 1}` + : part.partName + ? `${part.partName} ${index + 1}` + : undefined, + partName: part.partName ? `${part.partName} ${index + 1}` : undefined, + array: undefined, + arrayAxis: axis, + arrayOffset: index * spacing - centerOffset, + }) + } + } + return expanded +} + +function expandAroundDistributedParts(parts: PartComposePartInput[]): PartComposePartInput[] { + const expanded: PartComposePartInput[] = [] + for (const part of parts) { + const kind = normalizedPartKind(part) + if (kind === 'propeller_blade_set' || kind === 'mixer_blades') { + expanded.push({ + ...part, + around: undefined, + aroundCount: undefined, + aroundIndex: undefined, + aroundAngle: undefined, + }) + continue + } + const defaultCount = part.cornerPattern ? 4 : 1 + const count = clampInt(part.aroundCount, defaultCount, 1, 128) + if (part.around == null || part.aroundIndex != null || count <= 1) { + expanded.push(part) + continue + } + const originalId = part.id + for (let index = 0; index < count; index += 1) { + expanded.push({ + ...part, + id: originalId ? `${originalId}_${index + 1}` : undefined, + sourcePartId: part.sourcePartId ?? originalId, + name: part.name + ? `${part.name} ${index + 1}` + : part.partName + ? `${part.partName} ${index + 1}` + : undefined, + partName: part.partName ? `${part.partName} ${index + 1}` : undefined, + aroundIndex: index, + }) + } + } + return expanded +} + +function resolveConnectedParts(parts: PartComposePartInput[]): PartComposePartInput[] { + const resolved: PartComposePartInput[] = [] + const hasRelationPlacement = (part: PartComposePartInput) => + part.connectTo != null || + part.alignAbove != null || + part.alignBeside != null || + part.offsetFrom != null || + part.centeredOn != null || + part.around != null + const findParent = (connectTo: string | number | undefined): PartComposePartInput | undefined => { + if (typeof connectTo === 'number') return resolved[connectTo] + if (typeof connectTo !== 'string') return undefined + const normalized = normalizePartKind(connectTo) + return resolved.find( + (part) => + part.id === connectTo || + part.sourcePartId === connectTo || + part.name === connectTo || + part.kind === connectTo || + part.partType === connectTo || + (normalized !== null && normalizedPartKind(part) === normalized), + ) + } + + parts.forEach((part) => { + const kind = normalizedPartKind(part) + if (part.position && !hasRelationPlacement(part)) { + resolved.push({ + ...part, + position: positionWithArrayOffset(part, part.position), + }) + return + } + + const connectionParent = findParent(part.connectTo) + if (connectionParent) { + const parentKind = normalizedPartKind(connectionParent) + const parentCenter = partCenter(connectionParent, parentKind) + const parentPoint = part.connectPoint ?? part.anchor ?? 'front' + const childPoint = part.childPoint ?? part.childAnchor ?? 'back' + resolved.push({ + ...part, + position: positionWithArrayOffset( + part, + add( + add(parentCenter, connectionPointOffset(connectionParent, parentKind, parentPoint)), + negate(connectionPointOffset(part, kind, childPoint)), + ), + ), + }) + return + } + + const aboveParent = findParent(part.alignAbove) + if (aboveParent) { + const side = partSide(part.side) + resolved.push({ + ...part, + position: positionWithArrayOffset( + part, + side === 'bottom' + ? alignBesidePosition( + aboveParent, + normalizedPartKind(aboveParent), + { ...part, side }, + kind, + ) + : alignAbovePosition(aboveParent, normalizedPartKind(aboveParent), part, kind), + ), + }) + return + } + + const besideParent = findParent(part.alignBeside) + if (besideParent) { + resolved.push({ + ...part, + position: positionWithArrayOffset( + part, + alignBesidePosition(besideParent, normalizedPartKind(besideParent), part, kind), + ), + }) + return + } + + const offsetParent = findParent(part.offsetFrom) + if (offsetParent) { + resolved.push({ + ...part, + side: part.offsetDirection ?? part.side, + relationGap: (part.relationGap ?? 0) + (part.offsetDistance ?? 0), + position: positionWithArrayOffset( + part, + alignBesidePosition( + offsetParent, + normalizedPartKind(offsetParent), + { + ...part, + side: part.offsetDirection ?? part.side, + relationGap: (part.relationGap ?? 0) + (part.offsetDistance ?? 0), + }, + kind, + ), + ), + }) + return + } + + const centerParent = findParent(part.centeredOn) + if (centerParent) { + resolved.push({ + ...part, + position: positionWithArrayOffset( + part, + centeredOnPosition(centerParent, normalizedPartKind(centerParent), part, kind), + ), + }) + return + } + + const aroundParent = findParent(part.around) + if (aroundParent) { + resolved.push({ + ...part, + position: positionWithArrayOffset( + part, + aroundPosition(aroundParent, normalizedPartKind(aroundParent), part, kind), + ), + }) + return + } + + resolved.push(part) + }) + + return resolved +} + +export interface PartRelationshipLayoutInput { + parts: PartComposePartInput[] +} + +export interface BoundingBox { + min: Vec3 + max: Vec3 + size: Vec3 +} + +export interface LayoutAnchor { + id: string + role: string + position: Vec3 +} + +export interface PartPlacement { + partId: string + kind: string + semanticRole?: string + anchorId?: string + position: Vec3 +} + +export interface LayoutPlan { + family: FamilyId | string + layoutFamily?: LayoutFamilyId + anchors: LayoutAnchor[] + placements: PartPlacement[] + bounds: BoundingBox + parts: PartComposePartInput[] +} + +export interface LayoutProfileInput { + family: FamilyId | string + layoutFamily?: LayoutFamilyId + primarySemanticRole?: string +} + +export interface LayoutDimensions { + length?: number + width?: number + height?: number + diameter?: number +} + +export function resolvePlacedParts(plan: PartRelationshipLayoutInput): PartComposePartInput[] { + return resolveConnectedParts(expandArrayParts(expandAroundDistributedParts(plan.parts))) +} + +function layoutNumber(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback +} + +function layoutKind(part: PartComposePartInput) { + return String(part.kind ?? part.partType ?? part.type ?? part.id ?? 'part') +} + +function layoutRole(part: PartComposePartInput) { + return typeof part.semanticRole === 'string' && part.semanticRole.trim() + ? part.semanticRole.trim() + : layoutKind(part) +} + +function normalizeLayoutAnchor(value: unknown): string | undefined { + if (typeof value !== 'string' || !value.trim()) return undefined + const normalized = value + .trim() + .toLowerCase() + .replace(/[\s-]+/g, '_') + if (normalized === 'center') return 'shell_center' + return normalized +} + +function layoutOffset(value: unknown): Vec3 { + if (Array.isArray(value) && value.length >= 3) { + const [x, y, z] = value + return [ + typeof x === 'number' && Number.isFinite(x) ? x : 0, + typeof y === 'number' && Number.isFinite(y) ? y : 0, + typeof z === 'number' && Number.isFinite(z) ? z : 0, + ] + } + if (typeof value === 'number' && Number.isFinite(value)) return [value, 0, 0] + return [0, 0, 0] +} + +function addLayoutOffset(position: Vec3, offset: unknown): Vec3 { + return add(position, layoutOffset(offset)) +} + +function arrayAlongAxis(value: unknown): PartAxis | undefined { + const normalized = normalizeLayoutAnchor(value) + if (normalized === 'x' || normalized === 'length') return 'x' + if (normalized === 'y' || normalized === 'height' || normalized === 'vertical') return 'y' + if (normalized === 'z' || normalized === 'width' || normalized === 'depth') return 'z' + return undefined +} + +function layoutAxisSize(axis: PartAxis, dimensions: Required) { + if (axis === 'x') return dimensions.length + if (axis === 'y') return dimensions.height + return dimensions.width +} + +function distributeArrayAlong( + part: PartComposePartInput, + position: Vec3, + dimensions: Required, +) { + const axis = arrayAlongAxis(part.arrayAlong) + const count = axis ? clampInt(part.count, 1, 1, 64) : 1 + if (!axis || count <= 1) return [{ part, position }] + const spacing = clamp( + part.array?.spacing ?? part.arrayOffset, + (layoutAxisSize(axis, dimensions) * 0.72) / Math.max(1, count - 1), + 0.02, + 100, + ) + const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + return Array.from({ length: count }, (_, index) => { + const nextPosition: Vec3 = [...position] + nextPosition[axisIndex] += (index - (count - 1) / 2) * spacing + return { + part: { + ...part, + id: part.id ? `${part.id}_${index + 1}` : undefined, + sourcePartId: part.sourcePartId ? `${part.sourcePartId}_${index + 1}` : undefined, + count: undefined, + arrayAlong: undefined, + arrayOffset: undefined, + }, + position: nextPosition, + } + }) +} + +function inferLayoutFamily(profile: LayoutProfileInput): LayoutFamilyId | undefined { + if (profile.layoutFamily) return profile.layoutFamily + switch (profile.family) { + case 'pump': + case 'compressor': + case 'fan': + case 'fluid_machine': + return 'rotating_machine_layout' + case 'tank': + case 'reactor': + case 'process_equipment': + case 'heat_exchanger': + return 'vessel_layout' + case 'conveyor': + case 'grate_cooler': + case 'material_handling': + return 'linear_transport_layout' + case 'machine_tool': + case 'electrical': + case 'kiosk': + case 'forming_machine': + return 'box_enclosure_layout' + default: + return undefined + } +} + +function anchorForRole(layoutFamily: LayoutFamilyId | undefined, role: string) { + const normalized = role.toLowerCase() + if (layoutFamily === 'rotating_machine_layout') { + if (/base|skid|support/.test(normalized)) return 'base' + if (/motor|drive/.test(normalized)) return 'drive' + if (/casing|volute|body|compressor/.test(normalized)) return 'process_body' + if (/inlet|suction/.test(normalized)) return 'inlet' + if (/outlet|discharge/.test(normalized)) return 'outlet' + } + if (layoutFamily === 'vessel_layout') { + if (/shell|vessel|tank|reactor|body/.test(normalized)) return 'shell' + if (/support|base|skid/.test(normalized)) return 'support' + if (/top|inlet|feed|manway/.test(normalized)) return 'top_nozzle' + if (/drain|outlet|discharge/.test(normalized)) return 'side_nozzle' + if (/ladder|platform|access/.test(normalized)) return 'access' + } + if (layoutFamily === 'linear_transport_layout') { + if (/frame|support|leg/.test(normalized)) return 'frame' + if (/roller|flight|slat/.test(normalized)) return 'repeaters' + if (/belt|surface|trough|cover/.test(normalized)) return 'surface' + if (/motor|drive/.test(normalized)) return 'drive' + } + if (layoutFamily === 'box_enclosure_layout' || layoutFamily === 'generic_industrial_layout') { + if (/base|skid|foot/.test(normalized)) return 'base' + if (/body|enclosure|cabinet|chamber|frame/.test(normalized)) return 'body' + if (/control|display|screen/.test(normalized)) return 'controls' + if (/panel|door|window|opening|plate/.test(normalized)) return 'front_panel' + if (/vent|label|nameplate|warning/.test(normalized)) return 'details' + } + return 'body' +} + +function layoutAnchors( + layoutFamily: LayoutFamilyId | undefined, + dimensions: Required, +) { + const length = dimensions.length + const width = dimensions.width + const height = dimensions.height + const internal: LayoutAnchor[] = [ + { id: 'shell_center', role: 'shell_center', position: [0, height * 0.52, 0] }, + { id: 'top', role: 'top', position: [0, height, 0] }, + { id: 'bottom', role: 'bottom', position: [0, 0, 0] }, + { id: 'front', role: 'front', position: [0, height * 0.52, width * 0.52] }, + { id: 'back', role: 'back', position: [0, height * 0.52, -width * 0.52] }, + { id: 'left', role: 'left', position: [-length * 0.52, height * 0.52, 0] }, + { id: 'right', role: 'right', position: [length * 0.52, height * 0.52, 0] }, + { id: 'drive_side', role: 'drive_side', position: [-length * 0.42, height * 0.42, 0] }, + { id: 'service_side', role: 'service_side', position: [0, height * 0.55, width * 0.58] }, + ] + const rotating: LayoutAnchor[] = [ + { id: 'base', role: 'support_base', position: [0, height * 0.08, 0] }, + { id: 'drive', role: 'drive_motor', position: [-length * 0.28, height * 0.42, 0] }, + { id: 'process_body', role: 'main_casing', position: [length * 0.18, height * 0.42, 0] }, + { id: 'inlet', role: 'inlet_port', position: [length * 0.35, height * 0.42, width * 0.45] }, + { id: 'outlet', role: 'outlet_port', position: [length * 0.22, height * 0.68, 0] }, + { id: 'shell_center', role: 'shell_center', position: [0, height * 0.48, 0] }, + { id: 'drive_side', role: 'drive_side', position: [-length * 0.42, height * 0.36, 0] }, + { + id: 'service_side', + role: 'service_side', + position: [length * 0.18, height * 0.72, width * 0.55], + }, + ] + const vessel: LayoutAnchor[] = [ + { id: 'shell', role: 'vessel_shell', position: [0, height * 0.52, 0] }, + { id: 'shell_center', role: 'shell_center', position: [0, height * 0.52, 0] }, + { id: 'support', role: 'support_base', position: [0, height * 0.08, 0] }, + { id: 'top_nozzle', role: 'top_nozzle', position: [0, height * 1.03, 0] }, + { id: 'side_nozzle', role: 'side_nozzle', position: [0, height * 0.48, width * 0.56] }, + { + id: 'access', + role: 'access_platform', + position: [-length * 0.44, height * 0.5, width * 0.52], + }, + { id: 'drive_side', role: 'drive_side', position: [-length * 0.45, height * 0.22, 0] }, + { + id: 'service_side', + role: 'service_side', + position: [-length * 0.44, height * 0.5, width * 0.52], + }, + ] + const linear: LayoutAnchor[] = [ + { id: 'frame', role: 'transport_frame', position: [0, height * 0.42, 0] }, + { id: 'repeaters', role: 'repeating_elements', position: [0, height * 0.7, 0] }, + { id: 'surface', role: 'transport_surface', position: [0, height * 0.75, 0] }, + { id: 'drive', role: 'drive_motor', position: [-length * 0.44, height * 0.42, 0] }, + { id: 'shell_center', role: 'shell_center', position: [0, height * 0.48, 0] }, + { id: 'drive_side', role: 'drive_side', position: [-length * 0.48, height * 0.42, 0] }, + { id: 'service_side', role: 'service_side', position: [0, height * 0.68, width * 0.55] }, + ] + const box: LayoutAnchor[] = [ + { id: 'base', role: 'support_base', position: [0, height * 0.06, 0] }, + { id: 'body', role: 'enclosure_body', position: [0, height * 0.52, 0] }, + { id: 'shell_center', role: 'shell_center', position: [0, height * 0.52, 0] }, + { id: 'front_panel', role: 'front_panel', position: [0, height * 0.55, width * 0.51] }, + { + id: 'controls', + role: 'control_panel', + position: [length * 0.32, height * 0.58, width * 0.53], + }, + { + id: 'details', + role: 'detail_elements', + position: [-length * 0.3, height * 0.68, width * 0.53], + }, + { id: 'drive_side', role: 'drive_side', position: [-length * 0.46, height * 0.38, 0] }, + { + id: 'service_side', + role: 'service_side', + position: [length * 0.46, height * 0.58, width * 0.52], + }, + ] + const mergeInternal = (anchors: LayoutAnchor[]) => { + const ids = new Set(anchors.map((anchor) => anchor.id)) + return [...anchors, ...internal.filter((anchor) => !ids.has(anchor.id))] + } + switch (layoutFamily) { + case 'rotating_machine_layout': + return mergeInternal(rotating) + case 'vessel_layout': + return mergeInternal(vessel) + case 'linear_transport_layout': + return mergeInternal(linear) + case 'box_enclosure_layout': + case 'generic_industrial_layout': + return mergeInternal(box) + default: + return mergeInternal(box) + } +} + +function sideAnchorForPart(part: PartComposePartInput) { + return normalizeLayoutAnchor(part.anchor) ?? normalizeLayoutAnchor(part.side) +} + +function roleKey(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined +} + +function attachRolePosition( + part: PartComposePartInput, + parent: PartPlacement | undefined, + parentPart: PartComposePartInput | undefined, + dimensions: Required, +): Vec3 | undefined { + if (!parent || !parentPart) return undefined + const anchor = sideAnchorForPart(part) ?? 'shell_center' + const parentExtents = partHalfExtents(parentPart, normalizedPartKind(parentPart)) + const childExtents = partHalfExtents(part, normalizedPartKind(part)) + const center = parent.position + const along = (axis: PartAxis, sign: 1 | -1) => { + const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + const next: Vec3 = [...center] + next[index] += sign * (parentExtents[index] + childExtents[index] * 0.5) + return next + } + switch (anchor) { + case 'top': + return along('y', 1) + case 'bottom': + return along('y', -1) + case 'front': + return along('z', 1) + case 'back': + return along('z', -1) + case 'left': + return along('x', -1) + case 'right': + return along('x', 1) + case 'drive_side': + return [center[0] - dimensions.length * 0.36, center[1], center[2]] + case 'service_side': + return [center[0], center[1], center[2] + dimensions.width * 0.58] + default: + return center + } +} + +function resolveLayoutPlan( + profile: LayoutProfileInput, + parts: readonly PartComposePartInput[], + dimensions: LayoutDimensions = {}, +): LayoutPlan { + const resolvedDimensions: Required = { + length: layoutNumber(dimensions.length, layoutNumber(dimensions.diameter, 1.6)), + width: layoutNumber(dimensions.width, layoutNumber(dimensions.diameter, 0.8)), + height: layoutNumber(dimensions.height, 1.1), + diameter: layoutNumber(dimensions.diameter, layoutNumber(dimensions.width, 0.8)), + } + const layoutFamily = inferLayoutFamily(profile) + const anchors = layoutAnchors(layoutFamily, resolvedDimensions) + const anchorMap = new Map(anchors.map((anchor) => [anchor.id, anchor])) + const placements: PartPlacement[] = [] + const placedParts: PartComposePartInput[] = [] + const rolePlacements = new Map() + const registerPlacement = ( + part: PartComposePartInput, + index: number, + anchorId: string, + position: Vec3, + ) => { + const role = layoutRole(part) + const placement: PartPlacement = { + partId: String(part.id ?? `${layoutKind(part)}-${index + 1}`), + kind: layoutKind(part), + semanticRole: role, + anchorId, + position, + } + placements.push(placement) + placedParts.push({ ...part, position }) + const semanticKey = roleKey(role) + if (semanticKey && !rolePlacements.has(semanticKey)) + rolePlacements.set(semanticKey, { placement, part }) + const kindKey = roleKey(layoutKind(part)) + if (kindKey && !rolePlacements.has(kindKey)) rolePlacements.set(kindKey, { placement, part }) + } + parts.forEach((part, index) => { + const role = layoutRole(part) + const explicitAnchorId = sideAnchorForPart(part) + const anchorId = + explicitAnchorId && anchorMap.has(explicitAnchorId) + ? explicitAnchorId + : anchorForRole(layoutFamily, role) + const anchor = anchorMap.get(anchorId) ?? anchors[0] + const explicitPosition = Array.isArray(part.position) ? (part.position as Vec3) : undefined + const attachKey = roleKey(part.attachToRole) + const attached = attachKey ? rolePlacements.get(attachKey) : undefined + const attachedPosition = attachRolePosition( + part, + attached?.placement, + attached?.part, + resolvedDimensions, + ) + const basePosition = addLayoutOffset( + explicitPosition ?? + attachedPosition ?? + anchor?.position ?? [0, resolvedDimensions.height / 2, 0], + part.offset, + ) + for (const expanded of distributeArrayAlong(part, basePosition, resolvedDimensions)) { + registerPlacement(expanded.part, placedParts.length, anchorId, expanded.position) + } + }) + return { + family: profile.family, + layoutFamily, + anchors, + placements, + bounds: { + min: [-resolvedDimensions.length / 2, 0, -resolvedDimensions.width / 2], + max: [resolvedDimensions.length / 2, resolvedDimensions.height, resolvedDimensions.width / 2], + size: [resolvedDimensions.length, resolvedDimensions.height, resolvedDimensions.width], + }, + parts: placedParts, + } +} + +export function resolveLayout(plan: PartRelationshipLayoutInput): PartComposePartInput[] +export function resolveLayout( + profile: LayoutProfileInput, + parts: readonly PartComposePartInput[], + dimensions?: LayoutDimensions, +): LayoutPlan +export function resolveLayout( + first: PartRelationshipLayoutInput | LayoutProfileInput, + parts?: readonly PartComposePartInput[], + dimensions?: LayoutDimensions, +): PartComposePartInput[] | LayoutPlan { + if (Array.isArray(parts)) return resolveLayoutPlan(first as LayoutProfileInput, parts, dimensions) + return resolvePlacedParts(first as PartRelationshipLayoutInput) +} + +function familySpecForParts(present: PartComposeKind[]): PartFamilySpec { + const has = (kind: PartComposeKind) => present.includes(kind) + const group = ( + label: string, + anyOf: PartComposeKind[], + defaultPart: PartComposePartInput, + ): PartRequirementGroup => ({ label, anyOf, defaultPart }) + + if (has('desk_top') || has('leg_set') || has('drawer_stack')) { + return { + family: 'desk', + required: [ + group('desktop', ['desk_top'], { kind: 'desk_top' }), + group('legs/supports', ['leg_set', 'drawer_stack'], { kind: 'leg_set' }), + ], + optional: ['drawer_stack'], + recommendedDetails: [group('drawer stack', ['drawer_stack'], { kind: 'drawer_stack' })], + } + } + + if (has('electrical_cabinet') || has('cable_tray')) { + return { + family: 'electrical', + required: [group('cabinet', ['electrical_cabinet'], { kind: 'electrical_cabinet' })], + optional: ['cable_tray', 'nameplate', 'warning_label', 'vent_slats'], + recommendedDetails: [ + group('cable tray', ['cable_tray'], { + kind: 'cable_tray', + position: [0, 1.08, -0.32], + length: 1.1, + }), + group('nameplate', ['nameplate'], { + kind: 'nameplate', + position: [-0.12, 0.36, 0.13], + length: 0.16, + width: 0.05, + }), + group('warning label', ['warning_label'], { + kind: 'warning_label', + position: [-0.12, 0.7, 0.13], + length: 0.13, + width: 0.06, + }), + ], + } + } + + if (has('pipe_run') || has('pipe_elbow')) { + return { + family: 'pipe_system', + required: [group('straight pipe run', ['pipe_run'], { kind: 'pipe_run' })], + optional: ['pipe_elbow', 'flange_ring', 'valve_body'], + recommendedDetails: [ + group('elbow/bend', ['pipe_elbow'], { + kind: 'pipe_elbow', + position: [0.55, 0.55, 0], + radius: 0.055, + }), + group('flange', ['flange_ring'], { + kind: 'flange_ring', + connectTo: 'pipe_run', + connectPoint: 'open', + childPoint: 'back', + axis: 'x', + radius: 0.09, + }), + ], + } + } + + if (has('volute_casing') || has('impeller_blades') || has('inlet_port') || has('outlet_port')) { + return { + family: 'pump', + required: [ + group('base/skid', ['skid_base'], { kind: 'skid_base' }), + group('motor/body', ['ribbed_motor_body', 'rounded_machine_body', 'motor_housing'], { + kind: 'ribbed_motor_body', + position: [-0.28, 0.42, 0], + length: 0.48, + }), + group('volute casing', ['volute_casing'], { kind: 'volute_casing' }), + group('inlet port', ['inlet_port'], { + kind: 'inlet_port', + position: [0.22, 0.55, 0.4], + axis: 'z', + radius: 0.07, + }), + group('outlet port', ['outlet_port'], { + kind: 'outlet_port', + position: [0.47, 0.62, 0.12], + axis: 'x', + radius: 0.06, + }), + group('flange', ['flange_ring'], { + kind: 'flange_ring', + position: [0.22, 0.55, 0.54], + axis: 'z', + radius: 0.12, + }), + ], + optional: ['impeller_blades', 'control_box', 'vent_slats', 'bolt_pattern'], + recommendedDetails: [ + group('impeller', ['impeller_blades'], { + kind: 'impeller_blades', + position: [0.22, 0.55, 0.24], + count: 7, + radius: 0.14, + }), + group('nameplate', ['nameplate'], { kind: 'nameplate', position: [-0.28, 0.5, 0.19] }), + group('warning label', ['warning_label'], { + kind: 'warning_label', + position: [0.04, 0.62, 0.22], + }), + ], + } + } + + if (has('mixer_blades') || has('propeller_blade_set')) { + return { + family: 'unknown', + required: [], + optional: [], + recommendedDetails: [], + } + } + + if (has('fan_blade') || has('radial_blades') || has('protective_grill')) { + return { + family: 'fan', + required: [ + group('base', ['circular_base'], { kind: 'circular_base' }), + group('pole', ['vertical_pole'], { kind: 'vertical_pole' }), + group('support bracket', ['support_bracket'], { kind: 'support_bracket' }), + group('motor housing', ['motor_housing'], { kind: 'motor_housing' }), + group('editable fan blades', ['fan_blade', 'radial_blades'], { + kind: 'fan_blade', + count: 3, + }), + group('protective grill', ['protective_grill'], { kind: 'protective_grill' }), + ], + optional: ['control_knob'], + recommendedDetails: [group('control knob', ['control_knob'], { kind: 'control_knob' })], + } + } + + if (has('conveyor_frame') || has('roller_array') || has('belt_surface')) { + return { + family: 'conveyor', + required: [ + group('frame', ['conveyor_frame'], { kind: 'conveyor_frame' }), + group('rollers', ['roller_array'], { kind: 'roller_array' }), + group('belt', ['belt_surface'], { kind: 'belt_surface' }), + ], + optional: ['ribbed_motor_body', 'gearbox_body', 'warning_label'], + recommendedDetails: [ + group('drive motor', ['ribbed_motor_body'], { + kind: 'ribbed_motor_body', + position: [0.72, 0.5, 0.36], + radius: 0.08, + length: 0.24, + }), + group('warning label', ['warning_label'], { + kind: 'warning_label', + position: [0, 0.6, 0.24], + }), + ], + } + } + + if ( + has('tube_frame') || + has('chain_loop') || + (has('wheel_set') && (has('handlebar') || has('saddle') || has('fork'))) + ) { + return { + family: 'bicycle', + required: [ + group('wheels', ['wheel_set', 'wheel'], { + kind: 'wheel_set', + count: 2, + semanticRole: 'bicycle_tire', + }), + group('frame', ['tube_frame'], { kind: 'tube_frame', semanticRole: 'bicycle_frame' }), + group('fork', ['fork'], { kind: 'fork', semanticRole: 'bicycle_fork' }), + group('handlebar', ['handlebar'], { kind: 'handlebar' }), + group('saddle', ['saddle'], { kind: 'saddle' }), + group('chain', ['chain_loop'], { kind: 'chain_loop' }), + ], + optional: [], + recommendedDetails: [], + } + } + + if ( + has('aircraft_fuselage') || + has('aircraft_wing') || + has('aircraft_engine') || + has('aircraft_vertical_stabilizer') || + has('aircraft_horizontal_stabilizer') || + has('aircraft_landing_gear') + ) { + return { + family: 'aircraft', + required: [ + group('fuselage', ['aircraft_fuselage', 'streamlined_body'], { + kind: 'aircraft_fuselage', + id: 'fuselage', + }), + group('main wings', ['aircraft_wing', 'lofted_panel', 'airfoil_blade'], { + kind: 'aircraft_wing', + id: 'main-wings', + }), + group('aft engines', ['aircraft_engine'], { kind: 'aircraft_engine', id: 'engines' }), + group('vertical stabilizer', ['aircraft_vertical_stabilizer'], { + kind: 'aircraft_vertical_stabilizer', + id: 'vertical-stabilizer', + }), + group('horizontal stabilizer', ['aircraft_horizontal_stabilizer'], { + kind: 'aircraft_horizontal_stabilizer', + id: 'horizontal-stabilizer', + }), + group('landing gear', ['aircraft_landing_gear'], { + kind: 'aircraft_landing_gear', + id: 'landing-gear', + }), + ], + optional: ['window_strip', 'window_panel'], + recommendedDetails: [], + } + } + + if ( + has('body_shell') || + (has('wheel_set') && (has('window_strip') || has('light_pair') || has('bar_pair'))) + ) { + return { + family: 'vehicle', + required: [ + group('body', ['body_shell'], { kind: 'body_shell', semanticRole: 'vehicle_body' }), + group('wheels', ['wheel_set'], { + kind: 'wheel_set', + count: 4, + semanticRole: 'vehicle_tire', + }), + group('windows', ['window_strip'], { + kind: 'window_strip', + semanticRole: 'vehicle_window', + variant: 'vehicle_glasshouse', + }), + group('lights', ['light_pair'], { kind: 'light_pair', semanticRole: 'headlight' }), + group('bumper', ['bar_pair'], { kind: 'bar_pair' }), + ], + optional: ['seam_ring', 'nameplate'], + recommendedDetails: [ + group('panel seam', ['seam_ring'], { kind: 'seam_ring', axis: 'x', radius: 0.18 }), + ], + } + } + + if (has('valve_body') || has('handwheel')) { + return { + family: 'valve', + required: [ + group('valve body', ['valve_body'], { kind: 'valve_body' }), + group('handwheel', ['handwheel'], { + kind: 'handwheel', + connectTo: 'valve_body', + connectPoint: 'stem', + childPoint: 'center', + }), + ], + optional: ['flange_ring', 'bolt_pattern'], + recommendedDetails: [ + group('flanged ends', ['flange_ring'], { kind: 'flange_ring', radius: 0.12 }), + ], + } + } + + return { family: 'unknown', required: [], optional: [], recommendedDetails: [] } +} + +function partKinds(parts: PartComposePartInput[]): PartComposeKind[] { + return Array.from( + new Set(parts.map((part) => normalizedPartKind(part)).filter(Boolean)), + ) as PartComposeKind[] +} + +const singletonBlueprintParts = new Set([ + 'wheel_set', + 'tube_frame', + 'fork', + 'handlebar', + 'saddle', + 'chain_loop', + 'body_shell', + 'window_strip', + 'light_pair', + 'bar_pair', +]) + +function dedupeSingletonBlueprintParts(parts: PartComposePartInput[]): PartComposePartInput[] { + const seen = new Set() + return parts.filter((part) => { + const kind = normalizedPartKind(part) + if (!kind || !singletonBlueprintParts.has(kind)) return true + if (seen.has(kind)) return false + seen.add(kind) + return true + }) +} + +function hasAnyPart(present: PartComposeKind[], group: PartRequirementGroup): boolean { + return group.anyOf.some((kind) => present.includes(kind)) +} + +const aircraftRequiredPartKinds: PartComposeKind[] = [ + 'aircraft_fuselage', + 'aircraft_wing', + 'aircraft_engine', + 'aircraft_vertical_stabilizer', + 'aircraft_horizontal_stabilizer', + 'aircraft_landing_gear', +] + +function isAircraftPartKind(kind: PartComposeKind): boolean { + return ( + aircraftRequiredPartKinds.includes(kind) || + kind === 'streamlined_body' || + kind === 'lofted_panel' || + kind === 'airfoil_blade' + ) +} + +function aircraftDefaultParts(input: PartComposeInput): PartComposePartInput[] { + const dimensions = partInputDimensions(input) + const fuselageLength = clamp(dimensions.length ?? dimensions.depth, 1.12, 0.4, 20) + const scale = fuselageLength / 1.12 + const fuselageWidth = clamp(dimensions.width ?? dimensions.diameter, 0.13 * scale, 0.04, 3) + const fuselageHeight = clamp(dimensions.height, 0.145 * scale, 0.04, 3) + const gearRadius = clamp(undefined, 0.035 * scale, 0.012, 0.2) + const engineRadius = clamp(undefined, 0.032 * scale, 0.018, 0.34) + const verticalTailHeight = clamp(undefined, 0.21 * scale, 0.04, 1.15) + const fuselageCenterY = gearRadius * 3.8 + fuselageHeight * 0.6 + const wingY = fuselageCenterY - fuselageHeight * 0.18 + const engineY = wingY - Math.max(engineRadius * 1.1, fuselageHeight * 0.22) + const verticalTailY = fuselageCenterY + fuselageHeight * 0.48 + const horizontalTailY = verticalTailY + verticalTailHeight * 0.46 + const gearY = gearRadius * 1.08 + + return [ + { + kind: 'aircraft_fuselage', + id: 'fuselage', + name: 'Boeing 717 fuselage', + position: [0, fuselageCenterY, 0], + length: fuselageLength, + width: fuselageWidth, + height: fuselageHeight, + primaryColor: input.primaryColor ?? '#f8fafc', + accentColor: input.accentColor ?? '#0f8fb3', + noseRoundness: 0.42, + count: 14, + }, + { + kind: 'aircraft_wing', + id: 'main-wings', + name: 'low mounted swept wings', + position: [0.02 * scale, wingY, 0], + length: 0.95 * scale, + width: 0.14 * scale, + thickness: 0.009 * scale, + bladeSweep: 0.18, + }, + { + kind: 'aircraft_engine', + id: 'underwing-engines', + name: 'underwing turbofan engines', + position: [0.08 * scale, engineY, 0], + length: 0.16 * scale, + radius: engineRadius, + width: 0.36 * scale, + }, + { + kind: 'aircraft_vertical_stabilizer', + id: 'vertical-stabilizer', + name: 'swept vertical fin', + position: [-0.48 * scale, verticalTailY, 0], + length: 0.18 * scale, + height: verticalTailHeight, + width: 0.018 * scale, + }, + { + kind: 'aircraft_horizontal_stabilizer', + id: 't-tail', + name: 'T-tail horizontal stabilizer', + position: [-0.53 * scale, horizontalTailY, 0], + length: 0.3 * scale, + width: 0.07 * scale, + thickness: 0.008 * scale, + }, + { + kind: 'aircraft_landing_gear', + id: 'landing-gear', + name: 'tricycle landing gear', + position: [0.02 * scale, gearY, 0], + length: 0.62 * scale, + width: 0.32 * scale, + radius: gearRadius, + }, + ] +} + +function requestedDetails(input: PartComposeInput): boolean { + const text = `${input.name ?? ''}`.toLowerCase() + return /(detail|detailed|realistic|真实|细节|精细|铭牌|警示|螺栓|接缝|散热|label|nameplate|warning|bolt|seam)/i.test( + text, + ) +} + +function isAircraftIntent(input: PartComposeInput): boolean { + const text = [ + input.name, + input.partName, + input.geometryBrief, + ...(input.parts ?? []).map(partIdentityText), + ] + .map(textOf) + .join(' ') + .toLowerCase() + return /aircraft|airplane|airliner|plane|jet|boeing|airbus|fuselage|wing|t-tail|飞机|客机|波音|空客|机翼|机身/.test( + text, + ) +} + +export function assessPartVisualDetails(input: PartComposeInput = {}): PartVisualAssessment { + const present = partKinds(input.parts ?? []) + const blueprint = assessPartBlueprint(input) + const detailSet = new Set([ + ...blueprint.recommendedDetails, + 'nameplate', + 'warning_label', + 'seam_ring', + 'bolt_pattern', + 'vent_slats', + ]) + + const familySpecific: Partial> = { + pump: ['impeller_blades', 'nameplate', 'warning_label', 'flange_ring'], + fan: ['control_knob', 'protective_grill'], + conveyor: ['ribbed_motor_body', 'warning_label'], + vehicle: ['window_strip', 'light_pair', 'bar_pair', 'seam_ring'], + valve: ['flange_ring', 'handwheel'], + bicycle: ['chain_loop'], + desk: ['drawer_stack'], + pipe_system: ['pipe_elbow', 'flange_ring', 'valve_body'], + electrical: ['cable_tray', 'nameplate', 'warning_label', 'vent_slats'], + aircraft: [ + 'aircraft_fuselage', + 'aircraft_wing', + 'aircraft_engine', + 'aircraft_vertical_stabilizer', + 'aircraft_horizontal_stabilizer', + 'aircraft_landing_gear', + ], + } + + for (const kind of familySpecific[blueprint.family] ?? []) detailSet.add(kind) + + const expected = Array.from(detailSet) + const missingDetails = expected.filter((kind) => !present.includes(kind)) + const presentDetails = expected.filter((kind) => present.includes(kind)) + const score = + expected.length === 0 ? 1 : Number((presentDetails.length / expected.length).toFixed(4)) + return { + family: blueprint.family, + score, + presentDetails, + missingDetails, + recommendations: missingDetails.map((kind) => `Add visual detail ${kind}.`), + } +} + +function enhancePartBlueprintWithVisualDetails( + parts: PartComposePartInput[], + input: PartComposeInput, +): PartComposePartInput[] { + if (isRegistryPartPlanInput(input)) return parts + if (input.autoComplete === false) return parts + if (input.enhanceVisualDetails === false) return parts + const shouldEnhance = input.enhanceVisualDetails === true || requestedDetails(input) + if (!shouldEnhance) return parts + + const completed = [...parts] + const present = () => partKinds(completed) + const has = (kind: PartComposeKind) => present().includes(kind) + const addIfMissing = (part: PartComposePartInput) => { + const kind = normalizedPartKind(part) + if (kind && !has(kind)) completed.push(part) + } + const spec = familySpecForParts(present()) + if (spec.family === 'vehicle' && isAircraftIntent(input)) return parts + + switch (spec.family) { + case 'pump': + addIfMissing({ + kind: 'impeller_blades', + position: [0.22, 0.55, 0.24], + count: 7, + radius: 0.14, + }) + addIfMissing({ kind: 'nameplate', position: [-0.28, 0.5, 0.19] }) + addIfMissing({ kind: 'warning_label', position: [0.04, 0.62, 0.22] }) + break + case 'fan': + addIfMissing({ kind: 'control_knob' }) + break + case 'conveyor': + addIfMissing({ + kind: 'ribbed_motor_body', + position: [0.72, 0.5, 0.36], + radius: 0.08, + length: 0.24, + }) + addIfMissing({ kind: 'warning_label', position: [0, 0.6, 0.24] }) + break + case 'vehicle': + addIfMissing({ kind: 'seam_ring', axis: 'x', radius: 0.18 }) + addIfMissing({ kind: 'nameplate', position: [-0.42, 0.36, 0.28], length: 0.12, width: 0.05 }) + break + case 'valve': + addIfMissing({ + kind: 'flange_ring', + connectTo: 'valve_body', + connectPoint: 'inlet', + childPoint: 'back', + axis: 'x', + radius: 0.12, + }) + break + case 'desk': + addIfMissing({ kind: 'drawer_stack' }) + break + case 'pipe_system': + addIfMissing({ kind: 'pipe_elbow', position: [0.55, 0.55, 0], radius: 0.055 }) + addIfMissing({ + kind: 'flange_ring', + connectTo: 'pipe_run', + connectPoint: 'open', + childPoint: 'back', + axis: 'x', + radius: 0.09, + }) + break + case 'electrical': + addIfMissing({ kind: 'cable_tray', position: [0, 1.08, -0.32], length: 1.1 }) + addIfMissing({ kind: 'nameplate', position: [-0.12, 0.36, 0.13], length: 0.16, width: 0.05 }) + addIfMissing({ + kind: 'warning_label', + position: [-0.12, 0.7, 0.13], + length: 0.13, + width: 0.06, + }) + break + } + + return completed +} + +export function assessPartBlueprint(input: PartComposeInput = {}): PartBlueprintAssessment { + const present = partKinds(input.parts ?? []) + const spec = familySpecForParts(present) + const required = spec.required.map( + (requirement) => requirement.defaultPart.kind as PartComposeKind, + ) + const missing = spec.required + .filter((requirement) => !hasAnyPart(present, requirement)) + .map((requirement) => requirement.defaultPart.kind as PartComposeKind) + const recommendedDetails = spec.recommendedDetails.map( + (requirement) => requirement.defaultPart.kind as PartComposeKind, + ) + const missingDetails = spec.recommendedDetails + .filter((requirement) => !hasAnyPart(present, requirement)) + .map((requirement) => requirement.defaultPart.kind as PartComposeKind) + const requiredScore = + spec.required.length === 0 ? 1 : (spec.required.length - missing.length) / spec.required.length + const detailScore = + spec.recommendedDetails.length === 0 + ? 1 + : (spec.recommendedDetails.length - missingDetails.length) / spec.recommendedDetails.length + const score = Number((requiredScore * 0.82 + detailScore * 0.18).toFixed(4)) + const recommendations = [ + ...missing.map((kind) => `Add required ${kind}.`), + ...missingDetails.map((kind) => `Consider adding detail ${kind}.`), + ] + return { + family: spec.family, + required, + present, + missing, + optional: spec.optional, + recommendedDetails, + missingDetails, + score, + recommendations, + } +} + +function completePartBlueprint( + parts: PartComposePartInput[], + autoComplete: boolean | undefined, + input: PartComposeInput, +): PartComposePartInput[] { + const completed = dedupeSingletonBlueprintParts(parts) + const ballValve = + isBallValveIntent(input) || completed.some((part) => isBallValveIntent(input, part)) + const tuneValveDefaults = () => { + if (!ballValve) return + for (let i = 0; i < completed.length; i += 1) { + const part = completed[i] + if (!part) continue + const kind = normalizedPartKind(part) + if (kind === 'valve_body' && !part.valveStyle && !part.style && !part.variant) { + completed[i] = { ...part, valveStyle: 'ball' } + } + if (kind === 'handwheel' && !part.handleStyle && !part.style && !part.variant) { + completed[i] = { ...part, handleStyle: 'lever' } + } + } + } + if (isRegistryPartPlanInput(input)) { + tuneValveDefaults() + return completed + } + if (autoComplete === false) return completed + + if (isAircraftIntent(input)) { + const present = partKinds(completed) + const hasSpecificAircraftPart = present.some( + (kind) => aircraftRequiredPartKinds.includes(kind) || kind === 'aircraft_landing_gear', + ) + const hasGenericAircraftPart = present.some(isAircraftPartKind) + const defaults = aircraftDefaultParts(input) + if (!hasGenericAircraftPart) { + completed.push(...defaults) + } else if (hasSpecificAircraftPart) { + for (let index = 0; index < completed.length; index += 1) { + const part = completed[index] + if (!part) continue + const kind = normalizedPartKind(part) + const defaultPart = defaults.find((candidate) => normalizedPartKind(candidate) === kind) + if (defaultPart) completed[index] = { ...defaultPart, ...part } + } + const refreshedPresent = partKinds(completed) + for (const defaultPart of defaults) { + const defaultKind = normalizedPartKind(defaultPart) + if ( + defaultKind && + aircraftRequiredPartKinds.includes(defaultKind) && + !refreshedPresent.includes(defaultKind) + ) { + completed.push(defaultPart) + } + } + } + } + + for (let pass = 0; pass < 2; pass += 1) { + const present = partKinds(completed) + const spec = familySpecForParts(present) + if (spec.family === 'unknown') break + if (spec.family === 'vehicle' && isAircraftIntent(input)) break + + for (const requirement of spec.required) { + if (!hasAnyPart(present, requirement)) completed.push(requirement.defaultPart) + } + } + tuneValveDefaults() + + const completedKinds = partKinds(completed) + const completedFlangeCount = completed.filter( + (part) => normalizedPartKind(part) === 'flange_ring', + ).length + if (familySpecForParts(completedKinds).family === 'valve' && completedFlangeCount < 2) { + if (completedFlangeCount === 0) { + completed.push( + { + id: 'flange_inlet', + name: 'flange_inlet', + kind: 'flange_ring', + connectTo: 'valve_body', + connectPoint: 'inlet', + childPoint: 'front', + axis: 'x', + radius: 0.14, + }, + { + id: 'flange_outlet', + name: 'flange_outlet', + kind: 'flange_ring', + connectTo: 'valve_body', + connectPoint: 'outlet', + childPoint: 'back', + axis: 'x', + radius: 0.14, + }, + ) + } else { + completed.push({ + id: 'flange_outlet', + name: 'flange_outlet', + kind: 'flange_ring', + connectTo: 'valve_body', + connectPoint: 'outlet', + childPoint: 'back', + axis: 'x', + radius: 0.14, + }) + } + } + + return completed +} + +function semanticRoleForPartShape(kind: PartComposeKind, shape: PrimitiveShapeInput): string { + const name = shape.name?.toLowerCase() ?? '' + + switch (kind) { + case 'body_shell': + if (name.includes('body shell')) return 'vehicle_body' + if (name.includes('cabin')) return 'vehicle_cabin' + if (name.includes('pillar')) return 'vehicle_pillar' + if (name.includes('roof')) return 'vehicle_roof' + if (name.includes('deck')) return 'vehicle_deck' + if (name.includes('rocker')) return 'vehicle_rocker' + return 'vehicle_body_detail' + case 'wheel_set': + case 'wheel': + if (name.includes('bicycle') && name.includes('tire')) return 'bicycle_tire' + if ((name.includes('vehicle') || name.includes('car')) && name.includes('tire')) + return 'vehicle_tire' + if (name.includes('tire')) return 'wheel_tire' + if (name.includes('hub')) return 'wheel_hub' + return 'wheel_detail' + case 'window_panel': + return name.includes('vehicle') ? 'vehicle_window' : 'window_panel' + case 'window_strip': + return name.includes('vehicle') ? 'vehicle_window' : 'window_panel' + case 'light_pair': + return 'headlight' + case 'pyramid': + return 'pyramid' + case 'hemisphere': + return 'hemisphere' + case 'chimney_stack': + if (name.includes('base')) return 'chimney_base' + if (name.includes('rim')) return 'chimney_top_rim' + if (name.includes('seam')) return 'chimney_seam_ring' + if (name.includes('warning band')) return 'chimney_warning_band' + if (name.includes('door')) return 'access_door' + return 'chimney_body' + case 'bar_pair': + if (name.includes('front')) return 'front_bumper' + if (name.includes('rear')) return 'rear_bumper' + return 'bumper' + case 'tube_frame': + return name.includes('bicycle') || name.includes('bike') ? 'bicycle_frame' : 'tube_frame' + case 'fork': + return name.includes('bicycle') || name.includes('bike') ? 'bicycle_fork' : 'fork' + case 'handlebar': + return 'handlebar' + case 'saddle': + return 'saddle' + case 'chain_loop': + return 'chain_loop' + case 'radial_blades': + return name.includes('blade root') ? 'fan_blade_root' : 'fan_blade' + case 'fan_blade': + return name.includes('hub') ? 'fan_hub' : 'fan_blade' + case 'propeller_blade_set': + if (name.includes('hub')) return 'propeller_hub' + return 'propeller_blade' + case 'mixer_blades': + if (name.includes('root')) return 'mixer_blade_root' + if (name.includes('tip') || name.includes('edge')) return 'mixer_blade_edge' + return 'mixer_blade' + case 'airfoil_blade': + if (name.includes('hub')) return 'airfoil_hub' + return 'airfoil_blade' + case 'ellipsoid_shell': + if (name.includes('top access')) return 'ellipsoid_shell_top_opening' + if (name.includes('opening')) return 'ellipsoid_shell_opening' + if (name.includes('rim')) return 'ellipsoid_shell_rim' + return 'ellipsoid_shell' + case 'curved_lens_panel': + if (name.includes('rim')) return 'lens_rim' + return 'curved_lens' + case 'ergonomic_shell': + if (name.includes('button')) return 'mouse_button' + if (name.includes('scroll')) return 'scroll_wheel' + if (name.includes('nose')) return 'ergonomic_shell_nose' + if (name.includes('tail')) return 'ergonomic_shell_tail' + if (name.includes('base')) return 'ergonomic_shell_base' + return 'ergonomic_shell' + case 'aircraft_fuselage': + if (name.includes('cockpit')) return 'cockpit_window' + if (name.includes('window')) return 'cabin_window' + if (name.includes('stripe')) return 'aircraft_livery_stripe' + if (name.includes('nose')) return 'aircraft_nose' + if (name.includes('tail')) return 'aircraft_tail' + return 'aircraft_fuselage' + case 'aircraft_wing': + return 'aircraft_wing' + case 'aircraft_engine': + if (name.includes('fan')) return 'engine_fan' + if (name.includes('intake')) return 'engine_intake' + return 'engine_nacelle' + case 'aircraft_vertical_stabilizer': + return 'vertical_stabilizer' + case 'aircraft_horizontal_stabilizer': + return 'horizontal_stabilizer' + case 'aircraft_landing_gear': + if (name.includes('nose')) return 'aircraft_landing_gear_nose' + if (name.includes('main')) return 'aircraft_landing_gear_main' + return 'landing_gear_wheel' + case 'generic_body': + return name.includes('building') + ? 'building_body' + : name.includes('furniture') + ? 'furniture_body' + : 'main_body' + case 'generic_base': + return name.includes('cup') ? 'cup_platform' : 'support_base' + case 'generic_panel': + return 'panel' + case 'generic_handle': + return 'handle' + case 'generic_spout': + return 'spout' + case 'generic_control_panel': + return 'control_detail' + case 'generic_display': + return 'display' + case 'generic_foot_set': + return 'support_foot' + case 'generic_opening': + return 'opening' + case 'generic_detail_accent': + return 'detail_accent' + case 'manway_lid': + if (name.includes('gasket')) return 'manway_gasket' + if (name.includes('handle')) return 'manway_handle' + if (name.includes('bolt')) return 'manway_bolt' + return 'manway_lid' + case 'sanitary_nozzle': + if (name.includes('bead')) return 'sanitary_clamp_bead' + return 'sanitary_nozzle' + case 'jacket_shell': + if (name.includes('seam')) return 'jacket_seam' + return 'jacket_shell' + case 'sight_glass': + if (name.includes('rim')) return 'sight_glass_rim' + return 'sight_glass' + case 'sample_valve': + if (name.includes('handle')) return 'sample_valve_handle' + if (name.includes('body')) return 'sample_valve_body' + return 'sample_valve' + case 'instrument_port': + if (name.includes('gauge')) return 'instrument_gauge' + return 'instrument_port' + case 'stainless_highlight_panel': + return 'stainless_highlight_panel' + case 'mobile_platform_chassis': + if (name.includes('skirt')) return 'lower_bumper_skirt' + if (name.includes('deck')) return 'cargo_platform' + if (name.includes('status')) return 'status_light_strip' + return 'vehicle_body' + case 'lidar_sensor': + return name.includes('lens') ? 'sensor_lens' : 'navigation_sensor' + case 'emergency_stop_button': + if (name.includes('base')) return 'emergency_stop_base' + if (name.includes('guard')) return 'emergency_stop_guard' + return 'emergency_stop_button' + case 'status_light_strip': + return 'status_light_strip' + case 'operator_panel': + if (name.includes('screen')) return 'display_screen' + if (name.includes('button')) return 'control_button' + return 'control_panel' + case 'guard_fence': + if (name.includes('post')) return 'guard_fence_post' + return 'safety_barrier' + case 'pallet_table': + if (name.includes('leg')) return 'support_leg' + return 'pallet_table' + case 'bearing_block': + if (name.includes('base')) return 'bearing_base' + if (name.includes('ring')) return 'bearing_ring' + if (name.includes('bore')) return 'bearing_bore' + if (name.includes('bolt')) return 'mounting_bolt' + return 'bearing_block' + case 'structural_tower_frame': + if (name.includes('platform')) return 'multi_level_platform' + if (name.includes('rail')) return 'platform_guard_rail' + if (name.includes('internal stair flight')) return 'internal_stair_flight' + if (name.includes('internal stair landing')) return 'internal_stair_landing' + if (name.includes('stair flight')) return 'external_stair_flight' + if (name.includes('stair landing')) return 'external_stair_landing' + if (name.includes('diagonal') || name.includes('cross brace')) return 'tower_diagonal_brace' + if (name.includes('ladder')) return 'access_ladder' + if (name.includes('column')) return 'tower_column' + if (name.includes('beam')) return 'tower_beam' + return 'preheater_tower_body' + case 'cyclone_separator_unit': + if (name.includes('hopper') || name.includes('cone')) return 'cyclone_cone' + if (name.includes('outlet')) return 'cyclone_top_outlet' + if (name.includes('inlet') || name.includes('duct')) return 'preheater_gas_duct' + if (name.includes('drop pipe')) return 'meal_drop_pipe' + if (name.includes('band')) return 'cyclone_connection_band' + return 'preheater_cyclone' + case 'coupling_guard': + if (name.includes('flange')) return 'guard_end_flange' + return 'coupling_guard' + case 'motor_gearbox_unit': + if (name.includes('motor')) return 'drive_motor' + if (name.includes('shaft')) return 'output_shaft' + if (name.includes('rib')) return 'motor_cooling_rib' + return 'gearbox_body' + case 'pipe_manifold': + if (name.includes('branch')) return 'manifold_branch' + return 'pipe_manifold' + case 'hopper_body': + if (name.includes('outlet')) return 'hopper_outlet' + if (name.includes('leg')) return 'hopper_support_leg' + return 'hopper_body' + case 'conical_hopper': + if (name.includes('outlet')) return 'hopper_outlet_collar' + if (name.includes('leg')) return 'support_leg' + return 'conical_hopper' + case 'service_platform': + if (name.includes('post')) return 'platform_post' + if (name.includes('rail')) return 'guard_rail' + if (name.includes('ladder')) return 'access_ladder' + return 'service_platform' + case 'platform_with_ladder': + if (name.includes('rung')) return 'ladder_rung' + if (name.includes('side rail')) return 'ladder_side_rail' + if (name.includes('post')) return 'platform_post' + if (name.includes('rail')) return 'guard_rail' + if (name.includes('ladder')) return 'access_ladder' + return 'service_platform' + case 'kiosk_body': + return 'kiosk_body' + case 'kiosk_roof': + return 'roof' + case 'kiosk_opening': + return 'opening' + case 'kiosk_counter': + return 'service_counter' + case 'kiosk_sign': + return 'sign_panel' + case 'kiosk_awning': + return 'awning' + case 'streamlined_body': + if (name.includes('nose')) return 'streamlined_nose' + if (name.includes('tail')) return 'streamlined_tail' + if (name.includes('roof')) return 'streamlined_roof_arc' + return 'streamlined_body' + case 'lofted_panel': + if (name.includes('root')) return 'lofted_panel_root' + if (name.includes('tip')) return 'lofted_panel_tip' + if (name.includes('seam')) return 'lofted_panel_section' + return 'lofted_panel_segment' + case 'protective_grill': + return 'protective_grill' + case 'volute_casing': + return 'volute_casing' + case 'inlet_port': + return 'inlet_port' + case 'outlet_port': + return 'outlet_port' + case 'flange_ring': + if (name.includes('flange_inlet') || name.includes('inlet')) { + if (name.includes('gasket')) return 'flange_gasket' + return name.includes('bolt') ? 'flange_inlet_bolt' : 'flange_inlet' + } + if (name.includes('flange_outlet') || name.includes('outlet')) { + if (name.includes('gasket')) return 'flange_gasket' + return name.includes('bolt') ? 'flange_outlet_bolt' : 'flange_outlet' + } + return name.includes('bolt') ? 'flange_bolt' : 'flange_ring' + case 'flanged_nozzle': + if (name.includes('flange')) + return name.includes('bolt') ? 'nozzle_flange_bolt' : 'nozzle_flange' + if (name.includes('neck')) return 'flanged_nozzle' + return 'flanged_nozzle' + case 'inspection_hatch': + if (name.includes('hinge')) return 'hatch_hinge' + if (name.includes('handle')) return 'hatch_handle' + return 'inspection_hatch' + case 'valve_body': + if (name.includes('seat ring')) return 'seat_ring' + if (name.includes('ball bore')) return 'valve_bore' + if (name.includes('valve ball')) return 'valve_ball' + if (name.includes('bonnet bolt')) return 'bonnet_bolts' + if (name.includes('bonnet')) return 'bonnet' + if (name.includes('stem')) return 'stem' + if (name.includes('gate wedge')) return 'gate_wedge' + if (name.includes('yoke')) return 'yoke' + return 'valve_body' + case 'cylindrical_tank': + if (name.includes('nozzle')) return 'inlet_port' + if (name.includes('dished end')) return 'vessel_head' + return 'vessel_shell' + case 'liquid_volume': + return 'liquid_volume' + case 'agitator_tank': + if (name.includes('motor')) return 'agitator_motor' + if (name.includes('shaft')) return 'agitator_shaft' + if (name.includes('hub')) return 'reactor_impeller_hub' + if (name.includes('blade')) return 'reactor_impeller' + return 'reactor_vessel_shell' + case 'heat_exchanger': + if (name.includes('top nozzle')) return 'inlet_port' + if (name.includes('bottom nozzle')) return 'outlet_port' + if (name.includes('tube bundle')) return 'tube_bundle' + if (name.includes('channel head')) return 'heat_exchanger_channel_head' + return 'heat_exchanger_shell' + default: + return kind + } +} + +function normalizedRoleToken(role: unknown): string { + return typeof role === 'string' + ? role + .trim() + .toLowerCase() + .replace(/[\s-]+/g, '_') + : '' +} + +function shouldPreferPartShapeRole( + kind: PartComposeKind, + partRole: string, + inferredRole: string, +): boolean { + if (!partRole || partRole === inferredRole) return false + switch (kind) { + case 'tube_frame': + return ( + inferredRole === 'bicycle_frame' && + ['frame', 'bike_frame', 'bicycle', 'bike', 'complete_bicycle'].includes(partRole) + ) + case 'fork': + return ( + inferredRole === 'bicycle_fork' && + ['fork', 'front_fork', 'bike_fork', 'bicycle', 'bike', 'complete_bicycle'].includes( + partRole, + ) + ) + case 'handlebar': + return [ + 'bike_handlebar', + 'bike_handlebars', + 'bicycle_handlebar', + 'bicycle_handlebars', + ].includes(partRole) + case 'saddle': + return [ + 'seat', + 'bike_seat', + 'bicycle_seat', + 'bike_saddle', + 'bicycle_saddle', + 'bicycle', + 'bike', + ].includes(partRole) + case 'chain_loop': + return [ + 'chain', + 'bicycle_chain', + 'crank', + 'bicycle_crank', + 'chainring', + 'bicycle_chainring', + 'pedal', + 'pedals', + 'bicycle_pedals', + ].includes(partRole) + case 'cylindrical_tank': + case 'agitator_tank': + case 'heat_exchanger': + return true + default: + return false + } +} + +function semanticRoleForTaggedPartShape( + kind: PartComposeKind, + shape: PrimitiveShapeInput, + part: PartComposePartInput, +): string { + const inferredRole = semanticRoleForPartShape(kind, shape) + const partRole = normalizedRoleToken(part.semanticRole) + if (shouldPreferPartShapeRole(kind, partRole, inferredRole)) return inferredRole + return partRole || inferredRole +} + +function tagGeneratedPartShapes( + shapes: PrimitiveShapeInput[], + startIndex: number, + kind: PartComposeKind, + part: PartComposePartInput, + index: number, +) { + const sourcePartId = part.id ?? part.name ?? part.partName ?? `${kind}-${index + 1}` + for (let i = startIndex; i < shapes.length; i += 1) { + const shape = shapes[i] + if (!shape) continue + shape.sourcePartKind ??= part.sourcePartKind ?? kind + shape.sourcePartId ??= sourcePartId + shape.semanticGroup ??= part.semanticGroup ?? sourcePartId + shape.semanticRole ??= + kind === 'body_shell' + ? semanticRoleForPartShape(kind, shape) + : semanticRoleForTaggedPartShape(kind, shape, part) + } +} + +export function composePartPrimitives(input: PartComposeInput = {}): PrimitiveShapeInput[] { + input = normalizePartComposeInput(input) + const origin = input.position ?? [0, 0, 0] + const requestedParts = applyMixerPartDefaults(input.parts ?? [], input) + const completedBlueprintParts = completePartBlueprint(requestedParts, input.autoComplete, input) + const completedParts = applyContextualPartDefaults( + applyBicycleLayoutDefaults(applyVehicleLayoutDefaults(completedBlueprintParts, input), input), + input, + ) + const detailedParts = applyMixerPartDefaults( + enhancePartBlueprintWithVisualDetails(completedParts, input), + input, + ) + const parts = resolveLayout({ + parts: applyMixerPartDefaults( + applyBicycleLayoutDefaults( + applyContextualPartDefaults(applyVehicleLayoutDefaults(detailedParts, input), input), + input, + ), + input, + ), + }) + const shapes: PrimitiveShapeInput[] = [] + + parts.forEach((part, index) => { + const kind = normalizedPartKind(part) + if (!kind) return + + const startIndex = shapes.length + switch (kind) { + case 'circular_base': + shapes.push(...composeCircularBase(input, part, origin, index)) + break + case 'vertical_pole': + shapes.push(...composeVerticalPole(input, part, origin, index)) + break + case 'motor_housing': + shapes.push(...composeMotorHousing(input, part, origin, index)) + break + case 'fan_blade': + shapes.push(...composeFanBladeArray(input, part, origin)) + break + case 'radial_blades': + shapes.push(...composeRadialBlades(input, part, origin)) + break + case 'protective_grill': + shapes.push(...composeProtectiveGrill(input, part, origin)) + break + case 'pyramid': + shapes.push(...composePyramid(input, part, origin)) + break + case 'hemisphere': + shapes.push(...composeHemisphere(input, part, origin)) + break + case 'support_bracket': + shapes.push(...composeSupportBracket(input, part, origin)) + break + case 'control_knob': + shapes.push(...composeControlKnob(input, part, origin, index)) + break + case 'vent_slats': + case 'vent_grill': + shapes.push(...composeVentSlats(input, part, origin, kind)) + break + case 'skid_base': + shapes.push(...composeSkidBase(input, part, origin)) + break + case 'rounded_machine_body': + shapes.push(...composeRoundedMachineBody(input, part, origin)) + break + case 'volute_casing': + shapes.push(...composeVoluteCasing(input, part, origin)) + break + case 'impeller_blades': + shapes.push(...composeImpellerBlades(input, part, origin)) + break + case 'propeller_blade_set': + shapes.push(...composePropellerBladeSet(input, part, origin)) + break + case 'mixer_blades': + shapes.push(...composeMixerBlades(input, part, origin)) + break + case 'airfoil_blade': + shapes.push(...composeAirfoilBlade(input, part, origin)) + break + case 'ellipsoid_shell': + shapes.push(...composeEllipsoidShell(input, part, origin)) + break + case 'curved_lens_panel': + shapes.push(...composeCurvedLensPanel(input, part, origin)) + break + case 'ergonomic_shell': + shapes.push(...composeErgonomicShell(input, part, origin)) + break + case 'streamlined_body': + shapes.push(...composeStreamlinedBody(input, part, origin)) + break + case 'aircraft_fuselage': + shapes.push(...composeAircraftFuselage(input, part, origin)) + break + case 'aircraft_wing': + shapes.push(...composeAircraftWing(input, part, origin)) + break + case 'aircraft_engine': + shapes.push(...composeAircraftEngine(input, part, origin)) + break + case 'aircraft_vertical_stabilizer': + shapes.push(...composeAircraftVerticalStabilizer(input, part, origin)) + break + case 'aircraft_horizontal_stabilizer': + shapes.push(...composeAircraftHorizontalStabilizer(input, part, origin)) + break + case 'aircraft_landing_gear': + shapes.push(...composeAircraftLandingGear(input, part, origin)) + break + case 'generic_body': + shapes.push(...composeGenericBody(input, part, origin)) + break + case 'generic_base': + shapes.push(...composeGenericBase(input, part, origin)) + break + case 'generic_panel': + case 'generic_control_panel': + case 'generic_display': + case 'generic_opening': + case 'generic_detail_accent': + shapes.push(...composeGenericPanel(input, part, origin, kind)) + break + case 'generic_handle': + shapes.push(...composeGenericHandle(input, part, origin)) + break + case 'generic_spout': + shapes.push(...composeGenericSpout(input, part, origin)) + break + case 'generic_foot_set': + shapes.push(...composeGenericFootSet(input, part, origin)) + break + case 'mobile_platform_chassis': + shapes.push(...composeMobilePlatformChassis(input, part, origin)) + break + case 'lidar_sensor': + shapes.push(...composeLidarSensor(input, part, origin)) + break + case 'emergency_stop_button': + shapes.push(...composeEmergencyStopButton(input, part, origin)) + break + case 'status_light_strip': + shapes.push(...composeStatusLightStrip(input, part, origin)) + break + case 'operator_panel': + shapes.push(...composeOperatorPanel(input, part, origin)) + break + case 'guard_fence': + shapes.push(...composeGuardFence(input, part, origin)) + break + case 'pallet_table': + shapes.push(...composePalletTable(input, part, origin)) + break + case 'bearing_block': + shapes.push(...composeBearingBlock(input, part, origin)) + break + case 'support_roller_pair': + shapes.push(...composeSupportRollerPair(input, part, origin)) + break + case 'structural_tower_frame': + shapes.push(...composeStructuralTowerFrame(input, part, origin)) + break + case 'helical_ladder': + case 'helical_stair': + shapes.push(...composeHelicalStair(input, part, origin)) + break + case 'cyclone_separator_unit': + shapes.push(...composeCycloneSeparatorUnit(input, part, origin)) + break + case 'coupling_guard': + shapes.push(...composeCouplingGuard(input, part, origin)) + break + case 'motor_gearbox_unit': + shapes.push(...composeMotorGearboxUnit(input, part, origin)) + break + case 'pipe_manifold': + shapes.push(...composePipeManifold(input, part, origin)) + break + case 'hopper_body': + shapes.push(...composeHopperBody(input, part, origin)) + break + case 'conical_hopper': + shapes.push(...composeConicalHopper(input, part, origin)) + break + case 'service_platform': + shapes.push(...composeServicePlatform(input, part, origin)) + break + case 'platform_with_ladder': + shapes.push(...composePlatformWithLadder(input, part, origin)) + break + case 'kiosk_body': + shapes.push(...composeKioskBody(input, part, origin)) + break + case 'kiosk_roof': + shapes.push(...composeKioskRoof(input, part, origin)) + break + case 'kiosk_opening': + shapes.push(...composeKioskOpening(input, part, origin)) + break + case 'kiosk_counter': + shapes.push(...composeKioskCounter(input, part, origin)) + break + case 'kiosk_sign': + shapes.push(...composeKioskSign(input, part, origin)) + break + case 'kiosk_awning': + shapes.push(...composeKioskAwning(input, part, origin)) + break + case 'lofted_panel': + shapes.push(...composeLoftedPanel(input, part, origin)) + break + case 'pipe_port': + shapes.push(...composePipePort(input, part, origin, 'pipe_port')) + break + case 'inlet_port': + shapes.push(...composePipePort(input, part, origin, 'inlet_port')) + break + case 'outlet_port': + shapes.push(...composePipePort(input, part, origin, 'outlet_port')) + break + case 'flange_ring': + shapes.push(...composeFlangeRing(input, part, origin)) + break + case 'flanged_nozzle': + shapes.push(...composeFlangedNozzle(input, part, origin)) + break + case 'manway_lid': + shapes.push(...composeManwayLid(input, part, origin)) + break + case 'inspection_hatch': + shapes.push(...composeInspectionHatch(input, part, origin)) + break + case 'sanitary_nozzle': + shapes.push(...composeSanitaryNozzle(input, part, origin)) + break + case 'jacket_shell': + shapes.push(...composeJacketShell(input, part, origin)) + break + case 'sight_glass': + shapes.push(...composeSightGlass(input, part, origin)) + break + case 'sample_valve': + shapes.push(...composeSampleValve(input, part, origin)) + break + case 'instrument_port': + shapes.push(...composeInstrumentPort(input, part, origin)) + break + case 'stainless_highlight_panel': + shapes.push(...composeStainlessHighlightPanel(input, part, origin)) + break + case 'bolt_pattern': + shapes.push(...composeBoltPattern(input, part, origin)) + break + case 'control_box': + shapes.push(...composeControlBox(input, part, origin)) + break + case 'ribbed_motor_body': + shapes.push(...composeRibbedMotorBody(input, part, origin)) + break + case 'conveyor_frame': + shapes.push(...composeConveyorFrame(input, part, origin)) + break + case 'roller_array': + shapes.push(...composeRollerArray(input, part, origin)) + break + case 'belt_surface': + shapes.push(...composeBeltSurface(input, part, origin)) + break + case 'cylindrical_tank': + shapes.push(...composeCylindricalTank(input, part, origin)) + break + case 'storage_tank_shell': + shapes.push(...composeStorageTankShell(input, part, origin)) + break + case 'liquid_volume': + shapes.push(...composeLiquidVolume(input, part, origin)) + break + case 'cooling_tower_shell': + shapes.push(...composeCoolingTowerShell(input, part, origin)) + break + case 'cooling_tower_rim': + shapes.push(...composeCoolingTowerRim(input, part, origin)) + break + case 'chimney_stack': + shapes.push(...composeChimneyStack(input, part, origin)) + break + case 'valve_body': + shapes.push(...composeValveBody(input, part, origin)) + break + case 'handwheel': + shapes.push(...composeHandwheel(input, part, origin)) + break + case 'wheel': + case 'wheel_set': { + const partName = + `${part.id ?? ''} ${part.name ?? ''} ${part.partName ?? ''} ${part.kind ?? ''}`.toLowerCase() + const role = normalizedRoleToken(part.semanticRole) + const completeBicycleContext = isCompleteBicycleParts(input.parts ?? []) + const hasExplicitCount = + typeof part.count === 'number' && Number.isFinite(part.count) && part.count > 0 + const forceSingleWheel = + !completeBicycleContext && + !hasExplicitCount && + (kind === 'wheel' || + role === 'wheel' || + role === 'vehicle_wheel' || + role === 'car_wheel' || + role === 'bicycle_wheel' || + role === 'bike_wheel') + const wheelPart = { ...part, count: forceSingleWheel ? 1 : part.count } + const tireRole = wheelTireRole(input, wheelPart, partName) + shapes.push( + ...(tireRole === 'bicycle_tire' + ? composeBicycleWheels(input, wheelPart, origin) + : composeWheelSet(input, wheelPart, origin)), + ) + break + } + case 'tube_frame': + shapes.push(...composeBicycleFrame(input, part, origin)) + break + case 'fork': + shapes.push(...composeBicycleFork(input, part, origin)) + break + case 'handlebar': + shapes.push(...composeHandlebar(input, part, origin)) + break + case 'saddle': + shapes.push(...composeSaddle(input, part, origin)) + break + case 'chain_loop': + shapes.push(...composeChainLoop(input, part, origin)) + break + case 'body_shell': + shapes.push(...composeVehicleBody(input, part, origin)) + break + + case 'window_panel': + shapes.push(...composeWindowPanel(input, part, origin)) + break + case 'window_strip': + shapes.push(...composeWindowStrip(input, part, origin)) + break + case 'light_pair': + shapes.push(...composeHeadlights(input, part, origin)) + break + case 'bar_pair': + shapes.push(...composeBumper(input, part, origin)) + break + case 'gearbox_body': + shapes.push(...composeGearboxBody(input, part, origin)) + break + case 'filter_vessel': + shapes.push(...composeFilterVessel(input, part, origin)) + break + case 'heat_exchanger': + shapes.push(...composeHeatExchanger(input, part, origin)) + break + case 'agitator_tank': + shapes.push(...composeAgitatorTank(input, part, origin)) + break + case 'pipe_rack': + shapes.push(...composePipeRack(input, part, origin)) + break + case 'platform_ladder': + shapes.push(...composePlatformLadder(input, part, origin)) + break + case 'desk_top': + shapes.push(...composeDeskTop(input, part, origin)) + break + case 'leg_set': + shapes.push(...composeLegSet(input, part, origin)) + break + case 'drawer_stack': + shapes.push(...composeDrawerStack(input, part, origin)) + break + case 'electrical_cabinet': + shapes.push(...composeElectricalCabinet(input, part, origin)) + break + case 'pipe_run': + shapes.push(...composePipeRun(input, part, origin)) + break + case 'pipe_elbow': + shapes.push(...composePipeElbow(input, part, origin)) + break + case 'cable_tray': + shapes.push(...composeCableTray(input, part, origin)) + break + case 'nameplate': + shapes.push(...composeNameplate(input, part, origin)) + break + case 'warning_label': + shapes.push(...composeWarningLabel(input, part, origin)) + break + case 'seam_ring': + shapes.push(...composeSeamRing(input, part, origin)) + break + } + tagGeneratedPartShapes(shapes, startIndex, kind, part, index) + }) + + return shapes +} diff --git a/packages/core/src/lib/part-registry.test.ts b/packages/core/src/lib/part-registry.test.ts new file mode 100644 index 000000000..6f73e818d --- /dev/null +++ b/packages/core/src/lib/part-registry.test.ts @@ -0,0 +1,788 @@ +import { describe, expect, test } from 'bun:test' +import { + getPartCapabilityMetadata, + normalizeAircraftPartPlan, + normalizeCompressorPartPlan, + normalizeConveyorPartPlan, + normalizeDeskPartPlan, + normalizeElectricalPartPlan, + normalizeFanPartPlan, + normalizeGenericPartPlan, + normalizeHeatExchangerPartPlan, + normalizeKioskPartPlan, + normalizeMachineToolPartPlan, + normalizePartPlanForFamily, + normalizePipeSystemPartPlan, + normalizePumpPartPlan, + normalizeReactorPartPlan, + normalizeTankPartPlan, + normalizeVehiclePartPlan, + partCapabilitySummary, +} from './part-registry' + +describe('part registry', () => { + test('exposes LLM-safe part parameters', () => { + const summary = partCapabilitySummary('vehicle') + + expect(summary).toContain('vehicle.body_shell') + expect(summary).toContain('wheel_set') + expect(summary).toContain('radius:number[0.15,0.8]') + expect(summary).toContain('editable(dimensions=length|width|height') + expect(summary).toContain('materials=primaryColor') + }) + + test('classifies reusable part parameters for profile packs and LLM edits', () => { + const metadata = getPartCapabilityMetadata('pump') + const motor = metadata.find((part) => part.kind === 'ribbed_motor_body') + const flange = metadata.find((part) => part.kind === 'flange_ring') + + expect(motor).toEqual( + expect.objectContaining({ + id: 'pump.ribbed_motor_body', + family: 'pump', + semanticRole: 'drive_motor', + dimensionProperties: expect.arrayContaining(['length', 'radius']), + quantityProperties: expect.arrayContaining(['slatCount']), + materialProperties: expect.arrayContaining(['primaryColor']), + }), + ) + expect(flange).toEqual( + expect.objectContaining({ + kind: 'flange_ring', + quantityProperties: expect.arrayContaining(['boltCount']), + dimensionProperties: expect.arrayContaining(['radius']), + detailProperties: expect.arrayContaining(['detailLevel']), + }), + ) + }) + + test('exposes independent fan blade arrays for editable fan profiles', () => { + const metadata = getPartCapabilityMetadata('fan') + const blade = metadata.find((part) => part.kind === 'fan_blade') + const grill = metadata.find((part) => part.kind === 'protective_grill') + + expect(blade).toEqual( + expect.objectContaining({ + id: 'fan.fan_blade', + semanticRole: 'fan_blade', + quantityProperties: expect.arrayContaining(['count']), + dimensionProperties: expect.arrayContaining(['length', 'width', 'thickness']), + materialProperties: expect.arrayContaining(['primaryColor']), + }), + ) + expect(grill).toEqual( + expect.objectContaining({ + id: 'fan.protective_grill', + semanticRole: 'protective_grill', + detailProperties: expect.arrayContaining(['detailLevel']), + }), + ) + + const plan = normalizeFanPartPlan({ + parts: [ + { id: 'blades', kind: 'fan_blade', count: 6, primaryColor: '#ef4444' }, + { id: 'grill', kind: 'protective_grill', detailLevel: 'low' }, + ], + }) + expect(plan.parts.some((part) => part.kind === 'fan_blade' && part.count === 6)).toBe(true) + expect(plan.parts.find((part) => part.kind === 'protective_grill')).toEqual( + expect.objectContaining({ detailLevel: 'low', ringCount: 3, spokeCount: 12 }), + ) + expect( + normalizePartPlanForFamily('fan', { + parts: [{ id: 'grill', kind: 'protective_grill', detailLevel: 'low' }], + })?.parts.find((part) => part.kind === 'protective_grill'), + ).toEqual(expect.objectContaining({ ringCount: 3, spokeCount: 12 })) + }) + + test('preserves repeated industrial parts when explicit ids distinguish instances', () => { + const plan = normalizeTankPartPlan({ + parts: [ + { id: 'riding-ring-tail', kind: 'flange_ring', semanticRole: 'riding_ring' }, + { id: 'riding-ring-head', kind: 'flange_ring', semanticRole: 'riding_ring' }, + { id: 'girth-gear', kind: 'flange_ring', semanticRole: 'girth_gear' }, + { id: 'support-roller-tail', kind: 'bearing_block', semanticRole: 'support_roller' }, + { id: 'support-roller-head', kind: 'bearing_block', semanticRole: 'support_roller' }, + ], + }) + + expect(plan.parts.filter((part) => part.kind === 'flange_ring')).toHaveLength(3) + expect(plan.parts.filter((part) => part.kind === 'bearing_block')).toHaveLength(2) + expect(plan.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'girth-gear', semanticRole: 'girth_gear' }), + expect.objectContaining({ id: 'support-roller-head', semanticRole: 'support_roller' }), + ]), + ) + }) + + test('exposes reusable mobile platform parts for industrial profile packs', () => { + const metadata = getPartCapabilityMetadata('generic') + const chassis = metadata.find((part) => part.kind === 'mobile_platform_chassis') + const lidar = metadata.find((part) => part.kind === 'lidar_sensor') + const eStop = metadata.find((part) => part.kind === 'emergency_stop_button') + const lightStrip = metadata.find((part) => part.kind === 'status_light_strip') + const operatorPanel = metadata.find((part) => part.kind === 'operator_panel') + const guardFence = metadata.find((part) => part.kind === 'guard_fence') + const palletTable = metadata.find((part) => part.kind === 'pallet_table') + const bearingBlock = metadata.find((part) => part.kind === 'bearing_block') + const couplingGuard = metadata.find((part) => part.kind === 'coupling_guard') + const motorGearbox = metadata.find((part) => part.kind === 'motor_gearbox_unit') + const pipeManifold = metadata.find((part) => part.kind === 'pipe_manifold') + const hopperBody = metadata.find((part) => part.kind === 'hopper_body') + const servicePlatform = metadata.find((part) => part.kind === 'service_platform') + const hemisphere = metadata.find((part) => part.kind === 'hemisphere') + + expect(chassis).toEqual( + expect.objectContaining({ + id: 'generic.mobile_platform_chassis', + semanticRole: 'vehicle_body', + dimensionProperties: expect.arrayContaining(['length', 'width', 'height']), + materialProperties: expect.arrayContaining(['primaryColor', 'secondaryColor']), + }), + ) + expect(lidar).toEqual( + expect.objectContaining({ + semanticRole: 'navigation_sensor', + dimensionProperties: expect.arrayContaining(['radius', 'height']), + placementProperties: expect.arrayContaining(['axis']), + }), + ) + expect(eStop).toEqual( + expect.objectContaining({ + semanticRole: 'emergency_stop_button', + materialProperties: expect.arrayContaining(['color']), + }), + ) + expect(lightStrip).toEqual( + expect.objectContaining({ + semanticRole: 'status_light_strip', + placementProperties: expect.arrayContaining(['side']), + }), + ) + expect(operatorPanel).toEqual( + expect.objectContaining({ + semanticRole: 'control_panel', + materialProperties: expect.arrayContaining(['primaryColor', 'accentColor']), + }), + ) + expect(guardFence).toEqual( + expect.objectContaining({ + semanticRole: 'safety_barrier', + quantityProperties: expect.arrayContaining(['count']), + }), + ) + expect(palletTable).toEqual( + expect.objectContaining({ + semanticRole: 'pallet_table', + dimensionProperties: expect.arrayContaining(['length', 'width', 'height']), + }), + ) + expect(bearingBlock).toEqual( + expect.objectContaining({ + semanticRole: 'bearing_block', + dimensionProperties: expect.arrayContaining(['length', 'width', 'height', 'radius']), + }), + ) + expect(couplingGuard).toEqual( + expect.objectContaining({ + semanticRole: 'coupling_guard', + dimensionProperties: expect.arrayContaining(['length', 'radius', 'thickness']), + }), + ) + expect(motorGearbox).toEqual( + expect.objectContaining({ + semanticRole: 'drive_unit', + materialProperties: expect.arrayContaining(['primaryColor', 'secondaryColor']), + }), + ) + expect(pipeManifold).toEqual( + expect.objectContaining({ + semanticRole: 'pipe_manifold', + quantityProperties: expect.arrayContaining(['count']), + }), + ) + expect(hopperBody).toEqual( + expect.objectContaining({ + semanticRole: 'hopper_body', + dimensionProperties: expect.arrayContaining(['length', 'width', 'height']), + }), + ) + expect(servicePlatform).toEqual( + expect.objectContaining({ + semanticRole: 'service_platform', + dimensionProperties: expect.arrayContaining(['length', 'width', 'height']), + shapeProperties: expect.arrayContaining(['overallHeight']), + detailProperties: expect.arrayContaining(['detailLevel']), + }), + ) + expect(hemisphere).toEqual( + expect.objectContaining({ + id: 'generic.hemisphere', + semanticRole: 'hemisphere', + dimensionProperties: expect.arrayContaining(['radius', 'diameter', 'height']), + quantityProperties: expect.arrayContaining(['widthSegments', 'heightSegments']), + }), + ) + }) + + test('exposes reusable process-vessel detail parts for industry packs', () => { + const metadata = getPartCapabilityMetadata('generic') + const details = new Map(metadata.map((part) => [part.kind, part])) + + expect(details.get('manway_lid')).toEqual( + expect.objectContaining({ + semanticRole: 'manway_lid', + dimensionProperties: expect.arrayContaining(['radius', 'thickness']), + quantityProperties: expect.arrayContaining(['boltCount']), + }), + ) + expect(details.get('sanitary_nozzle')).toEqual( + expect.objectContaining({ + semanticRole: 'sanitary_nozzle', + placementProperties: expect.arrayContaining(['axis']), + }), + ) + expect(details.get('flanged_nozzle')).toEqual( + expect.objectContaining({ + semanticRole: 'flanged_nozzle', + dimensionProperties: expect.arrayContaining(['radius', 'length']), + shapeProperties: expect.arrayContaining(['flangeRadius', 'flangeThickness']), + quantityProperties: expect.arrayContaining(['boltCount']), + placementProperties: expect.arrayContaining(['axis', 'side']), + }), + ) + expect(details.get('inspection_hatch')).toEqual( + expect.objectContaining({ + semanticRole: 'inspection_hatch', + dimensionProperties: expect.arrayContaining(['radius', 'thickness']), + placementProperties: expect.arrayContaining(['axis', 'side']), + }), + ) + expect(details.get('jacket_shell')).toEqual( + expect.objectContaining({ + semanticRole: 'jacket_shell', + dimensionProperties: expect.arrayContaining(['radius', 'height', 'thickness']), + }), + ) + expect(details.get('sight_glass')).toEqual( + expect.objectContaining({ + semanticRole: 'sight_glass', + placementProperties: expect.arrayContaining(['side']), + materialProperties: expect.arrayContaining(['color']), + }), + ) + expect(details.get('sample_valve')).toEqual( + expect.objectContaining({ + semanticRole: 'sample_valve', + placementProperties: expect.arrayContaining(['side']), + }), + ) + expect(details.get('instrument_port')).toEqual( + expect.objectContaining({ + semanticRole: 'instrument_port', + placementProperties: expect.arrayContaining(['axis']), + }), + ) + expect(details.get('stainless_highlight_panel')).toEqual( + expect.objectContaining({ + semanticRole: 'stainless_highlight_panel', + materialProperties: expect.arrayContaining(['color']), + }), + ) + expect(details.get('conical_hopper')).toEqual( + expect.objectContaining({ + semanticRole: 'conical_hopper', + dimensionProperties: expect.arrayContaining(['radiusTop', 'radiusBottom', 'height']), + shapeProperties: expect.arrayContaining(['outletRadius']), + quantityProperties: expect.arrayContaining(['radialSegments']), + }), + ) + expect(details.get('platform_with_ladder')).toEqual( + expect.objectContaining({ + semanticRole: 'service_platform', + dimensionProperties: expect.arrayContaining(['length', 'width', 'height']), + quantityProperties: expect.arrayContaining(['rungCount']), + }), + ) + expect(details.get('helical_stair')).toEqual( + expect.objectContaining({ + semanticRole: 'external_spiral_stair', + dimensionProperties: expect.arrayContaining(['height', 'innerRadius', 'outerRadius']), + quantityProperties: expect.arrayContaining(['stepCount', 'ringCount']), + placementProperties: expect.arrayContaining(['startAngle']), + }), + ) + expect(details.get('helical_ladder')).toEqual( + expect.objectContaining({ + semanticRole: 'external_spiral_ladder', + dimensionProperties: expect.arrayContaining(['height', 'innerRadius', 'outerRadius']), + quantityProperties: expect.arrayContaining(['stepCount', 'ringCount']), + placementProperties: expect.arrayContaining(['startAngle']), + }), + ) + }) + + test('normalizes vehicle part aliases and clamps unsafe parameters', () => { + const plan = normalizeVehiclePartPlan({ + primaryColor: '#cc0000', + parts: [ + { kind: 'car body', params: { length: 4.8, width: 1.9, height: 1.4 } }, + { kind: 'huge tire', params: { count: 5, radius: 2.4 } }, + ], + }) + + expect(plan.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'body_shell', + semanticRole: 'vehicle_body', + length: 4.8, + primaryColor: '#cc0000', + }), + expect.objectContaining({ + kind: 'wheel_set', + semanticRole: 'vehicle_tire', + count: 4, + radius: 0.8, + }), + expect.objectContaining({ kind: 'window_strip' }), + expect.objectContaining({ kind: 'light_pair' }), + ]), + ) + expect(plan.warnings).toEqual( + expect.arrayContaining([ + 'wheel_set.count normalized from 5 to 4.', + 'wheel_set.radius clamped from 2.4 to 0.8.', + ]), + ) + }) + + test('normalizes desk parts and exposes drawer parameters', () => { + const summary = partCapabilitySummary('desk') + const plan = normalizeDeskPartPlan({ + name: 'office desk with drawers', + length: 1.5, + width: 0.7, + height: 0.75, + parts: [{ kind: 'drawer', params: { count: 9 } }], + }) + + expect(summary).toContain('desk.drawer_stack') + expect(summary).toContain('count:integer[1,6]') + expect(plan.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'desk_top', length: 1.5, width: 0.7 }), + expect.objectContaining({ kind: 'leg_set', length: 1.35, width: 0.574 }), + expect.objectContaining({ kind: 'drawer_stack', count: 6 }), + ]), + ) + expect(plan.warnings).toEqual( + expect.arrayContaining(['drawer_stack.count clamped from 9 to 6.']), + ) + }) + + test('normalizes aircraft aliases into a complete adjustable part plan', () => { + const summary = partCapabilitySummary('aircraft') + const plan = normalizeAircraftPartPlan({ + name: 'Boeing airliner', + length: 10, + primaryColor: '#ffffff', + parts: [ + { kind: 'fuselage', params: { count: 80, noseRoundness: 2 } }, + { kind: 'jet engine', params: { count: 5, radius: 1 } }, + { kind: 'wheel_set', semanticRole: 'aircraft_landing_gear_nose', params: { radius: 0.5 } }, + ], + }) + + expect(summary).toContain('aircraft.aircraft_fuselage') + expect(summary).toContain('aircraft.aircraft_engine') + expect(plan.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'aircraft_fuselage', + semanticRole: 'aircraft_fuselage', + length: 10, + primaryColor: '#ffffff', + count: 40, + noseRoundness: 1, + }), + expect.objectContaining({ + kind: 'aircraft_engine', + semanticRole: 'engine_nacelle', + count: 4, + radius: 0.36, + }), + expect.objectContaining({ + kind: 'aircraft_landing_gear', + semanticRole: 'landing_gear_wheel', + radius: 0.2, + }), + expect.objectContaining({ kind: 'aircraft_wing' }), + expect.objectContaining({ kind: 'aircraft_vertical_stabilizer' }), + expect.objectContaining({ kind: 'aircraft_horizontal_stabilizer' }), + ]), + ) + expect(plan.warnings).toEqual( + expect.arrayContaining([ + 'aircraft_fuselage.count clamped from 80 to 40.', + 'aircraft_fuselage.noseRoundness clamped from 2 to 1.', + 'aircraft_engine.count clamped from 5 to 4.', + 'aircraft_engine.radius clamped from 1 to 0.36.', + 'aircraft_landing_gear.radius clamped from 0.5 to 0.2.', + ]), + ) + }) + + test('builds generic fallback plans for unknown equipment', () => { + const summary = partCapabilitySummary('generic') + const plan = normalizeGenericPartPlan({ + name: 'futuristic coffee machine', + length: 1.2, + width: 0.6, + height: 1, + parts: [{ kind: 'control panel', params: { length: 8 } }], + }) + + expect(summary).toContain('generic.generic_body') + expect(summary).toContain('generic.generic_spout') + expect(plan.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'generic_body', semanticRole: 'main_body', length: 1.2 }), + expect.objectContaining({ + kind: 'generic_base', + semanticRole: 'support_base', + length: 1.296, + }), + expect.objectContaining({ + kind: 'generic_control_panel', + semanticRole: 'control_detail', + length: 4, + }), + expect.objectContaining({ kind: 'generic_spout', semanticRole: 'spout' }), + expect.objectContaining({ kind: 'generic_base', semanticRole: 'cup_platform' }), + ]), + ) + expect(plan.warnings).toEqual( + expect.arrayContaining(['generic_control_panel.length clamped from 8 to 4.']), + ) + }) + + test('normalizes kiosk aliases and preserves explicit part params', () => { + const summary = partCapabilitySummary('kiosk') + const plan = normalizeKioskPartPlan({ + name: 'ticket booth', + length: 2, + width: 1.4, + height: 2.4, + primaryColor: '#e5e7eb', + parts: [ + { kind: 'service window', params: { length: 0.9 } }, + { kind: 'sign', params: { length: 0.7, height: 0.2 } }, + ], + }) + + expect(summary).toContain('kiosk.kiosk_body') + expect(summary).toContain('kiosk.kiosk_opening') + expect(plan.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'kiosk_body', + semanticRole: 'kiosk_body', + length: 2, + width: 1.4, + height: 1.8719999999999999, + primaryColor: '#e5e7eb', + }), + expect.objectContaining({ kind: 'kiosk_roof', semanticRole: 'roof' }), + expect.objectContaining({ kind: 'kiosk_opening', semanticRole: 'opening', length: 0.9 }), + expect.objectContaining({ + kind: 'kiosk_sign', + semanticRole: 'sign_panel', + length: 0.7, + height: 0.2, + }), + expect.objectContaining({ kind: 'kiosk_awning', semanticRole: 'awning' }), + ]), + ) + }) + + test('normalizes industrial pump parts with adjustable dimensions', () => { + const summary = partCapabilitySummary('pump') + const plan = normalizePumpPartPlan({ + name: 'centrifugal pump', + length: 1.4, + width: 0.6, + height: 0.7, + primaryColor: '#64748b', + motorLength: 0.62, + inletDiameter: 0.18, + outletDiameter: 0.14, + flangeBoltCount: 10, + ribCount: 12, + parts: [{ kind: 'pump casing', params: { radius: 5 } }, { kind: 'flange' }], + }) + + expect(summary).toContain('pump.volute_casing') + expect(summary).toContain('pump.inlet_port') + expect(plan.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'skid_base', length: 1.4, width: 0.6 }), + expect.objectContaining({ + kind: 'ribbed_motor_body', + semanticRole: 'drive_motor', + length: 0.62, + radius: 0.168, + slatCount: 12, + }), + expect.objectContaining({ + kind: 'volute_casing', + semanticRole: 'volute_casing', + radius: 1.5, + primaryColor: '#64748b', + }), + expect.objectContaining({ kind: 'inlet_port', semanticRole: 'inlet_port', radius: 0.09 }), + expect.objectContaining({ + kind: 'outlet_port', + semanticRole: 'outlet_port', + radius: 0.07, + }), + expect.objectContaining({ kind: 'flange_ring', boltCount: 10 }), + ]), + ) + expect(plan.warnings).toEqual( + expect.arrayContaining(['volute_casing.radius clamped from 5 to 1.5.']), + ) + }) + + test('normalizes conveyor, electrical, and pipe industrial families', () => { + const conveyor = normalizeConveyorPartPlan({ + name: 'belt conveyor', + length: 4, + width: 0.8, + height: 0.9, + beltWidth: 0.64, + rollerCount: 14, + rollerRadius: 0.045, + legCount: 6, + }) + const electrical = normalizeElectricalPartPlan({ + name: 'control cabinet', + length: 0.9, + width: 0.35, + height: 1.8, + primaryColor: '#e5e7eb', + doorCount: 2, + ventRows: 8, + cableTrayRungCount: 11, + parts: [{ kind: 'cable tray', params: { length: 2 } }], + }) + const pipe = normalizePipeSystemPartPlan({ + name: 'process piping', + length: 3, + pipeDiameter: 0.16, + bendRadius: 0.42, + flangeBoltCount: 12, + valveStyle: 'ball', + parts: [{ kind: 'pipe elbow' }, { kind: 'flange' }, { kind: 'valve' }], + }) + const flange = pipe.parts.find((part) => part.kind === 'flange_ring') + + expect(conveyor.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'conveyor_frame', length: 4, width: 0.8, height: 0.9 }), + expect.objectContaining({ kind: 'conveyor_frame', legCount: 6 }), + expect.objectContaining({ kind: 'roller_array', length: 3.76, width: 0.64, count: 14 }), + expect.objectContaining({ kind: 'roller_array', radius: 0.045 }), + expect.objectContaining({ kind: 'belt_surface', length: 3.92, width: 0.64 }), + ]), + ) + expect(electrical.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'electrical_cabinet', + length: 0.9, + width: 0.35, + height: 1.8, + doorCount: 2, + slatCount: 8, + primaryColor: '#e5e7eb', + }), + expect.objectContaining({ kind: 'cable_tray', length: 2, slatCount: 11 }), + ]), + ) + expect(pipe.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'pipe_run', length: 3, radius: 0.08 }), + expect.objectContaining({ kind: 'pipe_elbow', radius: 0.08, bendRadius: 0.42 }), + expect.objectContaining({ kind: 'flange_ring', boltCount: 12 }), + expect.objectContaining({ kind: 'valve_body', radius: 0.08, valveStyle: 'ball' }), + ]), + ) + expect(flange?.radius).toBeCloseTo(0.124) + }) + + test('normalizes process equipment and machine tool families with editable part attributes', () => { + const summary = partCapabilitySummary() + const tank = normalizeTankPartPlan({ + name: 'vertical storage tank with access platform', + height: 3, + diameter: 1.2, + portDiameter: 0.16, + parts: [{ kind: 'platform' }, { kind: 'outlet' }], + }) + const reactor = normalizeReactorPartPlan({ + name: 'stirred reactor', + vesselHeight: 2, + diameter: 1.1, + nozzleDiameter: 0.16, + }) + const compressor = normalizeCompressorPartPlan({ + name: 'skid air compressor', + length: 2, + width: 0.8, + height: 0.8, + motorLength: 0.7, + portDiameter: 0.18, + parts: [{ kind: 'control panel' }], + }) + const exchanger = normalizeHeatExchangerPartPlan({ + name: 'shell and tube heat exchanger with support', + length: 2.4, + diameter: 0.5, + parts: [{ kind: 'support' }], + }) + const machine = normalizeMachineToolPartPlan({ + name: 'cnc machining center', + length: 2.8, + width: 1.1, + height: 1.7, + parts: [ + { + id: 'viewing_panel', + kind: 'generic_panel', + semanticRole: 'viewing_window', + centeredOn: 'enclosure', + side: 'front', + params: { length: 0.9, height: 0.7, thickness: 0.01, color: '#88CCEE' }, + }, + { + id: 'work_table', + kind: 'generic_panel', + semanticRole: 'work_table', + centeredOn: 'enclosure', + params: { length: 1, width: 0.7, thickness: 0.06 }, + }, + { + id: 'display', + kind: 'generic_display', + semanticRole: 'display_screen', + centeredOn: 'control_box', + side: 'front', + params: { length: 0.35, height: 0.28 }, + }, + { id: 'vents_left', kind: 'vent_slats', semanticRole: 'vent_panel', side: 'left' }, + { id: 'warning_front', kind: 'warning_label', semanticRole: 'warning_label' }, + { id: 'nameplate_front', kind: 'nameplate', semanticRole: 'nameplate' }, + ], + }) + + expect(summary).toContain('tank.cylindrical_tank') + expect(summary).toContain('reactor.agitator_tank') + expect(summary).toContain('compressor.rounded_machine_body') + expect(summary).toContain('heat_exchanger.heat_exchanger') + expect(summary).toContain('machine_tool.generic_body') + expect(tank.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'cylindrical_tank', length: 3, radius: 0.6, axis: 'y' }), + expect.objectContaining({ kind: 'outlet_port', radius: 0.08 }), + expect.objectContaining({ kind: 'platform_ladder' }), + ]), + ) + expect(reactor.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agitator_tank', height: 2, radius: 0.55 }), + expect.objectContaining({ kind: 'inlet_port', radius: 0.08 }), + expect.objectContaining({ kind: 'outlet_port', radius: 0.08 }), + ]), + ) + expect( + normalizeReactorPartPlan({ + name: 'stirred reactor', + parts: [{ id: 'impeller', kind: 'mixer_blades', count: 3 }], + }), + ).toMatchObject({ + warnings: [], + parts: expect.arrayContaining([ + expect.objectContaining({ + id: 'impeller', + kind: 'mixer_blades', + semanticRole: 'reactor_impeller', + count: 3, + }), + ]), + }) + expect(compressor.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'skid_base', semanticRole: 'machine_base' }), + expect.objectContaining({ kind: 'ribbed_motor_body', length: 0.7 }), + expect.objectContaining({ + kind: 'rounded_machine_body', + semanticRole: 'compressor_casing', + }), + expect.objectContaining({ kind: 'inlet_port', radius: 0.09 }), + expect.objectContaining({ kind: 'outlet_port', radius: 0.09 }), + expect.objectContaining({ kind: 'control_box', semanticRole: 'control_box' }), + ]), + ) + expect(exchanger.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'heat_exchanger', length: 2.4, radius: 0.25 }), + expect.objectContaining({ kind: 'skid_base', semanticRole: 'support_base' }), + ]), + ) + expect(machine.parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'generic_base', semanticRole: 'machine_base' }), + expect.objectContaining({ kind: 'generic_body', semanticRole: 'machine_enclosure' }), + expect.objectContaining({ kind: 'generic_panel', semanticRole: 'spindle_head' }), + expect.objectContaining({ + id: 'viewing_panel', + kind: 'generic_panel', + semanticRole: 'viewing_window', + centeredOn: 'enclosure', + side: 'front', + length: 0.9, + height: 0.7, + thickness: 0.01, + }), + expect.objectContaining({ + id: 'work_table', + kind: 'generic_panel', + semanticRole: 'work_table', + centeredOn: 'enclosure', + length: 1, + width: 0.7, + thickness: 0.06, + }), + expect.objectContaining({ kind: 'control_box', semanticRole: 'control_panel' }), + expect.objectContaining({ + id: 'display', + kind: 'generic_display', + semanticRole: 'display_screen', + centeredOn: 'control_box', + side: 'front', + length: 0.35, + height: 0.28, + }), + expect.objectContaining({ + id: 'vents_left', + kind: 'vent_slats', + semanticRole: 'vent_panel', + }), + expect.objectContaining({ id: 'warning_front', kind: 'warning_label' }), + expect.objectContaining({ id: 'nameplate_front', kind: 'nameplate' }), + ]), + ) + }) +}) diff --git a/packages/core/src/lib/part-registry.ts b/packages/core/src/lib/part-registry.ts new file mode 100644 index 000000000..b4caa7ced --- /dev/null +++ b/packages/core/src/lib/part-registry.ts @@ -0,0 +1,3949 @@ +import type { PartComposePartInput } from './part-compose' + +export type PartParameterType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'enum' + +export interface PartParameterDefinition { + type: PartParameterType + min?: number + max?: number + default?: unknown + values?: readonly unknown[] + description?: string +} + +export interface PartDefinition { + id: string + family: string + kind: string + semanticRole?: string + aliases: readonly string[] + required?: boolean + params: Record + attachTo?: string + layoutRole?: string + description: string +} + +export type PartEditableParameterRole = + | 'dimension' + | 'quantity' + | 'material' + | 'shape' + | 'detail' + | 'placement' + | 'metadata' + +export interface PartEditableParameter { + name: string + type: PartParameterType + role: PartEditableParameterRole + min?: number + max?: number + default?: unknown + values?: readonly unknown[] + description?: string +} + +export interface PartCapabilityMetadata { + id: string + family: string + kind: string + semanticRole?: string + aliases: readonly string[] + required: boolean + attachTo?: string + layoutRole?: string + description: string + editableParameters: readonly PartEditableParameter[] + editableProperties: readonly string[] + dimensionProperties: readonly string[] + quantityProperties: readonly string[] + materialProperties: readonly string[] + shapeProperties: readonly string[] + detailProperties: readonly string[] + placementProperties: readonly string[] +} + +export interface NormalizedPartPlan { + family: string + parts: PartComposePartInput[] + warnings: string[] +} + +export const VEHICLE_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'vehicle.body_shell', + family: 'vehicle', + kind: 'body_shell', + semanticRole: 'vehicle_body', + aliases: ['body', 'car_body', 'vehicle_body', 'main_body', 'chassis', '车身', '底盘'], + required: true, + description: 'Main vehicle body shell.', + params: { + length: { type: 'number', min: 2, max: 8, default: 4.4 }, + width: { type: 'number', min: 1, max: 3, default: 1.8 }, + height: { type: 'number', min: 0.7, max: 3, default: 1.35 }, + primaryColor: { type: 'color', default: '#cc0000' }, + vehicleStyle: { + type: 'enum', + values: ['sedan', 'suv', 'sports', 'truck', 'van'], + default: 'sedan', + }, + cornerRadius: { type: 'number', min: 0, max: 0.8 }, + }, + }, + { + id: 'vehicle.wheel_set', + family: 'vehicle', + kind: 'wheel_set', + semanticRole: 'vehicle_tire', + aliases: ['wheel', 'wheels', 'tire', 'tyre', 'vehicle_wheels', '车轮', '轮胎'], + required: true, + attachTo: 'body_shell', + layoutRole: 'lower_four_corners', + description: 'Vehicle wheel/tire set.', + params: { + count: { type: 'integer', values: [2, 4, 6], default: 4 }, + radius: { type: 'number', min: 0.15, max: 0.8, default: 0.38 }, + width: { type: 'number', min: 0.08, max: 0.5, default: 0.22 }, + wheelRadius: { type: 'number', min: 0.15, max: 0.8 }, + wheelWidth: { type: 'number', min: 0.08, max: 0.5 }, + hubColor: { type: 'color', default: '#d8d8d8' }, + }, + }, + { + id: 'vehicle.window_strip', + family: 'vehicle', + kind: 'window_strip', + semanticRole: 'vehicle_window', + aliases: ['window', 'windows', 'vehicle_windows', 'glass', 'windshield', '车窗', '玻璃'], + required: true, + attachTo: 'body_shell', + layoutRole: 'cabin_band', + description: 'Vehicle glasshouse/window band.', + params: { + height: { type: 'number', min: 0.12, max: 1.2, default: 0.42 }, + tint: { type: 'color', default: '#77aaff' }, + opacity: { type: 'number', min: 0.1, max: 1, default: 0.68 }, + variant: { type: 'enum', values: ['vehicle_glasshouse'], default: 'vehicle_glasshouse' }, + }, + }, + { + id: 'vehicle.light_pair', + family: 'vehicle', + kind: 'light_pair', + semanticRole: 'headlight', + aliases: ['light', 'lights', 'headlight', 'headlights', '车灯', '大灯'], + required: true, + attachTo: 'body_shell', + layoutRole: 'front_face', + description: 'Front light pair.', + params: { + size: { type: 'number', min: 0.04, max: 0.4, default: 0.12 }, + color: { type: 'color', default: '#f8fafc' }, + }, + }, + { + id: 'vehicle.bar_pair', + family: 'vehicle', + kind: 'bar_pair', + aliases: ['bumper', 'bumpers', 'front_bumper', 'rear_bumper', '保险杠'], + required: true, + attachTo: 'body_shell', + layoutRole: 'front_rear_bars', + description: 'Front and rear bumper bars.', + params: { + height: { type: 'number', min: 0.04, max: 0.35, default: 0.12 }, + thickness: { type: 'number', min: 0.02, max: 0.2, default: 0.06 }, + }, + }, + { + id: 'vehicle.seam_ring', + family: 'vehicle', + kind: 'seam_ring', + aliases: ['seam', 'panel_seam', 'trim', '腰线', '缝线'], + attachTo: 'body_shell', + layoutRole: 'body_detail', + description: 'Body seam or trim detail.', + params: { + radius: { type: 'number', min: 0.04, max: 0.5, default: 0.18 }, + }, + }, +] + +export const DESK_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'desk.desk_top', + family: 'desk', + kind: 'desk_top', + semanticRole: 'furniture_body', + aliases: ['desk', 'table', 'desktop', 'tabletop', 'worktop', 'office_desk', 'writing_desk'], + required: true, + description: 'Desk or table top slab.', + params: { + length: { type: 'number', min: 0.35, max: 4, default: 1.2 }, + width: { type: 'number', min: 0.2, max: 2, default: 0.6 }, + height: { type: 'number', min: 0.02, max: 0.18, default: 0.055 }, + primaryColor: { type: 'color', default: '#b7794b' }, + }, + }, + { + id: 'desk.leg_set', + family: 'desk', + kind: 'leg_set', + semanticRole: 'support_leg', + aliases: ['legs', 'table_legs', 'desk_legs', 'support_legs', 'feet', 'supports'], + required: true, + attachTo: 'desk_top', + layoutRole: 'four_corners_under_top', + description: 'Four desk/table legs with rear stretcher.', + params: { + length: { type: 'number', min: 0.25, max: 4, default: 1.08 }, + width: { type: 'number', min: 0.15, max: 2, default: 0.5 }, + height: { type: 'number', min: 0.12, max: 1.4, default: 0.7 }, + radius: { type: 'number', min: 0.008, max: 0.09, default: 0.025 }, + metalColor: { type: 'color', default: '#9ca3af' }, + }, + }, + { + id: 'desk.drawer_stack', + family: 'desk', + kind: 'drawer_stack', + semanticRole: 'drawer_stack', + aliases: ['drawer', 'drawers', 'drawer_stack', 'cabinet_drawers', 'side_drawers'], + attachTo: 'desk_top', + layoutRole: 'side_under_top', + description: 'Optional drawer cabinet under one side of a desk.', + params: { + length: { type: 'number', min: 0.14, max: 1.2, default: 0.34 }, + width: { type: 'number', min: 0.12, max: 1, default: 0.44 }, + height: { type: 'number', min: 0.16, max: 1.1, default: 0.52 }, + count: { type: 'integer', min: 1, max: 6, default: 3 }, + primaryColor: { type: 'color', default: '#a16207' }, + secondaryColor: { type: 'color', default: '#c08457' }, + }, + }, +] + +export const FAN_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'fan.circular_base', + family: 'fan', + kind: 'circular_base', + semanticRole: 'fan_base', + aliases: ['base', 'round_base', 'pedestal_base', 'fan_base'], + required: true, + description: 'Weighted circular base for a standing or pedestal fan.', + params: { + radius: { type: 'number', min: 0.05, max: 2, default: 0.28 }, + height: { type: 'number', min: 0.01, max: 0.4, default: 0.08 }, + primaryColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'fan.vertical_pole', + family: 'fan', + kind: 'vertical_pole', + semanticRole: 'fan_pole', + aliases: ['pole', 'stand', 'vertical_pole', 'pedestal_pole'], + required: true, + attachTo: 'circular_base', + layoutRole: 'vertical_support', + description: 'Vertical support pole for a standing fan.', + params: { + radius: { type: 'number', min: 0.005, max: 0.15, default: 0.025 }, + height: { type: 'number', min: 0.05, max: 4, default: 1.05 }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'fan.support_bracket', + family: 'fan', + kind: 'support_bracket', + semanticRole: 'fan_yoke', + aliases: ['support_bracket', 'yoke', 'tilt_bracket', 'neck_bracket'], + attachTo: 'vertical_pole', + layoutRole: 'head_support', + description: 'Yoke or bracket between pole and fan head.', + params: { + width: { type: 'number', min: 0.04, max: 1, default: 0.24 }, + height: { type: 'number', min: 0.03, max: 0.8, default: 0.16 }, + depth: { type: 'number', min: 0.01, max: 0.3, default: 0.045 }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'fan.motor_housing', + family: 'fan', + kind: 'motor_housing', + semanticRole: 'motor_housing', + aliases: ['motor', 'motor_housing', 'rear_motor', 'fan_head'], + required: true, + attachTo: 'support_bracket', + layoutRole: 'fan_head_center', + description: 'Rear motor housing at the center of the fan head.', + params: { + radius: { type: 'number', min: 0.03, max: 0.5, default: 0.11 }, + depth: { type: 'number', min: 0.03, max: 0.8, default: 0.16 }, + primaryColor: { type: 'color', default: '#30343b' }, + }, + }, + { + id: 'fan.fan_blade', + family: 'fan', + kind: 'fan_blade', + semanticRole: 'fan_blade', + aliases: ['fan_blade', 'blade', 'editable_blade', 'independent_blade'], + required: true, + attachTo: 'motor_housing', + layoutRole: 'radial_blade_array', + description: 'Independent editable fan blade array; each generated blade has its own part id.', + params: { + count: { type: 'integer', min: 1, max: 16, default: 1 }, + length: { type: 'number', min: 0.04, max: 1.2, default: 0.24 }, + width: { type: 'number', min: 0.012, max: 0.55 }, + thickness: { type: 'number', min: 0.003, max: 0.08, default: 0.018 }, + pitch: { type: 'number', min: -0.8, max: 0.8, default: 0.24 }, + bladeSweep: { type: 'number', min: -0.55, max: 0.55 }, + primaryColor: { type: 'color', default: '#8ec5ff' }, + includeHub: { type: 'boolean', default: true }, + }, + }, + { + id: 'fan.radial_blades', + family: 'fan', + kind: 'radial_blades', + semanticRole: 'fan_blade', + aliases: ['fan_blades', 'radial_blades', 'blade_set', 'impeller'], + attachTo: 'motor_housing', + layoutRole: 'radial_blade_set', + description: 'Composite radial fan blade set kept for compatibility with older profiles.', + params: { + count: { type: 'integer', min: 2, max: 8, default: 3 }, + bladeRadius: { type: 'number', min: 0.05, max: 1.4, default: 0.28 }, + bladeWidth: { type: 'number', min: 0.01, max: 0.8 }, + bladePitch: { type: 'number', min: -0.65, max: 0.65, default: 0.24 }, + primaryColor: { type: 'color', default: '#8ec5ff' }, + }, + }, + { + id: 'fan.protective_grill', + family: 'fan', + kind: 'protective_grill', + semanticRole: 'protective_grill', + aliases: ['grill', 'grille', 'cage', 'guard', 'protective_grill'], + attachTo: 'motor_housing', + layoutRole: 'front_guard', + description: 'Protective cage with rings and spokes around the fan blades.', + params: { + radius: { type: 'number', min: 0.08, max: 2, default: 0.36 }, + depth: { type: 'number', min: 0.005, max: 0.6, default: 0.12 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + ringCount: { type: 'integer', min: 1, max: 8, default: 4 }, + spokeCount: { type: 'integer', min: 6, max: 36, default: 18 }, + wireRadius: { type: 'number', min: 0.002, max: 0.05 }, + metalColor: { type: 'color', default: '#d1d5db' }, + }, + }, + { + id: 'fan.control_knob', + family: 'fan', + kind: 'control_knob', + semanticRole: 'control_knob', + aliases: ['knob', 'control_knob', 'speed_knob'], + attachTo: 'vertical_pole', + layoutRole: 'control_detail', + description: 'Small speed or oscillation control knob.', + params: { + radius: { type: 'number', min: 0.01, max: 0.2, default: 0.045 }, + depth: { type: 'number', min: 0.004, max: 0.12, default: 0.025 }, + accentColor: { type: 'color', default: '#ef4444' }, + }, + }, +] + +export const AIRCRAFT_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'aircraft.aircraft_fuselage', + family: 'aircraft', + kind: 'aircraft_fuselage', + semanticRole: 'aircraft_fuselage', + aliases: [ + 'aircraft', + 'airplane', + 'airliner', + 'plane', + 'jet', + 'fuselage', + 'fuselage_body', + 'streamlined_body', + 'airplane_body', + 'aircraft_body', + ], + required: true, + description: 'Streamlined aircraft fuselage with conformal windows and livery stripes.', + params: { + length: { type: 'number', min: 0.4, max: 20 }, + width: { type: 'number', min: 0.05, max: 3 }, + height: { type: 'number', min: 0.04, max: 3 }, + count: { type: 'integer', min: 6, max: 40 }, + primaryColor: { type: 'color' }, + accentColor: { type: 'color' }, + noseRoundness: { type: 'number', min: 0, max: 1 }, + tailTaper: { type: 'number', min: 0, max: 1 }, + roofArc: { type: 'number', min: 0, max: 0.4 }, + }, + }, + { + id: 'aircraft.aircraft_wing', + family: 'aircraft', + kind: 'aircraft_wing', + semanticRole: 'aircraft_wing', + aliases: [ + 'wing', + 'wings', + 'main_wing', + 'main_wings', + 'left_wing', + 'right_wing', + 'swept_wing', + 'airfoil_blade', + 'lofted_panel', + ], + required: true, + attachTo: 'aircraft_fuselage', + layoutRole: 'main_low_wings', + description: 'Symmetric swept aircraft wing pair.', + params: { + length: { type: 'number', min: 0.2, max: 16 }, + width: { type: 'number', min: 0.04, max: 1.2 }, + thickness: { type: 'number', min: 0.004, max: 0.08 }, + bladeSweep: { type: 'number', min: -0.5, max: 0.5 }, + verticalCurve: { type: 'number', min: -0.25, max: 0.25 }, + color: { type: 'color' }, + }, + }, + { + id: 'aircraft.aircraft_engine', + family: 'aircraft', + kind: 'aircraft_engine', + semanticRole: 'engine_nacelle', + aliases: [ + 'engine', + 'engines', + 'jet_engine', + 'jet_engines', + 'turbofan', + 'turbofans', + 'nacelle', + 'nacelles', + 'engine_nacelle', + 'engine_nacelles', + ], + required: true, + attachTo: 'aircraft_wing', + layoutRole: 'underwing_pair', + description: 'Aircraft turbofan nacelles with intake lips and fan disks.', + params: { + count: { type: 'integer', min: 1, max: 4 }, + radius: { type: 'number', min: 0.018, max: 0.36 }, + length: { type: 'number', min: 0.05, max: 1.25 }, + width: { type: 'number', min: 0.06, max: 4 }, + color: { type: 'color' }, + }, + }, + { + id: 'aircraft.aircraft_vertical_stabilizer', + family: 'aircraft', + kind: 'aircraft_vertical_stabilizer', + semanticRole: 'vertical_stabilizer', + aliases: [ + 'vertical_stabilizer', + 'vertical_stabiliser', + 'vertical_tail', + 'vertical_fin', + 'tail_fin', + 'rudder', + ], + required: true, + attachTo: 'aircraft_fuselage', + layoutRole: 'tail_fin', + description: 'Swept vertical tail fin.', + params: { + length: { type: 'number', min: 0.04, max: 1.5 }, + height: { type: 'number', min: 0.04, max: 1.4 }, + width: { type: 'number', min: 0.004, max: 0.16 }, + color: { type: 'color' }, + }, + }, + { + id: 'aircraft.aircraft_horizontal_stabilizer', + family: 'aircraft', + kind: 'aircraft_horizontal_stabilizer', + semanticRole: 'horizontal_stabilizer', + aliases: [ + 'horizontal_stabilizer', + 'horizontal_stabiliser', + 'horizontal_tail', + 'tailplane', + 't_tail', + 't_tail_stabilizer', + ], + required: true, + attachTo: 'aircraft_vertical_stabilizer', + layoutRole: 't_tail', + description: 'Horizontal tail stabilizer pair.', + params: { + length: { type: 'number', min: 0.08, max: 2.5 }, + width: { type: 'number', min: 0.03, max: 0.8 }, + thickness: { type: 'number', min: 0.003, max: 0.08 }, + verticalCurve: { type: 'number', min: -0.25, max: 0.25 }, + color: { type: 'color' }, + }, + }, + { + id: 'aircraft.aircraft_landing_gear', + family: 'aircraft', + kind: 'aircraft_landing_gear', + semanticRole: 'landing_gear_wheel', + aliases: [ + 'landing_gear', + 'landing_wheel', + 'landing_wheels', + 'landing_gear_wheel', + 'nose_gear', + 'main_gear', + 'wheel_set', + 'wheels', + ], + required: true, + attachTo: 'aircraft_fuselage', + layoutRole: 'tricycle_gear', + description: 'Tricycle landing gear wheels and struts.', + params: { + length: { type: 'number', min: 0.06, max: 2.5 }, + width: { type: 'number', min: 0.04, max: 1.4 }, + radius: { type: 'number', min: 0.012, max: 0.2 }, + wheelRadius: { type: 'number', min: 0.012, max: 0.2 }, + color: { type: 'color' }, + }, + }, +] + +export const GENERIC_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'generic.generic_body', + family: 'generic', + kind: 'generic_body', + semanticRole: 'main_body', + aliases: [ + 'body', + 'main_body', + 'housing', + 'shell', + 'cabinet_body', + 'equipment_body', + 'building_body', + 'furniture_body', + ], + required: true, + description: 'Generic primary body/housing for unknown long-tail objects.', + params: { + length: { type: 'number', min: 0.08, max: 8, default: 1 }, + width: { type: 'number', min: 0.05, max: 5, default: 0.65 }, + height: { type: 'number', min: 0.05, max: 5, default: 0.8 }, + primaryColor: { type: 'color', default: '#8b9aae' }, + cornerRadius: { type: 'number', min: 0, max: 0.5 }, + }, + }, + { + id: 'generic.generic_base', + family: 'generic', + kind: 'generic_base', + semanticRole: 'support_base', + aliases: ['base', 'support_base', 'platform', 'cup_platform', 'plinth', 'skid'], + required: true, + description: 'Generic base, plinth, or platform.', + params: { + length: { type: 'number', min: 0.08, max: 8, default: 1.08 }, + width: { type: 'number', min: 0.05, max: 5, default: 0.72 }, + height: { type: 'number', min: 0.01, max: 0.8, default: 0.08 }, + thickness: { type: 'number', min: 0.01, max: 0.8 }, + darkColor: { type: 'color', default: '#1f2937' }, + }, + }, + { + id: 'generic.generic_panel', + family: 'generic', + kind: 'generic_panel', + semanticRole: 'panel', + aliases: ['panel', 'front_panel', 'side_panel', 'access_panel', 'roof'], + description: 'Generic flat panel or cover.', + params: { + length: { type: 'number', min: 0.02, max: 4, default: 0.3 }, + height: { type: 'number', min: 0.02, max: 3, default: 0.22 }, + thickness: { type: 'number', min: 0.002, max: 0.4, default: 0.025 }, + color: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'generic.generic_handle', + family: 'generic', + kind: 'generic_handle', + semanticRole: 'handle', + aliases: ['handle', 'pull_handle', 'door_handle'], + description: 'Generic small handle or pull.', + params: { + length: { type: 'number', min: 0.03, max: 2, default: 0.22 }, + radius: { type: 'number', min: 0.004, max: 0.12, default: 0.018 }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.generic_spout', + family: 'generic', + kind: 'generic_spout', + semanticRole: 'spout', + aliases: ['spout', 'nozzle_spout', 'coffee_spout', 'dispense_spout'], + description: 'Generic dispensing spout or short nozzle.', + params: { + length: { type: 'number', min: 0.02, max: 1.2, default: 0.2 }, + radius: { type: 'number', min: 0.004, max: 0.2, default: 0.035 }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.generic_control_panel', + family: 'generic', + kind: 'generic_control_panel', + semanticRole: 'control_detail', + aliases: ['control_panel', 'control_detail', 'buttons', 'button_panel'], + description: 'Generic control/button panel.', + params: { + length: { type: 'number', min: 0.02, max: 4, default: 0.3 }, + height: { type: 'number', min: 0.02, max: 3, default: 0.22 }, + thickness: { type: 'number', min: 0.002, max: 0.4, default: 0.025 }, + accentColor: { type: 'color', default: '#38bdf8' }, + }, + }, + { + id: 'generic.generic_display', + family: 'generic', + kind: 'generic_display', + semanticRole: 'display', + aliases: ['display', 'screen', 'readout'], + description: 'Generic dark display or screen panel.', + params: { + length: { type: 'number', min: 0.02, max: 4, default: 0.28 }, + height: { type: 'number', min: 0.02, max: 3, default: 0.16 }, + thickness: { type: 'number', min: 0.002, max: 0.4, default: 0.025 }, + color: { type: 'color', default: '#0f172a' }, + }, + }, + { + id: 'generic.generic_foot_set', + family: 'generic', + kind: 'generic_foot_set', + semanticRole: 'support_foot', + aliases: ['feet', 'foot_set', 'support_feet'], + description: 'Generic four-foot support set.', + params: { + radius: { type: 'number', min: 0.006, max: 0.18, default: 0.035 }, + height: { type: 'number', min: 0.02, max: 0.8, default: 0.08 }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.generic_opening', + family: 'generic', + kind: 'generic_opening', + semanticRole: 'opening', + aliases: ['opening', 'door_opening', 'window_opening'], + description: 'Generic dark door/window/opening panel.', + params: { + length: { type: 'number', min: 0.02, max: 4, default: 0.24 }, + height: { type: 'number', min: 0.02, max: 3, default: 0.28 }, + thickness: { type: 'number', min: 0.002, max: 0.4, default: 0.025 }, + color: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.generic_detail_accent', + family: 'generic', + kind: 'generic_detail_accent', + semanticRole: 'detail_accent', + aliases: ['detail', 'detail_accent', 'accent', 'trim'], + description: 'Generic small accent/detail marker.', + params: { + length: { type: 'number', min: 0.02, max: 4, default: 0.24 }, + height: { type: 'number', min: 0.02, max: 3, default: 0.14 }, + thickness: { type: 'number', min: 0.002, max: 0.4, default: 0.025 }, + accentColor: { type: 'color', default: '#38bdf8' }, + }, + }, + { + id: 'generic.hemisphere', + family: 'generic', + kind: 'hemisphere', + semanticRole: 'hemisphere', + aliases: [ + 'hemisphere', + 'half_sphere', + 'half-sphere', + 'semi_sphere', + 'semi-sphere', + 'dome', + 'half_dome', + 'half-dome', + '\u534a\u7403', + '\u534a\u7403\u4f53', + '\u534a\u5706\u7403', + '\u534a\u5706\u5f62\u7403', + ], + description: 'Closed half-sphere/dome primitive with editable footprint diameter and height.', + params: { + radius: { type: 'number', min: 0.01, max: 10, default: 0.5 }, + diameter: { type: 'number', min: 0.02, max: 20, default: 1 }, + length: { type: 'number', min: 0.02, max: 20 }, + width: { type: 'number', min: 0.02, max: 20 }, + height: { type: 'number', min: 0.01, max: 10 }, + primaryColor: { type: 'color', default: '#94a3b8' }, + widthSegments: { type: 'integer', min: 8, max: 64, default: 32 }, + heightSegments: { type: 'integer', min: 4, max: 32, default: 16 }, + }, + }, + { + id: 'generic.manway_lid', + family: 'generic', + kind: 'manway_lid', + semanticRole: 'manway_lid', + aliases: ['manway_lid', 'manway_cover', 'access_lid', 'hatch_cover'], + description: 'Flat bolted manway or access cover for process vessels.', + params: { + radius: { type: 'number', min: 0.04, max: 1, default: 0.18 }, + thickness: { type: 'number', min: 0.006, max: 0.25, default: 0.035 }, + boltCount: { type: 'integer', min: 0, max: 24, default: 8 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.sanitary_nozzle', + family: 'generic', + kind: 'sanitary_nozzle', + semanticRole: 'sanitary_nozzle', + aliases: ['sanitary_nozzle', 'tri_clamp_nozzle', 'hygienic_nozzle', 'short_nozzle'], + description: 'Short hygienic vessel nozzle with clamp bead.', + params: { + radius: { type: 'number', min: 0.01, max: 0.5, default: 0.08 }, + length: { type: 'number', min: 0.03, max: 1, default: 0.18 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'generic.flanged_nozzle', + family: 'generic', + kind: 'flanged_nozzle', + semanticRole: 'flanged_nozzle', + aliases: ['flanged_nozzle', 'process_nozzle', 'nozzle_with_flange', 'flanged_pipe_nozzle'], + description: 'Process vessel nozzle with a visible raised flange and bolt pattern.', + params: { + radius: { type: 'number', min: 0.015, max: 0.8, default: 0.09 }, + length: { type: 'number', min: 0.06, max: 1.8, default: 0.26 }, + flangeRadius: { type: 'number', min: 0.03, max: 2.2, default: 0.16 }, + flangeThickness: { type: 'number', min: 0.008, max: 0.22, default: 0.025 }, + boltCount: { type: 'integer', min: 0, max: 24, default: 8 }, + includeBolts: { type: 'boolean', default: true }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + side: { type: 'enum', values: ['front', 'back', 'left', 'right', 'top', 'bottom'] }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'generic.inspection_hatch', + family: 'generic', + kind: 'inspection_hatch', + semanticRole: 'inspection_hatch', + aliases: ['inspection_hatch', 'access_hatch', 'round_hatch'], + description: + 'Round inspection hatch with a hinge block and handle for vessel or enclosure faces.', + params: { + radius: { type: 'number', min: 0.04, max: 1.2, default: 0.18 }, + thickness: { type: 'number', min: 0.006, max: 0.28, default: 0.035 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'z' }, + side: { type: 'enum', values: ['front', 'back', 'left', 'right', 'top', 'bottom'] }, + metalColor: { type: 'color', default: '#cbd5e1' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.jacket_shell', + family: 'generic', + kind: 'jacket_shell', + semanticRole: 'jacket_shell', + aliases: ['jacket_shell', 'outer_jacket', 'thermal_jacket', 'cooling_jacket', 'heating_jacket'], + description: 'Visible outer thermal jacket sleeve for process vessels.', + params: { + radius: { type: 'number', min: 0.08, max: 3, default: 0.52 }, + height: { type: 'number', min: 0.12, max: 8, default: 1.1 }, + thickness: { type: 'number', min: 0.004, max: 0.12, default: 0.018 }, + opacity: { type: 'number', min: 0.08, max: 1, default: 0.28 }, + primaryColor: { type: 'color', default: '#dbe3ea' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'generic.sight_glass', + family: 'generic', + kind: 'sight_glass', + semanticRole: 'sight_glass', + aliases: ['sight_glass', 'inspection_glass', 'view_glass', 'viewing_glass'], + description: 'Transparent vessel inspection glass with a metal rim.', + params: { + length: { type: 'number', min: 0.03, max: 1.4, default: 0.18 }, + height: { type: 'number', min: 0.03, max: 1.6, default: 0.24 }, + thickness: { type: 'number', min: 0.002, max: 0.08, default: 0.012 }, + side: { + type: 'enum', + values: ['left', 'right', 'top', 'bottom', 'front', 'back'], + default: 'front', + }, + opacity: { type: 'number', min: 0.12, max: 0.9, default: 0.42 }, + color: { type: 'color', default: '#93c5fd' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'generic.sample_valve', + family: 'generic', + kind: 'sample_valve', + semanticRole: 'sample_valve', + aliases: ['sample_valve', 'sampling_valve', 'sampling_port', 'sample_cock'], + description: 'Small vessel sampling valve with handle.', + params: { + radius: { type: 'number', min: 0.008, max: 0.25, default: 0.045 }, + length: { type: 'number', min: 0.04, max: 0.8, default: 0.22 }, + side: { type: 'enum', values: ['left', 'right', 'front', 'back'], default: 'front' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.instrument_port', + family: 'generic', + kind: 'instrument_port', + semanticRole: 'instrument_port', + aliases: [ + 'instrument_port', + 'gauge_port', + 'thermowell', + 'pressure_gauge', + 'temperature_probe', + 'sensor_port', + ], + description: 'Small gauge, thermowell, or instrument connection.', + params: { + radius: { type: 'number', min: 0.006, max: 0.22, default: 0.035 }, + length: { type: 'number', min: 0.03, max: 0.7, default: 0.16 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + darkColor: { type: 'color', default: '#0f172a' }, + }, + }, + { + id: 'generic.stainless_highlight_panel', + family: 'generic', + kind: 'stainless_highlight_panel', + semanticRole: 'stainless_highlight_panel', + aliases: [ + 'stainless_highlight_panel', + 'metal_highlight_panel', + 'polished_highlight', + 'stainless_reflection', + ], + description: + 'Subtle polished stainless reflection patch for cylindrical or flat equipment shells.', + params: { + length: { type: 'number', min: 0.02, max: 1.2, default: 0.16 }, + height: { type: 'number', min: 0.04, max: 4, default: 0.6 }, + thickness: { type: 'number', min: 0.001, max: 0.05, default: 0.006 }, + side: { + type: 'enum', + values: ['left', 'right', 'top', 'bottom', 'front', 'back'], + default: 'front', + }, + opacity: { type: 'number', min: 0.12, max: 0.95, default: 0.5 }, + color: { type: 'color', default: '#f8fafc' }, + }, + }, + { + id: 'generic.mobile_platform_chassis', + family: 'generic', + kind: 'mobile_platform_chassis', + semanticRole: 'vehicle_body', + aliases: [ + 'mobile_platform_chassis', + 'mobile_chassis', + 'agv_chassis', + 'amr_chassis', + 'robot_platform_chassis', + 'low_platform_body', + ], + description: + 'Low rounded mobile robot or AGV chassis with dark bumper skirt, main body, top load deck, and side status seams.', + params: { + length: { type: 'number', min: 0.3, max: 4, default: 1.45 }, + width: { type: 'number', min: 0.24, max: 2.4, default: 0.9 }, + height: { type: 'number', min: 0.08, max: 1.2, default: 0.28 }, + cornerRadius: { type: 'number', min: 0, max: 0.5, default: 0.16 }, + primaryColor: { type: 'color', default: '#e5e7eb' }, + secondaryColor: { type: 'color', default: '#334155' }, + darkColor: { type: 'color', default: '#111827' }, + accentColor: { type: 'color', default: '#38bdf8' }, + }, + }, + { + id: 'generic.lidar_sensor', + family: 'generic', + kind: 'lidar_sensor', + semanticRole: 'navigation_sensor', + aliases: [ + 'lidar', + 'lidar_sensor', + 'laser_scanner', + 'navigation_sensor', + 'safety_scanner', + 'front_scanner', + ], + description: 'Compact lidar or laser safety scanner with dark housing and translucent lens.', + params: { + radius: { type: 'number', min: 0.012, max: 0.18, default: 0.045 }, + height: { type: 'number', min: 0.006, max: 0.35 }, + length: { type: 'number', min: 0.006, max: 0.35 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + darkColor: { type: 'color', default: '#0f172a' }, + accentColor: { type: 'color', default: '#38bdf8' }, + }, + }, + { + id: 'generic.emergency_stop_button', + family: 'generic', + kind: 'emergency_stop_button', + semanticRole: 'emergency_stop_button', + aliases: ['emergency_stop', 'emergency_stop_button', 'e_stop', 'e_stop_button', 'stop_button'], + description: 'Red emergency stop button with dark base and protective guard ring.', + params: { + radius: { type: 'number', min: 0.012, max: 0.16, default: 0.04 }, + height: { type: 'number', min: 0.006, max: 0.25 }, + length: { type: 'number', min: 0.006, max: 0.25 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + color: { type: 'color', default: '#ef4444' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.status_light_strip', + family: 'generic', + kind: 'status_light_strip', + semanticRole: 'status_light_strip', + aliases: [ + 'status_light_strip', + 'light_strip', + 'led_strip', + 'signal_light_strip', + 'indicator_strip', + ], + description: + 'Thin colored status light strip for AGVs, robot cells, and industrial enclosures.', + params: { + length: { type: 'number', min: 0.04, max: 4, default: 0.7 }, + height: { type: 'number', min: 0.008, max: 0.25, default: 0.035 }, + thickness: { type: 'number', min: 0.002, max: 0.08, default: 0.012 }, + side: { type: 'enum', values: ['left', 'right', 'front', 'back'], default: 'left' }, + color: { type: 'color', default: '#38bdf8' }, + accentColor: { type: 'color', default: '#38bdf8' }, + }, + }, + { + id: 'generic.operator_panel', + family: 'generic', + kind: 'operator_panel', + semanticRole: 'control_panel', + aliases: ['operator_panel', 'hmi_panel', 'control_pendant', 'operator_console'], + description: 'Operator HMI/control panel with enclosure body, display screen, and buttons.', + params: { + width: { type: 'number', min: 0.12, max: 1.2, default: 0.32 }, + height: { type: 'number', min: 0.18, max: 2, default: 0.62 }, + depth: { type: 'number', min: 0.03, max: 0.5, default: 0.12 }, + primaryColor: { type: 'color', default: '#e5e7eb' }, + darkColor: { type: 'color', default: '#0f172a' }, + accentColor: { type: 'color', default: '#22c55e' }, + }, + }, + { + id: 'generic.guard_fence', + family: 'generic', + kind: 'guard_fence', + semanticRole: 'safety_barrier', + aliases: ['guard_fence', 'safety_fence', 'safety_guard', 'guard_rail', 'barrier_fence'], + description: 'Reusable safety fence or guard rail with posts and horizontal rails.', + params: { + length: { type: 'number', min: 0.25, max: 8, default: 1.8 }, + height: { type: 'number', min: 0.2, max: 3, default: 0.9 }, + width: { type: 'number', min: 0.02, max: 0.5, default: 0.08 }, + count: { type: 'integer', min: 2, max: 12, default: 4 }, + radius: { type: 'number', min: 0.006, max: 0.08, default: 0.018 }, + color: { type: 'color', default: '#facc15' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.pallet_table', + family: 'generic', + kind: 'pallet_table', + semanticRole: 'pallet_table', + aliases: ['pallet_table', 'pallet_station', 'pallet_deck', 'fixture_table'], + description: 'Reusable pallet or fixture table with deck and four support legs.', + params: { + length: { type: 'number', min: 0.25, max: 4, default: 1 }, + width: { type: 'number', min: 0.2, max: 3, default: 0.7 }, + height: { type: 'number', min: 0.08, max: 1.2, default: 0.28 }, + primaryColor: { type: 'color', default: '#475569' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.bearing_block', + family: 'generic', + kind: 'bearing_block', + semanticRole: 'bearing_block', + aliases: [ + 'bearing_block', + 'pillow_block', + 'pillow_block_bearing', + 'bearing_housing', + 'mounted_bearing', + ], + description: + 'Mounted bearing or pillow block with base, housing, bearing ring, bore, and mounting bolts.', + params: { + length: { type: 'number', min: 0.12, max: 1.6, default: 0.42 }, + width: { type: 'number', min: 0.08, max: 1, default: 0.22 }, + height: { type: 'number', min: 0.08, max: 1.2, default: 0.26 }, + radius: { type: 'number', min: 0.015, max: 0.4, default: 0.07 }, + metalColor: { type: 'color', default: '#64748b' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.coupling_guard', + family: 'generic', + kind: 'coupling_guard', + semanticRole: 'coupling_guard', + aliases: ['coupling_guard', 'shaft_guard', 'coupling_cover', 'rotating_shaft_guard'], + description: 'Half-cylinder safety guard over a shaft coupling with end flange plates.', + params: { + length: { type: 'number', min: 0.16, max: 2.4, default: 0.58 }, + radius: { type: 'number', min: 0.04, max: 0.7, default: 0.16 }, + thickness: { type: 'number', min: 0.006, max: 0.16, default: 0.028 }, + color: { type: 'color', default: '#facc15' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.motor_gearbox_unit', + family: 'generic', + kind: 'motor_gearbox_unit', + semanticRole: 'drive_unit', + aliases: [ + 'motor_gearbox_unit', + 'motor_reducer_unit', + 'drive_unit', + 'gearmotor', + 'motor_gearbox', + ], + description: 'Compact drive unit with ribbed motor, gearbox housing, and output shaft.', + params: { + length: { type: 'number', min: 0.3, max: 4, default: 1.05 }, + height: { type: 'number', min: 0.12, max: 1.8, default: 0.38 }, + radius: { type: 'number', min: 0.05, max: 0.8, default: 0.18 }, + primaryColor: { type: 'color', default: '#64748b' }, + secondaryColor: { type: 'color', default: '#475569' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'generic.pipe_manifold', + family: 'generic', + kind: 'pipe_manifold', + semanticRole: 'pipe_manifold', + aliases: ['pipe_manifold', 'manifold', 'header_pipe', 'branch_manifold'], + description: 'Process pipe manifold with a header pipe and repeated branch ports.', + params: { + length: { type: 'number', min: 0.25, max: 6, default: 1.2 }, + radius: { type: 'number', min: 0.012, max: 0.4, default: 0.065 }, + count: { type: 'integer', min: 2, max: 10, default: 4 }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'generic.hopper_body', + family: 'generic', + kind: 'hopper_body', + semanticRole: 'hopper_body', + aliases: ['hopper_body', 'feed_hopper', 'material_hopper', 'inlet_hopper'], + description: 'Tapered material hopper with outlet throat and support legs.', + params: { + length: { type: 'number', min: 0.25, max: 4, default: 0.9 }, + width: { type: 'number', min: 0.2, max: 3, default: 0.7 }, + height: { type: 'number', min: 0.25, max: 3.5, default: 0.8 }, + topLengthScale: { type: 'number', min: 0.2, max: 3, default: 1.65 }, + topWidthScale: { type: 'number', min: 0.2, max: 3, default: 1.45 }, + primaryColor: { type: 'color', default: '#94a3b8' }, + darkColor: { type: 'color', default: '#374151' }, + }, + }, + { + id: 'generic.conical_hopper', + family: 'generic', + kind: 'conical_hopper', + semanticRole: 'conical_hopper', + aliases: ['conical_hopper', 'cone_hopper', 'cone_discharge_hopper'], + description: 'Round conical discharge hopper with outlet collar and optional support legs.', + params: { + radiusTop: { type: 'number', min: 0.08, max: 3, default: 0.42 }, + radiusBottom: { type: 'number', min: 0.02, max: 1.2, default: 0.08 }, + outletRadius: { type: 'number', min: 0.02, max: 1.2, default: 0.08 }, + height: { type: 'number', min: 0.18, max: 5, default: 0.82 }, + radialSegments: { type: 'integer', min: 4, max: 64, default: 32 }, + includeSupportLegs: { type: 'boolean', default: true }, + primaryColor: { type: 'color', default: '#94a3b8' }, + darkColor: { type: 'color', default: '#374151' }, + }, + }, + { + id: 'generic.structural_tower_frame', + family: 'generic', + kind: 'structural_tower_frame', + semanticRole: 'preheater_tower_body', + aliases: [ + 'structural_tower_frame', + 'tower_frame', + 'steel_tower_frame', + 'preheater_tower_frame', + 'multi_level_tower_frame', + '塔架', + '钢结构塔架', + ], + description: + 'Multi-level industrial steel tower frame with columns, beams, grated decks, guard rails, and ladder.', + params: { + length: { type: 'number', min: 0.8, max: 12, default: 2.2 }, + width: { type: 'number', min: 0.6, max: 8, default: 1.6 }, + height: { type: 'number', min: 1.4, max: 18, default: 6 }, + levelCount: { type: 'integer', min: 2, max: 9, default: 5 }, + bayCount: { type: 'integer', min: 1, max: 5, default: 2 }, + stairFlights: { type: 'integer', min: 2, max: 9, default: 5 }, + stairPlacement: { type: 'enum', values: ['inside', 'outside'], default: 'outside' }, + externalStairs: { type: 'boolean', default: true }, + includeDiagonalBraces: { type: 'boolean', default: true }, + thickness: { type: 'number', min: 0.025, max: 0.18, default: 0.06 }, + metalColor: { type: 'color', default: '#475569' }, + darkColor: { type: 'color', default: '#111827' }, + accentColor: { type: 'color', default: '#1f2937' }, + }, + }, + { + id: 'generic.helical_stair', + family: 'generic', + kind: 'helical_stair', + semanticRole: 'external_spiral_stair', + aliases: [ + 'helical_stair', + 'spiral_stair', + 'external_spiral_stair', + 'tower_spiral_stair', + 'wraparound_stair', + 'ring_stair', + '环形楼梯', + '螺旋楼梯', + '塔外盘梯', + ], + description: + 'Wraparound helical access stair for tall process columns, with radial treads, posts, and guard rails.', + params: { + height: { type: 'number', min: 0.8, max: 18, default: 6 }, + innerRadius: { type: 'number', min: 0.08, max: 6, default: 0.9 }, + outerRadius: { type: 'number', min: 0.16, max: 7, default: 1.22 }, + width: { type: 'number', min: 0.12, max: 1.2, default: 0.32 }, + depth: { type: 'number', min: 0.04, max: 0.9, default: 0.22 }, + thickness: { type: 'number', min: 0.012, max: 0.16, default: 0.035 }, + stepCount: { type: 'integer', min: 8, max: 72, default: 18 }, + ringCount: { type: 'integer', min: 8, max: 72, default: 16 }, + sweepAngle: { type: 'number', min: 2.35, max: 31.42, default: 12.57 }, + startAngle: { type: 'number', min: -6.28, max: 6.28, default: 0 }, + railingHeight: { type: 'number', min: 0.18, max: 1.1, default: 0.42 }, + wireRadius: { type: 'number', min: 0.004, max: 0.08, default: 0.018 }, + metalColor: { type: 'color', default: '#64748b' }, + color: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'generic.helical_ladder', + family: 'generic', + kind: 'helical_ladder', + semanticRole: 'external_spiral_ladder', + aliases: [ + 'helical_ladder', + 'spiral_ladder', + 'spiral_access_ladder', + 'spiral_access_stair', + 'tower_helical_ladder', + 'column_spiral_ladder', + 'wraparound_ladder', + '环绕梯', + '螺旋梯', + '塔外螺旋梯', + ], + description: + 'Dedicated wraparound ladder for process columns and towers, with helical treads, posts, landings, and guard rails.', + params: { + height: { type: 'number', min: 0.8, max: 18, default: 6 }, + innerRadius: { type: 'number', min: 0.08, max: 6, default: 0.9 }, + outerRadius: { type: 'number', min: 0.16, max: 7, default: 1.22 }, + width: { type: 'number', min: 0.12, max: 1.2, default: 0.32 }, + depth: { type: 'number', min: 0.04, max: 0.9, default: 0.22 }, + thickness: { type: 'number', min: 0.012, max: 0.16, default: 0.035 }, + stepCount: { type: 'integer', min: 8, max: 72, default: 18 }, + ringCount: { type: 'integer', min: 8, max: 72, default: 16 }, + sweepAngle: { type: 'number', min: 2.35, max: 31.42, default: 12.57 }, + startAngle: { type: 'number', min: -6.28, max: 6.28, default: 0 }, + railingHeight: { type: 'number', min: 0.18, max: 1.1, default: 0.42 }, + wireRadius: { type: 'number', min: 0.004, max: 0.08, default: 0.018 }, + metalColor: { type: 'color', default: '#64748b' }, + color: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'generic.cyclone_separator_unit', + family: 'generic', + kind: 'cyclone_separator_unit', + semanticRole: 'preheater_cyclone', + aliases: [ + 'cyclone_separator_unit', + 'cyclone_unit', + 'preheater_cyclone', + 'cyclone_stage', + 'cyclone_separator', + '旋风筒', + '旋风分离器', + ], + description: + 'Cyclone separator stage with cylindrical body, conical hopper, top outlet, tangential inlet, and meal drop pipe.', + params: { + height: { type: 'number', min: 0.45, max: 4, default: 1.2 }, + radius: { type: 'number', min: 0.08, max: 1.4, default: 0.26 }, + bodyHeight: { type: 'number', min: 0.2, max: 2.8 }, + depth: { type: 'number', min: 0.08, max: 1.6 }, + length: { type: 'number', min: 0.05, max: 1.4 }, + thickness: { type: 'number', min: 0.025, max: 0.7 }, + primaryColor: { type: 'color', default: '#9ca3af' }, + metalColor: { type: 'color', default: '#64748b' }, + darkColor: { type: 'color', default: '#1f2937' }, + }, + }, + { + id: 'generic.service_platform', + family: 'generic', + kind: 'service_platform', + semanticRole: 'service_platform', + aliases: ['service_platform', 'maintenance_platform', 'inspection_platform', 'access_deck'], + description: 'Service or inspection platform with deck, posts, guard rails, and access ladder.', + params: { + length: { type: 'number', min: 0.3, max: 6, default: 1.2 }, + width: { type: 'number', min: 0.2, max: 3, default: 0.65 }, + height: { type: 'number', min: 0.2, max: 3.5, default: 0.9 }, + overallHeight: { type: 'number', min: 0.18, max: 1.4, default: 0.4 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + metalColor: { type: 'color', default: '#64748b' }, + color: { type: 'color', default: '#facc15' }, + }, + }, + { + id: 'generic.platform_with_ladder', + family: 'generic', + kind: 'platform_with_ladder', + semanticRole: 'service_platform', + aliases: ['platform_with_ladder', 'maintenance_platform_ladder', 'access_platform_ladder'], + description: 'Service platform with guard rails and explicit ladder rails/rungs.', + params: { + length: { type: 'number', min: 0.3, max: 6, default: 1.2 }, + width: { type: 'number', min: 0.2, max: 3, default: 0.65 }, + height: { type: 'number', min: 0.2, max: 3.5, default: 0.9 }, + overallHeight: { type: 'number', min: 0.18, max: 1.4, default: 0.4 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + rungCount: { type: 'integer', min: 3, max: 16, default: 6 }, + metalColor: { type: 'color', default: '#64748b' }, + color: { type: 'color', default: '#facc15' }, + }, + }, + { + id: 'generic.chimney_stack', + family: 'generic', + kind: 'chimney_stack', + semanticRole: 'stack_shell', + aliases: ['chimney_stack', 'process_stack', 'smokestack', 'exhaust_stack', 'stack_shell'], + description: + 'Tall industrial exhaust stack with optional bands, platform, and inlet connection.', + params: { + height: { type: 'number', min: 0.8, max: 30, default: 6 }, + radius: { type: 'number', min: 0.05, max: 2, default: 0.28 }, + thickness: { type: 'number', min: 0.01, max: 0.2, default: 0.04 }, + bandCount: { type: 'integer', min: 0, max: 8, default: 3 }, + warningStripes: { type: 'boolean', default: false }, + metalColor: { type: 'color', default: '#9ca3af' }, + accentColor: { type: 'color', default: '#ef4444' }, + }, + }, +] + +export const KIOSK_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'kiosk.kiosk_body', + family: 'kiosk', + kind: 'kiosk_body', + semanticRole: 'kiosk_body', + aliases: ['body', 'kiosk_body', 'booth_body', 'stall_body', 'small_building_body', 'walls'], + required: true, + description: 'Main kiosk or booth wall volume.', + params: { + length: { type: 'number', min: 0.4, max: 8, default: 1.8 }, + width: { type: 'number', min: 0.3, max: 5, default: 1.2 }, + height: { type: 'number', min: 0.4, max: 5, default: 1.65 }, + primaryColor: { type: 'color', default: '#d1d5db' }, + cornerRadius: { type: 'number', min: 0, max: 0.3 }, + }, + }, + { + id: 'kiosk.kiosk_roof', + family: 'kiosk', + kind: 'kiosk_roof', + semanticRole: 'roof', + aliases: ['roof', 'kiosk_roof', 'booth_roof', 'pavilion_roof', 'shed_roof'], + required: true, + attachTo: 'kiosk_body', + layoutRole: 'top_overhang', + description: 'Overhanging kiosk roof.', + params: { + length: { type: 'number', min: 0.4, max: 9, default: 2.1 }, + width: { type: 'number', min: 0.3, max: 6, default: 1.45 }, + height: { type: 'number', min: 0.04, max: 1.2, default: 0.28 }, + color: { type: 'color', default: '#7f1d1d' }, + variant: { type: 'enum', values: ['pitched', 'flat'], default: 'pitched' }, + }, + }, + { + id: 'kiosk.kiosk_opening', + family: 'kiosk', + kind: 'kiosk_opening', + semanticRole: 'opening', + aliases: ['opening', 'window', 'service_window', 'ticket_window', 'serving_window', 'door'], + required: true, + attachTo: 'kiosk_body', + layoutRole: 'front_service_opening', + description: 'Front service window, ticket window, or door opening.', + params: { + length: { type: 'number', min: 0.08, max: 5, default: 0.8 }, + height: { type: 'number', min: 0.08, max: 4, default: 0.75 }, + thickness: { type: 'number', min: 0.004, max: 0.5, default: 0.035 }, + color: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'kiosk.kiosk_counter', + family: 'kiosk', + kind: 'kiosk_counter', + semanticRole: 'service_counter', + aliases: ['counter', 'service_counter', 'serving_counter', 'sales_counter', 'shelf'], + attachTo: 'kiosk_opening', + layoutRole: 'front_counter', + description: 'Front service counter or shelf.', + params: { + length: { type: 'number', min: 0.08, max: 6, default: 1 }, + width: { type: 'number', min: 0.04, max: 2, default: 0.28 }, + thickness: { type: 'number', min: 0.02, max: 0.6, default: 0.08 }, + color: { type: 'color', default: '#9ca3af' }, + }, + }, + { + id: 'kiosk.kiosk_sign', + family: 'kiosk', + kind: 'kiosk_sign', + semanticRole: 'sign_panel', + aliases: ['sign', 'signage', 'sign_panel', 'shop_sign', 'name_sign', 'ticket_sign'], + attachTo: 'kiosk_body', + layoutRole: 'front_upper_sign', + description: 'Front sign panel.', + params: { + length: { type: 'number', min: 0.08, max: 6, default: 1 }, + height: { type: 'number', min: 0.04, max: 1.5, default: 0.26 }, + thickness: { type: 'number', min: 0.004, max: 0.4, default: 0.035 }, + accentColor: { type: 'color', default: '#facc15' }, + }, + }, + { + id: 'kiosk.kiosk_awning', + family: 'kiosk', + kind: 'kiosk_awning', + semanticRole: 'awning', + aliases: ['awning', 'canopy', 'sunshade', 'front_awning'], + attachTo: 'kiosk_body', + layoutRole: 'front_canopy', + description: 'Optional small canopy/awning over the opening.', + params: { + length: { type: 'number', min: 0.08, max: 7, default: 1.25 }, + width: { type: 'number', min: 0.04, max: 2.4, default: 0.45 }, + thickness: { type: 'number', min: 0.02, max: 0.8, default: 0.08 }, + color: { type: 'color', default: '#ef4444' }, + }, + }, +] + +export const PUMP_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'pump.skid_base', + family: 'pump', + kind: 'skid_base', + semanticRole: 'support_base', + aliases: ['base', 'skid', 'skid_base', 'pump_base', 'baseplate', 'foundation'], + required: true, + layoutRole: 'bottom_skid', + description: 'Pump skid base or baseplate.', + params: { + length: { type: 'number', min: 0.3, max: 6, default: 1.2 }, + width: { type: 'number', min: 0.12, max: 3, default: 0.55 }, + height: { type: 'number', min: 0.02, max: 0.8, default: 0.08 }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'pump.ribbed_motor_body', + family: 'pump', + kind: 'ribbed_motor_body', + semanticRole: 'drive_motor', + aliases: ['motor', 'electric_motor', 'drive_motor', 'motor_body', 'ribbed_motor'], + required: true, + attachTo: 'skid_base', + layoutRole: 'motor_on_skid', + description: 'Ribbed electric motor driving the pump.', + params: { + length: { type: 'number', min: 0.12, max: 3, default: 0.48 }, + radius: { type: 'number', min: 0.04, max: 1, default: 0.18 }, + count: { type: 'integer', min: 3, max: 20, default: 8 }, + slatCount: { type: 'integer', min: 3, max: 20 }, + primaryColor: { type: 'color', default: '#64748b' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'pump.volute_casing', + family: 'pump', + kind: 'volute_casing', + semanticRole: 'volute_casing', + aliases: ['volute', 'pump_casing', 'volute_casing', 'casing', 'housing', 'pump_body'], + required: true, + attachTo: 'skid_base', + layoutRole: 'front_pump_casing', + description: 'Spiral volute casing for a centrifugal pump or blower.', + params: { + radius: { type: 'number', min: 0.05, max: 1.5, default: 0.22 }, + depth: { type: 'number', min: 0.04, max: 1.2, default: 0.14 }, + primaryColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'pump.inlet_port', + family: 'pump', + kind: 'inlet_port', + semanticRole: 'inlet_port', + aliases: ['inlet', 'suction', 'suction_port', 'inlet_port', 'nozzle_inlet'], + required: true, + attachTo: 'volute_casing', + layoutRole: 'volute_inlet', + description: 'Pump suction inlet nozzle.', + params: { + radius: { type: 'number', min: 0.02, max: 0.7, default: 0.07 }, + length: { type: 'number', min: 0.04, max: 1.5, default: 0.22 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'z' }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'pump.outlet_port', + family: 'pump', + kind: 'outlet_port', + semanticRole: 'outlet_port', + aliases: ['outlet', 'discharge', 'discharge_port', 'outlet_port', 'nozzle_outlet'], + required: true, + attachTo: 'volute_casing', + layoutRole: 'volute_outlet', + description: 'Pump discharge outlet nozzle.', + params: { + radius: { type: 'number', min: 0.02, max: 0.7, default: 0.06 }, + length: { type: 'number', min: 0.04, max: 1.5, default: 0.2 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'pump.flange_ring', + family: 'pump', + kind: 'flange_ring', + semanticRole: 'flange', + aliases: ['flange', 'flanges', 'flange_ring', 'pipe_flange'], + attachTo: 'inlet_port', + layoutRole: 'port_flange', + description: 'Circular flange ring on a pump port.', + params: { + radius: { type: 'number', min: 0.03, max: 0.9, default: 0.12 }, + tubeRadius: { type: 'number', min: 0.004, max: 0.15, default: 0.018 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'z' }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + boltCount: { type: 'integer', min: 4, max: 16, default: 8 }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'pump.impeller_blades', + family: 'pump', + kind: 'impeller_blades', + semanticRole: 'impeller', + aliases: ['impeller', 'impeller_blades', 'pump_impeller', 'rotor'], + attachTo: 'volute_casing', + layoutRole: 'inside_volute', + description: 'Visible impeller blade set.', + params: { + count: { type: 'integer', min: 3, max: 14, default: 7 }, + radius: { type: 'number', min: 0.03, max: 1, default: 0.14 }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'pump.control_box', + family: 'pump', + kind: 'control_box', + semanticRole: 'control_box', + aliases: ['control_box', 'junction_box', 'terminal_box', 'controller'], + attachTo: 'ribbed_motor_body', + layoutRole: 'motor_top_box', + description: 'Small control or terminal box on the motor.', + params: { + length: { type: 'number', min: 0.04, max: 1, default: 0.16 }, + width: { type: 'number', min: 0.03, max: 0.8, default: 0.12 }, + height: { type: 'number', min: 0.02, max: 0.6, default: 0.08 }, + primaryColor: { type: 'color', default: '#1f2937' }, + }, + }, +] + +export const CONVEYOR_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'conveyor.conveyor_frame', + family: 'conveyor', + kind: 'conveyor_frame', + semanticRole: 'conveyor_frame', + aliases: ['conveyor', 'belt_conveyor', 'frame', 'conveyor_frame', 'support_frame'], + required: true, + layoutRole: 'long_frame', + description: 'Long conveyor support frame with rails and legs.', + params: { + length: { type: 'number', min: 0.4, max: 12, default: 3 }, + width: { type: 'number', min: 0.12, max: 3, default: 0.7 }, + height: { type: 'number', min: 0.12, max: 2.5, default: 0.65 }, + radius: { type: 'number', min: 0.006, max: 0.15, default: 0.025 }, + legCount: { type: 'integer', min: 2, max: 16 }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'conveyor.roller_array', + family: 'conveyor', + kind: 'roller_array', + semanticRole: 'roller_array', + aliases: ['rollers', 'roller_array', 'roller_bed', 'idlers'], + required: true, + attachTo: 'conveyor_frame', + layoutRole: 'rollers_on_frame', + description: 'Repeated rollers across the conveyor bed.', + params: { + count: { type: 'integer', min: 2, max: 32, default: 9 }, + length: { type: 'number', min: 0.3, max: 12, default: 2.8 }, + width: { type: 'number', min: 0.08, max: 3, default: 0.62 }, + radius: { type: 'number', min: 0.008, max: 0.2, default: 0.035 }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'conveyor.belt_surface', + family: 'conveyor', + kind: 'belt_surface', + semanticRole: 'belt_surface', + aliases: ['belt', 'belt_surface', 'conveyor_belt', 'rubber_belt'], + required: true, + attachTo: 'conveyor_frame', + layoutRole: 'top_belt', + description: 'Continuous dark belt surface.', + params: { + length: { type: 'number', min: 0.3, max: 12, default: 2.9 }, + width: { type: 'number', min: 0.08, max: 3, default: 0.62 }, + height: { type: 'number', min: 0.004, max: 0.16, default: 0.025 }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'conveyor.ribbed_motor_body', + family: 'conveyor', + kind: 'ribbed_motor_body', + semanticRole: 'drive_motor', + aliases: ['drive_motor', 'motor', 'gear_motor', 'conveyor_motor'], + attachTo: 'conveyor_frame', + layoutRole: 'end_drive_motor', + description: 'Side-mounted conveyor drive motor.', + params: { + length: { type: 'number', min: 0.08, max: 2, default: 0.28 }, + radius: { type: 'number', min: 0.03, max: 0.5, default: 0.08 }, + primaryColor: { type: 'color', default: '#64748b' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, +] + +export const ELECTRICAL_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'electrical.electrical_cabinet', + family: 'electrical', + kind: 'electrical_cabinet', + semanticRole: 'electrical_cabinet', + aliases: [ + 'electrical_cabinet', + 'control_cabinet', + 'power_cabinet', + 'switchgear', + 'control_panel', + 'cabinet', + ], + required: true, + layoutRole: 'upright_cabinet', + description: 'Industrial electrical or control cabinet body.', + params: { + length: { type: 'number', min: 0.18, max: 4, default: 0.8 }, + width: { type: 'number', min: 0.08, max: 2, default: 0.32 }, + height: { type: 'number', min: 0.3, max: 4, default: 1.6 }, + doorCount: { type: 'integer', min: 1, max: 4 }, + slatCount: { type: 'integer', min: 2, max: 10 }, + primaryColor: { type: 'color', default: '#d1d5db' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'electrical.cable_tray', + family: 'electrical', + kind: 'cable_tray', + semanticRole: 'cable_tray', + aliases: ['cable_tray', 'wire_tray', 'cable_duct', 'cable_ladder'], + attachTo: 'electrical_cabinet', + layoutRole: 'top_cable_tray', + description: 'Cable tray or cable duct connected to the cabinet.', + params: { + length: { type: 'number', min: 0.15, max: 8, default: 1.1 }, + width: { type: 'number', min: 0.04, max: 1, default: 0.16 }, + height: { type: 'number', min: 0.02, max: 0.5, default: 0.08 }, + slatCount: { type: 'integer', min: 2, max: 18 }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, +] + +export const PIPE_SYSTEM_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'pipe_system.pipe_run', + family: 'pipe_system', + kind: 'pipe_run', + semanticRole: 'pipe_run', + aliases: ['pipe', 'pipe_run', 'straight_pipe', 'pipeline', 'process_pipe', 'piping'], + required: true, + layoutRole: 'straight_run', + description: 'Straight industrial pipe run.', + params: { + length: { type: 'number', min: 0.15, max: 20, default: 2 }, + radius: { type: 'number', min: 0.01, max: 1, default: 0.06 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'pipe_system.pipe_elbow', + family: 'pipe_system', + kind: 'pipe_elbow', + semanticRole: 'pipe_elbow', + aliases: ['elbow', 'pipe_elbow', 'bend', 'pipe_bend', 'elbow90'], + attachTo: 'pipe_run', + layoutRole: 'pipe_bend', + description: 'Ninety-degree pipe elbow.', + params: { + radius: { type: 'number', min: 0.01, max: 1, default: 0.055 }, + bendRadius: { type: 'number', min: 0.03, max: 2, default: 0.22 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'pipe_system.flange_ring', + family: 'pipe_system', + kind: 'flange_ring', + semanticRole: 'flange', + aliases: ['flange', 'pipe_flange', 'flange_ring'], + attachTo: 'pipe_run', + layoutRole: 'pipe_end_flange', + description: 'Pipe flange ring.', + params: { + radius: { type: 'number', min: 0.02, max: 1.2, default: 0.09 }, + tubeRadius: { type: 'number', min: 0.004, max: 0.18, default: 0.014 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + boltCount: { type: 'integer', min: 4, max: 16, default: 8 }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'pipe_system.valve_body', + family: 'pipe_system', + kind: 'valve_body', + semanticRole: 'valve_body', + aliases: ['valve', 'inline_valve', 'valve_body'], + attachTo: 'pipe_run', + layoutRole: 'inline_valve', + description: 'Inline valve body for a pipe system.', + params: { + length: { type: 'number', min: 0.08, max: 2, default: 0.32 }, + radius: { type: 'number', min: 0.02, max: 1, default: 0.09 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + valveStyle: { type: 'string' }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, +] + +export const TANK_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'tank.storage_tank_shell', + family: 'tank', + kind: 'storage_tank_shell', + semanticRole: 'vessel_shell', + aliases: [ + 'atmospheric_storage_tank', + 'flat_roof_tank', + 'floating_roof_tank', + 'storage_tank_shell', + 'tank_farm_tank', + ], + required: true, + layoutRole: 'vessel_shell', + description: + 'Flat-roof atmospheric storage tank shell with visible roof, bottom, rims, and foundation.', + params: { + height: { type: 'number', min: 0.6, max: 24, default: 3.2 }, + radius: { type: 'number', min: 0.12, max: 6, default: 0.8 }, + axis: { type: 'enum', values: ['y'], default: 'y' }, + primaryColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'tank.cylindrical_tank', + family: 'tank', + kind: 'cylindrical_tank', + aliases: ['tank', 'storage_tank', 'vessel', 'pressure_vessel', 'vertical_tank', '储罐', '罐体'], + required: true, + layoutRole: 'vessel_shell', + description: 'Vertical or horizontal storage tank / pressure vessel shell.', + params: { + length: { type: 'number', min: 0.2, max: 10, default: 2.4 }, + radius: { type: 'number', min: 0.05, max: 3, default: 0.6 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + primaryColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'tank.cooling_tower_shell', + family: 'tank', + kind: 'cooling_tower_shell', + semanticRole: 'cooling_tower_shell', + aliases: [ + 'cooling_tower_shell', + 'natural_draft_cooling_tower', + 'hyperboloid_cooling_tower', + 'cooling_tower', + ], + required: true, + layoutRole: 'cooling_tower_shell', + description: 'Hyperboloid natural-draft cooling tower shell.', + params: { + radius: { type: 'number', min: 0.25, max: 6, default: 1.15 }, + waistRadius: { type: 'number', min: 0.16, max: 5, default: 0.72 }, + topRadius: { type: 'number', min: 0.2, max: 7, default: 1.08 }, + height: { type: 'number', min: 1.8, max: 24, default: 7.2 }, + primaryColor: { type: 'color', default: '#f8fafc' }, + }, + }, + { + id: 'tank.cooling_tower_rim', + family: 'tank', + kind: 'cooling_tower_rim', + semanticRole: 'top_steam_opening', + aliases: ['cooling_tower_rim', 'cooling_tower_opening', 'cooling_tower_top_rim'], + attachTo: 'cooling_tower_shell', + layoutRole: 'cooling_tower_top_opening', + description: 'Open circular rim at the top of a natural-draft cooling tower.', + params: { + radius: { type: 'number', min: 0.16, max: 7, default: 1.1 }, + tubeRadius: { type: 'number', min: 0.006, max: 0.28, default: 0.05 }, + primaryColor: { type: 'color', default: '#ffffff' }, + }, + }, + { + id: 'tank.skid_base', + family: 'tank', + kind: 'skid_base', + semanticRole: 'support_base', + aliases: ['base', 'skid', 'saddle', 'support_base', 'tank_base', '支座'], + attachTo: 'cylindrical_tank', + layoutRole: 'tank_support', + description: 'Tank support base or saddle.', + params: { + length: { type: 'number', min: 0.2, max: 10, default: 1.5 }, + width: { type: 'number', min: 0.08, max: 4, default: 0.8 }, + height: { type: 'number', min: 0.02, max: 1, default: 0.12 }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'tank.flange_ring', + family: 'tank', + kind: 'flange_ring', + semanticRole: 'flange_ring', + aliases: ['flange_ring', 'riding_ring', 'tyre_ring', 'girth_gear', 'support_ring'], + attachTo: 'cylindrical_tank', + layoutRole: 'shell_ring', + description: 'Reusable vessel or kiln shell ring, including riding rings and girth gears.', + params: { + radius: { type: 'number', min: 0.02, max: 3, default: 0.6 }, + tubeRadius: { type: 'number', min: 0.004, max: 0.3, default: 0.04 }, + depth: { type: 'number', min: 0.006, max: 0.3, default: 0.035 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + boltCount: { type: 'integer', min: 3, max: 24, default: 6 }, + includeBolts: { type: 'boolean', default: true }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'tank.bearing_block', + family: 'tank', + kind: 'bearing_block', + semanticRole: 'bearing_block', + aliases: ['bearing_block', 'support_roller', 'trunnion_roller', 'pillow_block'], + attachTo: 'cylindrical_tank', + layoutRole: 'shell_support_roller', + description: 'Mounted bearing or support roller block for horizontal vessels and rotary kilns.', + params: { + length: { type: 'number', min: 0.12, max: 1.6, default: 0.42 }, + width: { type: 'number', min: 0.08, max: 1, default: 0.22 }, + height: { type: 'number', min: 0.08, max: 1.2, default: 0.26 }, + radius: { type: 'number', min: 0.015, max: 0.4, default: 0.07 }, + metalColor: { type: 'color', default: '#64748b' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'tank.support_roller_pair', + family: 'tank', + kind: 'support_roller_pair', + semanticRole: 'support_roller', + aliases: [ + 'support_roller_pair', + 'support_roller_station', + 'trunnion_roller_pair', + 'kiln_support_roller', + '托轮组', + ], + attachTo: 'cylindrical_tank', + layoutRole: 'kiln_support_station', + description: + 'Rotary kiln support station with a foundation, two trunnion rollers, and a small thrust roller.', + params: { + length: { type: 'number', min: 0.28, max: 3, default: 0.9 }, + width: { type: 'number', min: 0.28, max: 4, default: 1.18 }, + height: { type: 'number', min: 0.12, max: 1.4, default: 0.34 }, + radius: { type: 'number', min: 0.035, max: 0.5, default: 0.095 }, + rollerLength: { type: 'number', min: 0.08, max: 1.2, default: 0.28 }, + metalColor: { type: 'color', default: '#64748b' }, + rollerColor: { type: 'color', default: '#374151' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'tank.motor_gearbox_unit', + family: 'tank', + kind: 'motor_gearbox_unit', + semanticRole: 'drive_unit', + aliases: ['motor_gearbox_unit', 'drive_unit', 'gearmotor', 'motor_reducer_unit'], + attachTo: 'cylindrical_tank', + layoutRole: 'side_drive_unit', + description: 'Compact side drive with motor, gearbox housing, and output shaft.', + params: { + length: { type: 'number', min: 0.3, max: 4, default: 1.05 }, + height: { type: 'number', min: 0.12, max: 1.8, default: 0.38 }, + radius: { type: 'number', min: 0.05, max: 0.8, default: 0.18 }, + primaryColor: { type: 'color', default: '#64748b' }, + secondaryColor: { type: 'color', default: '#475569' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'tank.coupling_guard', + family: 'tank', + kind: 'coupling_guard', + semanticRole: 'coupling_guard', + aliases: ['coupling_guard', 'shaft_guard', 'coupling_cover'], + attachTo: 'motor_gearbox_unit', + layoutRole: 'drive_guard', + description: 'Half-cylinder safety guard over a drive coupling.', + params: { + length: { type: 'number', min: 0.16, max: 2.4, default: 0.58 }, + radius: { type: 'number', min: 0.04, max: 0.7, default: 0.16 }, + thickness: { type: 'number', min: 0.006, max: 0.16, default: 0.028 }, + color: { type: 'color', default: '#facc15' }, + darkColor: { type: 'color', default: '#111827' }, + }, + }, + { + id: 'tank.hopper_body', + family: 'tank', + kind: 'hopper_body', + semanticRole: 'hopper_body', + aliases: ['hopper_body', 'feed_hopper', 'inlet_hopper', 'discharge_hopper'], + attachTo: 'cylindrical_tank', + layoutRole: 'feed_hopper', + description: 'Tapered feed or discharge hopper attached to process vessels.', + params: { + length: { type: 'number', min: 0.12, max: 3, default: 0.65 }, + width: { type: 'number', min: 0.12, max: 3, default: 0.48 }, + height: { type: 'number', min: 0.12, max: 3, default: 0.72 }, + primaryColor: { type: 'color', default: '#94a3b8' }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'tank.service_platform', + family: 'tank', + kind: 'service_platform', + semanticRole: 'service_platform', + aliases: ['service_platform', 'inspection_platform', 'maintenance_platform', 'guard_rail'], + attachTo: 'cylindrical_tank', + layoutRole: 'access_platform', + description: 'Service platform with support posts, guard rails, and access ladder.', + params: { + length: { type: 'number', min: 0.3, max: 8, default: 1.4 }, + width: { type: 'number', min: 0.16, max: 2, default: 0.42 }, + height: { type: 'number', min: 0.08, max: 3, default: 0.75 }, + overallHeight: { type: 'number', min: 0.08, max: 3, default: 0.45 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + metalColor: { type: 'color', default: '#64748b' }, + color: { type: 'color', default: '#facc15' }, + }, + }, + { + id: 'tank.inlet_port', + family: 'tank', + kind: 'inlet_port', + aliases: ['inlet', 'feed_nozzle', 'tank_inlet', 'top_nozzle', '入口', '进料口'], + attachTo: 'cylindrical_tank', + layoutRole: 'top_nozzle', + description: 'Tank inlet / feed nozzle.', + params: { + radius: { type: 'number', min: 0.01, max: 1, default: 0.08 }, + length: { type: 'number', min: 0.03, max: 2, default: 0.22 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'tank.outlet_port', + family: 'tank', + kind: 'outlet_port', + aliases: ['outlet', 'drain_nozzle', 'tank_outlet', 'bottom_nozzle', '出口', '排出口'], + attachTo: 'cylindrical_tank', + layoutRole: 'side_drain_nozzle', + description: 'Tank outlet / drain nozzle.', + params: { + radius: { type: 'number', min: 0.01, max: 1, default: 0.07 }, + length: { type: 'number', min: 0.03, max: 2, default: 0.22 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'tank.platform_ladder', + family: 'tank', + kind: 'platform_ladder', + semanticRole: 'access_platform', + aliases: ['ladder', 'platform', 'access_platform', 'guard_platform', '爬梯', '平台'], + attachTo: 'cylindrical_tank', + layoutRole: 'side_access', + description: 'Access platform and ladder for tank inspection.', + params: { + length: { type: 'number', min: 0.12, max: 4, default: 0.72 }, + width: { type: 'number', min: 0.12, max: 3, default: 0.48 }, + height: { type: 'number', min: 0.2, max: 6, default: 1.2 }, + radius: { type: 'number', min: 0.004, max: 0.08, default: 0.018 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + rungCount: { type: 'integer', min: 4, max: 16, default: 6 }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'tank.pipe_manifold', + family: 'tank', + kind: 'pipe_manifold', + semanticRole: 'pipe_manifold', + aliases: ['pipe_manifold', 'manifold', 'header_pipe', 'cooling_water_header'], + attachTo: 'cooling_tower_shell', + layoutRole: 'cooling_water_header', + description: 'Process pipe manifold or cooling-water header for vessel and tower groups.', + params: { + length: { type: 'number', min: 0.25, max: 12, default: 1.2 }, + radius: { type: 'number', min: 0.012, max: 0.4, default: 0.065 }, + count: { type: 'integer', min: 2, max: 10, default: 4 }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, +] + +export const REACTOR_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'reactor.agitator_tank', + family: 'reactor', + kind: 'agitator_tank', + aliases: ['reactor', 'reaction_kettle', 'stirred_tank', 'agitator_tank', '反应釜', '搅拌罐'], + required: true, + layoutRole: 'stirred_vessel', + description: 'Stirred reactor vessel with agitator motor, shaft, and impeller blades.', + params: { + height: { type: 'number', min: 0.2, max: 5, default: 1.4 }, + radius: { type: 'number', min: 0.06, max: 2, default: 0.42 }, + bottomStyle: { type: 'enum', values: ['dished', 'conical'], default: 'dished' }, + legStyle: { type: 'enum', values: ['vertical', 'splayed'], default: 'vertical' }, + legCount: { type: 'integer', min: 3, max: 4, default: 4 }, + primaryColor: { type: 'color', default: '#94a3b8' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + motorColor: { type: 'color', default: '#1f2937' }, + }, + }, + { + id: 'reactor.inlet_port', + family: 'reactor', + kind: 'inlet_port', + aliases: ['inlet', 'feed_nozzle', 'reactor_inlet', '进料口', '入口'], + required: true, + attachTo: 'agitator_tank', + layoutRole: 'feed_nozzle', + description: 'Reactor feed inlet nozzle.', + params: { + radius: { type: 'number', min: 0.01, max: 0.8, default: 0.06 }, + length: { type: 'number', min: 0.03, max: 1.5, default: 0.18 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'y' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'reactor.outlet_port', + family: 'reactor', + kind: 'outlet_port', + aliases: ['outlet', 'discharge_nozzle', 'reactor_outlet', '出料口', '出口'], + required: true, + attachTo: 'agitator_tank', + layoutRole: 'discharge_nozzle', + description: 'Reactor discharge outlet nozzle.', + params: { + radius: { type: 'number', min: 0.01, max: 0.8, default: 0.055 }, + length: { type: 'number', min: 0.03, max: 1.5, default: 0.18 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'reactor.mixer_blades', + family: 'reactor', + kind: 'mixer_blades', + semanticRole: 'reactor_impeller', + aliases: ['mixer_blades', 'agitator_blade', 'impeller_blade', 'paddle', 'blade'], + attachTo: 'agitator_tank', + layoutRole: 'internal_impeller', + description: 'Reactor agitator / mixer impeller blades.', + params: { + count: { type: 'integer', min: 2, max: 8, default: 3 }, + length: { type: 'number', min: 0.04, max: 2, default: 0.32 }, + width: { type: 'number', min: 0.02, max: 1, default: 0.12 }, + depth: { type: 'number', min: 0.004, max: 0.2, default: 0.018 }, + bladePitch: { type: 'number', min: -1, max: 1, default: 0.22 }, + curvature: { type: 'number', min: 0, max: 0.5, default: 0.08 }, + primaryColor: { type: 'color', default: '#cbd5e1' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'reactor.platform_ladder', + family: 'reactor', + kind: 'platform_ladder', + semanticRole: 'access_platform', + aliases: ['ladder', 'platform', 'access_platform', '爬梯', '平台'], + attachTo: 'agitator_tank', + layoutRole: 'side_access', + description: 'Reactor access platform and ladder.', + params: { + length: { type: 'number', min: 0.12, max: 4, default: 0.72 }, + width: { type: 'number', min: 0.12, max: 3, default: 0.48 }, + height: { type: 'number', min: 0.2, max: 6, default: 1.2 }, + radius: { type: 'number', min: 0.004, max: 0.08, default: 0.018 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + rungCount: { type: 'integer', min: 4, max: 16, default: 6 }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, +] + +export const COMPRESSOR_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'compressor.skid_base', + family: 'compressor', + kind: 'skid_base', + semanticRole: 'machine_base', + aliases: ['base', 'skid', 'compressor_base', '底座'], + required: true, + layoutRole: 'machine_skid', + description: 'Compressor skid base.', + params: { + length: { type: 'number', min: 0.3, max: 8, default: 1.8 }, + width: { type: 'number', min: 0.12, max: 4, default: 0.7 }, + height: { type: 'number', min: 0.02, max: 1, default: 0.12 }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'compressor.ribbed_motor_body', + family: 'compressor', + kind: 'ribbed_motor_body', + semanticRole: 'motor_body', + aliases: ['motor', 'drive_motor', 'electric_motor', '电机'], + required: true, + attachTo: 'skid_base', + layoutRole: 'drive_motor', + description: 'Ribbed electric drive motor.', + params: { + length: { type: 'number', min: 0.12, max: 3, default: 0.55 }, + radius: { type: 'number', min: 0.04, max: 1, default: 0.18 }, + slatCount: { type: 'integer', min: 3, max: 20, default: 8 }, + primaryColor: { type: 'color', default: '#64748b' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'compressor.rounded_machine_body', + family: 'compressor', + kind: 'rounded_machine_body', + semanticRole: 'compressor_casing', + aliases: ['compressor_casing', 'compressor_body', 'casing', '压缩机壳体'], + required: true, + attachTo: 'skid_base', + layoutRole: 'compressor_casing', + description: 'Rounded compressor casing.', + params: { + length: { type: 'number', min: 0.12, max: 4, default: 0.58 }, + width: { type: 'number', min: 0.08, max: 2, default: 0.36 }, + height: { type: 'number', min: 0.08, max: 2, default: 0.36 }, + primaryColor: { type: 'color', default: '#64748b' }, + }, + }, + { + id: 'compressor.inlet_port', + family: 'compressor', + kind: 'inlet_port', + aliases: ['inlet', 'suction', 'air_inlet', '入口', '进气口'], + required: true, + attachTo: 'rounded_machine_body', + layoutRole: 'suction_port', + description: 'Compressor inlet port.', + params: { + radius: { type: 'number', min: 0.01, max: 0.8, default: 0.07 }, + length: { type: 'number', min: 0.03, max: 1.5, default: 0.2 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'compressor.outlet_port', + family: 'compressor', + kind: 'outlet_port', + aliases: ['outlet', 'discharge', 'air_outlet', '出口', '排气口'], + required: true, + attachTo: 'rounded_machine_body', + layoutRole: 'discharge_port', + description: 'Compressor discharge outlet port.', + params: { + radius: { type: 'number', min: 0.01, max: 0.8, default: 0.06 }, + length: { type: 'number', min: 0.03, max: 1.5, default: 0.2 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'compressor.control_box', + family: 'compressor', + kind: 'control_box', + semanticRole: 'control_box', + aliases: ['control_box', 'controller', 'control_panel', '控制盒'], + attachTo: 'skid_base', + layoutRole: 'side_controller', + description: 'Compressor control box.', + params: { + length: { type: 'number', min: 0.04, max: 1.2, default: 0.2 }, + width: { type: 'number', min: 0.03, max: 1, default: 0.14 }, + height: { type: 'number', min: 0.03, max: 1, default: 0.12 }, + primaryColor: { type: 'color', default: '#1f2937' }, + }, + }, +] + +export const HEAT_EXCHANGER_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'heat_exchanger.heat_exchanger', + family: 'heat_exchanger', + kind: 'heat_exchanger', + aliases: ['heat_exchanger', 'condenser', 'cooler', 'shell_and_tube', '换热器', '冷凝器'], + required: true, + layoutRole: 'shell_and_tube_bundle', + description: 'Shell-and-tube heat exchanger body with channel heads and nozzles.', + params: { + length: { type: 'number', min: 0.24, max: 8, default: 1.6 }, + radius: { type: 'number', min: 0.05, max: 1.5, default: 0.24 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + primaryColor: { type: 'color', default: '#9ca3af' }, + }, + }, + { + id: 'heat_exchanger.skid_base', + family: 'heat_exchanger', + kind: 'skid_base', + semanticRole: 'support_base', + aliases: ['support', 'saddle', 'base', 'skid', '支座'], + attachTo: 'heat_exchanger', + layoutRole: 'support_saddles', + description: 'Heat exchanger support base.', + params: { + length: { type: 'number', min: 0.2, max: 8, default: 1.4 }, + width: { type: 'number', min: 0.08, max: 3, default: 0.48 }, + height: { type: 'number', min: 0.02, max: 1, default: 0.1 }, + metalColor: { type: 'color', default: '#64748b' }, + }, + }, +] + +export const MACHINE_TOOL_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'machine_tool.generic_base', + family: 'machine_tool', + kind: 'generic_base', + semanticRole: 'machine_base', + aliases: ['base', 'machine_base', 'bed', 'lathe_bed', '床身', '底座'], + required: true, + layoutRole: 'machine_bed', + description: 'Machine tool base / bed.', + params: { + length: { type: 'number', min: 0.4, max: 10, default: 2.4 }, + width: { type: 'number', min: 0.18, max: 5, default: 1.0 }, + thickness: { type: 'number', min: 0.03, max: 1, default: 0.16 }, + darkColor: { type: 'color', default: '#1f2937' }, + }, + }, + { + id: 'machine_tool.generic_body', + family: 'machine_tool', + kind: 'generic_body', + semanticRole: 'machine_enclosure', + aliases: ['enclosure', 'machine_body', 'cnc_enclosure', 'housing', '机床外壳'], + required: true, + attachTo: 'generic_base', + layoutRole: 'machine_enclosure', + description: 'Machine tool enclosure / main body.', + params: { + length: { type: 'number', min: 0.3, max: 8, default: 1.7 }, + width: { type: 'number', min: 0.16, max: 4, default: 0.8 }, + height: { type: 'number', min: 0.2, max: 4, default: 1.2 }, + primaryColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'machine_tool.generic_panel', + family: 'machine_tool', + kind: 'generic_panel', + semanticRole: 'spindle_head', + aliases: ['spindle', 'spindle_head', 'tool_head', '主轴', '主轴头'], + required: true, + attachTo: 'generic_body', + layoutRole: 'front_spindle_head', + description: 'Spindle head or tool head panel.', + params: { + length: { type: 'number', min: 0.05, max: 3, default: 0.45 }, + height: { type: 'number', min: 0.05, max: 2, default: 0.36 }, + thickness: { type: 'number', min: 0.004, max: 0.4, default: 0.05 }, + color: { type: 'color', default: '#334155' }, + }, + }, + { + id: 'machine_tool.viewing_panel', + family: 'machine_tool', + kind: 'generic_panel', + semanticRole: 'viewing_panel', + aliases: [ + 'viewing_panel', + 'viewing_window', + 'observation_window', + 'front_window', + 'transparent_panel', + 'window', + 'glass', + 'inspection_window', + ], + attachTo: 'generic_body', + layoutRole: 'front_viewing_window', + description: 'Transparent front viewing window for an enclosed CNC machine.', + params: { + length: { type: 'number', min: 0.05, max: 4, default: 0.9 }, + height: { type: 'number', min: 0.05, max: 2.5, default: 0.65 }, + thickness: { type: 'number', min: 0.003, max: 0.16, default: 0.012 }, + color: { type: 'color', default: '#88CCEE' }, + opacity: { type: 'number', min: 0.1, max: 1, default: 0.48 }, + }, + }, + { + id: 'machine_tool.work_table', + family: 'machine_tool', + kind: 'generic_panel', + semanticRole: 'work_table', + aliases: ['work_table', 'machine_table', 'fixture_table', 'bed_table', '工作台'], + attachTo: 'generic_body', + layoutRole: 'inside_work_table', + description: 'Internal work table or fixture table below the spindle.', + params: { + length: { type: 'number', min: 0.08, max: 5, default: 1.0 }, + width: { type: 'number', min: 0.04, max: 3, default: 0.65 }, + thickness: { type: 'number', min: 0.01, max: 0.4, default: 0.06 }, + color: { type: 'color', default: '#555566' }, + }, + }, + { + id: 'machine_tool.feed_chute', + family: 'machine_tool', + kind: 'generic_spout', + semanticRole: 'feed_chute', + aliases: ['feed_chute', 'feed_hopper', 'infeed', 'infeed_chute', 'material_inlet'], + attachTo: 'generic_body', + layoutRole: 'front_feed_chute', + description: 'Feed chute, hopper, or material inlet for enclosed production machinery.', + params: { + length: { type: 'number', min: 0.04, max: 2, default: 0.34 }, + radius: { type: 'number', min: 0.008, max: 0.35, default: 0.08 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'z' }, + darkColor: { type: 'color', default: '#374151' }, + }, + }, + { + id: 'machine_tool.discharge_chute', + family: 'machine_tool', + kind: 'generic_spout', + semanticRole: 'discharge_chute', + aliases: ['discharge_chute', 'outfeed', 'outfeed_chute', 'material_outlet', 'product_exit'], + attachTo: 'generic_body', + layoutRole: 'rear_discharge_chute', + description: 'Discharge chute or product outlet for enclosed production machinery.', + params: { + length: { type: 'number', min: 0.04, max: 2, default: 0.4 }, + radius: { type: 'number', min: 0.008, max: 0.35, default: 0.09 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'z' }, + darkColor: { type: 'color', default: '#374151' }, + }, + }, + { + id: 'machine_tool.control_box', + family: 'machine_tool', + kind: 'control_box', + semanticRole: 'control_panel', + aliases: ['control_panel', 'control_box', 'operator_panel', '控制面板'], + required: true, + attachTo: 'generic_body', + layoutRole: 'operator_panel', + description: 'Operator control panel / pendant.', + params: { + length: { type: 'number', min: 0.04, max: 1.5, default: 0.28 }, + width: { type: 'number', min: 0.03, max: 1, default: 0.16 }, + height: { type: 'number', min: 0.03, max: 1.2, default: 0.36 }, + primaryColor: { type: 'color', default: '#1f2937' }, + }, + }, + { + id: 'machine_tool.spindle_nose', + family: 'machine_tool', + kind: 'generic_spout', + semanticRole: 'spindle_nose', + aliases: ['spindle_nose', 'spindle_spout', 'tool_nose', 'tool_tip', 'cutter_nose'], + attachTo: 'generic_panel', + layoutRole: 'below_spindle_head', + description: 'Short spindle nose or tool tip below the spindle head.', + params: { + length: { type: 'number', min: 0.03, max: 0.6, default: 0.14 }, + radius: { type: 'number', min: 0.008, max: 0.2, default: 0.04 }, + darkColor: { type: 'color', default: '#18181B' }, + }, + }, + { + id: 'machine_tool.display_screen', + family: 'machine_tool', + kind: 'generic_display', + semanticRole: 'display_screen', + aliases: ['display', 'screen', 'display_screen', 'operator_screen', 'hmi_screen'], + attachTo: 'control_box', + layoutRole: 'control_panel_screen', + description: 'Operator HMI screen on the control panel.', + params: { + length: { type: 'number', min: 0.03, max: 1, default: 0.26 }, + height: { type: 'number', min: 0.02, max: 0.8, default: 0.18 }, + thickness: { type: 'number', min: 0.002, max: 0.08, default: 0.008 }, + color: { type: 'color', default: '#1A1A2E' }, + }, + }, + { + id: 'machine_tool.vent_panel', + family: 'machine_tool', + kind: 'vent_slats', + semanticRole: 'vent_panel', + aliases: ['vent', 'vents', 'vent_panel', 'vent_slats', 'louver', 'louvers', 'cooling_vent'], + attachTo: 'generic_body', + layoutRole: 'side_vent_panel', + description: 'Side ventilation slats for heat dissipation.', + params: { + length: { type: 'number', min: 0.06, max: 3, default: 0.48 }, + height: { type: 'number', min: 0.04, max: 2, default: 0.48 }, + thickness: { type: 'number', min: 0.004, max: 0.12, default: 0.02 }, + detailLevel: { type: 'enum', values: ['low', 'medium', 'high'], default: 'medium' }, + slatCount: { type: 'integer', min: 2, max: 18, default: 6 }, + color: { type: 'color', default: '#475569' }, + }, + }, + { + id: 'machine_tool.access_panel', + family: 'machine_tool', + kind: 'generic_detail_accent', + semanticRole: 'access_panel', + aliases: [ + 'access_panel', + 'maintenance_panel', + 'inspection_panel', + 'service_panel', + 'door_panel', + ], + attachTo: 'generic_body', + layoutRole: 'side_access_panel', + required: true, + description: 'Maintenance or inspection access panel on the machine enclosure.', + params: { + length: { type: 'number', min: 0.05, max: 3, default: 0.65 }, + height: { type: 'number', min: 0.04, max: 2, default: 0.45 }, + thickness: { type: 'number', min: 0.003, max: 0.12, default: 0.015 }, + accentColor: { type: 'color', default: '#666677' }, + }, + }, + { + id: 'machine_tool.warning_label', + family: 'machine_tool', + kind: 'warning_label', + semanticRole: 'warning_label', + aliases: ['warning', 'warning_label', 'safety_label', 'hazard_label'], + attachTo: 'generic_body', + layoutRole: 'front_warning_label', + description: 'Small safety warning label on the front enclosure.', + params: { + length: { type: 'number', min: 0.03, max: 0.8, default: 0.16 }, + height: { type: 'number', min: 0.02, max: 0.5, default: 0.08 }, + thickness: { type: 'number', min: 0.001, max: 0.04, default: 0.004 }, + }, + }, + { + id: 'machine_tool.nameplate', + family: 'machine_tool', + kind: 'nameplate', + semanticRole: 'nameplate', + aliases: ['nameplate', 'brand_plate', 'manufacturer_plate', '铭牌'], + attachTo: 'generic_body', + layoutRole: 'front_nameplate', + description: 'Manufacturer nameplate or model plate.', + params: { + length: { type: 'number', min: 0.03, max: 0.9, default: 0.2 }, + height: { type: 'number', min: 0.02, max: 0.45, default: 0.06 }, + thickness: { type: 'number', min: 0.001, max: 0.04, default: 0.004 }, + }, + }, +] + +export const OUTDOOR_AC_PART_DEFINITIONS: readonly PartDefinition[] = [ + { + id: 'outdoor_ac.rounded_machine_body', + family: 'outdoor_ac', + kind: 'rounded_machine_body', + semanticRole: 'outdoor_ac_body', + aliases: [ + 'outdoor_ac_body', + 'condenser_body', + 'casing', + 'cabinet', + 'enclosure', + 'machine_body', + ], + required: true, + layoutRole: 'enclosure', + description: 'Rounded rectangular outdoor AC condenser enclosure.', + params: { + length: { type: 'number', min: 0.12, max: 4, default: 0.86 }, + width: { type: 'number', min: 0.08, max: 2, default: 0.34 }, + height: { type: 'number', min: 0.08, max: 2.4, default: 0.62 }, + primaryColor: { type: 'color', default: '#8b9aae' }, + }, + }, + { + id: 'outdoor_ac.vent_grill', + family: 'outdoor_ac', + kind: 'vent_grill', + semanticRole: 'front_vent', + aliases: [ + 'vent_grill', + 'vent_grille', + 'front_vent', + 'front_grille', + 'fan_exhaust', + 'air_grille', + 'louver_grill', + ], + required: true, + attachTo: 'rounded_machine_body', + layoutRole: 'front_vent', + description: 'Front louver grille on the condenser casing.', + params: { + length: { type: 'number', min: 0.04, max: 3, default: 0.56 }, + width: { type: 'number', min: 0.01, max: 0.5, default: 0.04 }, + height: { type: 'number', min: 0.01, max: 1.5, default: 0.3 }, + slatCount: { type: 'integer', min: 2, max: 20, default: 8 }, + primaryColor: { type: 'color', default: '#cbd5e1' }, + }, + }, + { + id: 'outdoor_ac.radial_blades', + family: 'outdoor_ac', + kind: 'radial_blades', + semanticRole: 'fan_impeller', + aliases: [ + 'radial_blades', + 'fan_blades', + 'fan_impeller', + 'condenser_fan', + 'axial_fan', + 'impeller', + ], + required: true, + attachTo: 'vent_grill', + layoutRole: 'front_fan', + description: 'Visible condenser fan blades behind or within the front grille.', + params: { + count: { type: 'integer', min: 2, max: 8, default: 4 }, + bladeRadius: { type: 'number', min: 0.04, max: 1.2, default: 0.24 }, + bladeWidth: { type: 'number', min: 0.01, max: 0.5, default: 0.06 }, + bladePitch: { type: 'number', min: -0.65, max: 0.65, default: 0.24 }, + primaryColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'outdoor_ac.pipe_port', + family: 'outdoor_ac', + kind: 'pipe_port', + semanticRole: 'refrigerant_port', + aliases: [ + 'pipe_port', + 'refrigerant_port', + 'refrigerant_inlet', + 'refrigerant_outlet', + 'service_port', + 'connection_pipe', + ], + attachTo: 'rounded_machine_body', + layoutRole: 'side_pipe_port', + description: 'Small refrigerant pipe connection on the side of the condenser unit.', + params: { + radius: { type: 'number', min: 0.004, max: 0.18, default: 0.024 }, + length: { type: 'number', min: 0.02, max: 1, default: 0.12 }, + axis: { type: 'enum', values: ['x', 'y', 'z'], default: 'x' }, + metalColor: { type: 'color', default: '#94a3b8' }, + }, + }, + { + id: 'outdoor_ac.nameplate', + family: 'outdoor_ac', + kind: 'nameplate', + semanticRole: 'unit_label', + aliases: ['nameplate', 'unit_label', 'brand_plate', 'manufacturer_plate'], + attachTo: 'rounded_machine_body', + layoutRole: 'front_nameplate', + description: 'Small front nameplate label.', + params: { + length: { type: 'number', min: 0.03, max: 0.9, default: 0.18 }, + height: { type: 'number', min: 0.02, max: 0.45, default: 0.06 }, + thickness: { type: 'number', min: 0.001, max: 0.04, default: 0.004 }, + }, + }, + { + id: 'outdoor_ac.warning_label', + family: 'outdoor_ac', + kind: 'warning_label', + semanticRole: 'warning_label', + aliases: ['warning_label', 'warning_sticker', 'safety_label'], + attachTo: 'rounded_machine_body', + layoutRole: 'front_warning_label', + description: 'Small safety warning label.', + params: { + length: { type: 'number', min: 0.03, max: 0.9, default: 0.16 }, + height: { type: 'number', min: 0.02, max: 0.45, default: 0.05 }, + thickness: { type: 'number', min: 0.001, max: 0.04, default: 0.004 }, + }, + }, +] + +const partDefinitionsByFamily = new Map([ + ['vehicle', VEHICLE_PART_DEFINITIONS], + ['desk', DESK_PART_DEFINITIONS], + ['fan', FAN_PART_DEFINITIONS], + ['aircraft', AIRCRAFT_PART_DEFINITIONS], + ['generic', GENERIC_PART_DEFINITIONS], + ['kiosk', KIOSK_PART_DEFINITIONS], + ['pump', PUMP_PART_DEFINITIONS], + ['conveyor', CONVEYOR_PART_DEFINITIONS], + ['electrical', ELECTRICAL_PART_DEFINITIONS], + ['pipe_system', PIPE_SYSTEM_PART_DEFINITIONS], + ['tank', TANK_PART_DEFINITIONS], + ['reactor', REACTOR_PART_DEFINITIONS], + ['compressor', COMPRESSOR_PART_DEFINITIONS], + ['heat_exchanger', HEAT_EXCHANGER_PART_DEFINITIONS], + ['machine_tool', MACHINE_TOOL_PART_DEFINITIONS], + ['outdoor_ac', OUTDOOR_AC_PART_DEFINITIONS], +]) + +const INDUSTRIAL_PART_FAMILIES = new Set([ + 'pump', + 'fan', + 'conveyor', + 'electrical', + 'pipe_system', + 'tank', + 'reactor', + 'compressor', + 'heat_exchanger', + 'machine_tool', +]) + +const partAliasMapByFamily = new Map>() + +for (const [family, definitions] of partDefinitionsByFamily) { + const aliasMap = new Map() + const setAlias = (alias: string, definition: PartDefinition) => { + const key = normalizeKey(alias) + if (key && !aliasMap.has(key)) aliasMap.set(key, definition) + } + for (const definition of definitions) { + setAlias(definition.kind, definition) + aliasMap.set(normalizeKey(definition.id), definition) + if (definition.semanticRole) setAlias(definition.semanticRole, definition) + for (const alias of definition.aliases) setAlias(alias, definition) + } + partAliasMapByFamily.set(family, aliasMap) +} + +function normalizeKey(value: unknown): string { + return typeof value === 'string' + ? value + .trim() + .replace(/[\s_-]+/g, '_') + .toLowerCase() + : '' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function partIdentityCandidates(part: Record): string[] { + return Array.from( + new Set( + [part.id, part.semanticRole, part.name, part.partName, part.kind, part.partType, part.type] + .map(normalizeKey) + .filter(Boolean), + ), + ) +} + +function definitionForPart( + family: string, + part: Record, +): PartDefinition | undefined { + const aliasMap = partAliasMapByFamily.get(family) + if (!aliasMap) return undefined + const identities = partIdentityCandidates(part) + for (const identity of identities) { + if (aliasMap.has(identity)) return aliasMap.get(identity) + } + for (const identity of identities) { + for (const [alias, definition] of aliasMap) { + if (identity.includes(alias) || alias.includes(identity)) return definition + } + } + return undefined +} + +function numberValue(...values: unknown[]): number | undefined { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value + } + return undefined +} + +function stringValue(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value + } + return undefined +} + +function vec3Value(value: unknown): [number, number, number] | undefined { + if ( + !Array.isArray(value) || + value.length < 3 || + typeof value[0] !== 'number' || + typeof value[1] !== 'number' || + typeof value[2] !== 'number' || + !Number.isFinite(value[0]) || + !Number.isFinite(value[1]) || + !Number.isFinite(value[2]) + ) { + return undefined + } + return [value[0], value[1], value[2]] +} + +function clampParam( + value: unknown, + definition: PartParameterDefinition, + label: string, + warnings: string[], +) { + if (definition.type === 'color' || definition.type === 'string' || definition.type === 'enum') { + const raw = stringValue(value) + if (!raw) return definition.default + if (definition.values && !definition.values.includes(raw)) { + warnings.push(`${label} ignored unsupported value "${raw}".`) + return definition.default + } + return raw + } + if (definition.type === 'boolean') { + return typeof value === 'boolean' ? value : definition.default + } + if (definition.values?.length) { + const numeric = numberValue(value) + if (numeric == null) return definition.default + const closest = definition.values + .filter((candidate): candidate is number => typeof candidate === 'number') + .reduce((best, candidate) => + Math.abs(candidate - numeric) < Math.abs(best - numeric) ? candidate : best, + ) + if (closest !== numeric) warnings.push(`${label} normalized from ${numeric} to ${closest}.`) + return closest + } + const numeric = numberValue(value) + if (numeric == null) return definition.default + const min = definition.min ?? Number.NEGATIVE_INFINITY + const max = definition.max ?? Number.POSITIVE_INFINITY + const clamped = Math.max(min, Math.min(max, numeric)) + if (clamped !== numeric) warnings.push(`${label} clamped from ${numeric} to ${clamped}.`) + return definition.type === 'integer' ? Math.round(clamped) : clamped +} + +function normalizePartParams( + definition: PartDefinition, + raw: Record, + warnings: string[], +): Record { + const params = isRecord(raw.params) ? raw.params : {} + const read = (key: string) => raw[key] ?? params[key] + const normalized: Record = {} + for (const [key, paramDefinition] of Object.entries(definition.params)) { + const value = clampParam(read(key), paramDefinition, `${definition.kind}.${key}`, warnings) + if (value != null) normalized[key] = value + } + if (definition.kind === 'wheel_set') { + normalized.radius = normalized.radius ?? normalized.wheelRadius + normalized.width = normalized.width ?? normalized.wheelWidth + } + if (definition.kind === 'aircraft_landing_gear') { + normalized.radius = normalized.radius ?? normalized.wheelRadius + } + return normalized +} + +function mergeBodyDimensions( + part: PartComposePartInput, + input: Record, +): PartComposePartInput { + return { + ...part, + ...(numberValue(input.length) != null ? { length: numberValue(input.length) } : {}), + ...(numberValue(input.width, input.depth) != null + ? { width: numberValue(input.width, input.depth) } + : {}), + ...(numberValue(input.height) != null ? { height: numberValue(input.height) } : {}), + ...(stringValue(input.primaryColor, input.color) + ? { primaryColor: stringValue(input.primaryColor, input.color) } + : {}), + } +} + +function mergeDeskTopDimensions( + part: PartComposePartInput, + input: Record, +): PartComposePartInput { + return { + ...part, + ...(numberValue(input.length) != null ? { length: numberValue(input.length) } : {}), + ...(numberValue(input.width, input.depth) != null + ? { width: numberValue(input.width, input.depth) } + : {}), + ...(stringValue(input.primaryColor, input.color) + ? { primaryColor: stringValue(input.primaryColor, input.color) } + : {}), + } +} + +function mergeAircraftFuselageDimensions( + part: PartComposePartInput, + input: Record, +): PartComposePartInput { + return { + ...mergeBodyDimensions(part, input), + ...(stringValue(input.accentColor) ? { accentColor: stringValue(input.accentColor) } : {}), + } +} + +function mergeKioskPartDimensions( + definition: PartDefinition, + part: PartComposePartInput, + raw: Record, + input: Record, +): PartComposePartInput { + const params = isRecord(raw.params) ? raw.params : {} + const hasRaw = (key: string) => raw[key] != null || params[key] != null + const length = numberValue(input.length) ?? 1.8 + const width = numberValue(input.width, input.depth) ?? 1.2 + const height = numberValue(input.height) ?? 2.1 + if (definition.kind === 'kiosk_body') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('width') ? { width } : {}), + ...(!hasRaw('height') ? { height: height * 0.78 } : {}), + ...(stringValue(input.primaryColor, input.color) + ? { primaryColor: stringValue(input.primaryColor, input.color) } + : {}), + } + } + if (definition.kind === 'kiosk_roof') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 1.16 } : {}), + ...(!hasRaw('width') ? { width: width * 1.18 } : {}), + ...(!hasRaw('height') ? { height: height * 0.16 } : {}), + ...(stringValue(input.secondaryColor) ? { color: stringValue(input.secondaryColor) } : {}), + } + } + if (definition.kind === 'kiosk_opening') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.42 } : {}), + ...(!hasRaw('height') ? { height: height * 0.34 } : {}), + } + } + if (definition.kind === 'kiosk_counter') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.62 } : {}), + ...(!hasRaw('width') ? { width: width * 0.2 } : {}), + ...(!hasRaw('thickness') ? { thickness: height * 0.04 } : {}), + } + } + if (definition.kind === 'kiosk_sign') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.64 } : {}), + ...(!hasRaw('height') ? { height: height * 0.12 } : {}), + ...(stringValue(input.accentColor) ? { accentColor: stringValue(input.accentColor) } : {}), + } + } + if (definition.kind === 'kiosk_awning') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.72 } : {}), + ...(!hasRaw('width') ? { width: width * 0.32 } : {}), + ...(!hasRaw('thickness') ? { thickness: height * 0.04 } : {}), + } + } + return part +} + +function mergeIndustrialPartDimensions( + family: string, + definition: PartDefinition, + part: PartComposePartInput, + raw: Record, + input: Record, +): PartComposePartInput { + const params = isRecord(raw.params) ? raw.params : {} + const hasRaw = (key: string) => raw[key] != null || params[key] != null + const rawNumber = (...keys: string[]) => + numberValue(...keys.map((key) => raw[key] ?? params[key])) + const inputNumber = (...keys: string[]) => numberValue(...keys.map((key) => input[key])) + const inputString = (...keys: string[]) => stringValue(...keys.map((key) => input[key])) + const length = + numberValue(input.length) ?? (family === 'conveyor' ? 3 : family === 'pipe_system' ? 2 : 1.2) + const width = numberValue(input.width, input.depth, input.diameter) ?? 0.55 + const height = numberValue(input.height) ?? (family === 'electrical' ? 1.6 : 0.6) + const color = stringValue(input.primaryColor, input.color) + const metalColor = stringValue(input.metalColor, input.secondaryColor) + + if (family === 'pump') { + const motorRadius = + rawNumber('motorRadius') ?? + inputNumber('motorRadius', 'driveMotorRadius') ?? + Math.max(0.04, Math.min(width, height) * 0.28) + const portRadius = Math.max( + 0.02, + inputNumber('portRadius') ?? + (inputNumber('portDiameter') != null ? inputNumber('portDiameter')! / 2 : undefined) ?? + motorRadius * 0.42, + ) + const inletRadius = + inputNumber('inletRadius', 'suctionRadius') ?? + (inputNumber('inletDiameter', 'suctionDiameter') != null + ? inputNumber('inletDiameter', 'suctionDiameter')! / 2 + : undefined) + const outletRadius = + inputNumber('outletRadius', 'dischargeRadius') ?? + (inputNumber('outletDiameter', 'dischargeDiameter') != null + ? inputNumber('outletDiameter', 'dischargeDiameter')! / 2 + : undefined) + const boltCount = rawNumber('boltCount') ?? inputNumber('boltCount', 'flangeBoltCount') + if (definition.kind === 'skid_base') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('width') ? { width } : {}), + ...(!hasRaw('height') + ? { height: inputNumber('baseThickness', 'skidHeight') ?? Math.max(0.03, height * 0.12) } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'ribbed_motor_body') { + return { + ...part, + ...(!hasRaw('length') + ? { length: rawNumber('motorLength') ?? inputNumber('motorLength') ?? length * 0.38 } + : {}), + ...(!hasRaw('radius') ? { radius: motorRadius } : {}), + ...(!hasRaw('slatCount') && !hasRaw('count') + ? { slatCount: rawNumber('ribCount', 'finCount') ?? inputNumber('ribCount', 'finCount') } + : {}), + ...(color ? { primaryColor: color } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'volute_casing') { + return { + ...part, + ...(!hasRaw('radius') + ? { + radius: + inputNumber('casingRadius', 'voluteRadius') ?? + Math.max(0.05, Math.min(width, height) * 0.36), + } + : {}), + ...(!hasRaw('depth') + ? { depth: inputNumber('casingDepth', 'voluteDepth') ?? width * 0.28 } + : {}), + ...(color ? { primaryColor: color } : {}), + } + } + if (definition.kind === 'inlet_port' || definition.kind === 'outlet_port') { + const explicitPortRadius = definition.kind === 'inlet_port' ? inletRadius : outletRadius + return { + ...part, + ...(!hasRaw('radius') ? { radius: explicitPortRadius ?? portRadius } : {}), + ...(!hasRaw('length') + ? { + length: + inputNumber( + definition.kind === 'inlet_port' ? 'inletLength' : 'outletLength', + definition.kind === 'inlet_port' ? 'suctionLength' : 'dischargeLength', + ) ?? Math.max(0.06, width * 0.32), + } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'flange_ring') { + return { + ...part, + ...(!hasRaw('radius') ? { radius: portRadius * 1.65 } : {}), + ...(!hasRaw('tubeRadius') ? { tubeRadius: portRadius * 0.22 } : {}), + ...(!hasRaw('boltCount') && boltCount != null ? { boltCount } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'impeller_blades') { + return { + ...part, + ...(!hasRaw('radius') + ? { + radius: + inputNumber('impellerRadius') ?? Math.max(0.04, Math.min(width, height) * 0.23), + } + : {}), + ...(!hasRaw('count') && inputNumber('impellerBladeCount') != null + ? { count: inputNumber('impellerBladeCount') } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + } + + if (family === 'conveyor') { + const beltWidth = inputNumber('beltWidth') + const rollerCount = inputNumber('rollerCount', 'idlerCount') + const rollerRadius = inputNumber('rollerRadius', 'idlerRadius') + if (definition.kind === 'conveyor_frame') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('width') ? { width } : {}), + ...(!hasRaw('height') ? { height: inputNumber('frameHeight') ?? height } : {}), + ...(!hasRaw('legCount') && inputNumber('legCount', 'supportCount') != null + ? { legCount: inputNumber('legCount', 'supportCount') } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'roller_array') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.94 } : {}), + ...(!hasRaw('width') ? { width: beltWidth ?? width * 0.9 } : {}), + ...(!hasRaw('radius') + ? { radius: rollerRadius ?? Math.max(0.012, Math.min(width, height) * 0.045) } + : {}), + ...(!hasRaw('count') + ? { count: rollerCount ?? Math.max(4, Math.min(32, Math.round(length * 4))) } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'belt_surface') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.98 } : {}), + ...(!hasRaw('width') ? { width: beltWidth ?? width * 0.9 } : {}), + ...(!hasRaw('height') + ? { height: inputNumber('beltThickness') ?? Math.max(0.008, height * 0.035) } + : {}), + ...(stringValue(input.darkColor) ? { darkColor: stringValue(input.darkColor) } : {}), + } + } + if (definition.kind === 'ribbed_motor_body') { + return { + ...part, + ...(!hasRaw('length') + ? { + length: + inputNumber('motorLength', 'driveMotorLength') ?? Math.max(0.12, width * 0.34), + } + : {}), + ...(!hasRaw('radius') + ? { + radius: + inputNumber('motorRadius', 'driveMotorRadius') ?? Math.max(0.035, width * 0.09), + } + : {}), + ...(color ? { primaryColor: color } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + } + + if (family === 'electrical') { + const doorCount = inputNumber('doorCount') + const ventCount = inputNumber('ventCount', 'ventRows', 'slatCount') + if (definition.kind === 'electrical_cabinet') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('width') ? { width } : {}), + ...(!hasRaw('height') ? { height } : {}), + ...(!hasRaw('doorCount') && doorCount != null ? { doorCount } : {}), + ...(!hasRaw('slatCount') && !hasRaw('count') && ventCount != null + ? { slatCount: ventCount } + : {}), + ...(color ? { primaryColor: color } : {}), + } + } + if (definition.kind === 'cable_tray') { + return { + ...part, + ...(!hasRaw('length') ? { length: inputNumber('cableTrayLength') ?? length * 1.25 } : {}), + ...(!hasRaw('width') ? { width: inputNumber('cableTrayWidth') ?? width * 0.5 } : {}), + ...(!hasRaw('height') + ? { height: inputNumber('cableTrayHeight') ?? Math.max(0.03, height * 0.045) } + : {}), + ...(!hasRaw('slatCount') && inputNumber('cableTrayRungCount') != null + ? { slatCount: inputNumber('cableTrayRungCount') } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + } + + if (family === 'pipe_system') { + const explicitRadius = numberValue(input.radius, input.pipeRadius) + const explicitDiameter = numberValue(input.diameter, input.pipeDiameter) + const pipeRadius = + explicitRadius ?? + (explicitDiameter != null + ? explicitDiameter / 2 + : Math.max(0.02, Math.min(width, height) * 0.5)) + if (definition.kind === 'pipe_run') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('radius') ? { radius: pipeRadius } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'pipe_elbow' || definition.kind === 'valve_body') { + return { + ...part, + ...(!hasRaw('radius') ? { radius: pipeRadius } : {}), + ...(definition.kind === 'pipe_elbow' && !hasRaw('bendRadius') && !hasRaw('length') + ? { bendRadius: inputNumber('bendRadius', 'elbowRadius') ?? pipeRadius * 4.2 } + : {}), + ...(definition.kind === 'valve_body' && !hasRaw('length') + ? { length: inputNumber('valveLength') ?? Math.max(0.08, pipeRadius * 5) } + : {}), + ...(definition.kind === 'valve_body' && !hasRaw('valveStyle') && inputString('valveStyle') + ? { valveStyle: inputString('valveStyle') } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + const flangeBoltCount = inputNumber('boltCount', 'flangeBoltCount') + if (definition.kind === 'flange_ring') { + return { + ...part, + ...(!hasRaw('radius') ? { radius: pipeRadius * 1.55 } : {}), + ...(!hasRaw('tubeRadius') ? { tubeRadius: Math.max(0.004, pipeRadius * 0.22) } : {}), + ...(!hasRaw('boltCount') && flangeBoltCount != null ? { boltCount: flangeBoltCount } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + } + + if (family === 'tank') { + const tankHeight = inputNumber('tankHeight') ?? height + const tankRadius = + inputNumber('tankRadius', 'radius') ?? + (inputNumber('diameter', 'tankDiameter') != null + ? inputNumber('diameter', 'tankDiameter')! / 2 + : Math.max(0.08, width * 0.5)) + const portRadius = + inputNumber('portRadius') ?? + (inputNumber('portDiameter') != null ? inputNumber('portDiameter')! / 2 : undefined) ?? + tankRadius * 0.16 + if (definition.kind === 'cylindrical_tank') { + const horizontal = /horizontal|卧式|卧罐/i.test(textOf(input)) + const shellLength = horizontal ? length : tankHeight + return { + ...part, + ...(!hasRaw('length') ? { length: shellLength } : {}), + ...(!hasRaw('radius') ? { radius: tankRadius } : {}), + ...(!hasRaw('axis') ? { axis: horizontal ? 'x' : 'y' } : {}), + ...(!hasRaw('position') + ? { position: [0, horizontal ? tankRadius + 0.1 : shellLength / 2, 0] } + : {}), + ...(color ? { primaryColor: color } : {}), + } + } + if (definition.kind === 'skid_base') { + const baseHeight = + inputNumber('baseHeight', 'supportHeight') ?? Math.max(0.06, tankHeight * 0.06) + return { + ...part, + ...(!hasRaw('length') ? { length: Math.max(length, tankRadius * 2.2) } : {}), + ...(!hasRaw('width') ? { width: tankRadius * 2.2 } : {}), + ...(!hasRaw('height') ? { height: baseHeight } : {}), + ...(!hasRaw('position') ? { position: [0, baseHeight / 2, 0] } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'inlet_port' || definition.kind === 'outlet_port') { + const isInlet = definition.kind === 'inlet_port' + return { + ...part, + ...(!hasRaw('radius') ? { radius: portRadius } : {}), + ...(!hasRaw('length') ? { length: Math.max(0.06, tankRadius * 0.36) } : {}), + ...(!hasRaw('axis') ? { axis: isInlet ? 'y' : 'x' } : {}), + ...(!hasRaw('position') + ? { + position: isInlet + ? [0, tankHeight + tankRadius * 0.18, 0] + : [tankRadius * 1.08, tankHeight * 0.22, 0], + } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'platform_ladder') { + return { + ...part, + ...(!hasRaw('height') ? { height: tankHeight * 0.82 } : {}), + ...(!hasRaw('position') ? { position: [tankRadius * 1.28, tankHeight * 0.48, 0] } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + } + + if (family === 'reactor') { + const reactorHeight = inputNumber('vesselHeight', 'tankHeight') ?? height + const reactorRadius = + inputNumber('vesselRadius', 'tankRadius', 'radius') ?? + (inputNumber('diameter', 'vesselDiameter') != null + ? inputNumber('diameter', 'vesselDiameter')! / 2 + : Math.max(0.08, width * 0.5)) + const nozzleRadius = + inputNumber('nozzleRadius') ?? + (inputNumber('nozzleDiameter') != null ? inputNumber('nozzleDiameter')! / 2 : undefined) ?? + reactorRadius * 0.15 + if (definition.kind === 'agitator_tank') { + return { + ...part, + ...(!hasRaw('height') ? { height: reactorHeight } : {}), + ...(!hasRaw('radius') ? { radius: reactorRadius } : {}), + ...(!hasRaw('position') ? { position: [0, reactorHeight / 2, 0] } : {}), + ...(color ? { primaryColor: color } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'inlet_port' || definition.kind === 'outlet_port') { + const isInlet = definition.kind === 'inlet_port' + return { + ...part, + ...(!hasRaw('radius') ? { radius: nozzleRadius } : {}), + ...(!hasRaw('length') ? { length: Math.max(0.05, reactorRadius * 0.34) } : {}), + ...(!hasRaw('axis') ? { axis: isInlet ? 'y' : 'x' } : {}), + ...(!hasRaw('position') + ? { + position: isInlet + ? [-reactorRadius * 0.34, reactorHeight + reactorRadius * 0.16, 0] + : [reactorRadius * 1.08, reactorHeight * 0.22, 0], + } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'platform_ladder') { + return { + ...part, + ...(!hasRaw('height') ? { height: reactorHeight * 0.86 } : {}), + ...(!hasRaw('position') + ? { position: [reactorRadius * 1.32, reactorHeight * 0.48, 0] } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + } + + if (family === 'compressor') { + const motorRadius = inputNumber('motorRadius') ?? Math.max(0.05, Math.min(width, height) * 0.22) + const casingRadius = + inputNumber('casingRadius') ?? Math.max(0.08, Math.min(width, height) * 0.25) + const portRadius = + inputNumber('portRadius') ?? + (inputNumber('portDiameter') != null ? inputNumber('portDiameter')! / 2 : undefined) ?? + casingRadius * 0.28 + if (definition.kind === 'skid_base') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('width') ? { width } : {}), + ...(!hasRaw('height') ? { height: Math.max(0.06, height * 0.14) } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'ribbed_motor_body') { + return { + ...part, + ...(!hasRaw('length') ? { length: inputNumber('motorLength') ?? length * 0.34 } : {}), + ...(!hasRaw('radius') ? { radius: motorRadius } : {}), + ...(!hasRaw('position') ? { position: [-length * 0.22, height * 0.55, 0] } : {}), + ...(color ? { primaryColor: color } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'rounded_machine_body') { + return { + ...part, + ...(!hasRaw('length') ? { length: inputNumber('casingLength') ?? length * 0.34 } : {}), + ...(!hasRaw('width') ? { width: casingRadius * 1.8 } : {}), + ...(!hasRaw('height') ? { height: casingRadius * 1.8 } : {}), + ...(!hasRaw('position') ? { position: [length * 0.24, height * 0.55, 0] } : {}), + ...(color ? { primaryColor: color } : {}), + } + } + if (definition.kind === 'inlet_port' || definition.kind === 'outlet_port') { + const isInlet = definition.kind === 'inlet_port' + return { + ...part, + ...(!hasRaw('radius') ? { radius: portRadius } : {}), + ...(!hasRaw('length') ? { length: Math.max(0.06, width * 0.28) } : {}), + ...(!hasRaw('axis') ? { axis: 'x' } : {}), + ...(!hasRaw('position') + ? { position: [length * (isInlet ? 0.02 : 0.46), height * 0.58, 0] } + : {}), + ...(metalColor ? { metalColor } : {}), + } + } + if (definition.kind === 'control_box') { + return { + ...part, + ...(!hasRaw('position') ? { position: [-length * 0.42, height * 0.36, width * 0.38] } : {}), + } + } + } + + if (family === 'heat_exchanger') { + const shellRadius = + inputNumber('shellRadius', 'radius') ?? + (inputNumber('diameter', 'shellDiameter') != null + ? inputNumber('diameter', 'shellDiameter')! / 2 + : Math.max(0.06, Math.min(width, height) * 0.44)) + if (definition.kind === 'heat_exchanger') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('radius') ? { radius: shellRadius } : {}), + ...(!hasRaw('axis') ? { axis: 'x' } : {}), + ...(!hasRaw('position') ? { position: [0, shellRadius + 0.12, 0] } : {}), + ...(color ? { primaryColor: color } : {}), + } + } + if (definition.kind === 'skid_base') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.86 } : {}), + ...(!hasRaw('width') ? { width: shellRadius * 2.2 } : {}), + ...(!hasRaw('height') ? { height: Math.max(0.06, shellRadius * 0.38) } : {}), + ...(metalColor ? { metalColor } : {}), + } + } + } + + if (family === 'machine_tool') { + if (definition.kind === 'generic_base') { + return { + ...part, + ...(!hasRaw('length') ? { length } : {}), + ...(!hasRaw('width') ? { width } : {}), + ...(!hasRaw('thickness') ? { thickness: Math.max(0.08, height * 0.1) } : {}), + } + } + if (definition.kind === 'generic_body') { + return { + ...part, + ...(inputNumber('length') != null ? { length } : !hasRaw('length') ? { length } : {}), + ...(inputNumber('width', 'depth') != null ? { width } : !hasRaw('width') ? { width } : {}), + ...(inputNumber('height') != null ? { height } : !hasRaw('height') ? { height } : {}), + ...(!hasRaw('position') ? { position: [0, height * 0.56, 0] } : {}), + ...(color ? { primaryColor: color } : {}), + } + } + if (definition.kind === 'generic_panel') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.18 } : {}), + ...(!hasRaw('height') ? { height: height * 0.24 } : {}), + ...(!hasRaw('position') ? { position: [-length * 0.12, height * 0.58, width * 0.43] } : {}), + } + } + if (definition.kind === 'control_box') { + return { + ...part, + ...(!hasRaw('length') ? { length: length * 0.12 } : {}), + ...(!hasRaw('height') ? { height: height * 0.34 } : {}), + ...(!hasRaw('position') ? { position: [length * 0.38, height * 0.58, width * 0.5] } : {}), + } + } + } + + return part +} + +function textOf(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function shouldIncludeDeskDrawers(input: Record): boolean { + const text = textOf([ + input.name, + input.partName, + input.object, + input.prompt, + input.style, + ]).toLowerCase() + return /drawer|drawers|cabinet|storage|office|writing/.test(text) +} + +type GenericPartPlanCategory = 'equipment' | 'building' | 'furniture' | 'natural' | 'generic' + +function genericPartPlanCategory(input: Record): GenericPartPlanCategory { + const text = textOf([ + input.name, + input.partName, + input.object, + input.prompt, + input.style, + input.category, + input.geometryBrief, + ]).toLowerCase() + if (/coffee|espresso|\u5496\u5561\u673a|machine|equipment|device|appliance|console/.test(text)) { + return 'equipment' + } + if ( + /building|house|tower|pavilion|booth|kiosk|shed|\u5efa\u7b51|\u623f|\u4ead|\u68da/.test(text) + ) { + return 'building' + } + if ( + /furniture|chair|cabinet|shelf|sofa|bed|\u5bb6\u5177|\u6905|\u67dc|\u67b6|\u6c99\u53d1|\u5e8a/.test( + text, + ) + ) { + return 'furniture' + } + if ( + /landscape|garden|terrain|hill|mountain|pond|\u666f\u89c2|\u82b1\u56ed|\u5c71|\u6c60/.test(text) + ) { + return 'natural' + } + return 'generic' +} + +function isCoffeeLikeGeneric(input: Record): boolean { + return /coffee|espresso|\u5496\u5561\u673a/i.test(textOf(input)) +} + +function normalizePartForDefinition( + family: string, + definition: PartDefinition, + raw: Record, + input: Record, + warnings: string[], +): PartComposePartInput { + const params = normalizePartParams(definition, raw, warnings) + const preserveLayoutFields = INDUSTRIAL_PART_FAMILIES.has(family) + const position = preserveLayoutFields ? vec3Value(raw.position) : undefined + const rotation = preserveLayoutFields ? vec3Value(raw.rotation) : undefined + const id = preserveLayoutFields ? stringValue(raw.id) : undefined + const name = preserveLayoutFields ? stringValue(raw.name, raw.partName) : undefined + const side = preserveLayoutFields ? stringValue(raw.side) : undefined + const connectTo = preserveLayoutFields ? stringValue(raw.connectTo) : undefined + const connectPoint = preserveLayoutFields ? stringValue(raw.connectPoint) : undefined + const childPoint = preserveLayoutFields ? stringValue(raw.childPoint) : undefined + const centeredOn = preserveLayoutFields ? stringValue(raw.centeredOn) : undefined + const alignAbove = preserveLayoutFields ? stringValue(raw.alignAbove) : undefined + const alignBeside = preserveLayoutFields ? stringValue(raw.alignBeside) : undefined + const semanticRole = preserveLayoutFields + ? (stringValue(raw.semanticRole) ?? definition.semanticRole) + : definition.semanticRole + let part: PartComposePartInput = { + kind: definition.kind, + ...(semanticRole ? { semanticRole } : {}), + ...(id ? { id } : {}), + ...(name ? { name } : {}), + ...(position ? { position } : {}), + ...(rotation ? { rotation } : {}), + ...(side ? { side } : {}), + ...(connectTo ? { connectTo } : {}), + ...(connectPoint ? { connectPoint } : {}), + ...(childPoint ? { childPoint } : {}), + ...(centeredOn ? { centeredOn } : {}), + ...(alignAbove ? { alignAbove } : {}), + ...(alignBeside ? { alignBeside } : {}), + ...params, + } + + if (family === 'vehicle' && definition.kind === 'body_shell') { + part = mergeBodyDimensions(part, input) + } + if (family === 'desk' && definition.kind === 'desk_top') { + part = mergeDeskTopDimensions(part, input) + } + if (family === 'desk' && definition.kind === 'leg_set') { + const topLength = numberValue(input.length) + const topWidth = numberValue(input.width, input.depth) + const overallHeight = numberValue(input.height) + part = { + ...part, + ...(topLength != null ? { length: Math.max(0.25, topLength * 0.9) } : {}), + ...(topWidth != null ? { width: Math.max(0.15, topWidth * 0.82) } : {}), + ...(overallHeight != null ? { height: Math.max(0.12, overallHeight - 0.055) } : {}), + } + } + if (family === 'aircraft' && definition.kind === 'aircraft_fuselage') { + part = mergeAircraftFuselageDimensions(part, input) + } + if (family === 'generic' && definition.kind === 'generic_body') { + part = mergeBodyDimensions(part, input) + } + if (family === 'generic' && definition.kind === 'generic_base') { + const topLength = numberValue(input.length) + const topWidth = numberValue(input.width, input.depth) + const overallHeight = numberValue(input.height) + part = { + ...part, + ...(topLength != null ? { length: Math.max(0.08, topLength * 1.08) } : {}), + ...(topWidth != null ? { width: Math.max(0.05, topWidth * 1.08) } : {}), + ...(overallHeight != null ? { thickness: Math.max(0.01, overallHeight * 0.08) } : {}), + } + } + if (family === 'kiosk') { + part = mergeKioskPartDimensions(definition, part, raw, input) + } + if (INDUSTRIAL_PART_FAMILIES.has(family)) { + part = mergeIndustrialPartDimensions(family, definition, part, raw, input) + } + return part +} + +function normalizeFamilyPartPlan( + family: string, + definitions: readonly PartDefinition[], + input: Record, +): NormalizedPartPlan { + const warnings: string[] = [] + const rawParts = Array.isArray(input.parts) ? input.parts.filter(isRecord) : [] + const normalizedParts: PartComposePartInput[] = [] + const seen = new Set() + const seenDefinitionIds = new Set() + + for (const raw of rawParts) { + const definition = definitionForPart(family, raw) + if (!definition) { + warnings.push( + `Unknown ${family} part "${String(raw.kind ?? raw.name ?? raw.semanticRole ?? 'part')}" ignored.`, + ) + continue + } + const explicitId = stringValue(raw.id) + const dedupeKey = explicitId ? `${definition.id}:${normalizeKey(explicitId)}` : definition.id + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + seenDefinitionIds.add(definition.id) + normalizedParts.push(normalizePartForDefinition(family, definition, raw, input, warnings)) + } + + for (const definition of definitions) { + if (!definition.required || seenDefinitionIds.has(definition.id)) continue + normalizedParts.push(normalizePartForDefinition(family, definition, {}, input, warnings)) + seen.add(definition.id) + seenDefinitionIds.add(definition.id) + } + + if (family === 'vehicle' && !seen.has('seam_ring')) normalizedParts.push({ kind: 'seam_ring' }) + if (family === 'desk' && !seen.has('drawer_stack') && shouldIncludeDeskDrawers(input)) { + const drawerDefinition = definitions.find((definition) => definition.kind === 'drawer_stack') + if (drawerDefinition) { + normalizedParts.push( + normalizePartForDefinition(family, drawerDefinition, {}, input, warnings), + ) + } + } + if (family === 'kiosk') { + if (!seen.has('kiosk_sign')) { + const signDefinition = definitions.find((definition) => definition.kind === 'kiosk_sign') + if (signDefinition) { + normalizedParts.push( + normalizePartForDefinition(family, signDefinition, {}, input, warnings), + ) + } + } + if (!seen.has('kiosk_awning')) { + const awningDefinition = definitions.find((definition) => definition.kind === 'kiosk_awning') + if (awningDefinition) { + normalizedParts.push( + normalizePartForDefinition(family, awningDefinition, {}, input, warnings), + ) + } + } + } + + const definitionOrder = new Map(definitions.map((definition, index) => [definition.id, index])) + const orderForPart = (part: PartComposePartInput) => { + const semanticRole = normalizeKey(part.semanticRole) + const kind = normalizeKey(part.kind) + const definition = definitions.find( + (candidate) => + normalizeKey(candidate.kind) === kind && + (!semanticRole || normalizeKey(candidate.semanticRole) === semanticRole), + ) + return definitionOrder.get(definition?.id ?? '') ?? Number.MAX_SAFE_INTEGER + } + normalizedParts.sort((left, right) => orderForPart(left) - orderForPart(right)) + + return { family, parts: normalizedParts, warnings } +} + +export function getPartDefinitions(family: string): readonly PartDefinition[] { + return partDefinitionsByFamily.get(family) ?? [] +} + +const DIMENSION_PARAMETER_NAMES = new Set([ + 'length', + 'width', + 'height', + 'depth', + 'thickness', + 'radius', + 'diameter', + 'radiusTop', + 'radiusBottom', + 'majorRadius', + 'tubeRadius', + 'innerRadius', + 'outerRadius', + 'wheelRadius', + 'wheelWidth', + 'motorLength', + 'motorRadius', + 'casingLength', + 'casingRadius', + 'casingDepth', + 'shellDiameter', + 'shellRadius', + 'vesselHeight', + 'tankHeight', + 'portDiameter', + 'nozzleDiameter', + 'pipeDiameter', + 'pipeRadius', + 'bendRadius', + 'supportHeight', +]) + +const MATERIAL_PARAMETER_PATTERN = /(color|colour|tint|opacity|metalness|roughness|material)/i +const QUANTITY_PARAMETER_PATTERN = /(count|rows|columns|segments|slats|ribs|fins|bolts|doors)/i +const PLACEMENT_PARAMETER_PATTERN = /(offset|spacing|side|axis|angle|rotation|slope|position)/i +const DETAIL_PARAMETER_PATTERN = + /(detail|stripe|label|nameplate|vent|window|door|ladder|platform|handle)/i +const SHAPE_PARAMETER_PATTERN = + /(style|variant|round|radius|taper|arc|sweep|curve|blade|tooth|profile|truncated|topScale)/i + +function partEditableParameterRole( + key: string, + parameter: PartParameterDefinition, +): PartEditableParameterRole { + if (parameter.type === 'color' || MATERIAL_PARAMETER_PATTERN.test(key)) return 'material' + if (parameter.type === 'integer' || QUANTITY_PARAMETER_PATTERN.test(key)) return 'quantity' + if (DIMENSION_PARAMETER_NAMES.has(key)) return 'dimension' + if (PLACEMENT_PARAMETER_PATTERN.test(key)) return 'placement' + if (DETAIL_PARAMETER_PATTERN.test(key)) return 'detail' + if (parameter.type === 'enum' || SHAPE_PARAMETER_PATTERN.test(key)) return 'shape' + if (parameter.type === 'string' || parameter.type === 'boolean') return 'metadata' + return 'shape' +} + +function editableParameterFromDefinition( + key: string, + parameter: PartParameterDefinition, +): PartEditableParameter { + return { + name: key, + type: parameter.type, + role: partEditableParameterRole(key, parameter), + ...(parameter.min != null ? { min: parameter.min } : {}), + ...(parameter.max != null ? { max: parameter.max } : {}), + ...(parameter.default != null ? { default: parameter.default } : {}), + ...(parameter.values ? { values: parameter.values } : {}), + ...(parameter.description ? { description: parameter.description } : {}), + } +} + +function parameterNamesForRole( + parameters: readonly PartEditableParameter[], + role: PartEditableParameterRole, +): string[] { + return parameters + .filter((parameter) => parameter.role === role) + .map((parameter) => parameter.name) +} + +export function getPartCapabilityMetadata(family?: string): readonly PartCapabilityMetadata[] { + const definitions = family + ? getPartDefinitions(family) + : Array.from(partDefinitionsByFamily.values()).flat() + return definitions.map((definition) => { + const editableParameters = Object.entries(definition.params).map(([key, parameter]) => + editableParameterFromDefinition(key, parameter), + ) + return { + id: definition.id, + family: definition.family, + kind: definition.kind, + ...(definition.semanticRole ? { semanticRole: definition.semanticRole } : {}), + aliases: definition.aliases, + required: definition.required === true, + ...(definition.attachTo ? { attachTo: definition.attachTo } : {}), + ...(definition.layoutRole ? { layoutRole: definition.layoutRole } : {}), + description: definition.description, + editableParameters, + editableProperties: editableParameters.map((parameter) => parameter.name), + dimensionProperties: parameterNamesForRole(editableParameters, 'dimension'), + quantityProperties: parameterNamesForRole(editableParameters, 'quantity'), + materialProperties: parameterNamesForRole(editableParameters, 'material'), + shapeProperties: parameterNamesForRole(editableParameters, 'shape'), + detailProperties: parameterNamesForRole(editableParameters, 'detail'), + placementProperties: parameterNamesForRole(editableParameters, 'placement'), + } + }) +} + +function summarizeEditableGroups(metadata: PartCapabilityMetadata): string { + const groups = [ + metadata.dimensionProperties.length + ? `dimensions=${metadata.dimensionProperties.join('|')}` + : '', + metadata.quantityProperties.length ? `quantities=${metadata.quantityProperties.join('|')}` : '', + metadata.materialProperties.length ? `materials=${metadata.materialProperties.join('|')}` : '', + metadata.shapeProperties.length ? `shape=${metadata.shapeProperties.join('|')}` : '', + metadata.detailProperties.length ? `details=${metadata.detailProperties.join('|')}` : '', + metadata.placementProperties.length + ? `placement=${metadata.placementProperties.join('|')}` + : '', + ].filter(Boolean) + return groups.length ? ` editable(${groups.join('; ')})` : '' +} + +export function partCapabilitySummary(family?: string): string { + return getPartCapabilityMetadata(family) + .map((metadata) => { + const definition = partAliasMapByFamily.get(metadata.family)?.get(normalizeKey(metadata.id)) + const params = Object.entries(definition?.params ?? {}) + .map(([key, param]) => { + if (param.values?.length) return `${key}=${param.values.join('|')}` + const range = + param.min != null || param.max != null ? `[${param.min ?? ''},${param.max ?? ''}]` : '' + return `${key}:${param.type}${range}` + }) + .join(', ') + const role = metadata.semanticRole ? ` role=${metadata.semanticRole}` : '' + return `${metadata.id}${role}: ${params}${summarizeEditableGroups(metadata)}` + }) + .join('\n') +} + +export function normalizeVehiclePartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('vehicle', VEHICLE_PART_DEFINITIONS, input) +} + +export function normalizeDeskPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('desk', DESK_PART_DEFINITIONS, input) +} + +export function normalizeFanPartPlan(input: Record): NormalizedPartPlan { + const plan = normalizeFamilyPartPlan('fan', FAN_PART_DEFINITIONS, input) + for (const part of plan.parts) { + if (part.kind !== 'protective_grill') continue + const detailLevel = `${part.detailLevel ?? part.grillDetailLevel ?? ''}`.toLowerCase() + if (/low|simple|coarse|light|\u4f4e|\u7b80/i.test(detailLevel)) { + part.ringCount = 3 + part.spokeCount = 12 + } else if (/high|fine|detailed|dense|\u9ad8|\u7ec6|\u5bc6/i.test(detailLevel)) { + part.ringCount = 5 + part.spokeCount = 24 + } + } + return plan +} + +export function normalizeAircraftPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('aircraft', AIRCRAFT_PART_DEFINITIONS, input) +} + +export function normalizeGenericPartPlan(input: Record): NormalizedPartPlan { + const category = genericPartPlanCategory(input) + const plan = normalizeFamilyPartPlan('generic', GENERIC_PART_DEFINITIONS, input) + const length = numberValue(input.length) ?? 1 + const width = numberValue(input.width, input.depth) ?? 0.65 + const height = numberValue(input.height) ?? 0.8 + const hasKind = (kind: string, role?: string) => + plan.parts.some( + (part) => part.kind === kind && (role == null || normalizeKey(part.semanticRole) === role), + ) + const add = (part: PartComposePartInput) => { + if (!hasKind(String(part.kind), normalizeKey(part.semanticRole))) plan.parts.push(part) + } + + for (const part of plan.parts) { + if (part.kind === 'generic_body') { + if (category === 'building') part.semanticRole = 'building_body' + else if (category === 'furniture') part.semanticRole = 'furniture_body' + else if (category === 'natural') part.semanticRole = 'natural_mass' + else part.semanticRole = 'main_body' + } + if (part.kind === 'generic_base') { + part.semanticRole = category === 'natural' ? 'terrain_base' : 'support_base' + } + } + + if (category === 'equipment') { + add({ + kind: 'generic_control_panel', + semanticRole: 'control_detail', + length: length * 0.3, + height: height * 0.28, + accentColor: stringValue(input.accentColor) ?? '#38bdf8', + }) + add({ kind: 'generic_foot_set', semanticRole: 'support_foot' }) + if (isCoffeeLikeGeneric(input)) { + add({ + kind: 'generic_spout', + semanticRole: 'spout', + length: width * 0.22, + radius: Math.min(length, width) * 0.035, + }) + add({ + kind: 'generic_base', + semanticRole: 'cup_platform', + length: length * 0.44, + width: width * 0.28, + thickness: height * 0.055, + position: [0, height * 0.18, width * 0.56], + }) + } + } else if (category === 'building') { + add({ + kind: 'generic_panel', + semanticRole: 'roof', + length: length * 1.08, + height: height * 0.18, + color: '#7f1d1d', + position: [0, height * 0.92, 0], + }) + add({ + kind: 'generic_opening', + semanticRole: 'opening', + length: length * 0.22, + height: height * 0.34, + }) + } else if (category === 'furniture') { + add({ kind: 'generic_foot_set', semanticRole: 'support_leg' }) + add({ kind: 'generic_detail_accent', semanticRole: 'detail_accent' }) + } else if (category === 'natural') { + add({ kind: 'generic_detail_accent', semanticRole: 'detail_accent', accentColor: '#6b8f47' }) + } else { + add({ kind: 'generic_detail_accent', semanticRole: 'detail_accent' }) + } + + return plan +} + +export function normalizeKioskPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('kiosk', KIOSK_PART_DEFINITIONS, input) +} + +export function normalizePumpPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('pump', PUMP_PART_DEFINITIONS, input) +} + +export function normalizeConveyorPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('conveyor', CONVEYOR_PART_DEFINITIONS, input) +} + +export function normalizeElectricalPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('electrical', ELECTRICAL_PART_DEFINITIONS, input) +} + +export function normalizePipeSystemPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('pipe_system', PIPE_SYSTEM_PART_DEFINITIONS, input) +} + +export function normalizeTankPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('tank', TANK_PART_DEFINITIONS, input) +} + +export function normalizeReactorPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('reactor', REACTOR_PART_DEFINITIONS, input) +} + +export function normalizeCompressorPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('compressor', COMPRESSOR_PART_DEFINITIONS, input) +} + +export function normalizeHeatExchangerPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('heat_exchanger', HEAT_EXCHANGER_PART_DEFINITIONS, input) +} + +export function normalizeMachineToolPartPlan(input: Record): NormalizedPartPlan { + return normalizeFamilyPartPlan('machine_tool', MACHINE_TOOL_PART_DEFINITIONS, input) +} + +export function normalizePartPlanForFamily( + family: string, + input: Record, +): NormalizedPartPlan | undefined { + if (family === 'fan') return normalizeFanPartPlan(input) + const definitions = getPartDefinitions(family) + if (definitions.length === 0) return undefined + return normalizeFamilyPartPlan(family, definitions, input) +} diff --git a/packages/core/src/lib/part-taxonomy.ts b/packages/core/src/lib/part-taxonomy.ts new file mode 100644 index 000000000..9638808f9 --- /dev/null +++ b/packages/core/src/lib/part-taxonomy.ts @@ -0,0 +1,211 @@ +export type PartCapabilityCategory = + | 'structure' + | 'connection' + | 'mechanical' + | 'fluid' + | 'electrical' + | 'visual' + +export type GenericPartCapability = { + id: string + category: PartCapabilityCategory + label: string + partKinds: string[] + semanticRoles: string[] +} + +export type CoreComponentPartCapability = { + id: string + component: string + family?: string + partKind: string + semanticRole?: string + requiredRoles: string[] + aliases: string[] +} + +export const GENERIC_PART_CAPABILITIES: GenericPartCapability[] = [ + { + id: 'structure.enclosure', + category: 'structure', + label: 'enclosure / equipment shell', + partKinds: [ + 'rounded_machine_body', + 'electrical_cabinet', + 'streamlined_body', + 'ellipsoid_shell', + ], + semanticRoles: ['equipment_body', 'machine_enclosure', 'vehicle_body'], + }, + { + id: 'structure.base_frame', + category: 'structure', + label: 'base, skid, support frame', + partKinds: ['skid_base', 'conveyor_frame', 'platform_ladder', 'leg_set'], + semanticRoles: ['base', 'support_frame', 'vehicle_bumper'], + }, + { + id: 'connection.pipe_port', + category: 'connection', + label: 'pipe port, nozzle, flange and bolts', + partKinds: ['pipe_port', 'inlet_port', 'outlet_port', 'flange_ring', 'bolt_pattern'], + semanticRoles: ['pipe_port', 'flange', 'bolt'], + }, + { + id: 'mechanical.wheel_rotor', + category: 'mechanical', + label: 'wheel, rotor, shaft, blade set', + partKinds: [ + 'wheel_set', + 'vehicle_wheels', + 'bicycle_wheels', + 'fan_blade', + 'radial_blades', + 'propeller_blade_set', + 'impeller_blades', + 'airfoil_blade', + 'vertical_pole', + ], + semanticRoles: ['vehicle_tire', 'rotor', 'shaft', 'fan_blade'], + }, + { + id: 'mechanical.motion_axis', + category: 'mechanical', + label: 'linear axis, spindle, rail and moving head', + partKinds: [ + 'pipe_rack', + 'ribbed_motor_body', + 'gearbox_body', + 'rounded_machine_body', + 'aircraft_engine', + ], + semanticRoles: ['linear_rail', 'spindle', 'motor', 'machine_head'], + }, + { + id: 'fluid.flow_body', + category: 'fluid', + label: 'fluid body, tank, volute, valve and pipe runs', + partKinds: [ + 'cylindrical_tank', + 'volute_casing', + 'valve_body', + 'pipe_run', + 'pipe_elbow', + 'heat_exchanger', + ], + semanticRoles: ['tank_body', 'pump_volute', 'valve_body', 'pipe_run'], + }, + { + id: 'electrical.controls', + category: 'electrical', + label: 'control panel, buttons, indicators and cabinets', + partKinds: ['control_box', 'electrical_cabinet', 'warning_label', 'nameplate', 'cable_tray'], + semanticRoles: ['control_panel', 'indicator_light', 'electrical_cabinet'], + }, + { + id: 'visual.glass_label_vent', + category: 'visual', + label: 'glass, labels, vents, seams and grilles', + partKinds: [ + 'window_panel', + 'window_strip', + 'vehicle_windows', + 'curved_lens_panel', + 'vent_grill', + 'vent_slats', + 'nameplate', + 'warning_label', + 'seam_ring', + ], + semanticRoles: ['vehicle_window', 'glass_panel', 'vent_grille', 'nameplate'], + }, +] + +export const CORE_COMPONENT_PART_CAPABILITIES: CoreComponentPartCapability[] = [ + { + id: 'wheel.bicycle', + component: 'wheel', + family: 'bicycle', + partKind: 'wheel_set', + semanticRole: 'bicycle_wheel', + requiredRoles: ['bicycle_tire', 'bicycle_rim', 'bicycle_hub', 'bicycle_spoke'], + aliases: ['bicycle_wheel', 'bike_wheel', 'cycle_wheel', 'tire', 'rim'], + }, + { + id: 'wheel.vehicle', + component: 'wheel', + family: 'vehicle', + partKind: 'wheel_set', + semanticRole: 'vehicle_tire', + requiredRoles: ['vehicle_tire', 'wheel_hub'], + aliases: ['vehicle_wheel', 'car_wheel', 'automotive_wheel', 'tire', 'tyre', 'rim'], + }, + { + id: 'wheel.generic', + component: 'wheel', + partKind: 'wheel_set', + semanticRole: 'wheel_tire', + requiredRoles: ['wheel_tire', 'wheel_hub'], + aliases: ['wheel', 'single_wheel', 'tire', 'tyre', 'rim'], + }, + { + id: 'window.vehicle', + component: 'window', + family: 'vehicle', + partKind: 'window_panel', + semanticRole: 'vehicle_window', + requiredRoles: ['vehicle_window'], + aliases: ['vehicle_window', 'car_window', 'windshield', 'glass'], + }, + { + id: 'window.generic', + component: 'window', + partKind: 'window_panel', + semanticRole: 'window_panel', + requiredRoles: ['window_panel'], + aliases: ['window', 'glass_panel', 'windshield'], + }, + { + id: 'engine.generic', + component: 'engine', + partKind: 'ribbed_motor_body', + semanticRole: 'engine_body', + requiredRoles: ['engine_body'], + aliases: ['engine', 'motor', 'electric_motor'], + }, + { + id: 'engine.aircraft', + component: 'engine', + family: 'aircraft', + partKind: 'aircraft_engine', + semanticRole: 'engine_nacelle', + requiredRoles: ['engine_nacelle', 'engine_fan', 'engine_intake'], + aliases: ['aircraft_engine', 'jet_engine', 'engine_nacelle'], + }, + { + id: 'propeller.generic', + component: 'propeller', + partKind: 'propeller_blade_set', + requiredRoles: ['propeller_blade'], + aliases: ['propeller', 'propeller_blades', 'airscrew'], + }, + { + id: 'blade.generic', + component: 'blade', + partKind: 'airfoil_blade', + semanticRole: 'airfoil_blade', + requiredRoles: ['airfoil_blade'], + aliases: ['blade', 'airfoil', 'airfoil_blade', 'fan_blade'], + }, +] + +export function coreComponentPartKinds(): string[] { + return Array.from(new Set(CORE_COMPONENT_PART_CAPABILITIES.map((entry) => entry.partKind))) +} + +export function partCapabilitiesPrompt() { + return GENERIC_PART_CAPABILITIES.map( + (capability) => + `${capability.id}: ${capability.label}; partKinds=${capability.partKinds.join(', ')}; roles=${capability.semanticRoles.join(', ')}`, + ).join('\n') +} diff --git a/packages/core/src/lib/polygon-relations.ts b/packages/core/src/lib/polygon-relations.ts new file mode 100644 index 000000000..0da7ef6c3 --- /dev/null +++ b/packages/core/src/lib/polygon-relations.ts @@ -0,0 +1,102 @@ +export type Point2D = [number, number] + +export function pointOnSegment(point: Point2D, start: Point2D, end: Point2D, tolerance = 1e-6) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const cross = (point[0] - start[0]) * dz - (point[1] - start[1]) * dx + if (Math.abs(cross) > tolerance) return false + + const dot = + (point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1]) + return dot <= tolerance +} + +export function pointInPolygon( + point: Point2D, + polygon: Point2D[], + options?: { includeBoundary?: boolean }, +) { + if (polygon.length < 3) return false + const includeBoundary = options?.includeBoundary ?? true + if ( + polygon.some((start, index) => + pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!), + ) + ) { + return includeBoundary + } + + let inside = false + const [x, z] = point + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const current = polygon[i]! + const previous = polygon[j]! + const intersects = + current[1] > z !== previous[1] > z && + x < ((previous[0] - current[0]) * (z - current[1])) / (previous[1] - current[1]) + current[0] + if (intersects) inside = !inside + } + return inside +} + +export function segmentsIntersect(a: Point2D, b: Point2D, c: Point2D, d: Point2D) { + const cross = (ux: number, uz: number, vx: number, vz: number) => ux * vz - uz * vx + const abx = b[0] - a[0] + const abz = b[1] - a[1] + const acx = c[0] - a[0] + const acz = c[1] - a[1] + const adx = d[0] - a[0] + const adz = d[1] - a[1] + const cdx = d[0] - c[0] + const cdz = d[1] - c[1] + const cax = a[0] - c[0] + const caz = a[1] - c[1] + const cbx = b[0] - c[0] + const cbz = b[1] - c[1] + + const o1 = cross(abx, abz, acx, acz) + const o2 = cross(abx, abz, adx, adz) + const o3 = cross(cdx, cdz, cax, caz) + const o4 = cross(cdx, cdz, cbx, cbz) + + if (Math.sign(o1) !== Math.sign(o2) && Math.sign(o3) !== Math.sign(o4)) return true + return ( + pointOnSegment(c, a, b) || + pointOnSegment(d, a, b) || + pointOnSegment(a, c, d) || + pointOnSegment(b, c, d) + ) +} + +export function polygonsIntersect(left: Point2D[], right: Point2D[]) { + for (let leftIndex = 0; leftIndex < left.length; leftIndex++) { + const leftStart = left[leftIndex]! + const leftEnd = left[(leftIndex + 1) % left.length]! + for (let rightIndex = 0; rightIndex < right.length; rightIndex++) { + if ( + segmentsIntersect( + leftStart, + leftEnd, + right[rightIndex]!, + right[(rightIndex + 1) % right.length]!, + ) + ) { + return true + } + } + } + + return false +} + +export function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { + return inner.every((point) => pointInPolygon(point, outer)) +} + +export function polygonsOverlap(left: Point2D[], right: Point2D[]) { + return ( + polygonsIntersect(left, right) || + left.some((point) => pointInPolygon(point, right)) || + right.some((point) => pointInPolygon(point, left)) + ) +} diff --git a/packages/core/src/lib/primitive-compose.test.ts b/packages/core/src/lib/primitive-compose.test.ts new file mode 100644 index 000000000..3bf60d212 --- /dev/null +++ b/packages/core/src/lib/primitive-compose.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, test } from 'bun:test' +import { + expandPrimitiveShapeArrays, + extractPrimitiveShapeContract, + type PrimitiveArrayExpandableShape, + resolvePrimitiveWorldTransforms, +} from './primitive-compose' + +function expectVecClose(actual: [number, number, number], expected: [number, number, number]) { + expect(actual[0]).toBeCloseTo(expected[0], 6) + expect(actual[1]).toBeCloseTo(expected[1], 6) + expect(actual[2]).toBeCloseTo(expected[2], 6) +} + +describe('resolvePrimitiveWorldTransforms', () => { + test('expands linear primitive arrays in core', () => { + const shapes = expandPrimitiveShapeArrays([ + { + kind: 'rounded-panel', + name: 'louver blade', + position: [0, 1, 0], + length: 0.7, + width: 0.04, + thickness: 0.02, + array: { count: 4, step: [0, 0.08, 0] }, + }, + ]) + + expect(shapes).toHaveLength(4) + expect(shapes.map((shape) => shape.position?.[1])).toEqual([1, 1.08, 1.16, 1.24]) + expect(shapes.every((shape) => shape.array == null)).toBe(true) + }) + + test('expands grid primitive arrays and strips nested params array fields', () => { + const shapes = expandPrimitiveShapeArrays([ + { + kind: 'box', + params: { + position: [1, 2, 3], + array: { columns: 2, rows: 2, spacing: [0.4, 0, 0.3] }, + }, + }, + ]) + + expect(shapes).toHaveLength(4) + expect(shapes.map((shape) => shape.position)).toEqual([ + [1, 2, 3], + [1.4, 2, 3], + [1, 2, 3.3], + [1.4, 2, 3.3], + ]) + expect(shapes.every((shape) => !('array' in (shape.params ?? {})))).toBe(true) + }) + + test('extracts geometry capability contracts from primitive shapes', () => { + const contract = extractPrimitiveShapeContract({ + kind: 'sweep', + position: [0, 0, 0], + path: [ + [0, 0, 0], + [1, 0, 0], + ], + radius: 0.1, + bevelRadius: 0.02, + duct: { crossSection: 'round', radius: 0.1, wallThickness: 0.02 }, + ports: [ + { + id: 'out', + kind: 'outlet', + position: [1, 0, 0], + normal: [1, 0, 0], + radius: 0.1, + }, + ], + cutouts: [{ id: 'access', kind: 'round', radius: 0.05 }], + pattern: { id: 'bolts', kind: 'radial', count: 8 }, + }) + + expect(contract).toMatchObject({ + duct: { crossSection: 'round', radius: 0.1 }, + bevel: { radius: 0.02 }, + ports: [{ id: 'out', kind: 'outlet' }], + cutouts: [{ id: 'access', kind: 'round' }], + pattern: { id: 'bolts', kind: 'radial', count: 8 }, + }) + }) + + test('connects child bottom to parent top without manual half-height offset', () => { + const [base, child] = resolvePrimitiveWorldTransforms([ + { kind: 'box', position: [0, 0.5, 0], length: 1, width: 1, height: 1 }, + { + kind: 'box', + attachTo: 0, + anchor: 'top', + childAnchor: 'bottom', + position: [0, 0, 0], + length: 0.2, + width: 0.2, + height: 2, + }, + ]) + + expectVecClose(base!.position, [0, 0.5, 0]) + expectVecClose(child!.position, [0, 2, 0]) + }) + + test('orients cylinders with axis without requiring manual rotation', () => { + const [shape] = resolvePrimitiveWorldTransforms([ + { kind: 'cylinder', axis: 'x', position: [0, 0, 0], radius: 0.1, height: 2 }, + ]) + + expectVecClose(shape!.rotation, [0, 0, -Math.PI / 2]) + }) + + test('uses cylinder axis when resolving connection anchors', () => { + const [, child] = resolvePrimitiveWorldTransforms([ + { kind: 'sphere', position: [0, 0, 0], radius: 0.2 }, + { + kind: 'cylinder', + attachTo: 0, + anchor: 'front', + childAnchor: 'back', + axis: 'z', + position: [0, 0, 0], + radius: 0.1, + height: 2, + }, + ]) + + expectVecClose(child!.position, [0, 0, 1.2]) + }) + + test('vehicle wheels can use x-axis without manual rotation', () => { + const [shape] = resolvePrimitiveWorldTransforms([ + { kind: 'cylinder', axis: 'x', position: [0, 0.3, 1], radius: 0.3, height: 0.2 }, + ]) + + expectVecClose(shape!.rotation, [0, 0, -Math.PI / 2]) + }) + + test('capsules and half-cylinders share cylinder axis semantics', () => { + const [capsule, halfCylinder] = resolvePrimitiveWorldTransforms([ + { kind: 'capsule', axis: 'z', position: [0, 0, 0], radius: 0.2, height: 1.2 }, + { kind: 'half-cylinder', axis: 'x', position: [0, 0, 0], radius: 0.2, height: 1.2 }, + ]) + + expectVecClose(capsule!.rotation, [Math.PI / 2, 0, 0]) + expectVecClose(halfCylinder!.rotation, [0, 0, -Math.PI / 2]) + }) + + test('new tapered and ring primitives expose axis semantics', () => { + const [cone, frustum, hemisphere, torus] = resolvePrimitiveWorldTransforms([ + { kind: 'cone', axis: 'x', position: [0, 0, 0], radius: 0.2, height: 1.2 }, + { + kind: 'frustum', + axis: 'z', + position: [0, 0, 0], + radiusTop: 0.1, + radiusBottom: 0.3, + height: 1.2, + }, + { kind: 'hemisphere', axis: 'x', position: [0, 0, 0], radius: 0.4 }, + { kind: 'torus', axis: 'x', position: [0, 0, 0], majorRadius: 0.4, tubeRadius: 0.06 }, + ]) + + expectVecClose(cone!.rotation, [0, 0, -Math.PI / 2]) + expectVecClose(frustum!.rotation, [Math.PI / 2, 0, 0]) + expectVecClose(hemisphere!.rotation, [0, 0, -Math.PI / 2]) + expectVecClose(torus!.rotation, [0, Math.PI / 2, 0]) + }) + + test('trapezoid and wedge primitives use box-like anchor extents', () => { + const [, trapezoid, wedge] = resolvePrimitiveWorldTransforms( + [ + { kind: 'box', position: [0, 1, 0], length: 2, width: 2, height: 0.2 }, + { + kind: 'trapezoid-prism', + attachTo: 0, + anchor: 'top', + childAnchor: 'bottom', + position: [0, 0, 0], + length: 1, + width: 1, + height: 0.4, + topScale: [0.5, 0.7], + }, + { + kind: 'wedge', + attachTo: 1, + anchor: 'top', + childAnchor: 'bottom', + position: [0, 0, 0], + length: 1, + width: 1, + height: 0.4, + }, + ], + { positionMode: 'anchor-offset' }, + ) + + expectVecClose(trapezoid!.position, [0, 1.3, 0]) + expectVecClose(wedge!.position, [0, 1.7, 0]) + }) + + test('new curved primitives expose usable half-extents for anchor snapping', () => { + const [, child] = resolvePrimitiveWorldTransforms( + [ + { kind: 'rounded-panel', position: [0, 1, 0], length: 2, width: 1, thickness: 0.1 }, + { + kind: 'sweep', + attachTo: 0, + anchor: 'top', + childAnchor: 'bottom', + position: [0, 1.2, 0], + path: [ + [-0.5, 0, 0], + [0.5, 0, 0], + ], + radius: 0.05, + }, + ], + { positionMode: 'world-center' }, + ) + + expectVecClose(child!.position, [0, 1.1, 0]) + }) + + test('inherits parent rotation through matrix composition', () => { + const [, child] = resolvePrimitiveWorldTransforms([ + { + kind: 'box', + position: [0, 0, 0], + rotation: [0, Math.PI / 2, 0], + length: 1, + width: 1, + height: 1, + }, + { + kind: 'box', + attachTo: 0, + anchor: 'front', + childAnchor: 'back', + position: [0, 0, 0], + length: 1, + width: 1, + height: 1, + }, + ]) + + expectVecClose(child!.position, [1, 0, 0]) + }) + + // ── world-center mode ────────────────────────────────────────── + + test('world-center: snaps Y axis when child top attaches to parent bottom (desk leg)', () => { + const [desk, leg] = resolvePrimitiveWorldTransforms( + [ + { + kind: 'box', + name: 'desk top', + position: [0, 0.75, 0], + length: 1.4, + width: 0.7, + height: 0.05, + }, + { + kind: 'box', + name: 'leg', + attachTo: 0, + anchor: 'bottom', + childAnchor: 'top', + position: [-0.64, 0.36, 0.29], + length: 0.06, + width: 0.06, + height: 0.72, + }, + ], + { positionMode: 'world-center' }, + ) + + // Desk centered at [0, 0.75, 0] + expectVecClose(desk!.position, [0, 0.75, 0]) + + // LLM passes leg center at [-0.64, 0.36, 0.29]. + // Auto-snap: leg top should touch desk bottom (0.75 - 0.025 = 0.725). + // Leg center Y = 0.725 - 0.36 = 0.365. X and Z unchanged. + expectVecClose(leg!.position, [-0.64, 0.365, 0.29]) + }) + + test('world-center: keeps X/Z from LLM, only corrects anchor axis', () => { + const [, child] = resolvePrimitiveWorldTransforms( + [ + { kind: 'box', position: [2, 1, 3], length: 2, width: 2, height: 2 }, + { + kind: 'box', + attachTo: 0, + anchor: 'right', + childAnchor: 'left', + position: [5, 7, 9], + length: 1, + width: 1, + height: 1, + }, + ], + { positionMode: 'world-center' }, + ) + + // Parent right face at X = 2 + 1 = 3. + // Child left face at X = 5 - 0.5 = 4.5. + // Correction for X: 3 - 4.5 = -1.5 → snapped X = 5 - 1.5 = 3.5. + expectVecClose(child!.position, [3.5, 7, 9]) + }) + + test('world-center: no snap when anchors are on different axes', () => { + const [, child] = resolvePrimitiveWorldTransforms( + [ + { kind: 'box', position: [0, 0, 0], length: 2, width: 2, height: 2 }, + { + kind: 'box', + attachTo: 0, + anchor: 'top', + childAnchor: 'left', + position: [1, 2, 3], + length: 1, + width: 1, + height: 1, + }, + ], + { positionMode: 'world-center' }, + ) + + // anchor='top' (Y axis) and childAnchor='left' (X axis) — no snap, + // position used as-is. + expectVecClose(child!.position, [1, 2, 3]) + }) + + test('world-center: no snap when childAnchor is center', () => { + const [, child] = resolvePrimitiveWorldTransforms( + [ + { kind: 'box', position: [0, 0, 0], length: 2, width: 2, height: 2 }, + { + kind: 'box', + attachTo: 0, + anchor: 'top', + childAnchor: 'center', + position: [1, 2, 3], + length: 1, + width: 1, + height: 1, + }, + ], + { positionMode: 'world-center' }, + ) + + // childAnchor='center' has no axis → no snap. + expectVecClose(child!.position, [1, 2, 3]) + }) + + test('world-center: no attachTo still uses world-space center', () => { + const [shape] = resolvePrimitiveWorldTransforms( + [{ kind: 'box', position: [3, 4, 5], length: 1, width: 1, height: 1 }], + { positionMode: 'world-center' }, + ) + + expectVecClose(shape!.position, [3, 4, 5]) + }) + + // ── sphere scale ───────────────────────────────────────────────── + + test('sphere half-extents account for scale', () => { + const [, child] = resolvePrimitiveWorldTransforms( + [ + { kind: 'sphere', position: [0, 1, 0], radius: 0.5, scale: [2, 0.3, 1] }, + { + kind: 'box', + attachTo: 0, + anchor: 'top', + childAnchor: 'bottom', + position: [0, 0, 0], + length: 0.2, + width: 0.2, + height: 0.1, + }, + ], + { positionMode: 'anchor-offset' }, + ) + + // Parent sphere: radius 0.5, scale [2, 0.3, 1] → half-extents {x:1, y:0.15, z:0.5} + // anchor='top' → anchorOffset = [0, 0.15, 0] + // childAnchor='bottom' → childAnchorOffset = [0, -0.05, 0] + // localCenterOffset = [0,0,0] - [0,-0.05,0] = [0, 0.05, 0] + // finalPosition = [0,1,0] + [0,0.15,0] + [0,0.05,0] = [0, 1.2, 0] + expectVecClose(child!.position, [0, 1.2, 0]) + }) +}) diff --git a/packages/core/src/lib/primitive-compose.ts b/packages/core/src/lib/primitive-compose.ts new file mode 100644 index 000000000..992ffe129 --- /dev/null +++ b/packages/core/src/lib/primitive-compose.ts @@ -0,0 +1,894 @@ +import type { MaterialGradient } from '../schema/material' + +export type Vec3 = [number, number, number] + +export type PrimitiveShapeKind = + | 'box' + | 'cylinder' + | 'hollow-cylinder' + | 'cone' + | 'frustum' + | 'sphere' + | 'hemisphere' + | 'torus' + | 'wedge' + | 'trapezoid-prism' + | 'lathe' + | 'capsule' + | 'half-cylinder' + | 'rounded-panel' + | 'conformal-strip' + | 'extrude' + | 'sweep' +export type PrimitiveAnchor = 'top' | 'bottom' | 'center' | 'front' | 'back' | 'left' | 'right' +export type PrimitiveAxis = 'x' | 'y' | 'z' +export interface PrimitiveMaterialInput { + id?: string + preset?: string + gradient?: MaterialGradient + properties?: { + color?: string + roughness?: number + metalness?: number + opacity?: number + transparent?: boolean + side?: 'front' | 'back' | 'double' + } +} + +export interface PrimitiveGeometryBrief { + category?: string + units?: string + coordinateConvention?: string + coordinateSystem?: string + expectedDimensions?: { + length?: number + width?: number + height?: number + [key: string]: number | undefined + } + requiredRoles?: string[] + semanticRoles?: string[] + validationTargets?: string[] + assumptions?: string[] +} + +export interface PrimitiveArrayInput { + count?: number + columns?: number + rows?: number + layers?: number + spacing?: Vec3 | number + step?: Vec3 + axis?: PrimitiveAxis | string + mode?: 'expand' | 'metadata' | 'instanced' | string + patternId?: string +} + +export type PrimitiveCutoutKind = 'rectangular' | 'round' | 'slot' | 'polygon' | string +export type PrimitivePortKind = + | 'inlet' + | 'outlet' + | 'access' + | 'support' + | 'drive' + | 'instrument' + | 'generic' + | string +export type PrimitivePatternKind = 'linear' | 'grid' | 'radial' | string +export type PrimitiveDuctCrossSection = 'round' | 'rectangular' | 'oval' | string + +export interface PrimitiveCutoutInput { + id?: string + kind: PrimitiveCutoutKind + semanticRole?: string + position?: Vec3 + normal?: Vec3 + axis?: PrimitiveAxis | string + length?: number + width?: number + height?: number + radius?: number + depth?: number + profile?: [number, number][] + through?: boolean + bevelRadius?: number + bevelSegments?: number +} + +export interface PrimitivePortMarkerInput { + id?: string + kind?: PrimitivePortKind + semanticRole?: string + position?: Vec3 + normal?: Vec3 + axis?: PrimitiveAxis | string + radius?: number + width?: number + height?: number + direction?: 'in' | 'out' | 'bidirectional' | string + connectsTo?: string +} + +export interface PrimitivePatternInput { + id?: string + kind: PrimitivePatternKind + semanticRole?: string + count?: number + columns?: number + rows?: number + layers?: number + spacing?: Vec3 | number + step?: Vec3 + axis?: PrimitiveAxis | string + radius?: number + startAngle?: number + endAngle?: number + sourceShapeId?: string + mode?: 'expanded' | 'metadata' | 'instanced' | string + instances?: Array<{ + position?: Vec3 + rotation?: Vec3 + scale?: Vec3 + name?: string + }> +} + +export interface PrimitiveDuctInput { + crossSection?: PrimitiveDuctCrossSection + width?: number + height?: number + radius?: number + wallThickness?: number + taper?: { + startWidth?: number + startHeight?: number + startRadius?: number + endWidth?: number + endHeight?: number + endRadius?: number + } + branchPorts?: PrimitivePortMarkerInput[] +} + +export interface PrimitiveBevelContract { + radius?: number + chamfer?: number + segments?: number + size?: number + thickness?: number +} + +export interface PrimitiveShapeContract { + cutouts?: PrimitiveCutoutInput[] + ports?: PrimitivePortMarkerInput[] + pattern?: PrimitivePatternInput + duct?: PrimitiveDuctInput + bevel?: PrimitiveBevelContract +} + +export type PrimitiveEditableDimension = + | 'primary' + | 'uniform' + | 'length' + | 'width' + | 'height' + | 'depth' + | 'thickness' + | 'radius' + | 'diameter' + | 'majorRadius' + | 'tubeRadius' + | 'axisLength' + | 'profileX' + | 'profileY' + +export interface PrimitiveEditableHints { + primaryDimension?: PrimitiveEditableDimension | string + canScale?: Array + minFactor?: number + maxFactor?: number +} + +export interface PrimitiveShapeInput { + kind: PrimitiveShapeKind | string + name?: string + semanticRole?: string + semanticGroup?: string + sourcePartKind?: string + sourcePartId?: string + editableHints?: PrimitiveEditableHints + industrialArchetype?: string + industrialVariant?: string + position?: Vec3 + rotation?: Vec3 + scale?: Vec3 + length?: number + width?: number + height?: number + depth?: number + thickness?: number + cornerRadius?: number + bevelRadius?: number + chamfer?: number + cornerSegments?: number + radius?: number + axis?: PrimitiveAxis | string + capSegments?: number + radialSegments?: number + tubularSegments?: number + widthSegments?: number + heightSegments?: number + radiusTop?: number + radiusBottom?: number + majorRadius?: number + tubeRadius?: number + topScale?: [number, number] + topLengthScale?: number + topWidthScale?: number + slopeAxis?: 'x' | 'z' | string + slopeDirection?: 'positive' | 'negative' | string + attachTo?: number | string + anchor?: PrimitiveAnchor | string + childAnchor?: PrimitiveAnchor | string + wallThickness?: number + surface?: string + side?: 'left' | 'right' | string + xStart?: number + xEnd?: number + verticalOffset?: number + surfaceRadiusY?: number + surfaceRadiusZ?: number + surfaceLength?: number + endTaper?: number + materialPreset?: string + material?: PrimitiveMaterialInput + profile?: [number, number][] + holes?: [number, number][][] + path?: Vec3[] + segments?: number + arc?: number + bevelSize?: number + bevelThickness?: number + bevelSegments?: number + curveSegments?: number + closed?: boolean + cutouts?: PrimitiveCutoutInput[] + ports?: PrimitivePortMarkerInput[] + pattern?: PrimitivePatternInput + duct?: PrimitiveDuctInput + array?: PrimitiveArrayInput + arrayCount?: number + arrayStep?: Vec3 + arrayAxis?: PrimitiveAxis | string + arrayColumns?: number + arrayRows?: number + arrayLayers?: number + arraySpacing?: Vec3 | number +} + +export type PrimitiveArrayExpandableShape = Omit, 'material'> & { + material?: unknown + params?: Record + [key: string]: unknown +} + +export interface ResolvedPrimitiveTransform { + position: Vec3 + rotation: Vec3 +} + +interface HalfExtents { + x: number + y: number + z: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function numberField(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function integerField(value: unknown, fallback: number, min: number, max: number): number { + const resolved = numberField(value) ?? fallback + return Math.max(min, Math.min(max, Math.round(resolved))) +} + +function vec3Field(value: unknown): Vec3 | undefined { + return Array.isArray(value) && + value.length >= 3 && + value.slice(0, 3).every((entry) => typeof entry === 'number' && Number.isFinite(entry)) + ? [value[0] as number, value[1] as number, value[2] as number] + : undefined +} + +function compactPrimitiveContract(value: T): Partial { + return Object.fromEntries( + Object.entries(value as Record).filter(([, entry]) => { + if (entry === undefined) return false + if (Array.isArray(entry)) return entry.length > 0 + if (isRecord(entry)) return Object.keys(entry).length > 0 + return true + }), + ) as Partial +} + +export function extractPrimitiveShapeContract( + shape: PrimitiveShapeInput, +): PrimitiveShapeContract | undefined { + const bevel = compactPrimitiveContract({ + radius: shape.bevelRadius ?? shape.cornerRadius, + chamfer: shape.chamfer, + segments: shape.bevelSegments ?? shape.cornerSegments, + size: shape.bevelSize, + thickness: shape.bevelThickness, + }) + const contract = compactPrimitiveContract({ + cutouts: shape.cutouts, + ports: shape.ports, + pattern: shape.pattern, + duct: shape.duct, + bevel: Object.keys(bevel).length > 0 ? bevel : undefined, + }) + return Object.keys(contract).length > 0 ? (contract as PrimitiveShapeContract) : undefined +} + +function arrayStepFromAxis(axis: unknown, spacing: unknown): Vec3 | undefined { + const distance = numberField(spacing) + if (distance == null) return undefined + switch (typeof axis === 'string' ? axis.toLowerCase() : 'x') { + case 'y': + return [0, distance, 0] + case 'z': + return [0, 0, distance] + default: + return [distance, 0, 0] + } +} + +function stripPrimitiveArrayFields(shape: T): T { + const { + array: _array, + arrayCount: _arrayCount, + arrayStep: _arrayStep, + arrayAxis: _arrayAxis, + arrayColumns: _arrayColumns, + arrayRows: _arrayRows, + arrayLayers: _arrayLayers, + arraySpacing: _arraySpacing, + params: rawParams, + ...rest + } = shape + const params = isRecord(rawParams) + ? Object.fromEntries( + Object.entries(rawParams).filter( + ([key]) => + ![ + 'array', + 'arrayCount', + 'arrayStep', + 'arrayAxis', + 'arrayColumns', + 'arrayRows', + 'arrayLayers', + 'arraySpacing', + ].includes(key), + ), + ) + : rawParams + return { + ...rest, + ...(isRecord(params) && Object.keys(params).length > 0 ? { params } : {}), + } as T +} + +export function expandPrimitiveShapeArrays( + rawShapes: T[], + options: { maxExpandedPerShape?: number } = {}, +): T[] { + const maxExpandedPerShape = integerField(options.maxExpandedPerShape, 80, 1, 1000) + const expanded: T[] = [] + for (const shape of rawShapes) { + const record = shape as Record + const params = isRecord(record.params) ? record.params : {} + const array = isRecord(record.array) ? record.array : isRecord(params.array) ? params.array : {} + const read = (key: string) => record[key] ?? params[key] ?? array[key] + const columns = integerField(read('columns') ?? read('arrayColumns'), 1, 1, 24) + const rows = integerField(read('rows') ?? read('arrayRows'), 1, 1, 24) + const layers = integerField(read('layers') ?? read('arrayLayers'), 1, 1, 12) + const explicitCount = numberField(read('arrayCount') ?? read('count')) + const linearCount = explicitCount != null ? integerField(explicitCount, 1, 1, 80) : 1 + const total = columns * rows * layers > 1 ? columns * rows * layers : linearCount + if (total <= 1) { + expanded.push(stripPrimitiveArrayFields(shape)) + continue + } + + const spacing = vec3Field(read('spacing') ?? read('arraySpacing')) + const spacingScalar = numberField(read('spacing') ?? read('arraySpacing')) + const step = + vec3Field(read('step') ?? read('arrayStep')) ?? + (columns * rows * layers > 1 + ? [ + spacing?.[0] ?? spacingScalar ?? 0.25, + spacing?.[1] ?? spacingScalar ?? 0, + spacing?.[2] ?? spacingScalar ?? 0.25, + ] + : arrayStepFromAxis(read('axis') ?? read('arrayAxis'), spacingScalar ?? 0.25)) + const resolvedStep: Vec3 = step ?? [0.25, 0, 0] + const basePosition = vec3Field(record.position ?? params.position) ?? [0, 0, 0] + const baseName = + typeof (record.name ?? params.name) === 'string' + ? ((record.name ?? params.name) as string) + : undefined + const baseShape = stripPrimitiveArrayFields(shape) + + if (columns * rows * layers <= 1) { + for (let index = 0; index < Math.min(total, maxExpandedPerShape); index += 1) { + expanded.push({ + ...baseShape, + position: [ + basePosition[0] + index * resolvedStep[0], + basePosition[1] + index * resolvedStep[1], + basePosition[2] + index * resolvedStep[2], + ], + ...(baseName ? { name: `${baseName} ${index + 1}` } : {}), + } as T) + } + continue + } + + let emitted = 0 + for (let layer = 0; layer < layers; layer += 1) { + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + if (emitted >= total || emitted >= maxExpandedPerShape) break + expanded.push({ + ...baseShape, + position: [ + basePosition[0] + column * resolvedStep[0], + basePosition[1] + layer * resolvedStep[1], + basePosition[2] + row * resolvedStep[2], + ], + ...(baseName ? { name: `${baseName} ${emitted + 1}` } : {}), + } as T) + emitted += 1 + } + } + } + } + return expanded +} + +function getHalfExtents(spec: PrimitiveShapeInput): HalfExtents { + switch (spec.kind) { + case 'box': + return { + x: (spec.length ?? 1.0) / 2, + y: (spec.height ?? 1.0) / 2, + z: (spec.width ?? 1.0) / 2, + } + case 'cylinder': + case 'hollow-cylinder': + case 'cone': + case 'frustum': { + const r = + spec.kind === 'frustum' + ? Math.max(spec.radiusTop ?? 0.25, spec.radiusBottom ?? 0.5) + : (spec.radius ?? 0.5) + const halfHeight = (spec.height ?? 1.0) / 2 + switch (spec.axis) { + case 'x': + return { x: halfHeight, y: r, z: r } + case 'z': + return { x: r, y: r, z: halfHeight } + default: + return { x: r, y: halfHeight, z: r } + } + } + case 'capsule': + case 'half-cylinder': { + const r = spec.radius ?? 0.5 + const halfHeight = (spec.height ?? 1.0) / 2 + switch (spec.axis) { + case 'x': + return { x: halfHeight, y: r, z: r } + case 'z': + return { x: r, y: r, z: halfHeight } + default: + return { x: r, y: halfHeight, z: r } + } + } + case 'rounded-panel': + return { + x: (spec.length ?? 1.0) / 2, + y: (spec.thickness ?? spec.height ?? 0.04) / 2, + z: (spec.width ?? 0.5) / 2, + } + case 'conformal-strip': { + const xStart = spec.xStart ?? -((spec.length ?? 1) / 2) + const xEnd = spec.xEnd ?? (spec.length ?? 1) / 2 + return { + x: Math.max(0.005, Math.abs(xEnd - xStart) / 2), + y: (spec.surfaceRadiusY ?? 0.25) + (spec.thickness ?? 0.003), + z: (spec.surfaceRadiusZ ?? 0.25) + (spec.thickness ?? 0.003), + } + } + case 'sphere': { + const r = spec.radius ?? 0.5 + const sx = spec.scale?.[0] ?? 1 + const sy = spec.scale?.[1] ?? 1 + const sz = spec.scale?.[2] ?? 1 + return { x: r * sx, y: r * sy, z: r * sz } + } + case 'hemisphere': { + const r = spec.radius ?? 0.5 + const sx = spec.scale?.[0] ?? 1 + const sy = spec.scale?.[1] ?? 1 + const sz = spec.scale?.[2] ?? 1 + switch (spec.axis) { + case 'x': + return { x: (r * sx) / 2, y: r * sy, z: r * sz } + case 'z': + return { x: r * sx, y: r * sy, z: (r * sz) / 2 } + default: + return { x: r * sx, y: (r * sy) / 2, z: r * sz } + } + } + case 'torus': { + const ring = (spec.majorRadius ?? spec.radius ?? 0.5) + (spec.tubeRadius ?? 0.08) + const tube = spec.tubeRadius ?? 0.08 + switch (spec.axis) { + case 'x': + return { x: tube, y: ring, z: ring } + case 'y': + return { x: ring, y: tube, z: ring } + default: + return { x: ring, y: ring, z: tube } + } + } + case 'wedge': + case 'trapezoid-prism': + return { + x: (spec.length ?? 1.0) / 2, + y: (spec.height ?? 0.5) / 2, + z: (spec.width ?? 1.0) / 2, + } + case 'lathe': { + const profile = spec.profile ?? [ + [0, 0], + [0.5, 1], + ] + let maxX = 0 + let minY = Infinity + let maxY = -Infinity + for (const [x, y] of profile) { + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + return { x: maxX, y: (maxY - minY) / 2, z: maxX } + } + case 'extrude': { + const profile = spec.profile ?? [ + [-0.5, -0.25], + [0.5, -0.25], + [0.5, 0.25], + [-0.5, 0.25], + ] + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const [x, y] of profile) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + return { + x: Math.max(0.01, (maxX - minX) / 2), + y: Math.max(0.01, (maxY - minY) / 2), + z: (spec.depth ?? 0.1) / 2, + } + } + case 'sweep': { + const path = spec.path ?? [ + [-0.5, 0, 0], + [0.5, 0, 0], + ] + const r = spec.radius ?? 0.03 + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + let minZ = Infinity + let maxZ = -Infinity + for (const [x, y, z] of path) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + if (z < minZ) minZ = z + if (z > maxZ) maxZ = z + } + return { + x: Math.max(r, (maxX - minX) / 2 + r), + y: Math.max(r, (maxY - minY) / 2 + r), + z: Math.max(r, (maxZ - minZ) / 2 + r), + } + } + default: + return { x: 0.5, y: 0.5, z: 0.5 } + } +} + +function getAnchorOffset(anchor: string, he: HalfExtents): Vec3 { + switch (anchor) { + case 'top': + return [0, he.y, 0] + case 'bottom': + return [0, -he.y, 0] + case 'center': + return [0, 0, 0] + case 'front': + return [0, 0, he.z] + case 'back': + return [0, 0, -he.z] + case 'left': + return [-he.x, 0, 0] + case 'right': + return [he.x, 0, 0] + default: + return [0, he.y, 0] + } +} + +function getAnchorAxis(anchor: string): 'x' | 'y' | 'z' | null { + switch (anchor) { + case 'top': + case 'bottom': + return 'y' + case 'left': + case 'right': + return 'x' + case 'front': + case 'back': + return 'z' + default: + return null + } +} + +function rotateVector(v: Vec3, euler: Vec3): Vec3 { + let [x, y, z] = v + + const cz = Math.cos(euler[2]) + const sz = Math.sin(euler[2]) + ;[x, y] = [x * cz - y * sz, x * sz + y * cz] + + const cy = Math.cos(euler[1]) + const sy = Math.sin(euler[1]) + ;[x, z] = [x * cy + z * sy, -x * sy + z * cy] + + const cx = Math.cos(euler[0]) + const sx = Math.sin(euler[0]) + ;[y, z] = [y * cx - z * sx, y * sx + z * cx] + + return [x, y, z] +} + +type Mat3 = [number, number, number, number, number, number, number, number, number] + +function eulerToMatrix(euler: Vec3): Mat3 { + const [x, y, z] = euler + const cx = Math.cos(x) + const sx = Math.sin(x) + const cy = Math.cos(y) + const sy = Math.sin(y) + const cz = Math.cos(z) + const sz = Math.sin(z) + + return [ + cy * cz, + -cy * sz, + sy, + cx * sz + sx * sy * cz, + cx * cz - sx * sy * sz, + -sx * cy, + sx * sz - cx * sy * cz, + sx * cz + cx * sy * sz, + cx * cy, + ] +} + +function multiplyMatrix(a: Mat3, b: Mat3): Mat3 { + return [ + a[0] * b[0] + a[1] * b[3] + a[2] * b[6], + a[0] * b[1] + a[1] * b[4] + a[2] * b[7], + a[0] * b[2] + a[1] * b[5] + a[2] * b[8], + a[3] * b[0] + a[4] * b[3] + a[5] * b[6], + a[3] * b[1] + a[4] * b[4] + a[5] * b[7], + a[3] * b[2] + a[4] * b[5] + a[5] * b[8], + a[6] * b[0] + a[7] * b[3] + a[8] * b[6], + a[6] * b[1] + a[7] * b[4] + a[8] * b[7], + a[6] * b[2] + a[7] * b[5] + a[8] * b[8], + ] +} + +function rotateVectorByMatrix(v: Vec3, m: Mat3): Vec3 { + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ] +} + +function matrixToEuler(m: Mat3): Vec3 { + const y = Math.asin(Math.max(-1, Math.min(1, m[2]))) + const cy = Math.cos(y) + + if (Math.abs(cy) < 1e-8) { + return [0, y, Math.atan2(m[3], m[4])] + } + + return [Math.atan2(-m[5], m[8]), y, Math.atan2(-m[1], m[0])] +} + +function addVec(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function subtractVec(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +function getAxisRotation(spec: PrimitiveShapeInput): Vec3 { + if (spec.kind === 'torus') { + switch (spec.axis) { + case 'x': + return [0, Math.PI / 2, 0] + case 'y': + return [-Math.PI / 2, 0, 0] + default: + return [0, 0, 0] + } + } + + if ( + spec.kind !== 'cylinder' && + spec.kind !== 'hollow-cylinder' && + spec.kind !== 'cone' && + spec.kind !== 'frustum' && + spec.kind !== 'hemisphere' && + spec.kind !== 'capsule' && + spec.kind !== 'half-cylinder' + ) { + return [0, 0, 0] + } + + switch (spec.axis) { + case 'x': + return [0, 0, -Math.PI / 2] + case 'z': + return [Math.PI / 2, 0, 0] + default: + return [0, 0, 0] + } +} + +function getLocalRotationMatrix(spec: PrimitiveShapeInput): Mat3 { + const rotation = spec.rotation ?? [0, 0, 0] + return multiplyMatrix(eulerToMatrix(rotation), eulerToMatrix(getAxisRotation(spec))) +} + +function getSemanticRotationMatrix(spec: PrimitiveShapeInput): Mat3 { + return eulerToMatrix(spec.rotation ?? [0, 0, 0]) +} + +export interface ResolveTransformsOptions { + positionMode?: 'anchor-offset' | 'world-center' +} + +export function resolvePrimitiveWorldTransforms( + shapes: readonly PrimitiveShapeInput[], + options?: ResolveTransformsOptions, +): ResolvedPrimitiveTransform[] { + const results: ResolvedPrimitiveTransform[] = [] + const semanticRotations: Mat3[] = [] + + for (let i = 0; i < shapes.length; i++) { + const shape = shapes[i] + if (!shape) { + results[i] = { position: [0, 0, 0], rotation: [0, 0, 0] } + semanticRotations[i] = eulerToMatrix([0, 0, 0]) + continue + } + + const position = shape.position ?? [0, 0, 0] + const localRotationMatrix = getLocalRotationMatrix(shape) + const semanticRotationMatrix = getSemanticRotationMatrix(shape) + const localRotation = matrixToEuler(localRotationMatrix) + + if (typeof shape.attachTo !== 'number' || shape.attachTo >= i) { + results[i] = { position, rotation: localRotation } + semanticRotations[i] = semanticRotationMatrix + continue + } + + const parent = results[shape.attachTo] + const parentSpec = shapes[shape.attachTo] + const parentSemanticRotationMatrix = semanticRotations[shape.attachTo] + if (!parent || !parentSpec || !parentSemanticRotationMatrix) { + results[i] = { position, rotation: localRotation } + semanticRotations[i] = semanticRotationMatrix + continue + } + + const parentHE = getHalfExtents(parentSpec) + const anchor = shape.anchor ?? 'top' + const childAnchor = shape.childAnchor ?? 'center' + const childHE = getHalfExtents(shape) + const anchorOffset = getAnchorOffset(anchor, parentHE) + const composedSemanticRotationMatrix = multiplyMatrix( + parentSemanticRotationMatrix, + semanticRotationMatrix, + ) + const composedRotationMatrix = multiplyMatrix(parentSemanticRotationMatrix, localRotationMatrix) + const childAnchorOffset = getAnchorOffset(childAnchor, childHE) + + const useWorldCenter = options?.positionMode === 'world-center' + const anchorAxis = getAnchorAxis(anchor) + const childAnchorAxis = getAnchorAxis(childAnchor) + + if (useWorldCenter && anchorAxis && anchorAxis === childAnchorAxis) { + // World-center mode: position is the child's intended world-space center. + // Auto-snap only the anchor axis so childAnchor touches parent anchor. + const parentAnchorWorld = addVec( + parent.position, + rotateVectorByMatrix(anchorOffset, parentSemanticRotationMatrix), + ) + const childAnchorWorldOffset = rotateVectorByMatrix( + childAnchorOffset, + composedSemanticRotationMatrix, + ) + const childAnchorCurrent = addVec(position, childAnchorWorldOffset) + const correction = subtractVec(parentAnchorWorld, childAnchorCurrent) + + const axisIndex = anchorAxis === 'x' ? 0 : anchorAxis === 'y' ? 1 : 2 + const snapped: Vec3 = [position[0], position[1], position[2]] + snapped[axisIndex] += correction[axisIndex] + + results[i] = { + position: snapped, + rotation: matrixToEuler(composedRotationMatrix), + } + } else if (useWorldCenter) { + // World-center mode without snap: position is world-space center, + // only inherit parent rotation, no anchor-based position adjustment. + results[i] = { + position, + rotation: matrixToEuler(composedRotationMatrix), + } + } else { + // Anchor-offset mode (legacy): position is local offset from parent anchor + const localCenterOffset = subtractVec( + position, + rotateVectorByMatrix(childAnchorOffset, semanticRotationMatrix), + ) + const worldAnchorOffset = rotateVectorByMatrix(anchorOffset, parentSemanticRotationMatrix) + const worldLocalPos = rotateVectorByMatrix(localCenterOffset, parentSemanticRotationMatrix) + + results[i] = { + position: addVec(parent.position, addVec(worldAnchorOffset, worldLocalPos)), + rotation: matrixToEuler(composedRotationMatrix), + } + } + semanticRotations[i] = composedSemanticRotationMatrix + } + + return results +} diff --git a/packages/core/src/lib/primitive-facts.ts b/packages/core/src/lib/primitive-facts.ts new file mode 100644 index 000000000..7b9911870 --- /dev/null +++ b/packages/core/src/lib/primitive-facts.ts @@ -0,0 +1,300 @@ +import type { + PrimitiveMaterialInput, + PrimitiveShapeInput, + ResolvedPrimitiveTransform, + Vec3, +} from './primitive-compose' + +export interface PrimitiveShapeFact { + index: number + kind: string + name?: string + semanticRole?: string + semanticGroup?: string + sourcePartKind?: string + sourcePartId?: string + center: Vec3 + halfExtents: Vec3 + min: Vec3 + max: Vec3 + materialColor?: string +} + +export interface PrimitiveGeometryFacts { + shapeCount: number + bbox: { + min: Vec3 + max: Vec3 + } + dimensions: Vec3 + roles: Record + sourcePartKinds: Record + shapes: PrimitiveShapeFact[] +} + +function numeric(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function scaleAt(scale: Vec3 | undefined, index: 0 | 1 | 2): number { + return Math.abs(scale?.[index] ?? 1) +} + +function materialColor(material: PrimitiveMaterialInput | undefined): string | undefined { + const color = material?.properties?.color + return typeof color === 'string' && color.trim() ? color.trim() : undefined +} + +function inferPrimitiveSemanticRole(shape: PrimitiveShapeInput): string | undefined { + const name = shape.name?.toLowerCase() ?? '' + const sourcePartKind = shape.sourcePartKind?.toLowerCase() + + if (sourcePartKind === 'vehicle_body' && name.includes('body shell')) return 'vehicle_body' + if (sourcePartKind === 'vehicle_wheels' && name.includes('tire')) return 'vehicle_tire' + if (sourcePartKind === 'vehicle_windows') return 'vehicle_window' + if (sourcePartKind === 'headlights') return 'headlight' + if (sourcePartKind === 'bumper' && name.includes('front')) return 'front_bumper' + if (sourcePartKind === 'bumper' && name.includes('rear')) return 'rear_bumper' + if (sourcePartKind === 'bicycle_wheels' && name.includes('tire')) return 'bicycle_tire' + if (sourcePartKind === 'bicycle_frame') return 'bicycle_frame' + if (sourcePartKind === 'bicycle_fork') return 'bicycle_fork' + if (sourcePartKind === 'handlebar') return 'handlebar' + if (sourcePartKind === 'saddle') return 'saddle' + if (sourcePartKind === 'chain_loop') return 'chain_loop' + + if (name.includes('bicycle') && name.includes('tire')) return 'bicycle_tire' + if (name.includes('vehicle') && name.includes('tire')) return 'vehicle_tire' + if ((name.includes('car') || name.includes('vehicle')) && name.includes('wheel')) { + return shape.kind === 'torus' ? 'vehicle_tire' : 'vehicle_wheel_detail' + } + if (name.includes('windshield') || name.includes('window')) return 'vehicle_window' + if (name.includes('headlight') || name.includes('head light')) return 'headlight' + if (name.includes('front bumper')) return 'front_bumper' + if (name.includes('rear bumper')) return 'rear_bumper' + if ((name.includes('car') || name.includes('vehicle')) && name.includes('body')) { + return 'vehicle_body' + } + + return undefined +} + +export function getPrimitiveShapeHalfExtents(shape: PrimitiveShapeInput): Vec3 { + const sx = scaleAt(shape.scale, 0) + const sy = scaleAt(shape.scale, 1) + const sz = scaleAt(shape.scale, 2) + + switch (shape.kind) { + case 'box': + case 'wedge': + case 'trapezoid-prism': + return [ + (numeric(shape.length, 1) * sx) / 2, + (numeric(shape.height, 1) * sy) / 2, + (numeric(shape.width, 1) * sz) / 2, + ] + case 'rounded-panel': + return [ + (numeric(shape.length, 1) * sx) / 2, + (numeric(shape.thickness ?? shape.height, 0.04) * sy) / 2, + (numeric(shape.width, 0.5) * sz) / 2, + ] + case 'conformal-strip': { + const xStart = numeric(shape.xStart, -numeric(shape.length, 1) / 2) + const xEnd = numeric(shape.xEnd, numeric(shape.length, 1) / 2) + return [ + Math.max(0.005, Math.abs(xEnd - xStart) / 2) * sx, + (numeric(shape.surfaceRadiusY, 0.25) + numeric(shape.thickness, 0.003)) * sy, + (numeric(shape.surfaceRadiusZ, 0.25) + numeric(shape.thickness, 0.003)) * sz, + ] + } + case 'cylinder': + case 'hollow-cylinder': + case 'cone': + case 'frustum': { + const radius = + shape.kind === 'frustum' + ? Math.max(numeric(shape.radiusTop, 0.25), numeric(shape.radiusBottom, 0.5)) + : numeric(shape.radius, 0.5) + const halfHeight = numeric(shape.height, 1) / 2 + if (shape.axis === 'x') return [halfHeight * sx, radius * sy, radius * sz] + if (shape.axis === 'z') return [radius * sx, radius * sy, halfHeight * sz] + return [radius * sx, halfHeight * sy, radius * sz] + } + case 'capsule': + case 'half-cylinder': { + const radius = numeric(shape.radius, 0.5) + const halfHeight = numeric(shape.height, 1) / 2 + if (shape.axis === 'x') return [halfHeight * sx, radius * sy, radius * sz] + if (shape.axis === 'z') return [radius * sx, radius * sy, halfHeight * sz] + return [radius * sx, halfHeight * sy, radius * sz] + } + case 'sphere': { + const radius = numeric(shape.radius, 0.5) + return [radius * sx, radius * sy, radius * sz] + } + case 'hemisphere': { + const radius = numeric(shape.radius, 0.5) + if (shape.axis === 'x') return [(radius * sx) / 2, radius * sy, radius * sz] + if (shape.axis === 'z') return [radius * sx, radius * sy, (radius * sz) / 2] + return [radius * sx, (radius * sy) / 2, radius * sz] + } + case 'torus': { + const ring = numeric(shape.majorRadius ?? shape.radius, 0.5) + numeric(shape.tubeRadius, 0.08) + const tube = numeric(shape.tubeRadius, 0.08) + if (shape.axis === 'x') return [tube * sx, ring * sy, ring * sz] + if (shape.axis === 'y') return [ring * sx, tube * sy, ring * sz] + return [ring * sx, ring * sy, tube * sz] + } + case 'lathe': { + const profile = shape.profile ?? [ + [0, 0], + [0.5, 1], + ] + let maxRadius = 0 + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + for (const point of profile) { + const radius = numeric(point[0], 0) + const y = numeric(point[1], 0) + maxRadius = Math.max(maxRadius, Math.abs(radius)) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + } + return [maxRadius * sx, Math.max(0.01, (maxY - minY) / 2) * sy, maxRadius * sz] + } + case 'extrude': { + const profile = shape.profile ?? [ + [-0.5, -0.25], + [0.5, -0.25], + [0.5, 0.25], + [-0.5, 0.25], + ] + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + for (const point of profile) { + const x = numeric(point[0], 0) + const y = numeric(point[1], 0) + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + } + return [ + Math.max(0.01, (maxX - minX) / 2) * sx, + Math.max(0.01, (maxY - minY) / 2) * sy, + (numeric(shape.depth, 0.1) * sz) / 2, + ] + } + case 'sweep': { + const radius = numeric(shape.radius, 0.03) + const path = shape.path ?? [ + [-0.5, 0, 0], + [0.5, 0, 0], + ] + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const point of path) { + const x = numeric(point[0], 0) + const y = numeric(point[1], 0) + const z = numeric(point[2], 0) + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + return [ + Math.max(radius, (maxX - minX) / 2 + radius) * sx, + Math.max(radius, (maxY - minY) / 2 + radius) * sy, + Math.max(radius, (maxZ - minZ) / 2 + radius) * sz, + ] + } + default: + return [0.5 * sx, 0.5 * sy, 0.5 * sz] + } +} + +export function buildPrimitiveGeometryFacts( + shapes: readonly PrimitiveShapeInput[], + transforms: readonly ResolvedPrimitiveTransform[] = [], +): PrimitiveGeometryFacts { + const facts: PrimitiveShapeFact[] = [] + const roles: Record = {} + const sourcePartKinds: Record = {} + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + shapes.forEach((shape, index) => { + const center = transforms[index]?.position ?? shape.position ?? [0, 0, 0] + const halfExtents = getPrimitiveShapeHalfExtents(shape) + const min: Vec3 = [ + center[0] - halfExtents[0], + center[1] - halfExtents[1], + center[2] - halfExtents[2], + ] + const max: Vec3 = [ + center[0] + halfExtents[0], + center[1] + halfExtents[1], + center[2] + halfExtents[2], + ] + const semanticRole = shape.semanticRole ?? inferPrimitiveSemanticRole(shape) + const fact: PrimitiveShapeFact = { + index, + kind: String(shape.kind), + name: shape.name, + semanticRole, + semanticGroup: shape.semanticGroup, + sourcePartKind: shape.sourcePartKind, + sourcePartId: shape.sourcePartId, + center, + halfExtents, + min, + max, + materialColor: materialColor(shape.material), + } + + facts.push(fact) + if (semanticRole) roles[semanticRole] = (roles[semanticRole] ?? 0) + 1 + if (shape.sourcePartKind) { + sourcePartKinds[shape.sourcePartKind] = (sourcePartKinds[shape.sourcePartKind] ?? 0) + 1 + } + + minX = Math.min(minX, min[0]) + minY = Math.min(minY, min[1]) + minZ = Math.min(minZ, min[2]) + maxX = Math.max(maxX, max[0]) + maxY = Math.max(maxY, max[1]) + maxZ = Math.max(maxZ, max[2]) + }) + + if (facts.length === 0) { + return { + shapeCount: 0, + bbox: { min: [0, 0, 0], max: [0, 0, 0] }, + dimensions: [0, 0, 0], + roles, + sourcePartKinds, + shapes: facts, + } + } + + return { + shapeCount: facts.length, + bbox: { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] }, + dimensions: [maxX - minX, maxY - minY, maxZ - minZ], + roles, + sourcePartKinds, + shapes: facts, + } +} diff --git a/packages/core/src/lib/primitive-part-intent.ts b/packages/core/src/lib/primitive-part-intent.ts new file mode 100644 index 000000000..a43851fe1 --- /dev/null +++ b/packages/core/src/lib/primitive-part-intent.ts @@ -0,0 +1,58 @@ +const FAMILY_TERMS = + '(?:car|vehicle|truck|suv|sedan|automobile|auto|bicycle|bike|cycle|aircraft|airplane|plane|jet|pump|fan|robot|robot\\s+arm|machine|lathe|conveyor|tank|reactor|compressor)' + +const FAMILY_QUALIFIED_COMPONENT_TERMS = + '(?:steering\\s+wheel|wheel|tire|tyre|rim|hub|window|windshield|door|mirror|seat|dashboard|wiper|handle|headlight|taillight|tail\\s+light|bumper|wing|engine|propeller|blade|impeller|shaft|gear|bearing|flange|nozzle|port|valve|panel|guard|grille|belt|roller|motor|part|component|accessory|subpart)' + +const FAMILY_QUALIFIED_COMPONENT_PATTERNS = [ + new RegExp(`\\b${FAMILY_TERMS}\\s+(?:${FAMILY_QUALIFIED_COMPONENT_TERMS})\\b`, 'i'), + new RegExp( + `\\b${FAMILY_QUALIFIED_COMPONENT_TERMS}\\s+(?:for|of)\\s+(?:a\\s+|an\\s+|the\\s+)?${FAMILY_TERMS}\\b`, + 'i', + ), + /\b(?:part|component|accessory|subpart)\s+(?:for|of)\s+(?:a\s+|an\s+|the\s+)?(?:car|vehicle|aircraft|pump|fan|robot|machine|conveyor|reactor|compressor)\b/i, + /(?:\u6c7d\u8f66|\u8f66\u8f86|\u8f7f\u8f66|\u5361\u8f66|\u81ea\u884c\u8f66|\u5355\u8f66|\u98de\u673a|\u5ba2\u673a|\u6cf5|\u6c34\u6cf5|\u98ce\u6247|\u7535\u98ce\u6247|\u673a\u5668\u4eba|\u673a\u68b0\u81c2|\u673a\u5e8a|\u8f93\u9001\u673a|\u53cd\u5e94\u91dc|\u53cd\u5e94\u5668|\u538b\u7f29\u673a)(?:\u7684)?(?:\u65b9\u5411\u76d8|\u8f6e\u5b50|\u8f6e\u80ce|\u8f66\u8f6e|\u8f6e\u6bc2|\u8f66\u7a97|\u7a97\u6237|\u6321\u98ce\u73bb\u7483|\u8f66\u95e8|\u540e\u89c6\u955c|\u5ea7\u6905|\u4eea\u8868\u76d8|\u96e8\u5237|\u95e8\u628a\u624b|\u8f66\u706f|\u5927\u706f|\u5c3e\u706f|\u4fdd\u9669\u6760|\u673a\u7ffc|\u53d1\u52a8\u673a|\u87ba\u65cb\u6868|\u53f6\u7247|\u53f6\u8f6e|\u8f74|\u9f7f\u8f6e|\u8f74\u627f|\u6cd5\u5170|\u55b7\u5634|\u63a5\u53e3|\u7aef\u53e3|\u9600\u95e8|\u624b\u67c4|\u9762\u677f|\u62a4\u7f69|\u683c\u6805|\u76ae\u5e26|\u6eda\u7b52|\u7535\u673a|\u9a6c\u8fbe|\u96f6\u4ef6|\u90e8\u4ef6|\u914d\u4ef6)/, +] + +const STANDALONE_COMPONENT_PATTERNS = [ + /\b(?:steering\s+wheel|wheel|tire|tyre|rim|hub|windshield|mirror|seat|dashboard|wiper|door\s+handle|handle|headlight|taillight|bumper|wing|propeller|blade|impeller|shaft|panel|guard|grille|belt|roller|component|accessory|subpart)\b/i, + /(?:\u65b9\u5411\u76d8|\u8f6e\u5b50|\u8f6e\u80ce|\u8f66\u8f6e|\u8f6e\u6bc2|\u6321\u98ce\u73bb\u7483|\u540e\u89c6\u955c|\u5ea7\u6905|\u4eea\u8868\u76d8|\u96e8\u5237|\u95e8\u628a\u624b|\u8f66\u95e8|\u8f66\u7a97|\u7a97\u6237|\u8f66\u706f|\u5927\u706f|\u5c3e\u706f|\u4fdd\u9669\u6760|\u673a\u7ffc|\u87ba\u65cb\u6868|\u53f6\u7247|\u53f6\u8f6e|\u8f74|\u624b\u67c4|\u9762\u677f|\u62a4\u7f69|\u683c\u6805|\u76ae\u5e26|\u6eda\u7b52|\u96f6\u4ef6|\u90e8\u4ef6|\u914d\u4ef6)/, +] + +const COMPLETE_OBJECT_PATTERNS = [ + /\b(?:whole|complete|entire|full)\s+(?:car|vehicle|truck|suv|sedan|aircraft|airplane|plane|pump|fan|robot|machine|conveyor|reactor|compressor)\b/i, + /\b(?:make|create|generate|build)\s+(?:a\s+|an\s+|one\s+)(?:car|vehicle|truck|suv|sedan|aircraft|airplane|plane|pump|fan|robot|machine|conveyor|reactor|compressor)\b/i, + /(?:\u5b8c\u6574|\u6574\u4e2a|\u6574\u53f0|\u6574\u8f86|\u4e00\u8f86|\u4e00\u53f0|\u4e00\u67b6)(?:\u6c7d\u8f66|\u8f66\u8f86|\u8f7f\u8f66|\u5361\u8f66|\u98de\u673a|\u5ba2\u673a|\u6cf5|\u6c34\u6cf5|\u98ce\u6247|\u7535\u98ce\u6247|\u673a\u5668\u4eba|\u673a\u68b0\u81c2|\u673a\u5e8a|\u8f93\u9001\u673a|\u53cd\u5e94\u91dc|\u53cd\u5e94\u5668|\u538b\u7f29\u673a)/, +] + +function normalizedText(text: string): string { + return text.trim().toLowerCase() +} + +function hasCompleteObjectIntent(text: string): boolean { + return COMPLETE_OBJECT_PATTERNS.some((pattern) => pattern.test(text)) +} + +function hasStandaloneComponentIntent(text: string): boolean { + return STANDALONE_COMPONENT_PATTERNS.some((pattern) => pattern.test(text)) +} + +export function hasFamilyQualifiedPartIntent(text: string): boolean { + const normalized = normalizedText(text) + if (!normalized) return false + return FAMILY_QUALIFIED_COMPONENT_PATTERNS.some((pattern) => pattern.test(normalized)) +} + +export function hasComponentPartIntent(text: string): boolean { + const normalized = normalizedText(text) + if (!normalized) return false + if (hasStandaloneComponentIntent(normalized)) return true + if (hasFamilyQualifiedPartIntent(normalized)) return true + return false +} + +export function hasWholeObjectIntent(text: string): boolean { + const normalized = normalizedText(text) + if (!normalized) return false + return hasCompleteObjectIntent(normalized) && !hasComponentPartIntent(normalized) +} diff --git a/packages/core/src/lib/primitive-recipes.test.ts b/packages/core/src/lib/primitive-recipes.test.ts new file mode 100644 index 000000000..6aa464d89 --- /dev/null +++ b/packages/core/src/lib/primitive-recipes.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, test } from 'bun:test' +import { + composeRecipePrimitives, + findPrimitiveRecipe, + getPrimitiveRecipeGeometryBrief, + listPrimitiveRecipes, +} from './primitive-recipes' + +function expectBladeRotationMatchesRadialPlacement(shape: { + position?: number[] + rotation?: number[] +}) { + const [x = 0, , z = 0] = shape.position ?? [] + const radialLength = Math.hypot(x, z) + expect(radialLength).toBeGreaterThan(0.001) + const expectedAngle = Math.atan2(z, x) + const actualY = shape.rotation?.[1] ?? 0 + const actualZ = shape.rotation?.[2] ?? 0 + const wrappedDelta = Math.atan2( + Math.sin(actualZ + expectedAngle), + Math.cos(actualZ + expectedAngle), + ) + expect(actualY).toBeCloseTo(0, 4) + expect(wrappedDelta).toBeCloseTo(0, 4) +} + +describe('primitive recipe registry', () => { + test('keeps closed-form recipes independent from part composition', async () => { + const source = await Bun.file(`${import.meta.dir}/primitive-recipes.ts`).text() + + expect(source).not.toContain("from './part-compose'") + expect(source).not.toContain('composePartPrimitives') + }) + + test('lists only closed-form professional recipes and resolves aliases', () => { + expect(listPrimitiveRecipes().map((recipe) => recipe.id)).toEqual([ + 'gear.spur', + 'sprocket.chain', + 'pipe.flange', + 'pipe.elbow90', + 'fastener.hexBolt', + 'bearing.pillowBlock', + 'coupling.flexible', + 'plate.perforated', + 'valve.gate', + 'valve.ball', + 'robotArm.threeAxis', + 'mixer.impeller', + 'motor.servo', + 'process.vesselShell', + 'structure.platformLadder', + 'enclosure.roundedBox', + ]) + + expect(findPrimitiveRecipe({ name: '20 tooth spur gear' })?.id).toBe('gear.spur') + expect(findPrimitiveRecipe({ name: 'roller chain sprocket' })?.id).toBe('sprocket.chain') + expect(findPrimitiveRecipe({ name: 'ansi pipe flange' })?.id).toBe('pipe.flange') + expect(findPrimitiveRecipe({ name: '90 degree elbow fitting' })?.id).toBe('pipe.elbow90') + expect(findPrimitiveRecipe({ name: 'M8 hex bolt' })?.id).toBe('fastener.hexBolt') + expect(findPrimitiveRecipe({ name: 'pillow block bearing' })?.id).toBe('bearing.pillowBlock') + expect(findPrimitiveRecipe({ name: 'flexible shaft coupling' })?.id).toBe('coupling.flexible') + expect(findPrimitiveRecipe({ name: 'perforated sieve plate' })?.id).toBe('plate.perforated') + expect(findPrimitiveRecipe({ name: 'quarter turn ball valve' })?.id).toBe('valve.ball') + expect(findPrimitiveRecipe({ name: 'industrial servo motor' })?.id).toBe('motor.servo') + expect(findPrimitiveRecipe({ name: '\u4f3a\u670d\u7535\u673a' })?.id).toBe('motor.servo') + expect(findPrimitiveRecipe({ name: 'hollow pressure vessel shell' })?.id).toBe( + 'process.vesselShell', + ) + expect(findPrimitiveRecipe({ name: 'access platform ladder with guardrail' })?.id).toBe( + 'structure.platformLadder', + ) + expect(findPrimitiveRecipe({ name: 'rounded machine cabinet enclosure' })?.id).toBe( + 'enclosure.roundedBox', + ) + expect(findPrimitiveRecipe({ name: 'mud mixer with three flat blades' })?.id).toBe( + 'mixer.impeller', + ) + }) + + test('does not expose open-ended complete objects as recipes', () => { + for (const recipeId of [ + 'vehicle.sedan', + 'appliance.airConditionerOutdoorUnit', + 'fan.industrial', + 'machineTool.lathe', + 'machineTool.machiningCenter', + 'materialHandling.beltConveyor', + 'fluidMachine.centrifugalPump', + 'process.heatExchanger', + 'forming.injectionMolding', + ]) { + expect(findPrimitiveRecipe({ recipeId })?.id).toBeUndefined() + expect(composeRecipePrimitives({ recipeId })).toEqual([]) + } + + expect(findPrimitiveRecipe({ name: 'small red car' })?.id).toBeUndefined() + expect(findPrimitiveRecipe({ name: 'outdoor ac condenser unit' })?.id).toBeUndefined() + expect(findPrimitiveRecipe({ name: 'factory pedestal industrial fan' })?.id).toBeUndefined() + }) + + test('composes a parametric spur gear recipe with bore and keyway', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'gear.spur', + params: { + teeth: 20, + module: 4.5, + outerDiameter: 0.099, + thickness: 0.02, + boreDiameter: 0.025, + keywayWidth: 0.008, + keywayDepth: 0.004, + }, + }) + + expect(shapes).toHaveLength(1) + const gear = shapes[0] + expect(gear?.kind).toBe('extrude') + expect(gear?.semanticRole).toBe('spur_gear') + expect(gear?.profile?.length).toBe(80) + expect(gear?.holes).toHaveLength(1) + expect(gear?.holes?.[0]?.length).toBeGreaterThan(40) + expect(gear?.depth).toBeCloseTo(0.02) + }) + + test('composes a standard pipe flange recipe with raised face and bolt circle', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'pipe.flange', + params: { + nominalDiameter: 0.1, + outerDiameter: 0.22, + thickness: 0.024, + boltCircleDiameter: 0.18, + boltCount: 8, + }, + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole).filter(Boolean)) + + expect(roles.has('flange_body')).toBe(true) + expect(roles.has('raised_face')).toBe(true) + expect(roles.has('gasket')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'flange_bolt_hole')).toHaveLength(8) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'pipe.flange' })?.category).toBe( + 'pipe_flange', + ) + }) + + test('composes a roller chain sprocket recipe with teeth, hub, and bore', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'sprocket.chain', + params: { teeth: 16, module: 0.012, boreDiameter: 0.03, thickness: 0.018 }, + }) + const sprocket = shapes.find((shape) => shape.semanticRole === 'chain_sprocket') + + expect(sprocket?.kind).toBe('extrude') + expect(sprocket?.profile?.length).toBe(80) + expect(sprocket?.holes).toHaveLength(1) + expect(shapes.some((shape) => shape.semanticRole === 'sprocket_hub')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'sprocket_bore')).toBe(true) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'sprocket.chain' })?.category).toBe( + 'chain_sprocket', + ) + }) + + test('composes a 90 degree pipe elbow recipe with two end collars', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'pipe.elbow90', + params: { nominalDiameter: 0.1, bendRadius: 0.15, thickness: 0.008 }, + }) + + expect(shapes.some((shape) => shape.semanticRole === 'pipe_elbow_body')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'pipe_elbow_bore')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'elbow_inlet')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'elbow_outlet')).toBe(true) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'pipe.elbow90' })?.category).toBe( + 'pipe_elbow', + ) + }) + + test('composes a hex bolt recipe with shank, six-sided head, and thread crests', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'fastener.hexBolt', + params: { + nominalDiameter: 0.008, + shankLength: 0.04, + threadLength: 0.018, + }, + }) + const head = shapes.find((shape) => shape.semanticRole === 'hex_head') + + expect(shapes.some((shape) => shape.semanticRole === 'bolt_shank')).toBe(true) + expect(head?.radialSegments).toBe(6) + expect(shapes.filter((shape) => shape.semanticRole === 'thread_crest').length).toBeGreaterThan( + 4, + ) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'fastener.hexBolt' })?.category).toBe( + 'fastener', + ) + }) + + test('composes a pillow block bearing recipe with housing, insert, and mounting holes', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'bearing.pillowBlock', + params: { shaftDiameter: 0.05, length: 0.28 }, + }) + + expect(shapes.some((shape) => shape.semanticRole === 'pillow_block_base')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'bearing_housing')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'bearing_insert')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'bearing_bore')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'mounting_hole')).toHaveLength(2) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'bearing.pillowBlock' })?.category).toBe( + 'pillow_block_bearing', + ) + }) + + test('composes a flexible coupling recipe with two hubs and an elastomer spider', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'coupling.flexible', + params: { shaftDiameter: 0.04, jawCount: 6 }, + }) + + expect(shapes.some((shape) => shape.semanticRole === 'coupling_hub_left')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'coupling_hub_right')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'elastomer_spider')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'coupling_bore')).toHaveLength(2) + expect(shapes.filter((shape) => shape.semanticRole === 'set_screw')).toHaveLength(2) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'coupling.flexible' })?.category).toBe( + 'shaft_coupling', + ) + }) + + test('composes a perforated plate recipe with a regular hole grid', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'plate.perforated', + params: { rows: 3, columns: 5, length: 0.5, width: 0.24, holeDiameter: 0.03 }, + }) + const plate = shapes.find((shape) => shape.semanticRole === 'perforated_plate') + + expect(plate?.kind).toBe('extrude') + expect(plate?.holes).toHaveLength(15) + expect(shapes.filter((shape) => shape.semanticRole === 'perforation_hole')).toHaveLength(15) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'plate.perforated' })?.category).toBe( + 'perforated_plate', + ) + }) + + test('composes a hollow process vessel shell recipe with heads and nozzles', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'process.vesselShell', + params: { length: 1.6, radius: 0.32, wallThickness: 0.025 }, + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole).filter(Boolean)) + + expect(shapes.find((shape) => shape.semanticRole === 'vessel_shell')?.kind).toBe( + 'hollow-cylinder', + ) + expect(shapes.filter((shape) => shape.semanticRole === 'vessel_head')).toHaveLength(2) + expect(roles.has('vessel_seam')).toBe(true) + expect(roles.has('top_nozzle')).toBe(true) + expect(roles.has('manway_flange')).toBe(true) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'process.vesselShell' })?.category).toBe( + 'process_vessel_shell', + ) + }) + + test('composes an industrial platform ladder recipe with guard rails', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'structure.platformLadder', + params: { length: 1, width: 0.6, height: 1.4, rungCount: 7 }, + }) + + expect(shapes.some((shape) => shape.semanticRole === 'platform_deck')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'platform_post')).toHaveLength(4) + expect(shapes.filter((shape) => shape.semanticRole === 'guard_rail').length).toBeGreaterThan(1) + expect(shapes.filter((shape) => shape.semanticRole === 'ladder_rung')).toHaveLength(7) + expect( + getPrimitiveRecipeGeometryBrief({ recipeId: 'structure.platformLadder' })?.category, + ).toBe('industrial_access_platform') + }) + + test('composes a rounded box enclosure recipe with a transparent viewing window', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'enclosure.roundedBox', + params: { length: 1.4, width: 0.5, height: 1.1 }, + }) + const window = shapes.find((shape) => shape.semanticRole === 'viewing_window') + + expect(shapes.find((shape) => shape.semanticRole === 'machine_enclosure')?.kind).toBe('box') + expect(shapes.some((shape) => shape.semanticRole === 'access_door')).toBe(true) + expect(window?.kind).toBe('rounded-panel') + expect(window?.material?.properties?.transparent).toBe(true) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'enclosure.roundedBox' })?.category).toBe( + 'machine_enclosure', + ) + }) + + test('composes valve recipe variants without user-specified internal roles', () => { + const gate = composeRecipePrimitives({ recipeId: 'valve.gate' }) + expect(gate.some((shape) => shape.semanticRole === 'gate_wedge')).toBe(true) + expect(gate.some((shape) => shape.semanticRole === 'flange_inlet')).toBe(true) + expect(gate.some((shape) => shape.semanticRole === 'flange_outlet')).toBe(true) + + const ball = composeRecipePrimitives({ recipeId: 'valve.ball' }) + expect(ball.some((shape) => shape.semanticRole === 'valve_ball')).toBe(true) + expect(ball.some((shape) => shape.semanticRole === 'valve_bore')).toBe(true) + expect(ball.some((shape) => shape.semanticRole === 'gate_wedge')).toBe(false) + }) + + test('composes a three-axis robot arm recipe and provides validation brief', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'robotArm.threeAxis', + params: { baseShape: 'round', endEffector: 'gripper' }, + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole).filter(Boolean)) + + expect(roles.has('robot_base')).toBe(true) + expect(roles.has('base_joint')).toBe(true) + expect(roles.has('shoulder_joint')).toBe(true) + expect(roles.has('elbow_joint')).toBe(true) + expect(roles.has('end_effector')).toBe(true) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'robotArm.threeAxis' })?.category).toBe( + 'robot_arm', + ) + }) + + test('composes a friendly mud mixer impeller recipe', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'mixer.impeller', + params: { bladeCount: 3, bladeTilt: 30, shaftLength: 0.9 }, + }) + + expect(shapes.some((shape) => shape.semanticRole === 'mixer_shaft')).toBe(true) + expect(shapes.some((shape) => shape.semanticRole === 'mixer_hub')).toBe(true) + const bladeSurfaces = shapes.filter((shape) => shape.semanticRole === 'mixer_blade') + expect(bladeSurfaces).toHaveLength(3) + expect(bladeSurfaces.every((shape) => shape.kind === 'extrude')).toBe(true) + expect(bladeSurfaces.every((shape) => shape.sourcePartKind === 'mixer_blades')).toBe(true) + expect(bladeSurfaces.every((shape) => !shape.name?.includes('segment'))).toBe(true) + expect(bladeSurfaces.every((shape) => shape.name?.includes('taiji half mixer'))).toBe(true) + expect(bladeSurfaces.every((shape) => (shape.profile?.length ?? 0) >= 10)).toBe(true) + bladeSurfaces.forEach(expectBladeRotationMatchesRadialPlacement) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'mixer.impeller' })?.category).toBe('mixer') + }) + + test('composes a servo motor recipe with recognisable servo-specific roles', () => { + const shapes = composeRecipePrimitives({ recipeId: 'motor.servo' }) + const roles = new Set(shapes.map((shape) => shape.semanticRole).filter(Boolean)) + + expect(roles.has('servo_body')).toBe(true) + expect(roles.has('front_flange')).toBe(true) + expect(roles.has('output_shaft')).toBe(true) + expect(roles.has('encoder_cap')).toBe(true) + expect(roles.has('terminal_box')).toBe(true) + expect(roles.has('nameplate')).toBe(true) + expect(shapes.filter((shape) => shape.semanticRole === 'cooling_fin')).toHaveLength(6) + expect(shapes.filter((shape) => shape.semanticRole === 'flange_bolt')).toHaveLength(4) + expect(getPrimitiveRecipeGeometryBrief({ recipeId: 'motor.servo' })?.category).toBe('motor') + }) +}) diff --git a/packages/core/src/lib/primitive-recipes.ts b/packages/core/src/lib/primitive-recipes.ts new file mode 100644 index 000000000..702cf0418 --- /dev/null +++ b/packages/core/src/lib/primitive-recipes.ts @@ -0,0 +1,2584 @@ +import { radialExtrudeRotationInHorizontalPlane } from './orientation-utils' +import type { PrimitiveGeometryBrief, PrimitiveShapeInput, Vec3 } from './primitive-compose' +import { composeRobotArmPrimitives, type RobotArmComposeInput } from './robot-arm-compose' + +export type PrimitiveRecipeId = + | 'gear.spur' + | 'sprocket.chain' + | 'pipe.flange' + | 'pipe.elbow90' + | 'fastener.hexBolt' + | 'bearing.pillowBlock' + | 'coupling.flexible' + | 'plate.perforated' + | 'valve.gate' + | 'valve.ball' + | 'robotArm.threeAxis' + | 'mixer.impeller' + | 'motor.servo' + | 'process.vesselShell' + | 'structure.platformLadder' + | 'enclosure.roundedBox' + +export interface PrimitiveRecipeParams { + name?: string + color?: string + primaryColor?: string + secondaryColor?: string + accentColor?: string + darkColor?: string + metalColor?: string + size?: 'tiny' | 'small' | 'medium' | 'large' | string + sizeScale?: number + length?: number + width?: number + height?: number + depth?: number + diameter?: number + detail?: 'low' | 'medium' | 'high' | string + highFidelity?: boolean + enhanceVisualDetails?: boolean + style?: string + vehicleStyle?: string + valveStyle?: string + handleStyle?: string + axis?: string + axisCount?: number + baseShape?: 'round' | 'square' | 'pedestal' | string + endEffector?: 'gripper' | 'suction' | 'tool-flange' | string + pose?: 'rest' | 'reach-forward' | 'work-ready' | string + reach?: number + teeth?: number + module?: number + outerDiameter?: number + pitchDiameter?: number + rootDiameter?: number + thickness?: number + boreDiameter?: number + keywayWidth?: number + keywayDepth?: number + bladeCount?: number + bladeLength?: number + bladeWidth?: number + bladeThickness?: number + bladeTilt?: number + shaftDiameter?: number + shaftLength?: number + hubRadius?: number + bendRadius?: number + angle?: number + jawCount?: number + rows?: number + columns?: number + holeCount?: number + count?: number + holeDiameter?: number + boltSpacing?: number + nominalDiameter?: number + boltCircleDiameter?: number + boltCount?: number + headHeight?: number + headDiameter?: number + shankLength?: number + threadLength?: number + radius?: number + wallThickness?: number + cornerRadius?: number + railHeight?: number + rungCount?: number + position?: Vec3 +} + +export interface ComposeRecipeInput extends PrimitiveRecipeParams { + recipeId?: PrimitiveRecipeId | string + recipe?: PrimitiveRecipeId | string + id?: PrimitiveRecipeId | string + params?: PrimitiveRecipeParams + geometryBrief?: PrimitiveGeometryBrief +} + +export interface PrimitiveRecipeDefinition { + id: PrimitiveRecipeId + label: string + aliases: string[] + compose: (input: ComposeRecipeInput) => PrimitiveShapeInput[] + geometryBrief: (input: ComposeRecipeInput) => PrimitiveGeometryBrief +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function recipeParams(input: ComposeRecipeInput): PrimitiveRecipeParams { + return isRecord(input.params) ? { ...input, ...input.params } : input +} + +function textOf(value: unknown): string { + if (typeof value === 'string') return value.toLowerCase() + if (Array.isArray(value)) return value.map(textOf).join(' ') + if (typeof value === 'object' && value !== null) return Object.values(value).map(textOf).join(' ') + return '' +} + +function normalizeRecipeId(value: unknown): string { + return typeof value === 'string' + ? value + .trim() + .replace(/[\s_-]+/g, '.') + .toLowerCase() + : '' +} + +function recipeIdMatchesAlias(id: string, recipe: PrimitiveRecipeDefinition): boolean { + return recipe.aliases.some((alias) => normalizeRecipeId(alias) === id) +} + +function readRecipeId(input: ComposeRecipeInput): string { + return normalizeRecipeId(input.recipeId ?? input.recipe ?? input.id) +} + +function recipeIdAlias(_value: string): PrimitiveRecipeId | undefined { + return undefined +} + +function numberValue(...values: unknown[]): number | undefined { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value + } + return undefined +} + +function stringValue(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value + } + return undefined +} + +function clampNumber(value: number | undefined, fallback: number, min: number, max: number) { + const resolved = value ?? fallback + return Math.max(min, Math.min(max, resolved)) +} + +function integerValue(value: number | undefined, fallback: number, min: number, max: number) { + return Math.round(clampNumber(value, fallback, min, max)) +} + +function sizeScaleFor(params: PrimitiveRecipeParams): number | undefined { + const explicit = numberValue(params.sizeScale) + if (explicit != null) return explicit + switch (textOf(params.size)) { + case 'tiny': + case 'mini': + case 'micro': + return 0.62 + case 'small': + case 'compact': + case 'little': + case '小': + case '小型': + return 0.8 + case 'large': + case 'big': + case '大型': + return 1.18 + default: + return undefined + } +} + +function nameFor(input: ComposeRecipeInput, fallback: string): string { + const params = recipeParams(input) + return stringValue(params.name, input.name, fallback) ?? fallback +} + +function colorFor(params: PrimitiveRecipeParams, fallback: string): string { + return stringValue(params.primaryColor, params.color, fallback) ?? fallback +} + +function detailFor(params: PrimitiveRecipeParams): PrimitiveRecipeParams['detail'] { + return stringValue( + params.detail, + params.highFidelity ? 'high' : undefined, + ) as PrimitiveRecipeParams['detail'] +} + +function positionFor(params: PrimitiveRecipeParams, input: ComposeRecipeInput): Vec3 | undefined { + return params.position ?? input.position +} + +function polarPoint(radius: number, angle: number): [number, number] { + return [Math.cos(angle) * radius, Math.sin(angle) * radius] +} + +function circleProfile(radius: number, segments: number, startAngle = 0): [number, number][] { + return Array.from({ length: segments }, (_, index) => + polarPoint(radius, startAngle + (index / segments) * Math.PI * 2), + ) +} + +function spurGearRecipe(): PrimitiveRecipeDefinition { + const id = 'gear.spur' as PrimitiveRecipeId + const aliases = ['spur gear', 'gear', 'toothed gear', '直齿齿轮', '齿轮'] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const teeth = Math.max(6, Math.min(160, Math.round(numberValue(params.teeth, 20) ?? 20))) + const moduleMeters = + numberValue(params.module) != null ? (numberValue(params.module) as number) / 1000 : undefined + const pitchDiameter = + numberValue(params.pitchDiameter) ?? (moduleMeters ? moduleMeters * teeth : undefined) + const outerDiameter = + numberValue(params.outerDiameter) ?? + (moduleMeters ? moduleMeters * (teeth + 2) : undefined) ?? + 0.099 + const thickness = numberValue(params.thickness, params.height, params.width) ?? 0.02 + const outerRadius = outerDiameter / 2 + const rootRadius = + (numberValue(params.rootDiameter) ?? + (pitchDiameter ? pitchDiameter - (moduleMeters ?? 0) * 2.5 : undefined)) != null + ? (numberValue(params.rootDiameter) ?? + (pitchDiameter as number) - (moduleMeters ?? 0) * 2.5)! / 2 + : outerRadius * 0.795 + const boreRadius = (numberValue(params.boreDiameter) ?? 0.025) / 2 + const keywayWidth = numberValue(params.keywayWidth) ?? 0 + const keywayDepth = numberValue(params.keywayDepth) ?? (keywayWidth > 0 ? keywayWidth * 0.5 : 0) + + const profile: [number, number][] = [] + for (let tooth = 0; tooth < teeth; tooth += 1) { + const base = (tooth / teeth) * Math.PI * 2 + for (const [offset, radius] of [ + [0, rootRadius], + [0.25, outerRadius], + [0.75, outerRadius], + [1, rootRadius], + ] as const) { + profile.push(polarPoint(radius, base + (offset / teeth) * Math.PI * 2)) + } + } + + const boreSegments = Math.max(24, Math.min(96, teeth * 2)) + const bore = circleProfile(boreRadius, boreSegments) + if (keywayWidth > 0 && keywayDepth > 0) { + const half = keywayWidth / 2 + const top = boreRadius + keywayDepth + bore.push([half, boreRadius], [half, top], [-half, top], [-half, boreRadius]) + } + + return [ + { + kind: 'extrude', + name: nameFor(input, 'spur gear'), + semanticRole: 'spur_gear', + position: positionFor(params, input) ?? [0, thickness / 2, 0], + rotation: [Math.PI / 2, 0, 0], + profile, + holes: [bore], + depth: thickness, + bevelSize: Math.min(thickness * 0.03, outerRadius * 0.01), + bevelThickness: Math.min(thickness * 0.03, outerRadius * 0.01), + bevelSegments: 1, + material: { + properties: { + color: colorFor(params, '#6B6B6B'), + roughness: 0.35, + metalness: 0.9, + }, + }, + }, + ] + } + + return { + id, + label: 'Spur gear', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'gear', + units: 'm', + coordinateConvention: '+Y up; extrude lies on ground after rotation', + requiredRoles: ['spur_gear'], + validationTargets: [ + `${Math.round(numberValue(params.teeth, 20) ?? 20)} teeth`, + 'outer toothed profile', + 'bore/keyway holes when requested', + ], + } + }, + } +} + +function chainSprocketRecipe(): PrimitiveRecipeDefinition { + const id = 'sprocket.chain' as PrimitiveRecipeId + const aliases = ['chain sprocket', 'roller chain sprocket', 'sprocket', '链轮', '鏈輪'] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const teeth = integerValue(numberValue(params.teeth), 18, 8, 96) + const pitch = clampNumber(numberValue(params.module, params.pitchDiameter), 0.012, 0.004, 0.08) + const pitchDiameter = + numberValue(params.pitchDiameter) ?? pitch / Math.sin(Math.PI / Math.max(teeth, 3)) + const outerRadius = (numberValue(params.outerDiameter) ?? pitchDiameter + pitch * 1.25) / 2 + const rootRadius = + (numberValue(params.rootDiameter) ?? Math.max(pitchDiameter - pitch * 1.15, pitch)) / 2 + const thickness = clampNumber( + numberValue(params.thickness, params.height), + pitch * 1.35, + 0.006, + 0.18, + ) + const boreRadius = (numberValue(params.boreDiameter) ?? pitchDiameter * 0.22) / 2 + const hubRadius = clampNumber( + numberValue(params.hubRadius), + boreRadius * 1.85, + boreRadius * 1.25, + outerRadius * 0.72, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'roller chain sprocket') + const metal = { + properties: { color: colorFor(params, '#71717a'), roughness: 0.36, metalness: 0.9 }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#111827') ?? '#111827', + roughness: 0.52, + metalness: 0.35, + }, + } + const profile: [number, number][] = [] + for (let tooth = 0; tooth < teeth; tooth += 1) { + const base = (tooth / teeth) * Math.PI * 2 + for (const [offset, radius] of [ + [0, rootRadius], + [0.18, outerRadius * 0.96], + [0.5, outerRadius], + [0.82, outerRadius * 0.96], + [1, rootRadius], + ] as const) { + profile.push(polarPoint(radius, base + (offset / teeth) * Math.PI * 2)) + } + } + + return [ + { + kind: 'extrude', + name: `${name} toothed sprocket plate`, + semanticRole: 'chain_sprocket', + position: [origin[0], origin[1] + thickness / 2, origin[2]], + rotation: [Math.PI / 2, 0, 0], + profile, + holes: [circleProfile(boreRadius, Math.max(32, Math.min(96, teeth * 2)))], + depth: thickness, + bevelSize: Math.min(thickness * 0.04, outerRadius * 0.012), + bevelThickness: Math.min(thickness * 0.04, outerRadius * 0.012), + bevelSegments: 1, + material: metal, + }, + { + kind: 'hollow-cylinder', + name: `${name} central hub`, + semanticRole: 'sprocket_hub', + position: [origin[0], origin[1] + thickness + thickness * 0.32, origin[2]], + axis: 'y', + radius: hubRadius, + height: thickness * 0.64, + wallThickness: Math.max(hubRadius - boreRadius, 0.003), + radialSegments: 48, + material: metal, + }, + { + kind: 'cylinder', + name: `${name} dark shaft bore`, + semanticRole: 'sprocket_bore', + position: [origin[0], origin[1] + thickness + thickness * 0.66, origin[2]], + axis: 'y', + radius: boreRadius, + height: Math.max(thickness * 0.04, 0.002), + radialSegments: 32, + material: dark, + }, + ] + } + + return { + id, + label: 'Chain sprocket', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'chain_sprocket', + units: 'm', + coordinateConvention: '+Y sprocket axis; y=0 is bottom face', + requiredRoles: ['chain_sprocket', 'sprocket_hub', 'sprocket_bore'], + validationTargets: [ + `${integerValue(numberValue(params.teeth), 18, 8, 96)} roller-chain teeth`, + 'central bore', + 'raised hub for shaft mounting', + ], + } + }, + } +} + +function pipeFlangeRecipe(): PrimitiveRecipeDefinition { + const id = 'pipe.flange' as PrimitiveRecipeId + const aliases = [ + 'pipe flange', + 'standard flange', + 'ansi flange', + 'weld neck flange', + '法兰', + '管法兰', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const nominalDiameter = clampNumber( + numberValue(params.nominalDiameter, params.boreDiameter, params.diameter), + 0.12 * scale, + 0.03, + 1.2, + ) + const boreRadius = nominalDiameter / 2 + const outerRadius = + clampNumber( + numberValue(params.outerDiameter), + nominalDiameter * 2.15, + nominalDiameter * 1.35, + 3, + ) / 2 + const thickness = clampNumber( + numberValue(params.thickness, params.height), + Math.max(nominalDiameter * 0.22, 0.025 * scale), + 0.012, + 0.45, + ) + const boltCount = integerValue(numberValue(params.boltCount), 8, 4, 24) + const boltCircleRadius = + clampNumber( + numberValue(params.boltCircleDiameter), + outerRadius * 1.52, + boreRadius * 2.25, + outerRadius * 1.84, + ) / 2 + const boltHoleRadius = clampNumber( + numberValue(params.radius), + Math.max(nominalDiameter * 0.055, 0.008 * scale), + 0.004, + outerRadius * 0.12, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'standard pipe flange') + const metal = { + properties: { color: colorFor(params, '#9ca3af'), roughness: 0.34, metalness: 0.86 }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#111827') ?? '#111827', + roughness: 0.52, + metalness: 0.32, + }, + } + const gasket = { + properties: { + color: stringValue(params.accentColor, '#334155') ?? '#334155', + roughness: 0.68, + metalness: 0.05, + }, + } + const center: Vec3 = [origin[0], origin[1] + thickness / 2, origin[2]] + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${name} raised face flange ring`, + semanticRole: 'flange_body', + position: center, + axis: 'y', + radius: outerRadius, + height: thickness, + wallThickness: Math.max(outerRadius - boreRadius, 0.004), + radialSegments: 72, + material: metal, + }, + { + kind: 'hollow-cylinder', + name: `${name} raised face boss`, + semanticRole: 'raised_face', + position: [origin[0], origin[1] + thickness + thickness * 0.08, origin[2]], + axis: 'y', + radius: boreRadius * 1.42, + height: thickness * 0.16, + wallThickness: Math.max(boreRadius * 0.42, 0.004), + radialSegments: 64, + material: metal, + }, + { + kind: 'torus', + name: `${name} dark gasket line`, + semanticRole: 'gasket', + position: [origin[0], origin[1] + thickness + thickness * 0.18, origin[2]], + axis: 'y', + majorRadius: boreRadius * 1.08, + tubeRadius: Math.max(thickness * 0.035, 0.002), + tubularSegments: 64, + radialSegments: 10, + material: gasket, + }, + ] + + for (let index = 0; index < boltCount; index += 1) { + const angle = (index / boltCount) * Math.PI * 2 + shapes.push({ + kind: 'cylinder', + name: `${name} bolt hole ${index + 1}`, + semanticRole: 'flange_bolt_hole', + position: [ + origin[0] + Math.cos(angle) * boltCircleRadius, + origin[1] + thickness + 0.001, + origin[2] + Math.sin(angle) * boltCircleRadius, + ], + axis: 'y', + radius: boltHoleRadius, + height: Math.max(thickness * 0.06, 0.003), + radialSegments: 24, + material: dark, + }) + } + + return shapes + } + + return { + id, + label: 'Pipe flange', + aliases, + compose, + geometryBrief: (input) => input.geometryBrief ?? pipeFlangeBrief(input), + } +} + +function pipeFlangeBrief(input: ComposeRecipeInput): PrimitiveGeometryBrief { + const params = recipeParams(input) + const boltCount = integerValue(numberValue(params.boltCount), 8, 4, 24) + return { + category: 'pipe_flange', + units: 'm', + coordinateConvention: '+Y flange axis; y=0 is bottom face', + expectedDimensions: { + diameter: numberValue(params.outerDiameter), + thickness: numberValue(params.thickness, params.height), + boreDiameter: numberValue(params.nominalDiameter, params.boreDiameter, params.diameter), + }, + requiredRoles: ['flange_body', 'raised_face', 'gasket', 'flange_bolt_hole'], + validationTargets: [ + 'annular flange body with central bore', + 'raised face around the bore', + `${boltCount} evenly spaced bolt holes on a bolt circle`, + ], + } +} + +function pipeElbow90Recipe(): PrimitiveRecipeDefinition { + const id = 'pipe.elbow90' as PrimitiveRecipeId + const aliases = ['90 degree elbow', 'pipe elbow', 'elbow fitting', '90 elbow', '弯头', '90度弯头'] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const nominalDiameter = clampNumber( + numberValue(params.nominalDiameter, params.diameter, params.boreDiameter), + 0.12 * scale, + 0.025, + 1.2, + ) + const tubeRadius = nominalDiameter / 2 + const bendRadius = clampNumber( + numberValue(params.bendRadius, params.radius), + nominalDiameter * 1.5, + nominalDiameter * 0.9, + nominalDiameter * 6, + ) + const angle = clampNumber(numberValue(params.angle), 90, 15, 180) * (Math.PI / 180) + const wallThickness = clampNumber( + numberValue(params.thickness), + nominalDiameter * 0.08, + nominalDiameter * 0.025, + nominalDiameter * 0.18, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, '90 degree pipe elbow') + const metal = { + properties: { color: colorFor(params, '#94a3b8'), roughness: 0.35, metalness: 0.84 }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#0f172a') ?? '#0f172a', + roughness: 0.55, + metalness: 0.25, + }, + } + const endX: Vec3 = [origin[0] + bendRadius, origin[1], origin[2]] + const endY: Vec3 = [ + origin[0] + Math.cos(angle) * bendRadius, + origin[1] + Math.sin(angle) * bendRadius, + origin[2], + ] + + return [ + { + kind: 'torus', + name: `${name} curved elbow body`, + semanticRole: 'pipe_elbow_body', + position: origin, + majorRadius: bendRadius, + tubeRadius, + arc: angle, + tubularSegments: 64, + radialSegments: 20, + material: metal, + }, + { + kind: 'torus', + name: `${name} inner bore shadow`, + semanticRole: 'pipe_elbow_bore', + position: origin, + majorRadius: bendRadius, + tubeRadius: Math.max(tubeRadius - wallThickness, tubeRadius * 0.68), + arc: angle, + tubularSegments: 64, + radialSegments: 16, + material: dark, + }, + { + kind: 'hollow-cylinder', + name: `${name} inlet end collar`, + semanticRole: 'elbow_inlet', + position: endX, + axis: 'x', + radius: tubeRadius * 1.08, + height: Math.max(nominalDiameter * 0.22, 0.012), + wallThickness: Math.max(wallThickness, tubeRadius * 0.08), + radialSegments: 32, + material: metal, + }, + { + kind: 'hollow-cylinder', + name: `${name} outlet end collar`, + semanticRole: 'elbow_outlet', + position: endY, + rotation: [0, 0, angle], + axis: 'x', + radius: tubeRadius * 1.08, + height: Math.max(nominalDiameter * 0.22, 0.012), + wallThickness: Math.max(wallThickness, tubeRadius * 0.08), + radialSegments: 32, + material: metal, + }, + ] + } + + return { + id, + label: '90 degree pipe elbow', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'pipe_elbow', + units: 'm', + coordinateConvention: 'elbow arc lies in X/Y plane; +Y up', + expectedDimensions: { + diameter: numberValue(params.nominalDiameter, params.diameter, params.boreDiameter), + bendRadius: numberValue(params.bendRadius, params.radius), + }, + requiredRoles: ['pipe_elbow_body', 'pipe_elbow_bore', 'elbow_inlet', 'elbow_outlet'], + validationTargets: [ + `${Math.round(numberValue(params.angle, 90) ?? 90)} degree elbow arc`, + 'visible wall thickness / bore shadow', + 'two aligned end collars', + ], + } + }, + } +} + +function hexBoltRecipe(): PrimitiveRecipeDefinition { + const id = 'fastener.hexBolt' as PrimitiveRecipeId + const aliases = ['hex bolt', 'hex head bolt', 'standard bolt', 'bolt', '螺栓', '六角螺栓'] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const diameter = clampNumber( + numberValue(params.nominalDiameter, params.shaftDiameter, params.diameter), + 0.016 * scale, + 0.004, + 0.12, + ) + const radius = diameter / 2 + const shankLength = clampNumber( + numberValue(params.shankLength, params.length, params.height), + diameter * 5, + diameter * 1.5, + diameter * 18, + ) + const threadLength = clampNumber( + numberValue(params.threadLength), + shankLength * 0.45, + diameter * 0.8, + shankLength, + ) + const headHeight = clampNumber( + numberValue(params.headHeight), + diameter * 0.62, + diameter * 0.28, + diameter * 1.1, + ) + const headRadius = + clampNumber( + numberValue(params.headDiameter), + diameter * 1.55, + diameter * 1.2, + diameter * 2.4, + ) / 2 + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'standard hex bolt') + const metal = { + properties: { color: colorFor(params, '#cbd5e1'), roughness: 0.28, metalness: 0.9 }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#475569') ?? '#475569', + roughness: 0.42, + metalness: 0.72, + }, + } + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${name} cylindrical shank`, + semanticRole: 'bolt_shank', + position: [origin[0], origin[1] + shankLength / 2, origin[2]], + axis: 'y', + radius, + height: shankLength, + radialSegments: 32, + material: metal, + }, + { + kind: 'cylinder', + name: `${name} hex head`, + semanticRole: 'hex_head', + position: [origin[0], origin[1] + shankLength + headHeight / 2, origin[2]], + axis: 'y', + radius: headRadius, + height: headHeight, + radialSegments: 6, + material: metal, + }, + { + kind: 'cylinder', + name: `${name} circular head chamfer`, + semanticRole: 'head_chamfer', + position: [origin[0], origin[1] + shankLength + headHeight * 0.92, origin[2]], + axis: 'y', + radius: headRadius * 0.88, + height: Math.max(headHeight * 0.08, 0.001), + radialSegments: 6, + material: dark, + }, + ] + + const ringCount = Math.max( + 4, + Math.min(16, Math.round(threadLength / Math.max(diameter * 0.32, 0.002))), + ) + for (let index = 0; index < ringCount; index += 1) { + const y = origin[1] + diameter * 0.35 + (index / Math.max(ringCount - 1, 1)) * threadLength + shapes.push({ + kind: 'torus', + name: `${name} thread crest ${index + 1}`, + semanticRole: 'thread_crest', + position: [origin[0], y, origin[2]], + axis: 'y', + majorRadius: radius * 0.96, + tubeRadius: Math.max(radius * 0.06, 0.0006), + tubularSegments: 28, + radialSegments: 8, + material: dark, + }) + } + + return shapes + } + + return { + id, + label: 'Hex bolt', + aliases, + compose, + geometryBrief: (input) => input.geometryBrief ?? hexBoltBrief(input), + } +} + +function hexBoltBrief(input: ComposeRecipeInput): PrimitiveGeometryBrief { + const params = recipeParams(input) + return { + category: 'fastener', + units: 'm', + coordinateConvention: '+Y bolt axis; y=0 is threaded tip', + expectedDimensions: { + diameter: numberValue(params.nominalDiameter, params.shaftDiameter, params.diameter), + length: numberValue(params.shankLength, params.length, params.height), + headHeight: numberValue(params.headHeight), + }, + requiredRoles: ['bolt_shank', 'hex_head', 'thread_crest'], + validationTargets: [ + 'cylindrical shank', + 'six-sided hex head', + 'visible thread crests near the threaded end', + ], + } +} + +function pillowBlockBearingRecipe(): PrimitiveRecipeDefinition { + const id = 'bearing.pillowBlock' as PrimitiveRecipeId + const aliases = [ + 'pillow block bearing', + 'plummer block bearing', + 'bearing block', + 'mounted bearing', + '轴承座', + '带座轴承', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const shaftDiameter = clampNumber( + numberValue(params.shaftDiameter, params.nominalDiameter, params.boreDiameter), + 0.08 * scale, + 0.018, + 0.42, + ) + const boreRadius = shaftDiameter / 2 + const housingRadius = clampNumber( + numberValue(params.radius), + boreRadius * 2.1, + boreRadius * 1.55, + boreRadius * 3.2, + ) + const width = clampNumber( + numberValue(params.width, params.thickness), + shaftDiameter * 1.2, + shaftDiameter * 0.65, + shaftDiameter * 3.2, + ) + const baseLength = clampNumber( + numberValue(params.length), + housingRadius * 4.2, + housingRadius * 2.8, + housingRadius * 6.4, + ) + const baseWidth = clampNumber( + numberValue(params.depth), + width * 1.45, + width * 1.05, + width * 2.4, + ) + const baseHeight = clampNumber( + numberValue(params.height), + housingRadius * 0.55, + housingRadius * 0.28, + housingRadius * 0.9, + ) + const boltSpacing = clampNumber( + numberValue(params.boltSpacing), + baseLength * 0.62, + housingRadius * 1.8, + baseLength * 0.82, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'pillow block bearing') + const body = { + properties: { color: colorFor(params, '#475569'), roughness: 0.42, metalness: 0.68 }, + } + const metal = { + properties: { + color: stringValue(params.metalColor, '#cbd5e1') ?? '#cbd5e1', + roughness: 0.28, + metalness: 0.88, + }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#0f172a') ?? '#0f172a', + roughness: 0.58, + metalness: 0.24, + }, + } + const y = origin[1] + baseHeight + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${name} foot base`, + semanticRole: 'pillow_block_base', + position: [origin[0], origin[1] + baseHeight / 2, origin[2]], + length: baseLength, + width: baseWidth, + height: baseHeight, + material: body, + }, + { + kind: 'hollow-cylinder', + name: `${name} arched bearing housing`, + semanticRole: 'bearing_housing', + position: [origin[0], y + housingRadius * 0.7, origin[2]], + axis: 'x', + radius: housingRadius, + height: width, + wallThickness: Math.max(housingRadius - boreRadius, 0.006), + radialSegments: 48, + material: body, + }, + { + kind: 'hollow-cylinder', + name: `${name} shiny bearing insert`, + semanticRole: 'bearing_insert', + position: [origin[0], y + housingRadius * 0.7, origin[2]], + axis: 'x', + radius: boreRadius * 1.45, + height: width * 1.05, + wallThickness: Math.max(boreRadius * 0.45, 0.004), + radialSegments: 48, + material: metal, + }, + { + kind: 'cylinder', + name: `${name} dark shaft bore`, + semanticRole: 'bearing_bore', + position: [origin[0] + width * 0.54, y + housingRadius * 0.7, origin[2]], + axis: 'x', + radius: boreRadius, + height: Math.max(width * 0.05, 0.003), + radialSegments: 32, + material: dark, + }, + { + kind: 'cylinder', + name: `${name} grease nipple`, + semanticRole: 'grease_nipple', + position: [origin[0], y + housingRadius * 1.72, origin[2]], + axis: 'y', + radius: boreRadius * 0.16, + height: boreRadius * 0.42, + radialSegments: 16, + material: metal, + }, + ] + + for (const x of [-boltSpacing / 2, boltSpacing / 2]) { + shapes.push({ + kind: 'cylinder', + name: `${name} mounting bolt hole ${x < 0 ? 'left' : 'right'}`, + semanticRole: 'mounting_hole', + position: [origin[0] + x, origin[1] + baseHeight + 0.001, origin[2]], + axis: 'y', + radius: boreRadius * 0.34, + height: Math.max(baseHeight * 0.08, 0.004), + radialSegments: 24, + material: dark, + }) + } + + return shapes + } + + return { + id, + label: 'Pillow block bearing', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'pillow_block_bearing', + units: 'm', + coordinateConvention: '+X shaft axis, +Y up; y=0 is base bottom', + expectedDimensions: { + shaftDiameter: numberValue( + params.shaftDiameter, + params.nominalDiameter, + params.boreDiameter, + ), + length: numberValue(params.length), + width: numberValue(params.width, params.thickness), + }, + requiredRoles: [ + 'pillow_block_base', + 'bearing_housing', + 'bearing_insert', + 'bearing_bore', + 'mounting_hole', + ], + validationTargets: [ + 'base foot with two mounting holes', + 'arched bearing housing', + 'concentric bearing insert and shaft bore', + ], + } + }, + } +} + +function flexibleCouplingRecipe(): PrimitiveRecipeDefinition { + const id = 'coupling.flexible' as PrimitiveRecipeId + const aliases = [ + 'flexible coupling', + 'jaw coupling', + 'shaft coupling', + 'motor coupling', + '联轴器', + '弹性联轴器', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const shaftDiameter = clampNumber( + numberValue(params.shaftDiameter, params.nominalDiameter, params.boreDiameter), + 0.06 * scale, + 0.012, + 0.34, + ) + const boreRadius = shaftDiameter / 2 + const outerRadius = + clampNumber( + numberValue(params.outerDiameter, params.diameter), + shaftDiameter * 1.85, + shaftDiameter * 1.25, + shaftDiameter * 3.4, + ) / 2 + const length = clampNumber( + numberValue(params.length), + shaftDiameter * 3.6, + shaftDiameter * 2.1, + shaftDiameter * 8, + ) + const jawCount = integerValue(numberValue(params.jawCount, params.teeth), 6, 3, 12) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'flexible jaw coupling') + const hubMat = { + properties: { color: colorFor(params, '#64748b'), roughness: 0.34, metalness: 0.86 }, + } + const elastomer = { + properties: { + color: stringValue(params.accentColor, '#f97316') ?? '#f97316', + roughness: 0.7, + metalness: 0.02, + }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#111827') ?? '#111827', + roughness: 0.56, + metalness: 0.24, + }, + } + const hubLength = length * 0.42 + const gap = length * 0.08 + const y = origin[1] + outerRadius + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: `${name} left hub`, + semanticRole: 'coupling_hub_left', + position: [origin[0] - (hubLength + gap) / 2, y, origin[2]], + axis: 'x', + radius: outerRadius, + height: hubLength, + wallThickness: Math.max(outerRadius - boreRadius, 0.004), + radialSegments: 40, + material: hubMat, + }, + { + kind: 'hollow-cylinder', + name: `${name} right hub`, + semanticRole: 'coupling_hub_right', + position: [origin[0] + (hubLength + gap) / 2, y, origin[2]], + axis: 'x', + radius: outerRadius, + height: hubLength, + wallThickness: Math.max(outerRadius - boreRadius, 0.004), + radialSegments: 40, + material: hubMat, + }, + { + kind: 'cylinder', + name: `${name} elastomer spider insert`, + semanticRole: 'elastomer_spider', + position: [origin[0], y, origin[2]], + axis: 'x', + radius: outerRadius * 0.82, + height: gap * 1.35, + radialSegments: jawCount, + material: elastomer, + }, + ] + + for (const x of [origin[0] - length * 0.31, origin[0] + length * 0.31]) { + shapes.push({ + kind: 'cylinder', + name: `${name} dark shaft bore ${x < origin[0] ? 'left' : 'right'}`, + semanticRole: 'coupling_bore', + position: [x, y, origin[2]], + axis: 'x', + radius: boreRadius, + height: Math.max(length * 0.025, 0.004), + radialSegments: 28, + material: dark, + }) + } + + for (let index = 0; index < 2; index += 1) { + const x = index === 0 ? origin[0] - length * 0.28 : origin[0] + length * 0.28 + shapes.push({ + kind: 'cylinder', + name: `${name} radial set screw ${index + 1}`, + semanticRole: 'set_screw', + position: [x, y + outerRadius * 0.86, origin[2]], + axis: 'y', + radius: outerRadius * 0.08, + height: outerRadius * 0.24, + radialSegments: 16, + material: dark, + }) + } + + return shapes + } + + return { + id, + label: 'Flexible shaft coupling', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'shaft_coupling', + units: 'm', + coordinateConvention: '+X shaft axis; +Y up; y=0 touches floor', + expectedDimensions: { + shaftDiameter: numberValue( + params.shaftDiameter, + params.nominalDiameter, + params.boreDiameter, + ), + outerDiameter: numberValue(params.outerDiameter, params.diameter), + length: numberValue(params.length), + }, + requiredRoles: [ + 'coupling_hub_left', + 'coupling_hub_right', + 'elastomer_spider', + 'coupling_bore', + ], + validationTargets: [ + 'two coaxial metal hubs', + 'central elastomer spider insert', + 'visible bores and set screws', + ], + } + }, + } +} + +function perforatedPlateRecipe(): PrimitiveRecipeDefinition { + const id = 'plate.perforated' as PrimitiveRecipeId + const aliases = [ + 'perforated plate', + 'sieve plate', + 'screen plate', + 'filter plate', + '孔板', + '筛板', + '篩板', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const length = clampNumber(numberValue(params.length), 0.8 * scale, 0.12, 4) + const width = clampNumber(numberValue(params.width), 0.42 * scale, 0.08, 2.4) + const thickness = clampNumber( + numberValue(params.thickness, params.height), + 0.025 * scale, + 0.004, + 0.18, + ) + const rows = integerValue(numberValue(params.rows), 4, 1, 12) + const columns = integerValue(numberValue(params.columns, params.holeCount), 8, 1, 24) + const holeDiameter = clampNumber( + numberValue(params.holeDiameter, params.boreDiameter, params.nominalDiameter), + Math.min(length / (columns + 1), width / (rows + 1)) * 0.36, + 0.006, + Math.min(length / (columns + 1), width / (rows + 1)) * 0.72, + ) + const holeRadius = holeDiameter / 2 + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'perforated process plate') + const plateMat = { + properties: { color: colorFor(params, '#94a3b8'), roughness: 0.36, metalness: 0.76 }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#0f172a') ?? '#0f172a', + roughness: 0.58, + metalness: 0.2, + }, + } + const profile: [number, number][] = [ + [-length / 2, -width / 2], + [length / 2, -width / 2], + [length / 2, width / 2], + [-length / 2, width / 2], + ] + const holes: [number, number][][] = [] + const holeCenters: [number, number][] = [] + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const x = -length / 2 + ((column + 1) / (columns + 1)) * length + const z = -width / 2 + ((row + 1) / (rows + 1)) * width + holes.push(circleProfile(holeRadius, 20).map(([hx, hz]) => [x + hx, z + hz])) + holeCenters.push([x, z]) + } + } + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'extrude', + name: `${name} perforated plate body`, + semanticRole: 'perforated_plate', + position: [origin[0], origin[1] + thickness / 2, origin[2]], + rotation: [Math.PI / 2, 0, 0], + profile, + holes, + depth: thickness, + bevelSize: Math.min(thickness * 0.08, 0.004), + bevelThickness: Math.min(thickness * 0.08, 0.004), + bevelSegments: 1, + material: plateMat, + }, + ] + + for (const [x, z] of holeCenters.slice(0, 48)) { + shapes.push({ + kind: 'cylinder', + name: `${name} dark perforation`, + semanticRole: 'perforation_hole', + position: [origin[0] + x, origin[1] + thickness + 0.001, origin[2] + z], + axis: 'y', + radius: holeRadius, + height: Math.max(thickness * 0.05, 0.002), + radialSegments: 16, + material: dark, + }) + } + + return shapes + } + + return { + id, + label: 'Perforated plate', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + const rows = integerValue(numberValue(params.rows), 4, 1, 12) + const columns = integerValue(numberValue(params.columns, params.holeCount), 8, 1, 24) + return { + category: 'perforated_plate', + units: 'm', + coordinateConvention: 'plate lies in X/Z plane with +Y thickness', + expectedDimensions: { + length: numberValue(params.length), + width: numberValue(params.width), + thickness: numberValue(params.thickness, params.height), + }, + requiredRoles: ['perforated_plate', 'perforation_hole'], + validationTargets: [ + `${rows} by ${columns} regular hole grid`, + 'single plate body with actual circular cutouts', + 'dark visible perforation interiors', + ], + } + }, + } +} + +function composeValveRecipePrimitives( + input: ComposeRecipeInput, + kind: 'gate' | 'ball', +): PrimitiveShapeInput[] { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const length = clampNumber(numberValue(params.length), 0.7 * scale, 0.22, 2.4) + const bodyRadius = clampNumber( + numberValue(params.radius, params.height), + 0.14 * scale, + 0.045, + 0.6, + ) + const flangeRadius = clampNumber( + numberValue(params.outerDiameter), + bodyRadius * 1.45, + bodyRadius, + 1, + ) + const flangeThickness = clampNumber(numberValue(params.thickness), length * 0.07, 0.012, 0.18) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, kind === 'ball' ? 'ball valve' : 'gate valve') + const bodyColor = colorFor(params, '#64748b') + const darkColor = stringValue(params.darkColor, '#1f2937') ?? '#1f2937' + const metalColor = stringValue(params.metalColor, '#cbd5e1') ?? '#cbd5e1' + const body = { properties: { color: bodyColor, roughness: 0.42, metalness: 0.58 } } + const dark = { properties: { color: darkColor, roughness: 0.55, metalness: 0.25 } } + const metal = { properties: { color: metalColor, roughness: 0.3, metalness: 0.82 } } + const centerY = origin[1] + bodyRadius + const leftX = origin[0] - length / 2 + const rightX = origin[0] + length / 2 + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${name} main valve body`, + semanticRole: 'valve_body', + sourcePartKind: 'valve_body', + position: [origin[0], centerY, origin[2]], + axis: 'x', + radius: bodyRadius, + height: length, + radialSegments: 40, + material: body, + }, + { + kind: 'hollow-cylinder', + name: `${name} inlet flange`, + semanticRole: 'flange_inlet', + sourcePartKind: 'flange_ring', + position: [leftX - flangeThickness / 2, centerY, origin[2]], + axis: 'x', + radius: flangeRadius, + height: flangeThickness, + wallThickness: Math.max(flangeRadius - bodyRadius * 0.62, 0.006), + radialSegments: 48, + material: metal, + }, + { + kind: 'hollow-cylinder', + name: `${name} outlet flange`, + semanticRole: 'flange_outlet', + sourcePartKind: 'flange_ring', + position: [rightX + flangeThickness / 2, centerY, origin[2]], + axis: 'x', + radius: flangeRadius, + height: flangeThickness, + wallThickness: Math.max(flangeRadius - bodyRadius * 0.62, 0.006), + radialSegments: 48, + material: metal, + }, + { + kind: 'cylinder', + name: `${name} vertical stem`, + semanticRole: 'stem', + sourcePartKind: 'handwheel', + position: [origin[0], centerY + bodyRadius * 1.25, origin[2]], + axis: 'y', + radius: bodyRadius * 0.12, + height: bodyRadius * 1.5, + radialSegments: 24, + material: metal, + }, + ] + + if (kind === 'ball') { + shapes.push( + { + kind: 'sphere', + name: `${name} visible ball core`, + semanticRole: 'valve_ball', + sourcePartKind: 'valve_body', + position: [origin[0], centerY, origin[2]], + radius: bodyRadius * 0.62, + widthSegments: 32, + heightSegments: 16, + material: metal, + }, + { + kind: 'cylinder', + name: `${name} dark through bore`, + semanticRole: 'valve_bore', + sourcePartKind: 'valve_body', + position: [origin[0], centerY, origin[2]], + axis: 'x', + radius: bodyRadius * 0.25, + height: length * 0.72, + radialSegments: 24, + material: dark, + }, + { + kind: 'torus', + name: `${name} left seat ring`, + semanticRole: 'seat_ring', + sourcePartKind: 'valve_body', + position: [origin[0] - bodyRadius * 0.48, centerY, origin[2]], + axis: 'x', + majorRadius: bodyRadius * 0.38, + tubeRadius: bodyRadius * 0.035, + radialSegments: 10, + tubularSegments: 36, + material: dark, + }, + { + kind: 'torus', + name: `${name} right seat ring`, + semanticRole: 'seat_ring', + sourcePartKind: 'valve_body', + position: [origin[0] + bodyRadius * 0.48, centerY, origin[2]], + axis: 'x', + majorRadius: bodyRadius * 0.38, + tubeRadius: bodyRadius * 0.035, + radialSegments: 10, + tubularSegments: 36, + material: dark, + }, + { + kind: 'box', + name: `${name} quarter-turn lever handle`, + semanticRole: 'lever_handle', + sourcePartKind: 'handwheel', + position: [origin[0] + bodyRadius * 0.55, centerY + bodyRadius * 2.05, origin[2]], + length: bodyRadius * 1.7, + width: bodyRadius * 0.12, + height: bodyRadius * 0.1, + material: metal, + }, + ) + } else { + shapes.push( + { + kind: 'cylinder', + name: `${name} bonnet`, + semanticRole: 'bonnet', + sourcePartKind: 'valve_body', + position: [origin[0], centerY + bodyRadius * 0.85, origin[2]], + axis: 'y', + radius: bodyRadius * 0.55, + height: bodyRadius * 0.7, + radialSegments: 32, + material: body, + }, + { + kind: 'box', + name: `${name} internal gate wedge`, + semanticRole: 'gate_wedge', + sourcePartKind: 'valve_body', + position: [origin[0], centerY - bodyRadius * 0.1, origin[2]], + length: bodyRadius * 0.35, + width: bodyRadius * 1.0, + height: bodyRadius * 0.9, + material: dark, + }, + { + kind: 'torus', + name: `${name} handwheel`, + semanticRole: 'handwheel', + sourcePartKind: 'handwheel', + position: [origin[0], centerY + bodyRadius * 2.18, origin[2]], + axis: 'y', + majorRadius: bodyRadius * 0.72, + tubeRadius: bodyRadius * 0.055, + radialSegments: 12, + tubularSegments: 48, + material: metal, + }, + { + kind: 'box', + name: `${name} yoke bridge`, + semanticRole: 'yoke', + sourcePartKind: 'handwheel', + position: [origin[0], centerY + bodyRadius * 1.7, origin[2]], + length: bodyRadius * 1.0, + width: bodyRadius * 0.12, + height: bodyRadius * 0.18, + material: metal, + }, + ) + for (let index = 0; index < 6; index += 1) { + const angle = (index / 6) * Math.PI * 2 + shapes.push({ + kind: 'cylinder', + name: `${name} bonnet bolt ${index + 1}`, + semanticRole: 'bonnet_bolts', + sourcePartKind: 'bolt_pattern', + position: [ + origin[0] + Math.cos(angle) * bodyRadius * 0.55, + centerY + bodyRadius * 1.22, + origin[2] + Math.sin(angle) * bodyRadius * 0.55, + ], + axis: 'y', + radius: bodyRadius * 0.035, + height: bodyRadius * 0.08, + radialSegments: 12, + material: metal, + }) + } + } + + return shapes +} + +function valveRecipe(kind: 'gate' | 'ball'): PrimitiveRecipeDefinition { + const id = `valve.${kind}` as PrimitiveRecipeId + const label = kind === 'ball' ? 'Ball valve' : 'Gate valve' + const aliases = + kind === 'ball' + ? ['ball valve', 'quarter turn valve', '\u7403\u9600'] + : ['valve', 'gate valve', 'industrial valve', '\u9600\u95e8', '\u95f8\u9600'] + + return { + id, + label, + aliases, + compose: (input) => composeValveRecipePrimitives(input, kind), + geometryBrief: (input) => input.geometryBrief ?? valveBrief(kind), + } +} + +function valveBrief(kind: 'gate' | 'ball'): PrimitiveGeometryBrief { + return { + category: 'valve', + units: 'm', + coordinateConvention: '+X inlet/outlet axis, +Y up, +Z width; y=0 is ground', + requiredRoles: + kind === 'ball' + ? ['flange_inlet', 'flange_outlet', 'valve_ball', 'valve_bore', 'seat_ring', 'stem'] + : ['flange_inlet', 'flange_outlet', 'bonnet', 'stem', 'gate_wedge', 'bonnet_bolts', 'yoke'], + validationTargets: + kind === 'ball' + ? ['flanged ends', 'visible valve ball/bore', 'quarter-turn lever'] + : ['flanged ends', 'bonnet/stem/yoke', 'gate wedge'], + } +} + +function robotArmThreeAxisRecipe(): PrimitiveRecipeDefinition { + const id = 'robotArm.threeAxis' + const aliases = [ + 'robot arm', + 'robotic arm', + 'manipulator', + '3-axis robot arm', + '机器臂', + '机械臂', + ] + const robotInput = (input: ComposeRecipeInput): RobotArmComposeInput => { + const params = recipeParams(input) + return { + name: nameFor(input, '3-axis robot arm'), + position: positionFor(params, input), + style: stringValue(params.style, 'industrial'), + pose: stringValue(params.pose, 'work-ready'), + axisCount: numberValue(params.axisCount, 3), + baseShape: stringValue(params.baseShape, 'round'), + endEffector: stringValue(params.endEffector, 'gripper'), + reach: numberValue(params.reach, params.length), + detail: stringValue(params.detail, params.highFidelity ? 'high' : 'medium'), + } + } + + return { + id, + label: '3-axis robot arm', + aliases, + compose: (input) => composeRobotArmPrimitives(robotInput(input)), + geometryBrief: (input) => input.geometryBrief ?? robotArmBrief(input), + } +} + +function robotArmBrief(input: ComposeRecipeInput): PrimitiveGeometryBrief { + const params = recipeParams(input) + return { + category: 'robot_arm', + units: 'm', + coordinateConvention: '+X right, +Y up, +Z forward; y=0 is ground', + expectedDimensions: { + length: numberValue(params.reach, params.length), + }, + requiredRoles: [ + 'robot_base', + 'base_joint', + 'shoulder_joint', + 'upper_arm', + 'elbow_joint', + 'forearm', + 'end_effector', + ], + validationTargets: ['readable base', 'three joint housings', 'separate upper arm and forearm'], + } +} + +function taijiHalfBladeProfile( + length: number, + rootWidth: number, + bladeWidth: number, + longitudinalCurve: number, + steps: number, +): [number, number][] { + const profile: [number, number][] = [] + const halfRoot = rootWidth * 0.5 + const maxHalfWidth = bladeWidth * 0.78 + const halfWidthAt = (t: number) => { + const bulb = Math.sin(Math.PI * t) ** 0.48 + const outerWeight = 0.72 + t * 0.28 + const rootNeck = halfRoot * (1 - t) ** 2.2 + return rootNeck + maxHalfWidth * bulb * outerWeight + } + const spineOffset = (t: number) => + longitudinalCurve * (Math.sin(Math.PI * (t - 0.06)) + 0.28 * Math.sin(Math.PI * 2 * t)) + const innerCut = (t: number) => longitudinalCurve * 0.72 * Math.sin(Math.PI * t) * (1 - t * 0.35) + + for (let step = 0; step <= steps; step += 1) { + const t = step / steps + const x = length * (t - 0.5) + profile.push([x, spineOffset(t) + halfWidthAt(t) * (0.92 + t * 0.18)]) + } + for (let step = steps; step >= 0; step -= 1) { + const t = step / steps + const x = length * (t - 0.5) + const width = halfWidthAt(t) + profile.push([x, spineOffset(t) - width * (0.5 + 0.32 * (1 - t)) + innerCut(t)]) + } + return profile +} + +function composeMixerBladeRecipeShapes(args: { + name: string + origin: Vec3 + bladeCenterY: number + bladeCount: number + bladeLength: number + bladeWidth: number + bladeThickness: number + bladeTilt: number + hubRadius: number + material: PrimitiveShapeInput['material'] + detail?: PrimitiveRecipeParams['detail'] +}): PrimitiveShapeInput[] { + const rootWidth = Math.max(args.bladeThickness * 1.05, args.hubRadius * 0.36) + const profile = taijiHalfBladeProfile( + args.bladeLength, + rootWidth, + args.bladeWidth, + args.bladeWidth * 0.38, + args.detail === 'low' ? 12 : 24, + ) + return Array.from({ length: args.bladeCount }, (_, index) => { + const angle = (index * Math.PI * 2) / args.bladeCount + return { + kind: 'extrude', + name: `${args.name} taiji half mixer propeller blade ${index + 1}`, + semanticRole: 'mixer_blade', + semanticGroup: 'mixer_blades', + sourcePartKind: 'mixer_blades', + position: [ + args.origin[0] + Math.cos(angle) * (args.hubRadius + args.bladeLength * 0.5), + args.bladeCenterY, + args.origin[2] + Math.sin(angle) * (args.hubRadius + args.bladeLength * 0.5), + ], + rotation: radialExtrudeRotationInHorizontalPlane(angle, args.bladeTilt * 0.55), + profile, + depth: args.bladeThickness, + bevelSize: args.bladeThickness * 0.12, + bevelThickness: args.bladeThickness * 0.16, + bevelSegments: 1, + curveSegments: 16, + material: args.material, + } satisfies PrimitiveShapeInput + }) +} + +function mixerImpellerRecipe(): PrimitiveRecipeDefinition { + const id = 'mixer.impeller' as PrimitiveRecipeId + const aliases = [ + 'mixer impeller', + 'mud mixer', + 'mixing paddle', + 'agitator paddle', + 'impeller', + '\u6ce5\u6d46\u6405\u62cc', + '\u6405\u62cc\u90e8\u4ef6', + '\u6405\u62cc\u53f6\u7247', + '\u53f6\u8f6e', + ] + + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const bladeCount = integerValue(numberValue(params.bladeCount), 3, 2, 8) + const sizeScale = sizeScaleFor(params) ?? 1 + const shaftDiameter = clampNumber( + numberValue(params.shaftDiameter), + 0.07 * sizeScale, + 0.025, + 0.2, + ) + const shaftRadius = shaftDiameter / 2 + const shaftLength = clampNumber( + numberValue(params.shaftLength, params.height), + 0.9 * sizeScale, + 0.25, + 2.4, + ) + const bladeLength = clampNumber( + numberValue(params.bladeLength, params.length), + 0.34 * sizeScale, + 0.08, + 1.2, + ) + const bladeWidth = clampNumber( + numberValue(params.bladeWidth, params.width), + 0.13 * sizeScale, + 0.04, + 0.45, + ) + const bladeThickness = clampNumber( + numberValue(params.bladeThickness, params.thickness), + 0.028 * sizeScale, + 0.01, + 0.09, + ) + const bladeTilt = clampNumber(numberValue(params.bladeTilt), 0, 0, 60) * (Math.PI / 180) + const hubRadius = clampNumber( + numberValue(params.hubRadius), + shaftRadius * 1.45, + shaftRadius, + shaftRadius * 3.2, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const shaftCenterY = origin[1] + shaftLength / 2 + const bladeCenterY = origin[1] + Math.max(bladeWidth * 0.42, shaftLength * 0.12) + const metal = { + properties: { + color: colorFor(params, '#9ca3af'), + roughness: 0.42, + metalness: 0.62, + }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#1f2937') ?? '#1f2937', + roughness: 0.58, + metalness: 0.25, + }, + } + + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${nameFor(input, 'mud mixer impeller')} vertical shaft`, + semanticRole: 'mixer_shaft', + sourcePartKind: 'mixer_shaft', + position: [origin[0], shaftCenterY, origin[2]], + axis: 'y', + radius: shaftRadius, + height: shaftLength, + radialSegments: 32, + material: metal, + }, + { + kind: 'cylinder', + name: `${nameFor(input, 'mud mixer impeller')} lower hub`, + semanticRole: 'mixer_hub', + sourcePartKind: 'mixer_hub', + position: [origin[0], bladeCenterY, origin[2]], + axis: 'y', + radius: hubRadius, + height: Math.max(bladeThickness * 1.6, shaftDiameter * 0.55), + radialSegments: 32, + material: metal, + }, + ] + + shapes.push( + ...composeMixerBladeRecipeShapes({ + name: nameFor(input, 'mud mixer impeller'), + origin, + bladeCenterY, + bladeCount, + bladeLength, + bladeWidth, + bladeThickness, + bladeTilt, + hubRadius, + material: { + properties: { + color: stringValue(params.accentColor, params.secondaryColor, '#64748b') ?? '#64748b', + roughness: 0.5, + metalness: 0.45, + }, + }, + detail: detailFor(params) ?? 'medium', + }), + ) + + if (detailFor(params) !== 'low') { + shapes.push({ + kind: 'torus', + name: `${nameFor(input, 'mud mixer impeller')} hub clamp ring`, + semanticRole: 'mixer_hub_ring', + sourcePartKind: 'mixer_hub', + position: [origin[0], bladeCenterY + bladeThickness * 0.7, origin[2]], + axis: 'y', + majorRadius: hubRadius * 0.82, + tubeRadius: Math.max(bladeThickness * 0.18, 0.006), + radialSegments: 12, + tubularSegments: 32, + material: dark, + }) + } + + return shapes + } + + return { + id, + label: 'Mud mixer impeller', + aliases, + compose, + geometryBrief: (input) => input.geometryBrief ?? mixerImpellerBrief(input), + } +} + +function mixerImpellerBrief(input: ComposeRecipeInput): PrimitiveGeometryBrief { + const params = recipeParams(input) + return { + category: 'mixer', + units: 'm', + coordinateConvention: + '+Y up along shaft; blades are radial in X/Z near the lower shaft end; y=0 is ground', + expectedDimensions: { + height: numberValue(params.shaftLength, params.height), + bladeLength: numberValue(params.bladeLength, params.length), + }, + requiredRoles: ['mixer_shaft', 'mixer_hub', 'mixer_blades'], + validationTargets: [ + `${integerValue(numberValue(params.bladeCount), 3, 2, 8)} evenly spaced broad rounded propeller blades`, + 'vertical shaft', + 'lower hub connecting blades to shaft', + 'top-down circular impeller outline with 120-degree spacing for three blades', + 'narrow blade roots at the hub, broad rounded blade bodies, and rounded tips instead of rectangular blocks', + ], + assumptions: [ + 'Default to 3 evenly spaced blades when count is omitted.', + 'Default visible blade tilt is moderated so the blades read as pitched but mostly horizontal impeller paddles.', + 'Blade geometry uses a taiji-half propeller profile: narrow root, broad rounded body, rounded tip, and a longitudinal spine curve.', + ], + } +} + +function servoMotorRecipe(): PrimitiveRecipeDefinition { + const id = 'motor.servo' as PrimitiveRecipeId + const aliases = [ + 'servo motor', + 'industrial servo motor', + 'ac servo', + 'servo drive motor', + 'servo', + '\u4f3a\u670d\u7535\u673a', + '\u4f3a\u670d\u9a6c\u8fbe', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const length = clampNumber(numberValue(params.length), 0.72 * scale, 0.28, 1.8) + const bodyRadius = clampNumber( + numberValue(params.radius, params.height, params.width), + 0.18 * scale, + 0.07, + 0.46, + ) + const bodyLength = length * 0.58 + const flangeThickness = Math.max(length * 0.055, 0.025 * scale) + const encoderLength = Math.max(length * 0.12, 0.055 * scale) + const shaftDiameter = numberValue(params.shaftDiameter) + const shaftRadius = clampNumber( + shaftDiameter != null ? shaftDiameter / 2 : undefined, + bodyRadius * 0.18, + bodyRadius * 0.08, + bodyRadius * 0.32, + ) + const shaftLength = clampNumber( + numberValue(params.shaftLength), + length * 0.22, + length * 0.08, + length * 0.42, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'servo motor') + const bodyColor = colorFor(params, '#64748b') + const darkColor = stringValue(params.darkColor, '#111827') ?? '#111827' + const metalColor = stringValue(params.metalColor, '#cbd5e1') ?? '#cbd5e1' + const accentColor = stringValue(params.accentColor, '#f59e0b') ?? '#f59e0b' + const body = { properties: { color: bodyColor, roughness: 0.45, metalness: 0.5 } } + const dark = { properties: { color: darkColor, roughness: 0.58, metalness: 0.28 } } + const metal = { properties: { color: metalColor, roughness: 0.3, metalness: 0.82 } } + const accent = { properties: { color: accentColor, roughness: 0.34, metalness: 0.35 } } + const y = origin[1] + bodyRadius + const frontX = origin[0] + bodyLength / 2 + const rearX = origin[0] - bodyLength / 2 + const boltOffset = bodyRadius * 0.72 + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${name} ribbed cylindrical servo body`, + semanticRole: 'servo_body', + position: [origin[0], y, origin[2]], + axis: 'x', + radius: bodyRadius, + height: bodyLength, + radialSegments: 40, + material: body, + }, + { + kind: 'cylinder', + name: `${name} square front mounting flange`, + semanticRole: 'front_flange', + position: [frontX + flangeThickness / 2, y, origin[2]], + axis: 'x', + radius: bodyRadius * 1.24, + height: flangeThickness, + radialSegments: 4, + rotation: [0, 0, Math.PI / 4], + material: dark, + }, + { + kind: 'cylinder', + name: `${name} output shaft`, + semanticRole: 'output_shaft', + position: [frontX + flangeThickness + shaftLength / 2, y, origin[2]], + axis: 'x', + radius: shaftRadius, + height: shaftLength, + radialSegments: 32, + material: metal, + }, + { + kind: 'cylinder', + name: `${name} rear encoder cap`, + semanticRole: 'encoder_cap', + position: [rearX - encoderLength / 2, y, origin[2]], + axis: 'x', + radius: bodyRadius * 0.82, + height: encoderLength, + radialSegments: 36, + material: dark, + }, + { + kind: 'box', + name: `${name} top terminal box`, + semanticRole: 'terminal_box', + position: [origin[0] + bodyLength * 0.05, y + bodyRadius * 0.82, origin[2]], + length: bodyLength * 0.42, + width: bodyRadius * 0.62, + height: bodyRadius * 0.36, + cornerRadius: bodyRadius * 0.08, + material: dark, + }, + { + kind: 'rounded-panel', + name: `${name} side rating nameplate`, + semanticRole: 'nameplate', + position: [ + origin[0] + bodyLength * 0.06, + y - bodyRadius * 0.1, + origin[2] + bodyRadius * 1.01, + ], + length: bodyLength * 0.28, + width: bodyRadius * 0.035, + height: bodyRadius * 0.22, + cornerRadius: bodyRadius * 0.025, + material: accent, + }, + { + kind: 'cylinder', + name: `${name} cable gland`, + semanticRole: 'cable_gland', + position: [origin[0] + bodyLength * 0.22, y + bodyRadius * 1.06, origin[2]], + axis: 'y', + radius: bodyRadius * 0.12, + height: bodyRadius * 0.28, + radialSegments: 20, + material: metal, + }, + ] + + for (let index = 0; index < 6; index += 1) { + shapes.push({ + kind: 'torus', + name: `${name} cooling fin ${index + 1}`, + semanticRole: 'cooling_fin', + position: [rearX + bodyLength * (0.16 + index * 0.11), y, origin[2]], + axis: 'x', + majorRadius: bodyRadius * 0.99, + tubeRadius: bodyRadius * 0.018, + radialSegments: 10, + tubularSegments: 36, + material: metal, + }) + } + + for (const [dy, dz] of [ + [boltOffset, boltOffset], + [boltOffset, -boltOffset], + [-boltOffset, boltOffset], + [-boltOffset, -boltOffset], + ] as const) { + shapes.push({ + kind: 'cylinder', + name: `${name} flange mounting bolt`, + semanticRole: 'flange_bolt', + position: [frontX + flangeThickness + bodyRadius * 0.012, y + dy, origin[2] + dz], + axis: 'x', + radius: bodyRadius * 0.045, + height: bodyRadius * 0.04, + radialSegments: 16, + material: metal, + }) + } + + return shapes + } + + return { + id, + label: 'Servo motor', + aliases, + compose, + geometryBrief: (input) => input.geometryBrief ?? servoMotorBrief(input), + } +} + +function servoMotorBrief(input: ComposeRecipeInput): PrimitiveGeometryBrief { + const params = recipeParams(input) + return { + category: 'motor', + units: 'm', + coordinateConvention: '+X motor axis/output shaft direction, +Y up, y=0 is floor', + expectedDimensions: { + length: numberValue(params.length), + radius: numberValue(params.radius, params.height, params.width), + }, + requiredRoles: [ + 'servo_body', + 'front_flange', + 'output_shaft', + 'encoder_cap', + 'terminal_box', + 'nameplate', + ], + validationTargets: [ + 'ribbed cylindrical servo body', + 'square front mounting flange with four bolts', + 'projecting output shaft', + 'rear encoder cap', + 'terminal box, cable gland, and nameplate', + ], + } +} + +function recipeAxis(value: unknown, fallback: 'x' | 'y' | 'z'): 'x' | 'y' | 'z' { + return value === 'x' || value === 'y' || value === 'z' ? value : fallback +} + +function vesselShellRecipe(): PrimitiveRecipeDefinition { + const id = 'process.vesselShell' as PrimitiveRecipeId + const aliases = [ + 'vessel shell', + 'hollow vessel', + 'pressure vessel shell', + 'process vessel shell', + 'tank shell', + '\u7a7a\u5fc3\u7f50\u4f53', + '\u53cd\u5e94\u91dc\u7b52\u4f53', + '\u538b\u529b\u5bb9\u5668\u58f3\u4f53', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const axis = recipeAxis(params.axis, 'x') + const length = clampNumber(numberValue(params.length, params.height), 1.2 * scale, 0.24, 8) + const radius = clampNumber(numberValue(params.radius, params.diameter), 0.28 * scale, 0.04, 2.4) + const wallThickness = clampNumber( + numberValue(params.wallThickness, params.thickness), + radius * 0.08, + radius * 0.025, + radius * 0.28, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const center: Vec3 = axis === 'y' ? [origin[0], origin[1] + length / 2, origin[2]] : origin + const name = nameFor(input, 'process vessel shell') + const shell = { + properties: { color: colorFor(params, '#94a3b8'), roughness: 0.42, metalness: 0.48 }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#1f2937') ?? '#1f2937', + roughness: 0.58, + metalness: 0.26, + }, + } + const metal = { + properties: { + color: stringValue(params.metalColor, '#cbd5e1') ?? '#cbd5e1', + roughness: 0.28, + metalness: 0.78, + }, + } + const headScale = + axis === 'x' + ? ([radius * 0.34, radius, radius] as Vec3) + : ([radius, radius * 0.34, radius] as Vec3) + const sideA = + axis === 'y' + ? [center[0], center[1] - length * 0.52, center[2]] + : [center[0] - length * 0.52, center[1], center[2]] + const sideB = + axis === 'y' + ? [center[0], center[1] + length * 0.52, center[2]] + : [center[0] + length * 0.52, center[1], center[2]] + return [ + { + kind: 'hollow-cylinder', + name: `${name} hollow shell`, + semanticRole: 'vessel_shell', + position: center, + axis, + radius, + height: length, + wallThickness, + radialSegments: 56, + material: shell, + }, + { + kind: 'sphere', + name: `${name} dished head A`, + semanticRole: 'vessel_head', + position: sideA as Vec3, + radius: 1, + scale: headScale, + widthSegments: 56, + heightSegments: 24, + material: shell, + }, + { + kind: 'sphere', + name: `${name} dished head B`, + semanticRole: 'vessel_head', + position: sideB as Vec3, + radius: 1, + scale: headScale, + widthSegments: 56, + heightSegments: 24, + material: shell, + }, + { + kind: 'torus', + name: `${name} front seam ring`, + semanticRole: 'vessel_seam', + position: sideA as Vec3, + axis, + majorRadius: radius * 1.01, + tubeRadius: wallThickness * 0.42, + radialSegments: 10, + tubularSegments: 48, + material: metal, + }, + { + kind: 'torus', + name: `${name} rear seam ring`, + semanticRole: 'vessel_seam', + position: sideB as Vec3, + axis, + majorRadius: radius * 1.01, + tubeRadius: wallThickness * 0.42, + radialSegments: 10, + tubularSegments: 48, + material: metal, + }, + { + kind: 'hollow-cylinder', + name: `${name} top nozzle`, + semanticRole: 'top_nozzle', + position: + axis === 'y' + ? [center[0] + radius * 0.92, center[1] + length * 0.18, center[2]] + : [center[0], center[1] + radius * 1.1, center[2]], + axis: axis === 'y' ? 'x' : 'y', + radius: radius * 0.16, + height: radius * 0.55, + wallThickness: wallThickness * 0.7, + radialSegments: 28, + material: shell, + }, + { + kind: 'cylinder', + name: `${name} manway flange`, + semanticRole: 'manway_flange', + position: + axis === 'y' + ? [center[0] + radius * 1.08, center[1] - length * 0.16, center[2]] + : [center[0] - length * 0.18, center[1], center[2] + radius * 1.08], + axis: axis === 'y' ? 'x' : 'z', + radius: radius * 0.22, + height: wallThickness * 2.8, + radialSegments: 32, + material: dark, + }, + ] + } + + return { + id, + label: 'Process vessel shell', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'process_vessel_shell', + units: 'm', + coordinateConvention: 'axis may be x or y; vessel uses hollow shell plus dished heads', + expectedDimensions: { + length: numberValue(params.length, params.height), + radius: numberValue(params.radius, params.diameter), + wallThickness: numberValue(params.wallThickness, params.thickness), + }, + requiredRoles: ['vessel_shell', 'vessel_head', 'vessel_seam', 'top_nozzle'], + validationTargets: [ + 'hollow cylindrical vessel shell', + 'two dished heads', + 'visible seam rings, nozzle, and manway flange', + ], + } + }, + } +} + +function platformLadderRecipe(): PrimitiveRecipeDefinition { + const id = 'structure.platformLadder' as PrimitiveRecipeId + const aliases = [ + 'industrial platform ladder', + 'access platform ladder', + 'platform with guardrail', + '\u68c0\u4fee\u5e73\u53f0\u722c\u68af', + '\u5de5\u4e1a\u5e73\u53f0\u62a4\u680f', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const length = clampNumber(numberValue(params.length), 0.9 * scale, 0.24, 4) + const width = clampNumber(numberValue(params.width), 0.52 * scale, 0.16, 2.4) + const height = clampNumber(numberValue(params.height), 1.2 * scale, 0.32, 5) + const railHeight = clampNumber(numberValue(params.railHeight), height * 0.34, 0.12, 1.2) + const rungCount = integerValue(numberValue(params.rungCount, params.count), 6, 3, 18) + const r = clampNumber(numberValue(params.radius, params.thickness), 0.018 * scale, 0.004, 0.08) + const origin = positionFor(params, input) ?? [0, 0, 0] + const name = nameFor(input, 'industrial access platform') + const steel = { + properties: { color: colorFor(params, '#94a3b8'), roughness: 0.34, metalness: 0.72 }, + } + const deckY = origin[1] + height + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: `${name} grated deck`, + semanticRole: 'platform_deck', + position: [origin[0], deckY, origin[2]], + length, + width, + height: r * 1.2, + material: steel, + }, + ] + for (const x of [-length / 2, length / 2]) { + for (const z of [-width / 2, width / 2]) { + shapes.push({ + kind: 'cylinder', + name: `${name} platform post`, + semanticRole: 'platform_post', + position: [origin[0] + x, origin[1] + height / 2, origin[2] + z], + axis: 'y', + radius: r, + height, + radialSegments: 12, + material: steel, + }) + } + } + for (const z of [-width / 2, width / 2]) { + shapes.push({ + kind: 'cylinder', + name: `${name} guard rail`, + semanticRole: 'guard_rail', + position: [origin[0], deckY + railHeight, origin[2] + z], + axis: 'x', + radius: r, + height: length, + radialSegments: 12, + material: steel, + }) + } + const ladderX = origin[0] - length * 0.58 + for (const z of [origin[2] - width * 0.36, origin[2] - width * 0.18]) { + shapes.push({ + kind: 'cylinder', + name: `${name} ladder side rail`, + semanticRole: 'ladder_side_rail', + position: [ladderX, origin[1] + height / 2, z], + axis: 'y', + radius: r, + height, + radialSegments: 10, + material: steel, + }) + } + for (let i = 0; i < rungCount; i += 1) { + shapes.push({ + kind: 'cylinder', + name: `${name} ladder rung ${i + 1}`, + semanticRole: 'ladder_rung', + position: [ + ladderX, + origin[1] + ((i + 1) / (rungCount + 1)) * height, + origin[2] - width * 0.27, + ], + axis: 'z', + radius: r * 0.65, + height: width * 0.18, + radialSegments: 10, + material: steel, + }) + } + return shapes + } + + return { + id, + label: 'Platform ladder', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'industrial_access_platform', + units: 'm', + coordinateConvention: '+Y up; platform deck at requested height', + expectedDimensions: { + length: numberValue(params.length), + width: numberValue(params.width), + height: numberValue(params.height), + }, + requiredRoles: ['platform_deck', 'platform_post', 'guard_rail', 'ladder_rung'], + validationTargets: ['deck, posts, guard rails, ladder side rails, and rungs'], + } + }, + } +} + +function roundedBoxEnclosureRecipe(): PrimitiveRecipeDefinition { + const id = 'enclosure.roundedBox' as PrimitiveRecipeId + const aliases = [ + 'rounded machine enclosure', + 'rounded box enclosure', + 'machine cabinet enclosure', + 'cabinet with door and window', + '\u5706\u89d2\u7bb1\u4f53', + '\u8bbe\u5907\u5916\u58f3', + ] + const compose = (input: ComposeRecipeInput): PrimitiveShapeInput[] => { + const params = recipeParams(input) + const scale = sizeScaleFor(params) ?? 1 + const length = clampNumber(numberValue(params.length), 1.2 * scale, 0.18, 5) + const width = clampNumber(numberValue(params.width, params.depth), 0.52 * scale, 0.12, 2.4) + const height = clampNumber(numberValue(params.height), 0.82 * scale, 0.18, 3) + const cornerRadius = clampNumber( + numberValue(params.cornerRadius), + Math.min(length, width, height) * 0.06, + 0, + Math.min(length, width, height) * 0.24, + ) + const origin = positionFor(params, input) ?? [0, 0, 0] + const center: Vec3 = [origin[0], origin[1] + height / 2, origin[2]] + const name = nameFor(input, 'rounded machine enclosure') + const body = { + properties: { color: colorFor(params, '#64748b'), roughness: 0.46, metalness: 0.34 }, + } + const dark = { + properties: { + color: stringValue(params.darkColor, '#111827') ?? '#111827', + roughness: 0.58, + metalness: 0.18, + }, + } + const glass = { + preset: 'glass', + properties: { + color: stringValue(params.accentColor, '#38bdf8') ?? '#38bdf8', + roughness: 0.12, + metalness: 0.02, + opacity: 0.42, + transparent: true, + }, + } + return [ + { + kind: 'box', + name: `${name} rounded enclosure body`, + semanticRole: 'machine_enclosure', + position: center, + length, + width, + height, + cornerRadius, + cornerSegments: 8, + material: body, + }, + { + kind: 'rounded-panel', + name: `${name} front door panel`, + semanticRole: 'access_door', + position: [center[0], center[1], center[2] + width * 0.515], + length: length * 0.76, + width: height * 0.68, + thickness: width * 0.035, + cornerRadius: cornerRadius * 0.65, + cornerSegments: 5, + material: body, + }, + { + kind: 'rounded-panel', + name: `${name} inspection window`, + semanticRole: 'viewing_window', + position: [center[0], center[1] + height * 0.12, center[2] + width * 0.545], + length: length * 0.44, + width: height * 0.22, + thickness: width * 0.024, + cornerRadius: cornerRadius * 0.45, + cornerSegments: 5, + material: glass, + }, + { + kind: 'capsule', + name: `${name} vertical handle`, + semanticRole: 'door_handle', + position: [center[0] + length * 0.32, center[1] - height * 0.04, center[2] + width * 0.56], + axis: 'y', + radius: Math.min(length, height) * 0.014, + height: height * 0.24, + radialSegments: 12, + material: dark, + }, + ] + } + + return { + id, + label: 'Rounded box enclosure', + aliases, + compose, + geometryBrief: (input) => { + const params = recipeParams(input) + return { + category: 'machine_enclosure', + units: 'm', + coordinateConvention: '+X length, +Y up, +Z depth/front', + expectedDimensions: { + length: numberValue(params.length), + width: numberValue(params.width, params.depth), + height: numberValue(params.height), + }, + requiredRoles: ['machine_enclosure', 'access_door', 'viewing_window', 'door_handle'], + validationTargets: ['rounded body, editable door panel, transparent window, handle'], + } + }, + } +} + +const PRIMITIVE_RECIPES: PrimitiveRecipeDefinition[] = [ + spurGearRecipe(), + chainSprocketRecipe(), + pipeFlangeRecipe(), + pipeElbow90Recipe(), + hexBoltRecipe(), + pillowBlockBearingRecipe(), + flexibleCouplingRecipe(), + perforatedPlateRecipe(), + valveRecipe('gate'), + valveRecipe('ball'), + robotArmThreeAxisRecipe(), + mixerImpellerRecipe(), + servoMotorRecipe(), + vesselShellRecipe(), + platformLadderRecipe(), + roundedBoxEnclosureRecipe(), +] + +export function listPrimitiveRecipes(): PrimitiveRecipeDefinition[] { + return PRIMITIVE_RECIPES +} + +export function findPrimitiveRecipe( + input: ComposeRecipeInput, +): PrimitiveRecipeDefinition | undefined { + const id = readRecipeId(input) + if (id) { + const mappedAlias = recipeIdAlias(id) + if (mappedAlias) { + return PRIMITIVE_RECIPES.find((recipe) => recipe.id === mappedAlias) + } + const exact = PRIMITIVE_RECIPES.find((recipe) => normalizeRecipeId(recipe.id) === id) + if (exact) return exact + const alias = PRIMITIVE_RECIPES.find((recipe) => recipeIdMatchesAlias(id, recipe)) + if (alias) return alias + } + + const text = textOf([ + input.name, + input.recipeId, + input.recipe, + input.id, + input.params, + input.geometryBrief, + ]) + return PRIMITIVE_RECIPES.map((recipe) => ({ + recipe, + matchLength: Math.max( + 0, + ...recipe.aliases + .filter((alias) => text.includes(alias.toLowerCase())) + .map((alias) => alias.length), + ), + })) + .sort((a, b) => b.matchLength - a.matchLength) + .find((match) => match.matchLength > 0)?.recipe +} + +export function getPrimitiveRecipeGeometryBrief( + input: ComposeRecipeInput = {}, +): PrimitiveGeometryBrief | undefined { + const { geometryBrief: _ignoredGeometryBrief, ...recipeInput } = input + return findPrimitiveRecipe(input)?.geometryBrief(recipeInput) +} + +export function composeRecipePrimitives(input: ComposeRecipeInput = {}): PrimitiveShapeInput[] { + return findPrimitiveRecipe(input)?.compose(input) ?? [] +} diff --git a/packages/core/src/lib/primitive-registry.test.ts b/packages/core/src/lib/primitive-registry.test.ts new file mode 100644 index 000000000..b595b9422 --- /dev/null +++ b/packages/core/src/lib/primitive-registry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { + lowerDerivedPrimitiveShape, + normalizePrimitiveKindFromRegistry, + primitiveCapabilitySummary, +} from './primitive-registry' + +describe('primitive registry', () => { + test('normalizes primitive aliases and advertises derived primitives', () => { + expect(normalizePrimitiveKindFromRegistry('oval panel')).toBe('ellipse-panel') + expect(normalizePrimitiveKindFromRegistry('truss-tower')).toBe('box') + expect(normalizePrimitiveKindFromRegistry('truss beam')).toBe('box') + expect(normalizePrimitiveKindFromRegistry('\u534a\u7403')).toBe('hemisphere') + expect(normalizePrimitiveKindFromRegistry('\u534a\u5706\u5f62\u7403')).toBe('hemisphere') + expect(normalizePrimitiveKindFromRegistry('semi sphere')).toBe('hemisphere') + expect(normalizePrimitiveKindFromRegistry('金字塔')).toBe('pyramid') + expect(primitiveCapabilitySummary()).toContain('pyramid -> cone') + }) + + test('lowers derived primitives to canonical renderable shapes', () => { + const pyramid = lowerDerivedPrimitiveShape({ + kind: 'pyramid', + position: [0, 0.5, 0], + radius: 0.5, + height: 1, + }) + const ellipsoid = lowerDerivedPrimitiveShape({ + kind: 'ellipsoid', + position: [0, 1, 0], + length: 2, + width: 1, + height: 0.5, + }) + + expect(pyramid).toMatchObject({ kind: 'cone', radialSegments: 4 }) + expect(ellipsoid).toMatchObject({ kind: 'sphere', scale: [1, 0.25, 0.5] }) + }) +}) diff --git a/packages/core/src/lib/primitive-registry.ts b/packages/core/src/lib/primitive-registry.ts new file mode 100644 index 000000000..bb6b18589 --- /dev/null +++ b/packages/core/src/lib/primitive-registry.ts @@ -0,0 +1,449 @@ +import type { PrimitiveShapeInput } from './primitive-compose' + +export type PrimitiveParameterType = + | 'number' + | 'integer' + | 'string' + | 'boolean' + | 'vec3' + | 'profile' + +export interface PrimitiveParameterDefinition { + type: PrimitiveParameterType + min?: number + max?: number + default?: unknown + values?: readonly unknown[] + description?: string +} + +export interface PrimitiveDefinition { + kind: string + aliases: readonly string[] + params: Record + derivedFrom?: string + description: string +} + +const canonicalPrimitiveDefinitions: PrimitiveDefinition[] = [ + { + kind: 'box', + aliases: [ + 'cuboid', + 'cube', + 'rectangular-prism', + 'rectangular prism', + 'truss-tower', + 'truss tower', + 'truss-beam', + 'truss beam', + 'lattice-tower', + 'lattice tower', + ], + description: 'Solid rectangular cuboid.', + params: { + length: { type: 'number', min: 0.001 }, + width: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + cornerRadius: { type: 'number', min: 0 }, + }, + }, + { + kind: 'cylinder', + aliases: ['round-cylinder', '圆柱'], + description: 'Solid circular cylinder along an axis.', + params: { + radius: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + axis: { type: 'string', values: ['x', 'y', 'z'] }, + radialSegments: { type: 'integer', min: 8, max: 64, default: 32 }, + }, + }, + { + kind: 'hollow-cylinder', + aliases: ['tube', 'pipe', 'hollow', 'hollow cylinder', '管'], + description: 'Tube or pipe with wall thickness.', + params: { + radius: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + wallThickness: { type: 'number', min: 0.001 }, + axis: { type: 'string', values: ['x', 'y', 'z'] }, + }, + }, + { + kind: 'cone', + aliases: ['圆锥'], + description: 'Pointed circular cone.', + params: { + radius: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + radialSegments: { type: 'integer', min: 3, max: 64, default: 32 }, + axis: { type: 'string', values: ['x', 'y', 'z'] }, + }, + }, + { + kind: 'frustum', + aliases: ['truncated-cone', 'truncated cone', '圆台'], + description: 'Truncated circular cone.', + params: { + radiusTop: { type: 'number', min: 0.001 }, + radiusBottom: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + radialSegments: { type: 'integer', min: 3, max: 64, default: 32 }, + axis: { type: 'string', values: ['x', 'y', 'z'] }, + }, + }, + { + kind: 'sphere', + aliases: ['ball'], + description: 'Sphere; use scale for ellipsoids.', + params: { + radius: { type: 'number', min: 0.001 }, + scale: { type: 'vec3' }, + }, + }, + { + kind: 'hemisphere', + aliases: ['dome', 'half-sphere', 'half sphere', '半球'], + description: 'Half sphere or scaled dome.', + params: { + radius: { type: 'number', min: 0.001 }, + scale: { type: 'vec3' }, + axis: { type: 'string', values: ['x', 'y', 'z'] }, + }, + }, + { + kind: 'torus', + aliases: ['ring', 'donut', 'tyre', 'tire', '圆环'], + description: 'Ring or tire tube.', + params: { + majorRadius: { type: 'number', min: 0.001 }, + tubeRadius: { type: 'number', min: 0.001 }, + arc: { type: 'number', min: 0, max: Math.PI * 2 }, + }, + }, + { + kind: 'wedge', + aliases: ['ramp', 'triangular-prism', 'triangular prism', '楔形'], + description: 'Sloped wedge / triangular prism.', + params: { + length: { type: 'number', min: 0.001 }, + width: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + slopeAxis: { type: 'string', values: ['x', 'z'] }, + slopeDirection: { type: 'string', values: ['positive', 'negative'] }, + }, + }, + { + kind: 'trapezoid-prism', + aliases: ['trapezoid', 'trapezoidal-prism', 'trapezoidal prism', '梯形柱'], + description: 'Tapered rectangular prism.', + params: { + length: { type: 'number', min: 0.001 }, + width: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + topLengthScale: { type: 'number', min: 0.01, max: 2 }, + topWidthScale: { type: 'number', min: 0.01, max: 2 }, + }, + }, + { + kind: 'lathe', + aliases: ['revolve', 'revolved-profile', '旋转体'], + description: 'Revolved 2D profile.', + params: { profile: { type: 'profile' }, segments: { type: 'integer', min: 3, max: 96 } }, + }, + { + kind: 'capsule', + aliases: ['pill', 'rounded-cylinder', '胶囊'], + description: 'Rounded-end capsule bar.', + params: { + radius: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + axis: { type: 'string', values: ['x', 'y', 'z'] }, + }, + }, + { + kind: 'half-cylinder', + aliases: ['semicylinder', 'semi-cylinder', 'semi cylinder', '半圆柱'], + description: 'Semicircular cylinder.', + params: { + radius: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + axis: { type: 'string', values: ['x', 'y', 'z'] }, + }, + }, + { + kind: 'rounded-panel', + aliases: ['rounded-rectangle', 'rounded rectangle', 'rounded-box-panel', '圆角板'], + description: 'Thin rounded rectangle panel.', + params: { + length: { type: 'number', min: 0.001 }, + width: { type: 'number', min: 0.001 }, + thickness: { type: 'number', min: 0.001 }, + cornerRadius: { type: 'number', min: 0 }, + }, + }, + { + kind: 'conformal-strip', + aliases: ['conformal_strip', 'curved-strip', 'curved rectangle', 'curved-rectangle'], + description: 'Strip conforming to a curved surface.', + params: { + width: { type: 'number', min: 0.001 }, + thickness: { type: 'number', min: 0.001 }, + surfaceRadiusY: { type: 'number', min: 0.001 }, + surfaceRadiusZ: { type: 'number', min: 0.001 }, + }, + }, + { + kind: 'extrude', + aliases: ['extrusion', 'profile-extrude'], + description: '2D profile extruded through depth.', + params: { + profile: { type: 'profile' }, + depth: { type: 'number', min: 0.001 }, + }, + }, + { + kind: 'sweep', + aliases: ['path-tube', 'tube-sweep'], + description: 'Tube swept along a 3D path.', + params: { + path: { type: 'vec3' }, + radius: { type: 'number', min: 0.001 }, + }, + }, +] + +export const PRIMITIVE_DEFINITIONS: readonly PrimitiveDefinition[] = [ + ...canonicalPrimitiveDefinitions, + { + kind: 'ellipsoid', + aliases: ['ellipse', 'oval', 'spheroid', '椭球', '椭圆体'], + derivedFrom: 'sphere', + description: 'Scaled sphere lowered to sphere + scale.', + params: { + length: { type: 'number', min: 0.001 }, + width: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + radius: { type: 'number', min: 0.001 }, + }, + }, + { + kind: 'ellipse-panel', + aliases: ['oval-panel', 'ellipse plate', 'oval plate', '椭圆板'], + derivedFrom: 'extrude', + description: 'Thin oval/ellipse panel lowered to an extruded ellipse profile.', + params: { + length: { type: 'number', min: 0.001 }, + width: { type: 'number', min: 0.001 }, + thickness: { type: 'number', min: 0.001 }, + segments: { type: 'integer', min: 8, max: 96, default: 32 }, + }, + }, + { + kind: 'semi-ellipse-panel', + aliases: [ + 'half-ellipse', + 'half ellipse', + 'semi ellipse', + 'semicircle', + 'semi-circle', + '半椭圆', + ], + derivedFrom: 'extrude', + description: 'Thin half-ellipse panel lowered to an extruded profile.', + params: { + length: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + thickness: { type: 'number', min: 0.001 }, + segments: { type: 'integer', min: 8, max: 96, default: 24 }, + }, + }, + { + kind: 'pyramid', + aliases: ['square-pyramid', 'rectangular-pyramid', '金字塔'], + derivedFrom: 'cone', + description: 'Square or rectangular pyramid lowered to a four-segment cone.', + params: { + radius: { type: 'number', min: 0.001 }, + height: { type: 'number', min: 0.001 }, + truncated: { type: 'boolean' }, + topScale: { type: 'number', min: 0, max: 1 }, + }, + }, +] + +const primitiveAliasMap = new Map() +for (const definition of PRIMITIVE_DEFINITIONS) { + primitiveAliasMap.set(definition.kind, definition.kind) + for (const alias of definition.aliases) { + primitiveAliasMap.set( + alias + .trim() + .replace(/[\s_]+/g, '-') + .toLowerCase(), + definition.kind, + ) + } +} + +for (const alias of [ + '\u534a\u7403', + '\u534a\u7403\u4f53', + '\u534a\u5706\u7403', + '\u534a\u5706\u5f62\u7403', + 'semi-sphere', + 'semi sphere', +]) { + primitiveAliasMap.set( + alias + .trim() + .replace(/[\s_]+/g, '-') + .toLowerCase(), + 'hemisphere', + ) +} + +function normalizeName(value: unknown): string { + return typeof value === 'string' + ? value + .trim() + .replace(/[\s_]+/g, '-') + .toLowerCase() + : '' +} + +function numberValue(...values: unknown[]): number | undefined { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value + } + return undefined +} + +function integerValue(value: unknown, fallback: number, min: number, max: number): number { + return Math.max( + min, + Math.min( + max, + Math.round(typeof value === 'number' && Number.isFinite(value) ? value : fallback), + ), + ) +} + +function ellipseProfile(rx: number, ry: number, segments: number): [number, number][] { + return Array.from({ length: segments }, (_, index) => { + const angle = (index / segments) * Math.PI * 2 + return [Math.cos(angle) * rx, Math.sin(angle) * ry] + }) +} + +function semiEllipseProfile(rx: number, ry: number, segments: number): [number, number][] { + const arc: [number, number][] = Array.from({ length: segments + 1 }, (_, index) => { + const angle = Math.PI - (index / segments) * Math.PI + return [Math.cos(angle) * rx, Math.sin(angle) * ry] as [number, number] + }) + return [...arc, [rx, 0], [-rx, 0]] +} + +export function normalizePrimitiveKindFromRegistry(value: unknown): string { + const normalized = normalizeName(value) + return primitiveAliasMap.get(normalized) ?? normalized +} + +export function getPrimitiveDefinition(kind: unknown): PrimitiveDefinition | undefined { + const normalized = normalizePrimitiveKindFromRegistry(kind) + return PRIMITIVE_DEFINITIONS.find((definition) => definition.kind === normalized) +} + +export function primitiveCapabilitySummary(): string { + return PRIMITIVE_DEFINITIONS.map((definition) => { + const params = Object.keys(definition.params).join(', ') + const derived = definition.derivedFrom ? ` -> ${definition.derivedFrom}` : '' + return `${definition.kind}${derived}: ${params}` + }).join('\n') +} + +export function lowerDerivedPrimitiveShape(shape: PrimitiveShapeInput): PrimitiveShapeInput { + const kind = normalizePrimitiveKindFromRegistry(shape.kind) + if (kind === 'ellipsoid') { + const length = numberValue(shape.length, shape.width) + const height = numberValue(shape.height) + const depth = numberValue(shape.depth, shape.width) + const computedScale: [number, number, number] = [ + (length ?? 1) / 2, + (height ?? length ?? 1) / 2, + (depth ?? length ?? 1) / 2, + ] + return { + ...shape, + kind: 'sphere', + radius: shape.radius ?? 1, + scale: + length != null || height != null || depth != null + ? computedScale + : (shape.scale ?? computedScale), + } + } + + if (kind === 'ellipse-panel') { + const length = numberValue(shape.length, shape.width, shape.radius) ?? 1 + const width = numberValue(shape.width, shape.depth, shape.radius) ?? length + const depth = numberValue(shape.thickness, shape.depth, shape.height) ?? 0.04 + const segments = integerValue(shape.segments, 32, 8, 96) + return { + ...shape, + kind: 'extrude', + profile: ellipseProfile(length / 2, width / 2, segments), + depth, + segments, + } + } + + if (kind === 'semi-ellipse-panel') { + const length = numberValue(shape.length, shape.width, shape.radius) ?? 1 + const height = numberValue(shape.height, shape.radius) ?? length / 2 + const depth = numberValue(shape.thickness, shape.depth, shape.width) ?? 0.04 + const segments = integerValue(shape.segments, 24, 8, 96) + return { + ...shape, + kind: 'extrude', + profile: semiEllipseProfile(length / 2, height, segments), + depth, + segments, + } + } + + if (kind === 'pyramid') { + const record = shape as PrimitiveShapeInput & { truncated?: boolean } + const radius = + numberValue(shape.radius, shape.length != null ? shape.length / Math.SQRT2 : undefined) ?? 0.5 + const height = numberValue(shape.height) ?? 1 + const topScale = numberValue( + Array.isArray(shape.topScale) ? shape.topScale[0] : undefined, + shape.topLengthScale, + shape.topWidthScale, + ) + if (record.truncated || (topScale != null && topScale > 0)) { + return { + ...shape, + kind: 'frustum', + radiusBottom: radius, + radiusTop: Math.max(0.001, radius * Math.max(0.01, topScale ?? 0.2)), + height, + radialSegments: 4, + rotation: shape.rotation ?? [0, Math.PI / 4, 0], + } + } + return { + ...shape, + kind: 'cone', + radius, + height, + radialSegments: 4, + rotation: shape.rotation ?? [0, Math.PI / 4, 0], + } + } + + if (kind !== shape.kind) return { ...shape, kind } + return shape +} diff --git a/packages/core/src/lib/primitive-revision.test.ts b/packages/core/src/lib/primitive-revision.test.ts new file mode 100644 index 000000000..4df632384 --- /dev/null +++ b/packages/core/src/lib/primitive-revision.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from 'bun:test' +import type { PrimitiveShapeInput } from './primitive-compose' +import { applyPrimitiveRevision, selectPrimitiveShapeIndexes } from './primitive-revision' + +const carShapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: 'vehicle body shell', + semanticRole: 'vehicle_body', + sourcePartKind: 'vehicle_body', + position: [0, 0.4, 0], + length: 4, + width: 1.8, + height: 0.5, + material: { properties: { color: '#cc0000' } }, + }, + { + kind: 'trapezoid-prism', + name: 'vehicle cabin frame', + semanticRole: 'vehicle_cabin', + sourcePartKind: 'vehicle_body', + position: [0, 0.9, 0], + length: 1.2, + width: 1.1, + height: 0.35, + topLengthScale: 0.75, + topWidthScale: 0.75, + }, + { + kind: 'rounded-panel', + name: 'side window left', + semanticRole: 'vehicle_window', + sourcePartKind: 'vehicle_windows', + position: [0, 0.9, -0.55], + rotation: [Math.PI / 2, 0, 0], + length: 1, + width: 0.2, + thickness: 0.01, + material: { properties: { color: '#1e3a8a' } }, + }, + { + kind: 'rounded-panel', + name: 'vehicle roof cap', + semanticRole: 'vehicle_roof', + sourcePartKind: 'vehicle_body', + position: [0, 1.16, 0], + length: 0.9, + width: 0.8, + thickness: 0.04, + }, +] + +describe('primitive revision DSL', () => { + test('selects shapes by semantic and source metadata', () => { + expect(selectPrimitiveShapeIndexes(carShapes, { semanticRole: 'vehicle_window' })).toEqual([2]) + expect(selectPrimitiveShapeIndexes(carShapes, { sourcePartKind: 'vehicle_body' })).toEqual([ + 0, 1, 3, + ]) + expect(selectPrimitiveShapeIndexes(carShapes, { nameIncludes: 'roof' })).toEqual([3]) + }) + + test('treats index with semantic metadata as an occurrence selector when global index does not match', () => { + expect( + selectPrimitiveShapeIndexes(carShapes, { semanticRole: 'vehicle_window', index: 0 }), + ).toEqual([2]) + expect( + selectPrimitiveShapeIndexes(carShapes, { sourcePartKind: 'vehicle_body', occurrence: 2 }), + ).toEqual([3]) + }) + + test('removes repeated semantic selections against stable pre-removal indexes', () => { + const wheelShapes: PrimitiveShapeInput[] = [ + { + kind: 'torus', + name: 'rear tire', + semanticRole: 'bicycle_tire', + position: [-1, 0, 0], + majorRadius: 1, + tubeRadius: 0.1, + }, + { + kind: 'torus', + name: 'rear rim', + semanticRole: 'bicycle_rim', + position: [-1, 0, 0], + majorRadius: 0.8, + tubeRadius: 0.05, + }, + { + kind: 'cylinder', + name: 'rear hub', + semanticRole: 'bicycle_hub', + position: [-1, 0, 0], + axis: 'z', + radius: 0.1, + height: 0.2, + }, + ...Array.from({ length: 8 }, (_, index) => ({ + kind: 'cylinder' as const, + name: `rear spoke ${index + 1}`, + semanticRole: 'bicycle_spoke', + position: [-1, 0, 0] as [number, number, number], + axis: 'x' as const, + radius: 0.01, + height: 0.5, + })), + { + kind: 'torus', + name: 'front tire', + semanticRole: 'bicycle_tire', + position: [1, 0, 0], + majorRadius: 1, + tubeRadius: 0.1, + }, + { + kind: 'torus', + name: 'front rim', + semanticRole: 'bicycle_rim', + position: [1, 0, 0], + majorRadius: 0.8, + tubeRadius: 0.05, + }, + { + kind: 'cylinder', + name: 'front hub', + semanticRole: 'bicycle_hub', + position: [1, 0, 0], + axis: 'z', + radius: 0.1, + height: 0.2, + }, + ...Array.from({ length: 8 }, (_, index) => ({ + kind: 'cylinder' as const, + name: `front spoke ${index + 1}`, + semanticRole: 'bicycle_spoke', + position: [1, 0, 0] as [number, number, number], + axis: 'x' as const, + radius: 0.01, + height: 0.5, + })), + ] + + const result = applyPrimitiveRevision({ + shapes: wheelShapes, + operations: [ + { op: 'remove', selector: { semanticRole: 'bicycle_tire', index: 1 } }, + { op: 'remove', selector: { semanticRole: 'bicycle_rim', index: 1 } }, + { op: 'remove', selector: { semanticRole: 'bicycle_hub', index: 1 } }, + ...Array.from({ length: 8 }, (_, offset) => ({ + op: 'remove' as const, + selector: { semanticRole: 'bicycle_spoke', index: 8 + offset }, + })), + ], + }) + + expect(result.issues).toEqual([]) + expect(result.shapes).toHaveLength(11) + expect(result.shapes.some((shape) => shape.name?.includes('front'))).toBe(false) + expect(result.shapes.filter((shape) => shape.semanticRole === 'bicycle_spoke')).toHaveLength(8) + }) + + test('does not throw when legacy shapes contain malformed profile data', () => { + const malformed: PrimitiveShapeInput[] = [ + { + kind: 'extrude', + name: 'bad legacy extrude', + semanticRole: 'water_surface', + profile: { curve: 'sine' } as unknown as [number, number][], + depth: 0.1, + }, + ] + + const result = applyPrimitiveRevision({ + shapes: malformed, + operations: [ + { + op: 'transform', + selector: { semanticRole: 'water_surface' }, + scale: [1.2, 1, 1.2], + }, + ], + }) + + expect(result.issues).toEqual([]) + expect(result.shapes[0]?.profile).toBeUndefined() + expect(result.shapes[0]?.position).toEqual([0, 0, 0]) + }) + + test('replaces a subassembly and inherits body material for added pillars', () => { + const result = applyPrimitiveRevision({ + shapes: carShapes, + operations: [ + { + op: 'replace', + selector: { semanticRole: 'vehicle_cabin' }, + shapes: [ + { + kind: 'trapezoid-prism', + name: 'integrated glasshouse', + semanticRole: 'vehicle_cabin', + sourcePartKind: 'vehicle_windows', + position: [0, 0.93, 0], + length: 1.55, + width: 1.06, + height: 0.34, + topLengthScale: 0.78, + topWidthScale: 0.78, + material: { properties: { color: '#1e3a8a', opacity: 0.78, transparent: true } }, + }, + { + kind: 'box', + name: 'A pillar left', + semanticRole: 'vehicle_pillar', + position: [0.6, 0.98, -0.5], + length: 0.05, + width: 0.05, + height: 0.34, + }, + ], + }, + { + op: 'materialFrom', + selector: { semanticRole: 'vehicle_pillar' }, + from: { semanticRole: 'vehicle_body' }, + }, + ], + }) + + expect(result.issues).toEqual([]) + expect(result.shapes.some((shape) => shape.name === 'vehicle cabin frame')).toBe(false) + const pillar = result.shapes.find((shape) => shape.semanticRole === 'vehicle_pillar') + expect(pillar?.material?.properties?.color).toBe('#cc0000') + }) + + test('aligns one shape edge to another shape edge', () => { + const result = applyPrimitiveRevision({ + shapes: carShapes, + operations: [ + { + op: 'align', + selector: { nameIncludes: 'side window left' }, + to: { semanticRole: 'vehicle_roof' }, + edge: 'top', + toEdge: 'bottom', + }, + ], + }) + + expect(result.issues).toEqual([]) + const window = result.shapes.find((shape) => shape.name === 'side window left') + const roof = result.shapes.find((shape) => shape.semanticRole === 'vehicle_roof') + expect((window?.position?.[1] ?? 0) + (window?.width ?? 0) / 2).toBeCloseTo( + (roof?.position?.[1] ?? 0) - (roof?.thickness ?? 0) / 2, + ) + }) + + test('bakes transform scale into common gear primitive dimensions', () => { + const gearShapes: PrimitiveShapeInput[] = [ + { + kind: 'hollow-cylinder', + name: 'gear_disc', + semanticRole: 'gear_disc', + position: [0, 0.01, 0], + axis: 'y', + radius: 0.045, + height: 0.02, + }, + { + kind: 'lathe', + name: 'tooth_ring', + semanticRole: 'tooth_ring', + position: [0, 0.01, 0], + profile: [ + [0.039375, -0.01], + [0.0495, 0], + [0.039375, 0.01], + ], + }, + { + kind: 'box', + name: 'keyway', + semanticRole: 'keyway', + position: [0, 0.01, 0.015], + length: 0.008, + width: 0.005, + height: 0.02, + }, + ] + + const result = applyPrimitiveRevision({ + shapes: gearShapes, + operations: [{ op: 'transform', selector: {}, scale: [10, 1, 10] }], + }) + + expect(result.issues).toEqual([]) + expect(result.shapes[0]?.radius).toBeCloseTo(0.45) + expect(result.shapes[0]?.height).toBeCloseTo(0.02) + expect(result.shapes[1]?.profile?.[1]?.[0]).toBeCloseTo(0.495) + expect(result.shapes[2]?.length).toBeCloseTo(0.08) + expect(result.shapes[2]?.width).toBeCloseTo(0.05) + expect(result.shapes[2]?.position?.[2]).toBeCloseTo(0.15) + }) + + test('bakes transform scale into extrude profile and depth', () => { + const result = applyPrimitiveRevision({ + shapes: [ + { + kind: 'extrude', + name: 'single_piece_spur_gear', + semanticRole: 'spur_gear', + position: [0, 0.01, 0], + profile: [ + [0.0495, 0], + [0, 0.0495], + [-0.0495, 0], + [0, -0.0495], + ], + depth: 0.02, + }, + ], + operations: [ + { op: 'transform', selector: { semanticRole: 'spur_gear' }, scale: [10, 1, 10] }, + ], + }) + + expect(result.issues).toEqual([]) + expect(result.shapes[0]?.profile?.[0]?.[0]).toBeCloseTo(0.495) + expect(result.shapes[0]?.profile?.[1]?.[1]).toBeCloseTo(0.0495) + expect(result.shapes[0]?.depth).toBeCloseTo(0.2) + }) + + test('can target only vehicle tires for a local wheel size revision', () => { + const result = applyPrimitiveRevision({ + shapes: [ + { + kind: 'box', + name: 'body', + semanticRole: 'vehicle_body', + position: [0, 0.5, 0], + length: 4, + width: 1.8, + height: 0.6, + }, + { + kind: 'torus', + name: 'front tire', + semanticRole: 'vehicle_tire', + position: [-1.2, 0.35, -0.95], + axis: 'x', + majorRadius: 0.28, + tubeRadius: 0.08, + }, + { + kind: 'torus', + name: 'rear tire', + semanticRole: 'vehicle_tire', + position: [1.2, 0.35, -0.95], + axis: 'x', + majorRadius: 0.28, + tubeRadius: 0.08, + }, + ], + operations: [ + { + op: 'transform', + selector: { semanticRole: 'vehicle_tire' }, + scale: [1.4, 1.4, 1.4], + }, + ], + }) + + expect(result.issues).toEqual([]) + expect(result.shapes[0]?.length).toBe(4) + expect(result.shapes[1]?.majorRadius).toBeCloseTo(0.392) + expect(result.shapes[1]?.tubeRadius).toBeCloseTo(0.112) + expect(result.shapes[2]?.majorRadius).toBeCloseTo(0.392) + expect(result.shapes[2]?.tubeRadius).toBeCloseTo(0.112) + }) + + test('scales selected semantic parts through editable primary dimensions', () => { + const result = applyPrimitiveRevision({ + shapes: [ + { + kind: 'box', + name: 'outdoor ac fan blade 1', + semanticRole: 'fan_blade', + semanticGroup: 'front_fan', + sourcePartKind: 'radial_blades', + position: [0.05, 0.4, 0.2], + length: 0.12, + width: 0.01, + height: 0.02, + editableHints: { + primaryDimension: 'length', + canScale: ['length', 'width', 'height'], + }, + }, + { + kind: 'cylinder', + name: 'outdoor ac fan hub', + semanticRole: 'fan_hub', + semanticGroup: 'front_fan', + sourcePartKind: 'radial_blades', + position: [0, 0.4, 0.2], + axis: 'z', + radius: 0.03, + height: 0.02, + }, + ], + operations: [ + { + op: 'scaleSemantic', + selector: { semanticRole: 'fan_blade' }, + dimension: 'primary', + factor: 1.35, + }, + ], + }) + + expect(result.issues).toEqual([]) + expect(result.changedShapeCount).toBe(1) + expect(result.shapes[0]?.length).toBeCloseTo(0.162) + expect(result.shapes[0]?.width).toBeCloseTo(0.01) + expect(result.shapes[1]?.radius).toBeCloseTo(0.03) + }) +}) diff --git a/packages/core/src/lib/primitive-revision.ts b/packages/core/src/lib/primitive-revision.ts new file mode 100644 index 000000000..e48c805ca --- /dev/null +++ b/packages/core/src/lib/primitive-revision.ts @@ -0,0 +1,937 @@ +import { + type PrimitiveEditableDimension, + type PrimitiveMaterialInput, + type PrimitiveShapeInput, + resolvePrimitiveWorldTransforms, + type Vec3, +} from './primitive-compose' +import { + buildPrimitiveGeometryFacts, + getPrimitiveShapeHalfExtents, + type PrimitiveShapeFact, +} from './primitive-facts' + +export type PrimitiveShapeSelector = { + index?: number + occurrence?: number + semanticRole?: string + semanticGroup?: string + sourcePartKind?: string + sourcePartId?: string + kind?: string + nameIncludes?: string +} + +export type PrimitiveRevisionEdge = + | 'top' + | 'bottom' + | 'front' + | 'back' + | 'left' + | 'right' + | 'center' + +export type PrimitiveRevisionOperation = + | { op: 'add'; shapes: PrimitiveShapeInput[] } + | { op: 'remove'; selector: PrimitiveShapeSelector } + | { op: 'replace'; selector: PrimitiveShapeSelector; shapes: PrimitiveShapeInput[] } + | { + op: 'transform' + selector: PrimitiveShapeSelector + position?: Vec3 + delta?: Vec3 + rotation?: Vec3 + scale?: Vec3 + } + | { + op: 'resize' + selector: PrimitiveShapeSelector + length?: number + width?: number + height?: number + depth?: number + thickness?: number + radius?: number + radiusTop?: number + radiusBottom?: number + majorRadius?: number + tubeRadius?: number + } + | { + op: 'scaleSemantic' + selector: PrimitiveShapeSelector + dimension?: PrimitiveEditableDimension | string + factor: number + } + | { + op: 'materialFrom' + selector: PrimitiveShapeSelector + from: PrimitiveShapeSelector + } + | { + op: 'setMaterial' + selector: PrimitiveShapeSelector + color?: string + material?: PrimitiveMaterialInput + materialPreset?: string + } + | { + op: 'align' + selector: PrimitiveShapeSelector + to: PrimitiveShapeSelector + edge: PrimitiveRevisionEdge + toEdge?: PrimitiveRevisionEdge + offset?: number + } + +export interface PrimitiveRevisionInput { + shapes: PrimitiveShapeInput[] + operations: PrimitiveRevisionOperation[] +} + +export interface PrimitiveRevisionResult { + shapes: PrimitiveShapeInput[] + issues: string[] + changedShapeCount: number +} + +function cloneShape(shape: PrimitiveShapeInput): PrimitiveShapeInput { + return { + ...shape, + position: shape.position ? [...shape.position] : undefined, + rotation: shape.rotation ? [...shape.rotation] : undefined, + scale: shape.scale ? [...shape.scale] : undefined, + material: shape.material + ? { + ...shape.material, + gradient: shape.material.gradient + ? { + ...shape.material.gradient, + stops: shape.material.gradient.stops.map((stop) => ({ ...stop })), + } + : undefined, + properties: shape.material.properties ? { ...shape.material.properties } : undefined, + } + : undefined, + editableHints: shape.editableHints + ? { + ...shape.editableHints, + canScale: shape.editableHints.canScale ? [...shape.editableHints.canScale] : undefined, + } + : undefined, + profile: cloneProfile(shape.profile), + holes: cloneHoles(shape.holes), + path: clonePath(shape.path), + } +} + +function isFiniteTuple2(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length >= 2 && + typeof value[0] === 'number' && + Number.isFinite(value[0]) && + typeof value[1] === 'number' && + Number.isFinite(value[1]) + ) +} + +function isFiniteTuple3(value: unknown): value is Vec3 { + return ( + Array.isArray(value) && + value.length >= 3 && + typeof value[0] === 'number' && + Number.isFinite(value[0]) && + typeof value[1] === 'number' && + Number.isFinite(value[1]) && + typeof value[2] === 'number' && + Number.isFinite(value[2]) + ) +} + +function cloneProfile(value: unknown): [number, number][] | undefined { + if (!Array.isArray(value)) return undefined + const points = value.filter(isFiniteTuple2).map(([x, y]) => [x, y] as [number, number]) + return points.length > 0 ? points : undefined +} + +function cloneHoles(value: unknown): [number, number][][] | undefined { + if (!Array.isArray(value)) return undefined + const holes = value + .filter(Array.isArray) + .map((hole) => cloneProfile(hole)) + .filter((hole): hole is [number, number][] => Array.isArray(hole) && hole.length > 0) + return holes.length > 0 ? holes : undefined +} + +function clonePath(value: unknown): Vec3[] | undefined { + if (!Array.isArray(value)) return undefined + const points = value.filter(isFiniteTuple3).map(([x, y, z]) => [x, y, z] as Vec3) + return points.length > 0 ? points : undefined +} + +function cloneMaterial( + material: PrimitiveMaterialInput | undefined, +): PrimitiveMaterialInput | undefined { + if (!material) return undefined + return { + ...material, + gradient: material.gradient + ? { + ...material.gradient, + stops: material.gradient.stops.map((stop) => ({ ...stop })), + } + : undefined, + properties: material.properties ? { ...material.properties } : undefined, + } +} + +function normalizeText(value: string | undefined) { + return value?.trim().toLowerCase() ?? '' +} + +function selectorLabel(selector: PrimitiveShapeSelector) { + return JSON.stringify(selector) +} + +export function selectPrimitiveShapeIndexes( + shapes: readonly PrimitiveShapeInput[], + selector: PrimitiveShapeSelector | undefined, +): number[] { + if (!selector) return [] + + const nameNeedle = normalizeText(selector.nameIncludes) + const role = normalizeText(selector.semanticRole) + const group = normalizeText(selector.semanticGroup) + const sourceKind = normalizeText(selector.sourcePartKind) + const sourceId = normalizeText(selector.sourcePartId) + const kind = normalizeText(selector.kind) + + const hasMetadataSelector = Boolean(role || group || sourceKind || sourceId || kind || nameNeedle) + const matches = shapes.flatMap((shape, index) => { + if (role && normalizeText(shape.semanticRole) !== role) return [] + if (group && normalizeText(shape.semanticGroup) !== group) return [] + if (sourceKind && normalizeText(shape.sourcePartKind) !== sourceKind) return [] + if (sourceId && normalizeText(shape.sourcePartId) !== sourceId) return [] + if (kind && normalizeText(String(shape.kind)) !== kind) return [] + if (nameNeedle && !normalizeText(shape.name).includes(nameNeedle)) return [] + return [index] + }) + + const ordinal = + typeof selector.occurrence === 'number' && Number.isInteger(selector.occurrence) + ? selector.occurrence + : hasMetadataSelector && + typeof selector.index === 'number' && + Number.isInteger(selector.index) + ? selector.index + : undefined + + if (ordinal != null) { + const match = matches[ordinal] + return match != null ? [match] : [] + } + if (typeof selector.index === 'number' && Number.isInteger(selector.index)) { + return selector.index >= 0 && selector.index < shapes.length ? [selector.index] : [] + } + return matches +} + +function eulerToMatrix( + euler: Vec3, +): [number, number, number, number, number, number, number, number, number] { + const [x, y, z] = euler + const cx = Math.cos(x) + const sx = Math.sin(x) + const cy = Math.cos(y) + const sy = Math.sin(y) + const cz = Math.cos(z) + const sz = Math.sin(z) + + return [ + cy * cz, + -cy * sz, + sy, + cx * sz + sx * sy * cz, + cx * cz - sx * sy * sz, + -sx * cy, + sx * sz - cx * sy * cz, + sx * cz + cx * sy * sz, + cx * cy, + ] +} + +function rotatedHalfExtents(half: Vec3, rotation: Vec3): Vec3 { + const m = eulerToMatrix(rotation) + return [ + Math.abs(m[0]) * half[0] + Math.abs(m[1]) * half[1] + Math.abs(m[2]) * half[2], + Math.abs(m[3]) * half[0] + Math.abs(m[4]) * half[1] + Math.abs(m[5]) * half[2], + Math.abs(m[6]) * half[0] + Math.abs(m[7]) * half[1] + Math.abs(m[8]) * half[2], + ] +} + +function shapeFactFor( + shapes: readonly PrimitiveShapeInput[], + index: number, +): PrimitiveShapeFact | undefined { + const transforms = resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }) + const baseFact = buildPrimitiveGeometryFacts(shapes, transforms).shapes.find( + (fact) => fact.index === index, + ) + const shape = shapes[index] + if (!baseFact || !shape) return baseFact + const transform = transforms[index] + const center = transform?.position ?? shape.position ?? baseFact.center + const halfExtents = rotatedHalfExtents( + getPrimitiveShapeHalfExtents(shape), + transform?.rotation ?? shape.rotation ?? [0, 0, 0], + ) + return { + ...baseFact, + center, + halfExtents, + min: [center[0] - halfExtents[0], center[1] - halfExtents[1], center[2] - halfExtents[2]], + max: [center[0] + halfExtents[0], center[1] + halfExtents[1], center[2] + halfExtents[2]], + } +} + +function edgeValue(fact: PrimitiveShapeFact, edge: PrimitiveRevisionEdge): number { + switch (edge) { + case 'top': + return fact.max[1] + case 'bottom': + return fact.min[1] + case 'front': + return fact.max[2] + case 'back': + return fact.min[2] + case 'left': + return fact.min[0] + case 'right': + return fact.max[0] + default: + return fact.center[1] + } +} + +function edgeAxis(edge: PrimitiveRevisionEdge): 0 | 1 | 2 { + switch (edge) { + case 'left': + case 'right': + return 0 + case 'front': + case 'back': + return 2 + default: + return 1 + } +} + +function ensurePosition(shape: PrimitiveShapeInput): Vec3 { + return shape.position ? [...shape.position] : [0, 0, 0] +} + +function operationShapes(value: PrimitiveShapeInput[] | undefined): PrimitiveShapeInput[] { + return Array.isArray(value) ? value.map(cloneShape) : [] +} + +function validScale(value: number | undefined) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 1 +} + +function averageScale(...values: number[]) { + return values.reduce((sum, value) => sum + validScale(value), 0) / Math.max(1, values.length) +} + +function scaleNumber(value: number | undefined, factor: number) { + return value != null ? value * validScale(factor) : undefined +} + +function scaleVec3(value: Vec3 | undefined, scale: Vec3): Vec3 | undefined { + if (!value) return undefined + return [ + value[0] * validScale(scale[0]), + value[1] * validScale(scale[1]), + value[2] * validScale(scale[2]), + ] +} + +function scalePositionAroundPivot(position: Vec3, pivot: Vec3, scale: Vec3): Vec3 { + return [ + pivot[0] + (position[0] - pivot[0]) * validScale(scale[0]), + pivot[1] + (position[1] - pivot[1]) * validScale(scale[1]), + pivot[2] + (position[2] - pivot[2]) * validScale(scale[2]), + ] +} + +function selectionBoundsPivot( + shapes: readonly PrimitiveShapeInput[], + indexes: readonly number[], +): Vec3 { + let min: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + let max: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY] + let found = false + + for (const index of indexes) { + const fact = shapeFactFor(shapes, index) + if (!fact) continue + found = true + min = [ + Math.min(min[0], fact.min[0]), + Math.min(min[1], fact.min[1]), + Math.min(min[2], fact.min[2]), + ] + max = [ + Math.max(max[0], fact.max[0]), + Math.max(max[1], fact.max[1]), + Math.max(max[2], fact.max[2]), + ] + } + + if (!found) { + return indexes + .reduce( + (sum, index) => { + const position = ensurePosition(shapes[index] as PrimitiveShapeInput) + return [sum[0] + position[0], sum[1] + position[1], sum[2] + position[2]] + }, + [0, 0, 0], + ) + .map((value) => value / Math.max(1, indexes.length)) as Vec3 + } + + return [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] +} + +function scalePrimitiveShapeGeometry(shape: PrimitiveShapeInput, scale: Vec3): PrimitiveShapeInput { + const sx = validScale(scale[0]) + const sy = validScale(scale[1]) + const sz = validScale(scale[2]) + const uniform = averageScale(sx, sy, sz) + const xz = averageScale(sx, sz) + const yz = averageScale(sy, sz) + const xy = averageScale(sx, sy) + + switch (shape.kind) { + case 'box': + case 'wedge': + case 'trapezoid-prism': + return { + ...shape, + length: scaleNumber(shape.length, sx), + width: scaleNumber(shape.width, sz), + height: scaleNumber(shape.height, sy), + cornerRadius: scaleNumber(shape.cornerRadius, Math.min(sx, sy, sz)), + } + case 'rounded-panel': + return { + ...shape, + length: scaleNumber(shape.length, sx), + width: scaleNumber(shape.width, sz), + thickness: scaleNumber(shape.thickness ?? shape.height, sy), + height: scaleNumber(shape.height, sy), + cornerRadius: scaleNumber(shape.cornerRadius, Math.min(sx, sz)), + } + case 'cylinder': + case 'hollow-cylinder': + case 'cone': + case 'capsule': + case 'half-cylinder': { + const axis = shape.axis ?? 'y' + const heightScale = axis === 'x' ? sx : axis === 'z' ? sz : sy + const radiusScale = axis === 'x' ? yz : axis === 'z' ? xy : xz + return { + ...shape, + height: scaleNumber(shape.height, heightScale), + radius: scaleNumber(shape.radius, radiusScale), + wallThickness: scaleNumber(shape.wallThickness, radiusScale), + } + } + case 'frustum': { + const axis = shape.axis ?? 'y' + const heightScale = axis === 'x' ? sx : axis === 'z' ? sz : sy + const radiusScale = axis === 'x' ? yz : axis === 'z' ? xy : xz + return { + ...shape, + height: scaleNumber(shape.height, heightScale), + radiusTop: scaleNumber(shape.radiusTop, radiusScale), + radiusBottom: scaleNumber(shape.radiusBottom, radiusScale), + radius: scaleNumber(shape.radius, radiusScale), + } + } + case 'torus': { + const axis = shape.axis ?? 'y' + const radiusScale = axis === 'x' ? yz : axis === 'z' ? xy : xz + return { + ...shape, + majorRadius: scaleNumber(shape.majorRadius, radiusScale), + tubeRadius: scaleNumber(shape.tubeRadius, radiusScale), + radius: scaleNumber(shape.radius, radiusScale), + } + } + case 'sphere': + case 'hemisphere': { + const existingScale = shape.scale ?? [1, 1, 1] + return { + ...shape, + radius: scaleNumber(shape.radius, uniform), + scale: [existingScale[0] * sx, existingScale[1] * sy, existingScale[2] * sz], + } + } + case 'lathe': + return { + ...shape, + profile: scaleProfile(shape.profile, xz, sy), + } + case 'extrude': + return { + ...shape, + profile: scaleProfile(shape.profile, sx, sy), + holes: scaleHoles(shape.holes, sx, sy), + depth: scaleNumber(shape.depth, sz), + bevelSize: scaleNumber(shape.bevelSize, uniform), + bevelThickness: scaleNumber(shape.bevelThickness, uniform), + } + case 'sweep': + return { + ...shape, + path: clonePath(shape.path)?.map((point) => [point[0] * sx, point[1] * sy, point[2] * sz]), + radius: scaleNumber(shape.radius, uniform), + } + default: + return { ...shape, scale: scaleVec3(shape.scale ?? [1, 1, 1], scale) } + } +} + +function normalizeEditableDimension(value: unknown): PrimitiveEditableDimension | undefined { + if (typeof value !== 'string') return undefined + const normalized = value + .trim() + .replace(/[\s_-]+/g, '') + .toLowerCase() + switch (normalized) { + case 'primary': + return 'primary' + case 'uniform': + case 'all': + case 'overall': + return 'uniform' + case 'length': + case 'long': + case 'longer': + return 'length' + case 'width': + case 'wide': + case 'wider': + return 'width' + case 'height': + case 'tall': + case 'taller': + return 'height' + case 'depth': + return 'depth' + case 'thickness': + case 'thick': + case 'thicker': + return 'thickness' + case 'radius': + return 'radius' + case 'diameter': + return 'diameter' + case 'majorradius': + return 'majorRadius' + case 'tuberadius': + return 'tubeRadius' + case 'axislength': + return 'axisLength' + case 'profilex': + return 'profileX' + case 'profiley': + return 'profileY' + default: + return undefined + } +} + +function defaultPrimaryDimension(shape: PrimitiveShapeInput): PrimitiveEditableDimension { + switch (shape.kind) { + case 'box': + case 'rounded-panel': + case 'wedge': + case 'trapezoid-prism': + return 'length' + case 'cylinder': + case 'hollow-cylinder': + case 'cone': + case 'frustum': + case 'capsule': + case 'half-cylinder': + return 'axisLength' + case 'torus': + return 'majorRadius' + case 'sphere': + case 'hemisphere': + return 'radius' + case 'extrude': + return 'profileX' + case 'lathe': + return 'profileY' + default: + return 'uniform' + } +} + +function resolveEditableDimension( + shape: PrimitiveShapeInput, + requested: unknown, +): PrimitiveEditableDimension { + const requestedDimension = normalizeEditableDimension(requested) + if (requestedDimension && requestedDimension !== 'primary') return requestedDimension + const hinted = normalizeEditableDimension(shape.editableHints?.primaryDimension) + return hinted && hinted !== 'primary' ? hinted : defaultPrimaryDimension(shape) +} + +function clampEditableFactor(shape: PrimitiveShapeInput, factor: number): number { + const fallbackMin = 0.2 + const fallbackMax = 4 + const min = + typeof shape.editableHints?.minFactor === 'number' && + Number.isFinite(shape.editableHints.minFactor) + ? shape.editableHints.minFactor + : fallbackMin + const max = + typeof shape.editableHints?.maxFactor === 'number' && + Number.isFinite(shape.editableHints.maxFactor) + ? shape.editableHints.maxFactor + : fallbackMax + return Math.max(min, Math.min(max, validScale(factor))) +} + +function canScaleDimension(shape: PrimitiveShapeInput, dimension: PrimitiveEditableDimension) { + const allowed = shape.editableHints?.canScale + ?.map((entry) => normalizeEditableDimension(entry)) + .filter(Boolean) + return !allowed?.length || allowed.includes(dimension) || allowed.includes('primary') +} + +function scaleProfile( + profile: [number, number][] | undefined, + xFactor: number, + yFactor: number, +): [number, number][] | undefined { + return cloneProfile(profile)?.map(([x, y]) => [x * xFactor, y * yFactor]) +} + +function scaleHoles( + holes: [number, number][][] | undefined, + xFactor: number, + yFactor: number, +): [number, number][][] | undefined { + return cloneHoles(holes)?.map((hole) => hole.map(([x, y]) => [x * xFactor, y * yFactor])) +} + +function scalePrimitiveShapeDimension( + shape: PrimitiveShapeInput, + requestedDimension: unknown, + rawFactor: number, +): { shape: PrimitiveShapeInput; issue?: string } { + const dimension = resolveEditableDimension(shape, requestedDimension) + if (!canScaleDimension(shape, dimension)) { + return { + shape, + issue: `${shape.name ?? shape.kind}: editableHints do not allow scaling dimension "${dimension}".`, + } + } + + const factor = clampEditableFactor(shape, rawFactor) + switch (dimension) { + case 'uniform': + return { shape: scalePrimitiveShapeGeometry(shape, [factor, factor, factor]) } + case 'length': + return { shape: { ...shape, length: scaleNumber(shape.length, factor) } } + case 'width': + return { shape: { ...shape, width: scaleNumber(shape.width, factor) } } + case 'height': + return { shape: { ...shape, height: scaleNumber(shape.height, factor) } } + case 'depth': + return { shape: { ...shape, depth: scaleNumber(shape.depth, factor) } } + case 'thickness': + return { + shape: { + ...shape, + thickness: scaleNumber(shape.thickness ?? shape.height, factor), + ...(shape.thickness == null ? { height: scaleNumber(shape.height, factor) } : {}), + }, + } + case 'axisLength': + return { shape: { ...shape, height: scaleNumber(shape.height, factor) } } + case 'radius': + return { + shape: { + ...shape, + radius: scaleNumber(shape.radius, factor), + radiusTop: scaleNumber(shape.radiusTop, factor), + radiusBottom: scaleNumber(shape.radiusBottom, factor), + }, + } + case 'diameter': + return { + shape: { + ...shape, + radius: scaleNumber(shape.radius, factor), + radiusTop: scaleNumber(shape.radiusTop, factor), + radiusBottom: scaleNumber(shape.radiusBottom, factor), + majorRadius: scaleNumber(shape.majorRadius, factor), + }, + } + case 'majorRadius': + return { + shape: { + ...shape, + majorRadius: scaleNumber(shape.majorRadius ?? shape.radius, factor), + radius: shape.majorRadius == null ? scaleNumber(shape.radius, factor) : shape.radius, + }, + } + case 'tubeRadius': + return { shape: { ...shape, tubeRadius: scaleNumber(shape.tubeRadius, factor) } } + case 'profileX': + return { + shape: { + ...shape, + profile: scaleProfile(shape.profile, factor, 1), + holes: scaleHoles(shape.holes, factor, 1), + }, + } + case 'profileY': + return { + shape: { + ...shape, + profile: scaleProfile(shape.profile, 1, factor), + holes: scaleHoles(shape.holes, 1, factor), + }, + } + default: + return { shape: scalePrimitiveShapeGeometry(shape, [factor, factor, factor]) } + } +} + +export function applyPrimitiveRevision(input: PrimitiveRevisionInput): PrimitiveRevisionResult { + const issues: string[] = [] + let changedShapeCount = 0 + let shapes = input.shapes.map(cloneShape) + + for (let operationIndex = 0; operationIndex < input.operations.length; operationIndex += 1) { + const operation = input.operations[operationIndex] as PrimitiveRevisionOperation + const label = `operation ${operationIndex + 1} (${operation.op})` + + if (operation.op === 'remove') { + const removeSet = new Set() + let cursor = operationIndex + while (cursor < input.operations.length) { + const removeOperation = input.operations[cursor] + if (removeOperation?.op !== 'remove') break + const removeLabel = `operation ${cursor + 1} (${removeOperation.op})` + const indexes = selectPrimitiveShapeIndexes(shapes, removeOperation.selector) + if (indexes.length === 0) { + issues.push( + `${removeLabel}: selector matched no shapes: ${selectorLabel(removeOperation.selector)}`, + ) + } + for (const index of indexes) removeSet.add(index) + cursor += 1 + } + if (removeSet.size > 0) { + shapes = shapes.filter((_, index) => !removeSet.has(index)) + changedShapeCount += removeSet.size + } + operationIndex = cursor - 1 + continue + } + + if (operation.op === 'add') { + const added = operationShapes(operation.shapes) + if (added.length === 0) { + issues.push(`${label}: add requires at least one shape.`) + continue + } + shapes = [...shapes, ...added] + changedShapeCount += added.length + continue + } + + const indexes = selectPrimitiveShapeIndexes(shapes, operation.selector) + if (indexes.length === 0) { + issues.push(`${label}: selector matched no shapes: ${selectorLabel(operation.selector)}`) + continue + } + + if (operation.op === 'replace') { + const replacements = operationShapes(operation.shapes) + if (replacements.length === 0) { + issues.push(`${label}: replace requires at least one replacement shape.`) + continue + } + const replaceSet = new Set(indexes) + const firstIndex = Math.min(...indexes) + const next: PrimitiveShapeInput[] = [] + for (let i = 0; i < shapes.length; i += 1) { + if (i === firstIndex) next.push(...replacements) + if (!replaceSet.has(i)) next.push(shapes[i] as PrimitiveShapeInput) + } + shapes = next + changedShapeCount += replaceSet.size + replacements.length + continue + } + + if (operation.op === 'transform') { + const scalePivot = operation.scale ? selectionBoundsPivot(shapes, indexes) : undefined + + for (const index of indexes) { + const shape = shapes[index] + if (!shape) continue + const position = ensurePosition(shape) + const scaledShape = operation.scale + ? scalePrimitiveShapeGeometry(shape, operation.scale) + : shape + shapes[index] = { + ...scaledShape, + position: operation.position + ? [...operation.position] + : operation.delta + ? [ + position[0] + operation.delta[0], + position[1] + operation.delta[1], + position[2] + operation.delta[2], + ] + : operation.scale && scalePivot + ? scalePositionAroundPivot(position, scalePivot, operation.scale) + : shape.position, + rotation: operation.rotation ? [...operation.rotation] : shape.rotation, + } + changedShapeCount += 1 + } + continue + } + + if (operation.op === 'resize') { + for (const index of indexes) { + const shape = shapes[index] + if (!shape) continue + shapes[index] = { + ...shape, + ...(operation.length != null ? { length: operation.length } : {}), + ...(operation.width != null ? { width: operation.width } : {}), + ...(operation.height != null ? { height: operation.height } : {}), + ...(operation.depth != null ? { depth: operation.depth } : {}), + ...(operation.thickness != null ? { thickness: operation.thickness } : {}), + ...(operation.radius != null ? { radius: operation.radius } : {}), + ...(operation.radiusTop != null ? { radiusTop: operation.radiusTop } : {}), + ...(operation.radiusBottom != null ? { radiusBottom: operation.radiusBottom } : {}), + ...(operation.majorRadius != null ? { majorRadius: operation.majorRadius } : {}), + ...(operation.tubeRadius != null ? { tubeRadius: operation.tubeRadius } : {}), + } + changedShapeCount += 1 + } + continue + } + + if (operation.op === 'scaleSemantic') { + if (typeof operation.factor !== 'number' || !Number.isFinite(operation.factor)) { + issues.push(`${label}: scaleSemantic requires a finite factor.`) + continue + } + for (const index of indexes) { + const shape = shapes[index] + if (!shape) continue + const scaled = scalePrimitiveShapeDimension(shape, operation.dimension, operation.factor) + if (scaled.issue) { + issues.push(`${label}: ${scaled.issue}`) + continue + } + shapes[index] = scaled.shape + changedShapeCount += 1 + } + continue + } + + if (operation.op === 'materialFrom') { + const sourceIndex = selectPrimitiveShapeIndexes(shapes, operation.from)[0] + const material = + sourceIndex != null ? cloneMaterial(shapes[sourceIndex]?.material) : undefined + const materialPreset = sourceIndex != null ? shapes[sourceIndex]?.materialPreset : undefined + if (!material && !materialPreset) { + issues.push( + `${label}: materialFrom source has no material: ${selectorLabel(operation.from)}`, + ) + continue + } + for (const index of indexes) { + const shape = shapes[index] + if (!shape) continue + shapes[index] = { ...shape, material, materialPreset } + changedShapeCount += 1 + } + continue + } + + if (operation.op === 'setMaterial') { + const material = + operation.material != null + ? cloneMaterial(operation.material) + : operation.color + ? { type: 'standard' as const, properties: { color: operation.color } } + : undefined + if (!material && !operation.materialPreset) { + issues.push(`${label}: setMaterial requires color, material, or materialPreset.`) + continue + } + for (const index of indexes) { + const shape = shapes[index] + if (!shape) continue + shapes[index] = { + ...shape, + material, + materialPreset: operation.materialPreset, + } + changedShapeCount += 1 + } + continue + } + + if (operation.op === 'align') { + const targetIndex = selectPrimitiveShapeIndexes(shapes, operation.to)[0] + if (targetIndex == null) { + issues.push(`${label}: align target matched no shapes: ${selectorLabel(operation.to)}`) + continue + } + const targetFact = shapeFactFor(shapes, targetIndex) + if (!targetFact) { + issues.push(`${label}: align target has no geometry facts.`) + continue + } + const axis = edgeAxis(operation.edge) + const targetValue = + edgeValue(targetFact, operation.toEdge ?? operation.edge) + (operation.offset ?? 0) + for (const index of indexes) { + const fact = shapeFactFor(shapes, index) + const shape = shapes[index] + if (!fact || !shape) continue + const currentValue = edgeValue(fact, operation.edge) + const delta = targetValue - currentValue + const position = ensurePosition(shape) + position[axis] += delta + shapes[index] = { ...shape, position } + changedShapeCount += 1 + } + } + } + + for (const [index, shape] of shapes.entries()) { + if (!shape.position) { + const halfExtents = getPrimitiveShapeHalfExtents(shape) + shapes[index] = { ...shape, position: [0, halfExtents[1], 0] } + } + } + + return { shapes, issues, changedShapeCount } +} diff --git a/packages/core/src/lib/primitive-semantic-validation.test.ts b/packages/core/src/lib/primitive-semantic-validation.test.ts new file mode 100644 index 000000000..8dbe4182e --- /dev/null +++ b/packages/core/src/lib/primitive-semantic-validation.test.ts @@ -0,0 +1,665 @@ +import { describe, expect, test } from 'bun:test' +import { composeAssemblyPrimitives } from './assembly-compose' +import { composePartPrimitives } from './part-compose' +import type { PrimitiveShapeInput } from './primitive-compose' +import { resolvePrimitiveWorldTransforms } from './primitive-compose' +import { composeRecipePrimitives } from './primitive-recipes' +import { validatePrimitiveSemantics } from './primitive-semantic-validation' +import { composeRobotArmPrimitives } from './robot-arm-compose' + +function validate(shapes: PrimitiveShapeInput[], prompt: string, category: string) { + return validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt, + geometryBrief: { category }, + }, + ) +} + +describe('validatePrimitiveSemantics', () => { + test('ignores negated mixer and tank targets when detecting semantic family', () => { + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: 'tower mast', + semanticRole: 'mast_tower', + position: [0, 6, 0], + length: 1, + width: 1, + height: 12, + }, + { + kind: 'box', + name: 'main jib', + semanticRole: 'main_jib', + position: [5, 12, 0], + length: 10, + width: 0.2, + height: 0.2, + }, + ] + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: + 'Generate a construction tower crane. Do not generate a mixer tank, storage tank, airplane, or bicycle.', + geometryBrief: { category: 'construction tower crane' }, + }, + ) + + expect(result.family).not.toBe('mixer') + expect(result.issues.join('\n')).not.toContain('mixer requires') + }) + + test('accepts a bicycle wheel component without requiring a complete bicycle', () => { + const shapes = composePartPrimitives({ + name: 'single bicycle wheel', + geometryBrief: { + category: 'bicycle_component', + requiredRoles: ['bicycle_tire:1', 'bicycle_rim:1', 'bicycle_hub:1', 'bicycle_spoke:8'], + }, + parts: [{ kind: 'wheel_set', semanticRole: 'bicycle_wheel', radius: 0.35 }], + }) + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'generate one bicycle wheel', + geometryBrief: { + category: 'bicycle_component', + requiredRoles: ['bicycle_tire:1', 'bicycle_rim:1', 'bicycle_hub:1', 'bicycle_spoke:8'], + }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.family).toBe('unknown') + expect(result.facts.roles.bicycle_tire).toBe(1) + expect(result.facts.roles.bicycle_spoke).toBe(8) + }) + + test('accepts a red vehicle assembled from reusable primitive parts', () => { + const shapes = composePartPrimitives({ + name: 'Red sedan', + primaryColor: '#cc0000', + parts: [{ kind: 'vehicle_body', length: 4.4, width: 1.8, height: 1.35 }], + }) + + const result = validate(shapes, 'red sedan car', 'vehicle') + + expect(result.ok).toBe(true) + expect(result.family).toBe('vehicle') + expect(result.facts.roles.vehicle_body).toBe(1) + expect(result.facts.roles.vehicle_tire).toBe(4) + expect(result.facts.roles.vehicle_window).toBeGreaterThanOrEqual(4) + expect(result.facts.roles.headlight).toBe(2) + expect(result.facts.roles.front_bumper).toBe(1) + expect(result.facts.roles.rear_bumper).toBe(1) + }) + + test('rejects vehicle geometry that cannot satisfy four-wheel car semantics', () => { + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: 'bad car body', + semanticRole: 'vehicle_body', + position: [0, 0.55, 0], + length: 4, + width: 1.8, + height: 0.7, + material: { properties: { color: '#cc0000' } }, + }, + { + kind: 'torus', + name: 'bad car left tire', + semanticRole: 'vehicle_tire', + position: [-1.2, 0.25, -0.85], + axis: 'z', + majorRadius: 0.28, + tubeRadius: 0.07, + }, + { + kind: 'torus', + name: 'bad car right tire', + semanticRole: 'vehicle_tire', + position: [1.2, 0.25, -0.85], + axis: 'z', + majorRadius: 0.28, + tubeRadius: 0.07, + }, + { + kind: 'rounded-panel', + name: 'bad car windshield', + semanticRole: 'vehicle_window', + position: [0.4, 1.02, 0], + length: 0.4, + width: 1, + thickness: 0.02, + }, + { + kind: 'sphere', + name: 'bad car left headlight', + semanticRole: 'headlight', + position: [1.95, 0.55, -0.45], + radius: 0.05, + }, + { + kind: 'sphere', + name: 'bad car right headlight', + semanticRole: 'headlight', + position: [1.95, 0.55, 0.45], + radius: 0.05, + }, + { + kind: 'box', + name: 'front bumper', + semanticRole: 'front_bumper', + position: [2.05, 0.32, 0], + length: 0.06, + width: 1.5, + height: 0.08, + }, + { + kind: 'box', + name: 'rear bumper', + semanticRole: 'rear_bumper', + position: [-2.05, 0.32, 0], + length: 0.06, + width: 1.5, + height: 0.08, + }, + ] + + const result = validate(shapes, 'red car', 'vehicle') + + expect(result.ok).toBe(false) + expect(result.issues).toContain( + 'vehicle requires exactly 4 tires arranged as two axles, got 2.', + ) + }) + + test('does not treat revision-only vehicle roles as a complete passenger car request', () => { + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: 'agv cart body', + semanticRole: 'vehicle_body', + position: [0, 0.35, 0], + length: 1.4, + width: 0.8, + height: 0.35, + }, + { + kind: 'torus', + name: 'left drive tire', + semanticRole: 'vehicle_tire', + position: [-0.4, 0.2, -0.45], + axis: 'z', + majorRadius: 0.16, + tubeRadius: 0.04, + }, + { + kind: 'box', + name: 'navigation sensor mast', + semanticRole: 'navigation_sensor', + position: [0.45, 0.75, 0], + length: 0.08, + width: 0.08, + height: 0.35, + }, + ] + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'make it blue', + geometryBrief: { category: 'generic body assembly' }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.family).toBe('unknown') + expect(result.issues).not.toContain('vehicle requires exactly 1 main body shell, got 1.') + expect(result.issues.some((issue) => issue.includes('vehicle requires'))).toBe(false) + }) + + test('accepts car tire required-role aliases for single vehicle wheel components', () => { + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'torus', + name: 'single car tire', + semanticRole: 'vehicle_tire', + position: [0, 0.3, 0], + axis: 'z', + majorRadius: 0.32, + tubeRadius: 0.08, + }, + { + kind: 'cylinder', + name: 'single car wheel hub', + semanticRole: 'wheel_hub', + position: [0, 0.3, 0], + axis: 'z', + radius: 0.16, + height: 0.04, + }, + ] + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: '\u751f\u6210\u4e00\u4e2a\u6c7d\u8f66\u8f6e\u80ce', + geometryBrief: { category: 'vehicle component', requiredRoles: ['car_tire'] }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.family).toBe('unknown') + expect(result.issues).not.toContain('required semantic role "vehicle_tire" is missing.') + expect(result.issues.some((issue) => issue.includes('vehicle requires exactly 4 tires'))).toBe( + false, + ) + }) + + test('treats vehicle-domain component briefs as single parts, not complete cars', () => { + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'torus', + name: 'steering wheel outer rim', + position: [0, 1, 0], + axis: 'z', + majorRadius: 0.24, + tubeRadius: 0.025, + }, + { + kind: 'cylinder', + name: 'steering wheel center hub', + position: [0, 1, 0], + axis: 'z', + radius: 0.06, + height: 0.04, + }, + ...[0, 1, 2].map( + (index): PrimitiveShapeInput => ({ + kind: 'box', + name: 'steering wheel spoke', + position: [0, 1, 0], + rotation: [0, 0, (index * Math.PI * 2) / 3], + length: 0.34, + width: 0.018, + height: 0.018, + }), + ), + ] + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'generate a steering wheel', + geometryBrief: { + category: 'vehicle', + requiredRoles: ['steering_wheel_rim', 'steering_wheel_hub', 'steering_wheel_spoke'], + }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.family).toBe('unknown') + expect(result.issues).not.toContain('vehicle requires exactly 1 main body shell, got 0.') + }) + + test('accepts common steering wheel role aliases from Chinese generation plans', () => { + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'torus', + semanticRole: 'wheel_rim', + position: [0, 0, 0], + axis: 'y', + majorRadius: 0.175, + tubeRadius: 0.015, + }, + { + kind: 'cylinder', + semanticRole: 'center_hub', + position: [0, 0, 0], + axis: 'y', + radius: 0.06, + height: 0.08, + }, + { + kind: 'capsule', + semanticRole: 'spoke', + position: [0.0875, 0, 0], + axis: 'x', + radius: 0.012, + height: 0.115, + }, + ] + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: '生成一个汽车方向盘', + geometryBrief: { + category: 'automotive steering wheel', + requiredRoles: ['wheel_rim', 'center_hub', 'spoke'], + }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.family).toBe('unknown') + expect(result.issues).not.toContain('required semantic role "center_hub" is missing.') + expect(result.issues).not.toContain('required semantic role "spoke" is missing.') + }) + + test('accepts aircraft part defaults with common LLM role aliases', () => { + const shapes = composePartPrimitives({ + name: 'Boeing airliner', + length: 8, + geometryBrief: { category: 'aircraft', expectedDimensions: { length: 8 } }, + parts: [{ kind: 'aircraft_fuselage' }], + }) + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: '生成一个波音客机,长8米', + geometryBrief: { + category: 'aircraft', + requiredRoles: [ + 'aircraft_body', + 'complete_airframe', + 'fuselage_body', + 'aircraft_fuselage', + 'aircraft_wing', + 'aircraft_horizontal_stabilizer', + 'aircraft_vertical_stabilizer', + 'aircraft_landing_gear_main', + 'cockpit_windows', + 'aircraft_window', + 'engine_nacelle_left', + 'engine_nacelle_right', + 'engine', + ], + }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.facts.roles.engine_nacelle_left).toBe(1) + expect(result.facts.roles.engine_nacelle_right).toBe(1) + expect(shapes.length).toBeLessThanOrEqual(80) + }) + + test('accepts a bicycle with one deduplicated two-wheel wheelset', () => { + const shapes = composePartPrimitives({ + name: 'Duplicate wheelset bike', + parts: [{ kind: 'bicycle_wheels' }, { kind: 'bike_wheelset' }, { kind: 'bicycle_frame' }], + }) + + const result = validate(shapes, 'red bicycle', 'bicycle') + + expect(result.ok).toBe(true) + expect(result.facts.roles.bicycle_tire).toBe(2) + expect(result.facts.roles.bicycle_frame).toBeGreaterThan(0) + expect(result.facts.roles.bicycle_fork).toBeGreaterThan(0) + expect(result.facts.roles.handlebar).toBeGreaterThan(0) + expect(result.facts.roles.saddle).toBeGreaterThan(0) + expect(result.facts.roles.chain_loop).toBeGreaterThan(0) + }) + + test('accepts bicycle part-kind aliases in geometry brief required roles', () => { + const shapes = composePartPrimitives({ + name: 'Correct bicycle', + parts: [ + { kind: 'bicycle_wheels' }, + { kind: 'bicycle_frame' }, + { kind: 'bicycle_fork' }, + { kind: 'handlebar' }, + { kind: 'saddle' }, + { kind: 'chain_loop' }, + ], + }) + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: '生成一个新的正确自行车模型', + geometryBrief: { + category: 'bicycle', + requiredRoles: ['bicycle_wheels', 'frame', 'fork', 'handlebar', 'saddle', 'chain_drive'], + }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.issues).not.toContain('required semantic role "chain_drive" is missing.') + expect(result.issues).not.toContain('required semantic role "chain_loop" is missing.') + }) + + test('accepts LLM-style complete bicycle aliases without losing canonical roles', () => { + const shapes = composePartPrimitives({ + name: 'red bicycle', + primaryColor: '#CC0000', + parts: [ + { id: 'frame', kind: 'bicycle_frame', semanticRole: 'frame' }, + { id: 'fork', kind: 'bicycle_fork', semanticRole: 'fork' }, + { id: 'wheel_front', kind: 'bicycle_wheel', semanticRole: 'wheel' }, + { id: 'wheel_rear', kind: 'bicycle_wheel', semanticRole: 'wheel' }, + { id: 'handlebar', kind: 'bicycle_handlebar', semanticRole: 'bicycle_handlebar' }, + { id: 'seat', kind: 'bicycle_seat', semanticRole: 'bicycle_saddle' }, + { id: 'crank', kind: 'bicycle_crank', semanticRole: 'crank' }, + { id: 'chainring', kind: 'bicycle_chainring', semanticRole: 'chainring' }, + { id: 'pedals', kind: 'bicycle_pedals', semanticRole: 'pedal' }, + { id: 'chain', kind: 'bicycle_chain', semanticRole: 'chain' }, + ], + }) + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: '生成一辆红色自行车', + geometryBrief: { + category: 'complete_bicycle', + requiredRoles: [ + 'frame', + 'fork', + 'wheel_front', + 'wheel_rear', + 'bicycle_handlebar', + 'bicycle_saddle', + 'crank', + 'chainring', + 'pedals', + 'chain', + ], + }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.facts.roles.bicycle_tire).toBe(2) + expect(result.facts.roles.bicycle_frame).toBeGreaterThan(0) + expect(result.facts.roles.bicycle_fork).toBeGreaterThan(0) + expect(result.facts.roles.handlebar).toBeGreaterThan(0) + expect(result.facts.roles.saddle).toBeGreaterThan(0) + expect(result.facts.roles.crank).toBeGreaterThan(0) + expect(result.facts.roles.chainring).toBeGreaterThan(0) + expect(result.facts.roles.pedal).toBe(2) + }) + + test('accepts chain_drive semantic role as a bicycle chain-loop equivalent', () => { + const shapes = composePartPrimitives({ + name: 'Correct bicycle', + parts: [ + { kind: 'bicycle_wheels' }, + { kind: 'bicycle_frame' }, + { kind: 'bicycle_fork' }, + { kind: 'handlebar' }, + { kind: 'saddle' }, + { kind: 'chain_loop' }, + ], + }).map((shape) => + shape.semanticRole === 'chain_loop' ? { ...shape, semanticRole: 'chain_drive' } : shape, + ) + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'generate a bicycle', + geometryBrief: { + category: 'complete_bicycle', + requiredRoles: [ + 'bicycle_tire', + 'bicycle_frame', + 'bicycle_fork', + 'handlebar', + 'saddle', + 'chain_loop', + ], + }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.facts.roles.chain_drive).toBeGreaterThan(0) + expect(result.issues).not.toContain('bicycle requires chain_loop.') + }) + + test('accepts compose_robot_arm output with readable semantic roles', () => { + const shapes = composeRobotArmPrimitives({ + name: '3-axis robot arm', + axisCount: 3, + baseShape: 'round', + pose: 'work-ready', + endEffector: 'gripper', + }) + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'generate a 3-axis robot arm with round base', + geometryBrief: { category: 'robot_arm' }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.family).toBe('robot_arm') + expect(result.facts.roles.robot_base).toBe(1) + expect(result.facts.roles.base_joint).toBe(1) + expect(result.facts.roles.shoulder_joint).toBe(1) + expect(result.facts.roles.elbow_joint).toBe(1) + expect(result.facts.roles.upper_arm).toBe(1) + expect(result.facts.roles.forearm).toBe(1) + expect(result.facts.roles.end_effector).toBe(1) + }) + + test('accepts industrial role aliases from LLM blueprints', () => { + const shapes = [ + { kind: 'box', semanticRole: 'support_base', sourcePartKind: 'skid_base', length: 2.6 }, + { kind: 'cylinder', semanticRole: 'volute_casing', radius: 0.3, height: 0.28 }, + { kind: 'cylinder', semanticRole: 'inlet_port', radius: 0.12, height: 0.35 }, + { kind: 'cylinder', semanticRole: 'outlet_port', radius: 0.1, height: 0.35 }, + { kind: 'cylinder', semanticRole: 'drive_motor', radius: 0.28, height: 1.1 }, + { kind: 'box', semanticRole: 'control_box', sourcePartKind: 'control_box' }, + { kind: 'torus', semanticRole: 'flange', sourcePartKind: 'flange_ring' }, + ] as const + + const result = validatePrimitiveSemantics(shapes, [], { + prompt: 'generate an industrial centrifugal pump skid', + geometryBrief: { + category: 'pump', + requiredRoles: [ + 'base_frame', + 'pump_volute', + 'inlet_nozzle', + 'outlet_nozzle', + 'drive_motor', + 'junction_box', + 'shaft_coupling', + 'inlet_flange', + 'outlet_flange', + ], + }, + }) + + expect(result.ok).toBe(true) + }) + + test('accepts process vessel nozzles and manways as visible process ports', () => { + const shapes = composePartPrimitives({ + name: 'Horizontal pressure storage tank', + parts: [ + { kind: 'cylindrical_tank', semanticRole: 'vessel_shell', length: 2.2, radius: 0.34 }, + ], + }) + + const result = validate( + shapes, + 'horizontal pressure storage tank with top nozzle and manway flange', + 'process_equipment', + ) + + expect(result.ok).toBe(true) + expect(result.issues).not.toContain('process equipment requires visible process ports.') + }) + + test('accepts mixer impeller recipe with shaft, hub, and radial blades', () => { + const shapes = composeRecipePrimitives({ + recipeId: 'mixer.impeller', + params: { bladeCount: 3 }, + }) + + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'generate a mud mixer with one rod and three inclined flat blades', + geometryBrief: { category: 'mixer' }, + }, + ) + + expect(result.ok).toBe(true) + expect(result.family).toBe('mixer') + expect(result.facts.roles.mixer_shaft).toBe(1) + expect(result.facts.roles.mixer_hub).toBe(1) + expect(result.facts.roles.mixer_blade).toBe(3) + }) + + test('accepts industrial assembly families through semantic validation', () => { + for (const input of [ + { family: 'machine_tool', object: 'lathe' }, + { family: 'machine_tool', object: 'machining center' }, + { family: 'conveyor', object: 'belt conveyor' }, + { family: 'pump', object: 'centrifugal pump' }, + { family: 'distillation_tower', object: 'heat exchanger tower' }, + { family: 'machine_tool', object: 'laser cutter' }, + ]) { + const shapes = composeAssemblyPrimitives(input) + const result = validatePrimitiveSemantics( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: `generate ${input.object}`, + geometryBrief: { category: 'industrial_equipment' }, + }, + ) + + expect(result.ok).toBe(true) + } + }) +}) diff --git a/packages/core/src/lib/primitive-semantic-validation.ts b/packages/core/src/lib/primitive-semantic-validation.ts new file mode 100644 index 000000000..cbc71ad59 --- /dev/null +++ b/packages/core/src/lib/primitive-semantic-validation.ts @@ -0,0 +1,1734 @@ +import type { FamilyId } from './family-registry' +import type { + PrimitiveGeometryBrief, + PrimitiveShapeInput, + ResolvedPrimitiveTransform, +} from './primitive-compose' +import { + buildPrimitiveGeometryFacts, + type PrimitiveGeometryFacts, + type PrimitiveShapeFact, +} from './primitive-facts' +import { hasComponentPartIntent } from './primitive-part-intent' + +type SemanticFamily = FamilyId | 'unknown' + +export interface PrimitiveSemanticValidationOptions { + toolName?: string + prompt?: string + sourceArgs?: Record + geometryBrief?: PrimitiveGeometryBrief +} + +export interface PrimitiveSemanticValidationResult { + ok: boolean + family: SemanticFamily + score: number + issues: string[] + warnings: string[] + recommendations: string[] + facts: PrimitiveGeometryFacts +} + +function textOf(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function sourceText(args: Record | undefined): string { + if (!args) return '' + const parts = [args.name, args.partName, args.category, args.model].map(textOf) + if (Array.isArray(args.parts)) { + for (const part of args.parts) { + if (typeof part !== 'object' || part === null) continue + const record = part as Record + parts.push( + textOf(record.kind), + textOf(record.partType), + textOf(record.type), + textOf(record.name), + ) + } + } + return parts.filter(Boolean).join(' ') +} + +function detectFamily( + facts: PrimitiveGeometryFacts, + options: PrimitiveSemanticValidationOptions, +): SemanticFamily { + // Use only intent-bearing text sources, not role/partKind keys — those cause false positives + // (e.g. wheel_set gives semanticRole='vehicle_tire' which matches /vehicle/ on aircraft, + // propeller_blade_set normalizes to 'mixer_blades' which matches /mixer/ on aircraft). + const rawIntentText = [ + options.geometryBrief?.category, + options.prompt, + sourceText(options.sourceArgs), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + const rawPromptIntentText = [options.geometryBrief?.category, options.prompt] + .filter(Boolean) + .join(' ') + .toLowerCase() + const intentText = stripNegatedTargetClauses(rawIntentText) + const promptIntentText = stripNegatedTargetClauses(rawPromptIntentText) + const declaredFamily = textOf(options.sourceArgs?.family).toLowerCase() + const declaredLayoutFamily = textOf(options.sourceArgs?.layoutFamily).toLowerCase() + const hasDeviceProfile = textOf(options.sourceArgs?.deviceProfile).length > 0 + + if ( + hasDeviceProfile && + (declaredFamily === 'generic' || declaredLayoutFamily === 'generic_industrial_layout') + ) { + return 'unknown' + } + if (hasDeviceProfile) { + if ( + declaredLayoutFamily === 'linear_transport_layout' || + declaredFamily === 'material_handling' || + declaredFamily === 'conveyor' + ) { + return 'material_handling' + } + if ( + declaredLayoutFamily === 'vessel_layout' || + declaredFamily === 'tank' || + declaredFamily === 'reactor' || + declaredFamily === 'heat_exchanger' + ) { + return 'process_equipment' + } + if ( + declaredLayoutFamily === 'rotating_machine_layout' || + declaredFamily === 'pump' || + declaredFamily === 'compressor' || + declaredFamily === 'fluid_machine' + ) { + return 'fluid_machine' + } + if (declaredLayoutFamily === 'box_enclosure_layout') { + return 'machine_tool' + } + } + + if ( + /reactor|reaction[_\s-]?(kettle|vessel)|stirred[_\s-]?tank|\u53cd\u5e94\u91dc|\u53cd\u61c9\u91dc|\u53cd\u5e94\u5668|\u53cd\u61c9\u5668/.test( + intentText, + ) + ) { + return 'process_equipment' + } + + if ( + /mixer|impeller|agitator|mixing[_\s-]?paddle|mud[_\s-]?mixer|\u6ce5\u6d46\u6405\u62cc|\u6405\u62cc\u90e8\u4ef6|\u6405\u62cc\u53f6\u7247|\u53f6\u8f6e/.test( + intentText, + ) + ) { + return 'mixer' + } + + if ( + hasComponentPartIntent(promptIntentText) || + (!promptIntentText && hasComponentPartIntent(sourceText(options.sourceArgs).toLowerCase())) || + hasComponentScopedBrief(options.geometryBrief) + ) { + return 'unknown' + } + + // Aircraft check first — must precede vehicle/mixer to avoid role-key false positives + if ( + /aircraft|airplane|airliner|plane|jet|fuselage|aircraft_fuselage|aircraft_wing/.test(intentText) + ) { + return 'unknown' + } + + // Role/partKind keys are only used for families whose detection cannot be faked by role names + const rolesText = [ + Object.keys(facts.roles).join(' '), + Object.keys(facts.sourcePartKinds).join(' '), + ].join(' ') + + const text = `${intentText} ${rolesText}`.toLowerCase() + + if ( + /tricycle|cargo[_\s-]?trike|cargo[_\s-]?bike|rickshaw|pushcart|handcart|\u4e09\u8f6e\u8f66|\u8d27\u8fd0\u81ea\u884c\u8f66/.test( + text, + ) + ) { + return 'unknown' + } + if (/bicycle|bike/.test(intentText)) return 'bicycle' + if (/vehicle|sedan|suv|automobile|(?:^|[\s_-])(?:car|auto)(?:$|[\s_-])/.test(intentText)) { + return 'vehicle' + } + if (/valve|gate_valve|gate valve|\u9600\u95e8|\u95f8\u9600/.test(text)) return 'valve' + if (/robot[_\s-]?arm|cobot|manipulator|\u673a\u5668\u81c2|\u673a\u68b0\u81c2/.test(text)) { + return 'robot_arm' + } + if ( + /distillation[_\s-]?(tower|column)|fractionat(?:ion|or)|rectification[_\s-]?(tower|column)|distillation_column_shell|\u84b8\u998f\u5854|\u84b8\u992e\u5854|\u7cbe\u998f\u5854|\u7cbe\u992e\u5854|\u5854\u5668/.test( + text, + ) + ) { + return 'distillation_tower' + } + if ( + /machine_tool|cnc|lathe|machining[_\s-]?center|laser[_\s-]?cutter|\u6570\u63a7|\u8f66\u5e8a|\u52a0\u5de5\u4e2d\u5fc3|\u5207\u5272\u673a/.test( + text, + ) + ) { + return 'machine_tool' + } + if ( + /forming_machine|injection[_\s-]?molding|hydraulic[_\s-]?press|press[_\s-]?frame|\u6ce8\u5851\u673a|\u6db2\u538b\u673a|\u51b2\u538b\u673a/.test( + text, + ) + ) { + return 'forming_machine' + } + if ( + /material_handling|conveyor|belt_surface|roller_array|grate_cooler|cooler_grate_bed|\u8f93\u9001\u673a|\u6d41\u6c34\u7ebf|\u7be6\u51b7\u673a/.test( + text, + ) + ) { + return 'material_handling' + } + if ( + /fluid_machine|pump|centrifugal|pump_casing|compressor|compressor_casing|\u79bb\u5fc3\u6cf5|\u6cf5|\u538b\u7f29\u673a|\u58d3\u7e2e\u6a5f/.test( + text, + ) + ) { + return 'fluid_machine' + } + if ( + /process_equipment|heat[_\s-]?exchanger|condenser|cooler|reactor|reactor_vessel|vessel_shell|\u6362\u70ed\u5668|\u51b7\u51dd\u5668|\u51b7\u5374\u5668|\u53cd\u5e94\u91dc|\u53cd\u61c9\u91dc/.test( + text, + ) + ) { + return 'process_equipment' + } + return 'unknown' +} + +function stripNegatedTargetClauses(text: string): string { + return text + .replace( + /\b(?:do\s+not|don't|dont|never|avoid|not)\s+(?:generate|create|make|build|model|use)?\s*([^.!?;\n]+)/gi, + ' ', + ) + .replace(/(?:不要生成|不要|别生成|不是|避免生成|禁止生成)\s*([^。!?;\n]+)/g, ' ') +} + +function factsBy( + facts: PrimitiveGeometryFacts, + predicate: (fact: PrimitiveShapeFact) => boolean, +): PrimitiveShapeFact[] { + return facts.shapes.filter(predicate) +} + +function hasRole(fact: PrimitiveShapeFact, roles: string[]): boolean { + return fact.semanticRole != null && roles.includes(fact.semanticRole) +} + +function factName(fact: PrimitiveShapeFact): string { + return fact.name?.toLowerCase() ?? '' +} + +function isVehicleBody(fact: PrimitiveShapeFact): boolean { + return ( + hasRole(fact, ['vehicle_body']) || + (fact.sourcePartKind === 'vehicle_body' && factName(fact).includes('body shell')) + ) +} + +function isVehicleTire(fact: PrimitiveShapeFact): boolean { + const name = factName(fact) + if (name.includes('steering')) return false + if (/arch|fender|shadow/.test(name)) return false + if (/hub|rim|spoke|axle|cap|bolt/.test(name)) return false + const tireLikeKind = + fact.kind === 'torus' || fact.kind === 'cylinder' || fact.kind === 'hollow-cylinder' + return ( + hasRole(fact, ['vehicle_tire', 'car_tire', 'car_tires', 'vehicle_tyre', 'car_tyre']) || + (fact.sourcePartKind === 'vehicle_wheels' && tireLikeKind && /tire|wheel/.test(name)) || + (tireLikeKind && + (/(vehicle|car).*tire/.test(name) || name.includes('tire') || name.includes('wheel')) && + !name.includes('bicycle')) + ) +} + +function isVehicleWindow(fact: PrimitiveShapeFact): boolean { + const name = factName(fact) + return ( + hasRole(fact, ['vehicle_window', 'vehicle_glass', 'glass']) || + fact.sourcePartKind === 'vehicle_windows' || + name.includes('windshield') || + name.includes('window') || + name.includes('glass') + ) +} + +function isHeadlight(fact: PrimitiveShapeFact): boolean { + return ( + hasRole(fact, ['headlight', 'vehicle_headlight']) || + fact.sourcePartKind === 'headlights' || + factName(fact).includes('headlight') + ) +} + +function isBumper(fact: PrimitiveShapeFact): boolean { + return ( + hasRole(fact, ['front_bumper', 'rear_bumper', 'bumper', 'vehicle_bumper']) || + fact.sourcePartKind === 'bumper' || + factName(fact).includes('bumper') + ) +} + +function isBicycleTire(fact: PrimitiveShapeFact): boolean { + const name = factName(fact) + return ( + hasRole(fact, ['bicycle_tire']) || + (fact.sourcePartKind === 'bicycle_wheels' && fact.kind === 'torus' && name.includes('tire')) || + (fact.kind === 'torus' && name.includes('bicycle') && name.includes('tire')) + ) +} + +function countClusters(values: number[], tolerance: number): number { + const sorted = [...values].sort((a, b) => a - b) + let clusters = 0 + let current: number | undefined + for (const value of sorted) { + if (current == null || Math.abs(value - current) > tolerance) { + clusters += 1 + current = value + } else { + current = (current + value) / 2 + } + } + return clusters +} + +function normalizeRequiredRole(role: string): string { + const normalized = role + .trim() + .toLowerCase() + .replace(/[:=]\s*\d+$/, '') + .replace(/[\s-]+/g, '_') + + switch (normalized) { + case 'bicycle_wheel': + case 'bicycle_wheels': + case 'bike_wheel': + case 'bike_wheels': + return 'bicycle_wheels' + case 'wheel_front': + case 'front_bicycle_wheel': + case 'bicycle_front_wheel': + return 'front_wheel' + case 'wheel_rear': + case 'rear_bicycle_wheel': + case 'bicycle_rear_wheel': + return 'rear_wheels' + case 'bicycle_frame': + case 'bike_frame': + return 'bicycle_frame' + case 'bicycle_fork': + case 'bike_fork': + return 'bicycle_fork' + case 'bike_handlebar': + case 'bike_handlebars': + case 'bicycle_handlebar': + case 'bicycle_handlebars': + return 'handlebar' + case 'front_tire': + return 'front_wheel' + case 'rear_tire': + case 'rear_tires': + return 'rear_wheels' + case 'car_tire': + case 'car_tires': + case 'auto_tire': + case 'auto_tires': + case 'automobile_tire': + case 'automobile_tires': + case 'vehicle_tire': + case 'vehicle_tires': + case 'tyre': + case 'tyres': + case 'car_tyre': + case 'car_tyres': + case 'vehicle_tyre': + case 'vehicle_tyres': + return 'vehicle_tire' + case 'chain': + case 'bicycle_chain': + case 'chain_loop': + case 'chain_drive': + case 'bicycle_chain_drive': + case 'drivetrain': + case 'drive_train': + return 'chain_loop' + case 'bicycle_crank': + return 'crank' + case 'bicycle_chainring': + return 'chainring' + case 'pedals': + case 'bicycle_pedal': + case 'bicycle_pedals': + return 'pedal' + case 'seat': + case 'bike_seat': + case 'bicycle_seat': + case 'bike_saddle': + case 'bicycle_saddle': + case 'saddle': + return 'saddle' + case 'vehicle_wheel': + case 'vehicle_wheels': + case 'car_wheel': + case 'car_wheels': + return 'vehicle_wheels' + case 'vehicle_windows': + case 'vehicle_window': + case 'vehicle_glass': + case 'car_window': + case 'car_glass': + case 'glass': + case 'car_windows': + case 'windows': + return 'vehicle_windows' + case 'lights': + case 'vehicle_light': + case 'vehicle_lights': + case 'vehicle_headlight': + case 'vehicle_headlights': + case 'vehicle_taillight': + case 'vehicle_taillights': + case 'car_headlight': + case 'car_headlights': + case 'car_taillight': + case 'car_taillights': + case 'headlight': + case 'headlights': + case 'taillight': + case 'taillights': + return 'headlights' + case 'vehicle_bumper': + case 'vehicle_bumpers': + case 'car_bumper': + case 'car_bumpers': + case 'bumper': + case 'bumpers': + return 'bumper' + case 'base': + case 'base_frame': + case 'bottom_base': + case 'skid': + case 'skid_base': + case 'base_skid': + case 'pump_base': + case 'support_base': + case 'support_frame': + case 'support_leg': + case 'support_legs': + case 'support_foot': + case 'support_feet': + return 'support_base' + case 'filter_housing': + case 'tall_housing': + case 'collector_housing': + return 'filter_housing' + case 'cooler_bed': + case 'grate_bed': + case 'cooler_grate': + return 'cooler_grate_bed' + case 'cooling_air_boxes': + case 'air_box': + case 'air_boxes': + return 'cooling_air_box' + case 'inlet_chute': + case 'feed_chute': + case 'inlet_duct': + case 'steam_inlet': + case 'steam_inlet_nozzle': + return 'inlet_port' + case 'outlet_chute': + case 'discharge_chute': + case 'outlet_duct': + case 'clean_air_outlet': + case 'exhaust_outlet': + case 'exhaust_outlet_nozzle': + return 'outlet_port' + case 'inspection_door': + case 'inspection_doors': + case 'access_door': + case 'access_doors': + return 'access_panel' + case 'pulse_jet_headers': + case 'pulse_header': + case 'pulse_headers': + return 'pulse_jet_header' + case 'filter_bag': + case 'filter_bags': + case 'filter_bags_row': + case 'filter_bag_rows': + return 'filter_bag_row' + case 'turbine_body': + case 'turbine_shell': + case 'turbine_casing': + return 'turbine_casing' + case 'rotor': + case 'rotor_axis': + case 'rotor_shaft': + return 'rotor_shaft' + case 'bearing': + case 'bearings': + case 'bearing_housings': + return 'bearing_housing' + case 'lubrication': + case 'lube_unit': + return 'lubrication_unit' + case 'plate_stack': + case 'plate_pack': + case 'heat_transfer_plate_stack': + return 'plate_stack' + case 'heat_transfer_plates': + case 'exchanger_plate': + case 'exchanger_plates': + return 'heat_transfer_plate' + case 'fixed_frame': + case 'fixed_end': + return 'fixed_end_frame' + case 'movable_pressure_plate': + case 'end_pressure_plate': + return 'pressure_plate' + case 'tie_rods': + case 'tie_bar': + case 'tie_bars': + return 'tie_rod' + case 'guide_bars': + case 'top_guide_bar': + case 'bottom_guide_bar': + return 'guide_bar' + case 'pump_body': + case 'pump_casing': + case 'pump_volute': + case 'volute': + case 'volute_casing': + return 'volute_casing' + case 'inlet': + case 'inlet_nozzle': + case 'top_nozzle': + case 'feed_nozzle': + case 'suction': + case 'suction_nozzle': + case 'inlet_port': + return 'inlet_port' + case 'outlet': + case 'outlet_nozzle': + case 'discharge': + case 'discharge_nozzle': + case 'outlet_port': + return 'outlet_port' + case 'motor': + case 'drive_motor': + case 'motor_body': + return 'drive_motor' + case 'coupling': + case 'shaft_coupling': + case 'coupling_area': + case 'coupling_guard': + case 'coupling_housing': + return 'coupling_housing' + case 'junction_box': + case 'control_box': + case 'control_panel': + case 'equipment_control_panel': + return 'control_panel' + case 'equipment_nameplate': + return 'nameplate' + case 'safety_label': + return 'warning_label' + case 'tank_body': + case 'tank_shell': + case 'cylindrical_tank': + case 'vessel_shell': + return 'vessel_shell' + case 'access_ladder': + case 'ladder': + case 'platform_ladder': + case 'access_platform': + return 'access_platform' + case 'top_manway': + case 'manway': + case 'manway_flange': + return 'inlet_port' + case 'drain_nozzle': + case 'drain_port': + return 'outlet_port' + case 'robot_base_plate': + case 'robot_pedestal': + case 'robot_swivel': + return 'robot_base' + case 'robot_lower_arm': + return 'upper_arm' + case 'robot_upper_arm': + return 'upper_arm' + case 'robot_forearm': + return 'forearm' + case 'robot_upper_arm_joint': + return 'shoulder_joint' + case 'robot_forearm_joint': + return 'elbow_joint' + case 'robot_wrist': + case 'wrist': + return 'wrist_joint' + case 'welding_torch': + case 'torch': + return 'end_effector' + case 'work_table_base': + case 'work_table_top': + return 'work_table' + case 'control_cabinet': + case 'control_cabinet_body': + return 'control_panel' + case 'safety_barrier': + return 'safety_barrier' + case 'inlet_flange': + case 'suction_flange': + case 'flange_inlet': + return 'flange_inlet' + case 'outlet_flange': + case 'discharge_flange': + case 'flange_outlet': + return 'flange_outlet' + case 'bonnet_bolt': + case 'bonnet_bolts': + case 'bonnet_bolt_pattern': + return 'bonnet_bolts' + case 'mixer_blade': + case 'mixer_blades': + case 'agitator_blade': + case 'agitator_blades': + case 'impeller_blade': + case 'impeller_blades': + return 'mixer_blades' + case 'mixer_rod': + case 'mixer_shaft': + case 'shaft': + case 'rod': + return 'mixer_shaft' + case 'mixer_hub': + case 'hub': + return 'mixer_hub' + case 'valve_stem': + return 'stem' + case 'valve_yoke': + return 'yoke' + case 'wedge': + case 'gate': + case 'gate_wedge': + return 'gate_wedge' + // Aircraft roles — normalize to canonical names checked by satisfiesRequiredRole + case 'fuselage': + case 'fuselage_body': + case 'body_fuselage': + case 'aircraft_body': + case 'airplane_body': + case 'airliner_body': + case 'aircraft_fuselage': + case 'aircraft_fuselage_body': + case 'airplane_fuselage': + case 'airplane_fuselage_body': + return 'aircraft_fuselage' + case 'airframe': + case 'complete_airframe': + case 'aircraft_airframe': + case 'complete_aircraft': + case 'complete_airplane': + case 'airplane_airframe': + return 'aircraft_complete_airframe' + case 'main_wing': + case 'main_wings': + case 'aircraft_wing': + case 'aircraft_wing_left': + case 'aircraft_wing_right': + case 'aircraft_left_wing': + case 'aircraft_right_wing': + case 'airplane_wing': + case 'airplane_wing_left': + case 'airplane_wing_right': + case 'left_wing': + case 'right_wing': + case 'wing': + case 'wings': + return 'aircraft_wing' + case 'horizontal_stabilizer': + case 'horizontal_stab': + case 'aircraft_tail_horizontal': + case 'aircraft_horizontal_tail': + case 'aircraft_horizontal_stabilizer': + case 'airplane_tail_horizontal': + case 'tail_horizontal': + case 'horizontal_tail': + case 'htail': + return 'aircraft_horizontal_stabilizer' + case 'vertical_stabilizer': + case 'vertical_stab': + case 'aircraft_tail_vertical': + case 'aircraft_vertical_tail': + case 'aircraft_vertical_stabilizer': + case 'airplane_tail_vertical': + case 'tail_vertical': + case 'vertical_tail': + case 'vtail': + case 'tail_fin': + return 'aircraft_vertical_stabilizer' + case 'propeller': + case 'aircraft_propeller': + case 'airplane_propeller': + return 'aircraft_propeller' + case 'landing_gear': + case 'aircraft_landing_gear': + case 'aircraft_landing_gear_main': + case 'aircraft_main_landing_gear': + case 'main_landing_gear': + case 'landing_gear_main': + return 'aircraft_landing_gear_main' + case 'nose_gear': + case 'nose_wheel': + case 'aircraft_landing_gear_nose': + case 'aircraft_nose_landing_gear': + case 'landing_gear_nose': + return 'aircraft_landing_gear_nose' + case 'aircraft_window': + case 'aircraft_windows': + case 'cabin_window': + case 'cabin_windows': + case 'aircraft_cabin_window': + case 'aircraft_cabin_windows': + case 'cockpit_window': + case 'cockpit_windows': + case 'aircraft_cockpit_window': + case 'aircraft_cockpit_windows': + return 'aircraft_window' + case 'aircraft_main_wing': + case 'aircraft_wings': + return 'aircraft_wing' + case 'engine': + case 'engines': + case 'aircraft_engine': + case 'aircraft_engines': + case 'engine_nacelle': + case 'aircraft_engine_nacelle': + case 'nacelle': + return 'aircraft_engine_nacelle' + case 'engine_nacelle_left': + case 'aircraft_engine_left': + case 'aircraft_left_engine': + case 'left_engine_nacelle': + case 'left_engine': + case 'aircraft_engine_nacelle_left': + case 'aircraft_left_engine_nacelle': + return 'engine_nacelle_left' + case 'engine_nacelle_right': + case 'aircraft_engine_right': + case 'aircraft_right_engine': + case 'right_engine_nacelle': + case 'right_engine': + case 'aircraft_engine_nacelle_right': + case 'aircraft_right_engine_nacelle': + return 'engine_nacelle_right' + case 'steering_wheel': + case 'steering': + case 'steering_rim': + case 'steering_wheel_outer_rim': + case 'wheel_rim': + return 'steering_wheel_rim' + case 'steering_wheel_center': + case 'steering_center': + case 'steering_hub': + case 'steering_wheel_center_hub': + case 'center_hub': + case 'centre_hub': + case 'central_hub': + case 'wheel_center_hub': + return 'steering_wheel_hub' + case 'spoke': + case 'spokes': + case 'steering_spoke': + case 'steering_spokes': + case 'steering_wheel_spokes': + case 'wheel_spoke': + case 'wheel_spokes': + return 'steering_wheel_spoke' + default: + return normalized + } +} + +function requiredRoles(brief: PrimitiveGeometryBrief | undefined): string[] { + return Array.from( + new Set( + [...(brief?.requiredRoles ?? []), ...(brief?.semanticRoles ?? [])].map(normalizeRequiredRole), + ), + ) +} + +function roleAliases(sourceArgs: Record | undefined, role: string): string[] { + const aliases = sourceArgs?.roleAliases + if (typeof aliases !== 'object' || aliases === null || Array.isArray(aliases)) return [] + const raw = (aliases as Record)[role] + return Array.isArray(raw) + ? raw + .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + .map(normalizeRequiredRole) + : [] +} + +const INDUSTRIAL_SOFT_REQUIRED_ROLES = new Set([ + 'access_panel', + 'access_platform', + 'support_base', + 'pulse_jet_header', + 'filter_bag_row', + 'inspection_door', + 'control_panel', + 'display_screen', + 'vent_panel', + 'lubrication_unit', + 'bearing_housing', + 'tie_rod', + 'guide_bar', +]) + +const INDUSTRIAL_HARD_REQUIRED_ROLES = new Set([ + 'cooler_grate_bed', + 'cooler_housing', + 'filter_housing', + 'turbine_casing', + 'plate_stack', + 'heat_transfer_plate', + 'volute_casing', + 'vessel_shell', + 'inlet_port', + 'outlet_port', +]) + +function isIndustrialFamily(family: SemanticFamily): boolean { + return ( + family === 'machine_tool' || + family === 'distillation_tower' || + family === 'forming_machine' || + family === 'material_handling' || + family === 'fluid_machine' || + family === 'process_equipment' || + family === 'unknown' + ) +} + +function shouldSoftFailRequiredRole(role: string, family: SemanticFamily): boolean { + if (!isIndustrialFamily(family)) return false + if (INDUSTRIAL_HARD_REQUIRED_ROLES.has(role)) return false + return ( + INDUSTRIAL_SOFT_REQUIRED_ROLES.has(role) || + /door|panel|platform|ladder|support|guard|bolt|label|header|row|rod|bar|unit|housing/.test(role) + ) +} + +function hasComponentScopedBrief(brief: PrimitiveGeometryBrief | undefined): boolean { + const category = brief?.category?.toLowerCase() ?? '' + if (!/(vehicle|car|automobile|auto|bicycle|bike|cycle)/.test(category)) return false + + const roles = requiredRoles(brief) + if (roles.length === 0) return false + + const completeVehicleRoles = new Set([ + 'body_shell', + 'wheel_set', + 'window_strip', + 'light_pair', + 'bar_pair', + 'vehicle_body', + 'vehicle_tire', + 'vehicle_window', + 'vehicle_headlight', + 'vehicle_taillight', + 'headlight', + 'taillight', + 'front_bumper', + 'rear_bumper', + 'bumper', + 'vehicle_bumper', + 'bicycle_frame', + 'bicycle_fork', + 'handlebar', + 'saddle', + 'chain_loop', + ]) + if (roles.some((role) => completeVehicleRoles.has(role))) return false + + return roles.some((role) => + /steering|mirror|dashboard|seat|wiper|door|handle|windshield|wheel|tire|tyre|rim|hub|spoke|component|part|accessory|subpart/.test( + role, + ), + ) +} + +function satisfiesRequiredRole(facts: PrimitiveGeometryFacts, role: string): boolean { + if ((facts.roles[role] ?? 0) > 0) return true + if ((facts.sourcePartKinds[role] ?? 0) > 0) return true + const hasText = (pattern: RegExp) => + facts.shapes.some((fact) => + pattern.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + + switch (role) { + case 'support_base': + return ( + (facts.roles.support_base ?? 0) > 0 || + (facts.roles.machine_base ?? 0) > 0 || + (facts.roles.skid_base ?? 0) > 0 || + (facts.roles.support_leg ?? 0) > 0 || + (facts.roles.support_foot ?? 0) > 0 || + (facts.sourcePartKinds.skid_base ?? 0) > 0 || + (facts.sourcePartKinds.generic_base ?? 0) > 0 || + (facts.sourcePartKinds.generic_foot_set ?? 0) > 0 || + hasText(/support|skid|base|leg|foot/) + ) + case 'filter_housing': + return ( + (facts.roles.filter_housing ?? 0) > 0 || + (facts.roles.machine_enclosure ?? 0) > 0 || + (facts.roles.main_body ?? 0) > 0 || + (facts.sourcePartKinds.generic_body ?? 0) > 0 || + hasText(/filter.*housing|baghouse|collector.*housing|main.*body/) + ) + case 'cooler_grate_bed': + return hasText(/grate|cooler.*bed|bed.*cooler|belt_surface/) + case 'cooler_housing': + return hasText(/cooler.*housing|housing.*cooler|enclosure|body|shell/) + case 'cooling_air_box': + return hasText(/air.*box|cooling.*box|plenum|under.?grate/) + case 'access_panel': + return ( + (facts.roles.access_panel ?? 0) > 0 || + (facts.roles.panel ?? 0) > 0 || + (facts.sourcePartKinds.generic_panel ?? 0) > 0 || + hasText(/access|inspection|door|panel|hatch/) + ) + case 'pulse_jet_header': + return hasText(/pulse|header|manifold|pipe|tube/) + case 'filter_bag_row': + return hasText(/filter.*bag|bag.*row|bag.*array|internal.*filter|panel/) + case 'inlet_port': + return ( + (facts.roles.inlet_port ?? 0) > 0 || + (facts.roles.feed_chute ?? 0) > 0 || + (facts.roles.inlet_chute ?? 0) > 0 || + (facts.roles.inlet_duct ?? 0) > 0 || + hasText(/inlet|suction|feed.*chute|infeed|material.*in|steam.*in/) + ) + case 'outlet_port': + return ( + (facts.roles.outlet_port ?? 0) > 0 || + (facts.roles.discharge_chute ?? 0) > 0 || + (facts.roles.outlet_chute ?? 0) > 0 || + (facts.roles.outlet_duct ?? 0) > 0 || + (facts.roles.clean_air_outlet ?? 0) > 0 || + hasText(/outlet|discharge|exhaust|outfeed|clean.*air|material.*out/) + ) + case 'turbine_casing': + return ( + (facts.roles.turbine_casing ?? 0) > 0 || + (facts.roles.compressor_casing ?? 0) > 0 || + (facts.sourcePartKinds.rounded_machine_body ?? 0) > 0 || + hasText(/turbine.*casing|casing.*turbine|rounded.*body|machine.*body/) + ) + case 'rotor_shaft': + return hasText(/rotor|shaft|axis|spindle/) + case 'bearing_housing': + return hasText(/bearing|pedestal|end.*cap|housing/) + case 'lubrication_unit': + return hasText(/lubrication|lube|oil|control|box/) + case 'plate_stack': + return hasText(/plate.*stack|stack.*plate|plate.*pack|main.*body/) + case 'heat_transfer_plate': + return hasText(/heat.*transfer.*plate|exchanger.*plate|plate/) + case 'fixed_end_frame': + return hasText(/fixed.*frame|end.*frame|frame/) + case 'pressure_plate': + return hasText(/pressure.*plate|movable.*plate|end.*plate|panel/) + case 'tie_rod': + return hasText(/tie.*rod|tie.*bar|rod|rail/) + case 'guide_bar': + return hasText(/guide.*bar|guide.*rail|rail/) + case 'steering_wheel_rim': + return facts.shapes.some((fact) => { + const text = + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase() + return ( + /steering.*(rim|wheel|ring)|(rim|wheel|ring).*steering/.test(text) || + fact.kind === 'torus' + ) + }) + case 'steering_wheel_hub': + if ((facts.roles.center_hub ?? 0) > 0) return true + if ((facts.roles.centre_hub ?? 0) > 0) return true + if ((facts.roles.central_hub ?? 0) > 0) return true + return facts.shapes.some((fact) => { + const text = + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase() + return /(steering|wheel|方向盘).*(hub|center|centre|中央|中心)|(hub|center|centre|中央|中心).*(steering|wheel|方向盘)/.test( + text, + ) + }) + case 'steering_wheel_spoke': + if ((facts.roles.spoke ?? 0) > 0) return true + if ((facts.roles.spokes ?? 0) > 0) return true + return facts.shapes.some((fact) => { + const text = + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase() + return /(steering|wheel|方向盘).*spoke|spoke.*(steering|wheel|方向盘)|辐条/.test(text) + }) + case 'wheel': + case 'wheels': + case 'wheelset': + return facts.shapes.some((fact) => { + const text = `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}` + .toLowerCase() + .trim() + return /(wheel|tire|tyre)/.test(text) || fact.kind === 'torus' + }) + case 'front_wheel': + if ((facts.roles.bicycle_tire ?? 0) >= 2) return true + return facts.shapes.some((fact) => { + const text = + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase() + return /front.*(wheel|tire|tyre)|(wheel|tire|tyre).*front/.test(text) + }) + case 'rear_wheel': + case 'rear_wheels': + if ((facts.roles.bicycle_tire ?? 0) >= 2) return true + return facts.shapes.some((fact) => { + const text = + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase() + return /rear.*(wheel|tire|tyre)|(wheel|tire|tyre).*rear/.test(text) + }) + case 'frame': + return (facts.roles.bicycle_frame ?? 0) > 0 || (facts.sourcePartKinds.bicycle_frame ?? 0) > 0 + case 'fork': + case 'front_fork': + return (facts.roles.bicycle_fork ?? 0) > 0 || (facts.sourcePartKinds.bicycle_fork ?? 0) > 0 + case 'bicycle_wheels': + return (facts.sourcePartKinds.bicycle_wheels ?? 0) > 0 || (facts.roles.bicycle_tire ?? 0) >= 2 + case 'handlebar': + return ( + (facts.roles.handlebar ?? 0) > 0 || + (facts.roles.bicycle_handlebar ?? 0) > 0 || + (facts.roles.bicycle_handlebars ?? 0) > 0 || + (facts.sourcePartKinds.handlebar ?? 0) > 0 || + facts.shapes.some((fact) => + /handlebar|handlebars/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'saddle': + return ( + (facts.roles.saddle ?? 0) > 0 || + (facts.roles.bicycle_saddle ?? 0) > 0 || + (facts.roles.bicycle_seat ?? 0) > 0 || + (facts.roles.seat ?? 0) > 0 || + (facts.sourcePartKinds.saddle ?? 0) > 0 || + facts.shapes.some((fact) => + /saddle|seat/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'crank': + return ( + (facts.roles.crank ?? 0) > 0 || + facts.shapes.some((fact) => + /crank|bottom.bracket/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'chainring': + return ( + (facts.roles.chainring ?? 0) > 0 || + facts.shapes.some((fact) => + /chainring|front.sprocket/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'pedal': + return ( + (facts.roles.pedal ?? 0) > 0 || + facts.shapes.some((fact) => + /pedal/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'chain_loop': + return ( + (facts.roles.chain_loop ?? 0) > 0 || + (facts.roles.chain_drive ?? 0) > 0 || + (facts.sourcePartKinds.chain_loop ?? 0) > 0 || + facts.shapes.some((fact) => + /chain|drivetrain|drive.train|chainring|sprocket/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'vehicle_tire': + return factsBy(facts, isVehicleTire).length > 0 + case 'vehicle_wheels': + return (facts.sourcePartKinds.vehicle_wheels ?? 0) > 0 || (facts.roles.vehicle_tire ?? 0) >= 4 + case 'vehicle_windows': + return ( + (facts.sourcePartKinds.vehicle_windows ?? 0) > 0 || + (facts.roles.vehicle_window ?? 0) > 0 || + (facts.roles.vehicle_glass ?? 0) > 0 || + (facts.roles.glass ?? 0) > 0 + ) + case 'headlights': + return ( + (facts.sourcePartKinds.headlights ?? 0) > 0 || + (facts.sourcePartKinds.light_pair ?? 0) > 0 || + (facts.roles.headlight ?? 0) + + (facts.roles.vehicle_headlight ?? 0) + + (facts.roles.vehicle_taillight ?? 0) + + (facts.roles.taillight ?? 0) >= + 2 + ) + case 'bumper': + return ( + (facts.sourcePartKinds.bumper ?? 0) > 0 || + (facts.roles.vehicle_bumper ?? 0) >= 2 || + ((facts.roles.front_bumper ?? 0) > 0 && (facts.roles.rear_bumper ?? 0) > 0) + ) + case 'control_panel': + return ( + (facts.roles.control_panel ?? 0) > 0 || + (facts.roles.control_box ?? 0) > 0 || + (facts.roles.control_detail ?? 0) > 0 || + (facts.sourcePartKinds.control_box ?? 0) > 0 || + (facts.sourcePartKinds.generic_control_panel ?? 0) > 0 + ) + case 'display_screen': + return ( + (facts.roles.display_screen ?? 0) > 0 || + (facts.roles.display ?? 0) > 0 || + (facts.roles.control_panel ?? 0) > 0 || + (facts.sourcePartKinds.generic_display ?? 0) > 0 || + hasText(/display|screen|hmi|control/) + ) + case 'vent_panel': + return ( + (facts.roles.vent_panel ?? 0) > 0 || + (facts.roles.detail_accent ?? 0) > 0 || + (facts.sourcePartKinds.generic_detail_accent ?? 0) > 0 || + hasText(/vent|louver|slat|detail|accent|panel/) + ) + case 'drive_motor': + return ( + (facts.roles.drive_motor ?? 0) > 0 || + (facts.roles.motor_body ?? 0) > 0 || + (facts.sourcePartKinds.ribbed_motor_body ?? 0) > 0 + ) + case 'coupling_housing': + return ( + (facts.roles.coupling_housing ?? 0) > 0 || + (facts.roles.shaft_coupling ?? 0) > 0 || + ((facts.roles.drive_motor ?? 0) > 0 && (facts.roles.volute_casing ?? 0) > 0) + ) + case 'flange_inlet': + return ( + (facts.roles.flange_inlet ?? 0) > 0 || + (facts.roles.inlet_flange ?? 0) > 0 || + (facts.roles.flange ?? 0) > 0 || + (facts.sourcePartKinds.flange_ring ?? 0) > 0 + ) + case 'flange_outlet': + return ( + (facts.roles.flange_outlet ?? 0) > 0 || + (facts.roles.outlet_flange ?? 0) > 0 || + (facts.roles.flange ?? 0) > 0 || + (facts.sourcePartKinds.flange_ring ?? 0) > 0 + ) + case 'vessel_shell': + return ( + (facts.roles.vessel_shell ?? 0) > 0 || + (facts.roles.cylindrical_tank ?? 0) > 0 || + (facts.sourcePartKinds.cylindrical_tank ?? 0) > 0 + ) + case 'access_platform': + return ( + (facts.roles.access_platform ?? 0) > 0 || + (facts.roles.platform_ladder ?? 0) > 0 || + (facts.sourcePartKinds.platform_ladder ?? 0) > 0 + ) + case 'work_table': + return ( + (facts.roles.work_table ?? 0) > 0 || + (facts.roles.fixture_table ?? 0) > 0 || + facts.shapes.some((fact) => + /work.?table|fixture.?table/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'safety_barrier': + return ( + (facts.roles.safety_barrier ?? 0) > 0 || + facts.shapes.some((fact) => + /safety.?barrier|guard.?rail|fence/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'mixer_blades': + return (facts.roles.mixer_blade ?? 0) >= 3 || (facts.sourcePartKinds.mixer_blades ?? 0) >= 3 + case 'mixer_shaft': + return (facts.roles.mixer_shaft ?? 0) > 0 || (facts.sourcePartKinds.mixer_shaft ?? 0) > 0 + case 'mixer_hub': + return (facts.roles.mixer_hub ?? 0) > 0 || (facts.sourcePartKinds.mixer_hub ?? 0) > 0 + // Aircraft roles — satisfied by semanticRole, sourcePartKind, or compatible part kinds + case 'aircraft_fuselage': + return ( + (facts.roles.aircraft_fuselage ?? 0) > 0 || + (facts.sourcePartKinds.aircraft_fuselage ?? 0) > 0 || + (facts.sourcePartKinds.streamlined_body ?? 0) > 0 || + (facts.roles.fuselage ?? 0) > 0 + ) + case 'aircraft_complete_airframe': + return ( + satisfiesRequiredRole(facts, 'aircraft_fuselage') && + satisfiesRequiredRole(facts, 'aircraft_wing') && + satisfiesRequiredRole(facts, 'aircraft_horizontal_stabilizer') && + satisfiesRequiredRole(facts, 'aircraft_vertical_stabilizer') + ) + case 'aircraft_wing': + return ( + (facts.roles.aircraft_wing ?? 0) > 0 || + (facts.sourcePartKinds.aircraft_wing ?? 0) > 0 || + (facts.sourcePartKinds.airfoil_blade ?? 0) > 0 || + (facts.sourcePartKinds.lofted_panel ?? 0) > 0 || + (facts.roles.main_wing ?? 0) > 0 || + (facts.roles.wing ?? 0) > 0 + ) + case 'aircraft_horizontal_stabilizer': + return ( + (facts.roles.aircraft_horizontal_stabilizer ?? 0) > 0 || + (facts.roles.horizontal_stabilizer ?? 0) > 0 || + (facts.roles.htail ?? 0) > 0 || + facts.shapes.some((f) => + /horizontal.stab|h.stab|htail/.test( + `${f.semanticRole ?? ''} ${f.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'aircraft_vertical_stabilizer': + return ( + (facts.roles.aircraft_vertical_stabilizer ?? 0) > 0 || + (facts.roles.vertical_stabilizer ?? 0) > 0 || + (facts.roles.tail_fin ?? 0) > 0 || + (facts.roles.vtail ?? 0) > 0 || + facts.shapes.some((f) => + /vertical.stab|v.stab|vtail|tail.fin/.test( + `${f.semanticRole ?? ''} ${f.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'aircraft_propeller': + return ( + (facts.roles.aircraft_propeller ?? 0) > 0 || + (facts.sourcePartKinds.propeller_blade_set ?? 0) > 0 || + (facts.sourcePartKinds.mixer_blades ?? 0) > 0 || + (facts.roles.propeller ?? 0) > 0 || + (facts.roles.mixer_blade ?? 0) >= 2 + ) + case 'aircraft_landing_gear_main': + return ( + (facts.roles.aircraft_landing_gear_main ?? 0) > 0 || + (facts.roles.landing_gear_wheel ?? 0) >= 2 || + (facts.roles.landing_gear ?? 0) > 0 || + (facts.sourcePartKinds.wheel_set ?? 0) > 0 || + (facts.sourcePartKinds.aircraft_landing_gear ?? 0) > 0 || + facts.shapes.some((f) => + /landing.gear|main.gear/.test(`${f.semanticRole ?? ''} ${f.name ?? ''}`.toLowerCase()), + ) + ) + case 'aircraft_landing_gear_nose': + return ( + (facts.roles.aircraft_landing_gear_nose ?? 0) > 0 || + (facts.roles.landing_gear_wheel ?? 0) > 0 || + (facts.roles.nose_wheel ?? 0) > 0 || + (facts.roles.nose_gear ?? 0) > 0 || + facts.shapes.some((f) => + /nose.gear|nose.wheel/.test(`${f.semanticRole ?? ''} ${f.name ?? ''}`.toLowerCase()), + ) + ) + case 'aircraft_window': + return ( + (facts.roles.aircraft_window ?? 0) > 0 || + (facts.roles.cabin_window ?? 0) > 0 || + (facts.roles.cockpit_window ?? 0) > 0 || + (facts.sourcePartKinds.window_strip ?? 0) > 0 || + (facts.sourcePartKinds.window_panel ?? 0) > 0 + ) + case 'aircraft_engine_nacelle': + return ( + (facts.roles.aircraft_engine_nacelle ?? 0) > 0 || + (facts.roles.engine_nacelle ?? 0) > 0 || + (facts.roles.engine_nacelle_left ?? 0) > 0 || + (facts.roles.engine_nacelle_right ?? 0) > 0 || + (facts.roles.nacelle ?? 0) > 0 || + (facts.sourcePartKinds.aircraft_engine ?? 0) > 0 || + facts.shapes.some((f) => + /nacelle|engine.pod|engine.mount/.test( + `${f.semanticRole ?? ''} ${f.name ?? ''}`.toLowerCase(), + ), + ) + ) + case 'engine_nacelle_left': + return ( + (facts.roles.engine_nacelle_left ?? 0) > 0 || + (facts.sourcePartKinds.aircraft_engine ?? 0) > 0 || + (facts.roles.engine_nacelle ?? 0) >= 2 || + facts.shapes.some((f) => /left.*nacelle|nacelle.*left/.test(factName(f))) + ) + case 'engine_nacelle_right': + return ( + (facts.roles.engine_nacelle_right ?? 0) > 0 || + (facts.sourcePartKinds.aircraft_engine ?? 0) > 0 || + (facts.roles.engine_nacelle ?? 0) >= 2 || + facts.shapes.some((f) => /right.*nacelle|nacelle.*right/.test(factName(f))) + ) + default: + return false + } +} + +function requestedRed(options: PrimitiveSemanticValidationOptions): boolean { + const text = [ + options.prompt, + options.geometryBrief?.category, + sourceText(options.sourceArgs), + textOf(options.sourceArgs?.primaryColor), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + return /\bred\b|#cc0000|#ff0000|红色|紅色/.test(text) +} + +function isRedColor(color: string | undefined): boolean { + if (!color) return false + const normalized = color.trim().toLowerCase() + if (normalized === 'red') return true + const hex = normalized.match(/^#?([0-9a-f]{6})$/) + if (!hex) return false + const value = hex[1] + if (!value) return false + const r = Number.parseInt(value.slice(0, 2), 16) + const g = Number.parseInt(value.slice(2, 4), 16) + const b = Number.parseInt(value.slice(4, 6), 16) + return r >= 150 && g <= 90 && b <= 90 +} + +function validateRequiredRoles( + facts: PrimitiveGeometryFacts, + options: PrimitiveSemanticValidationOptions, + family: SemanticFamily, + issues: string[], + warnings: string[], +) { + for (const role of requiredRoles(options.geometryBrief)) { + const aliases = roleAliases(options.sourceArgs, role) + if ( + !satisfiesRequiredRole(facts, role) && + !aliases.some((alias) => satisfiesRequiredRole(facts, alias)) + ) { + const message = `required semantic role "${role}" is missing.` + if (shouldSoftFailRequiredRole(role, family)) { + warnings.push(message) + } else { + issues.push(message) + } + } + } +} + +function validateVehicle( + facts: PrimitiveGeometryFacts, + options: PrimitiveSemanticValidationOptions, + issues: string[], + warnings: string[], +) { + const bodies = factsBy(facts, isVehicleBody) + const body = bodies[0] + const tires = factsBy(facts, isVehicleTire) + const windows = factsBy(facts, isVehicleWindow) + const headlights = factsBy(facts, isHeadlight) + const bumpers = factsBy(facts, isBumper) + + if (bodies.length !== 1) { + issues.push(`vehicle requires exactly 1 main body shell, got ${bodies.length}.`) + } + if (tires.length !== 4) { + issues.push(`vehicle requires exactly 4 tires arranged as two axles, got ${tires.length}.`) + } + if (windows.length === 0) issues.push('vehicle requires windows/glass above the body.') + if (headlights.length < 2) { + issues.push(`vehicle requires left/right headlights, got ${headlights.length}.`) + } + if (bumpers.length < 2) { + issues.push(`vehicle requires front and rear bumper bars, got ${bumpers.length}.`) + } + + if (body && tires.length === 4) { + const averageTireRadius = + tires.reduce((total, tire) => total + Math.max(tire.halfExtents[1], tire.halfExtents[2]), 0) / + tires.length + const tolerance = Math.max(0.04, averageTireRadius * 1.15) + if ( + countClusters( + tires.map((tire) => tire.center[0]), + tolerance, + ) < 2 + ) { + issues.push( + 'vehicle tires must form two separated front/rear axle positions along the length axis.', + ) + } + if ( + countClusters( + tires.map((tire) => tire.center[2]), + tolerance, + ) < 2 + ) { + issues.push('vehicle tires must form left/right pairs across the body width.') + } + const bodyWidth = body.max[2] - body.min[2] + const tireSpread = + Math.max(...tires.map((tire) => tire.center[2])) - + Math.min(...tires.map((tire) => tire.center[2])) + if (tireSpread < bodyWidth * 0.55) { + warnings.push( + 'vehicle tire width spread is narrow; wheels may read as hidden under the body.', + ) + } + } + + if (body && windows.length > 0 && windows.some((window) => window.center[1] <= body.center[1])) { + issues.push('vehicle windows must sit above the main body centerline.') + } + + if (body && headlights.length > 0) { + const frontLimit = body.max[0] - (body.max[0] - body.min[0]) * 0.18 + const rearLimit = body.min[0] + (body.max[0] - body.min[0]) * 0.18 + if ( + !headlights.some((light) => light.center[0] >= frontLimit || light.center[0] <= rearLimit) + ) { + issues.push('vehicle headlights must be placed near one longitudinal end of the body.') + } + } + + if (body && bumpers.length >= 2) { + const frontLimit = body.max[0] - (body.max[0] - body.min[0]) * 0.18 + const rearLimit = body.min[0] + (body.max[0] - body.min[0]) * 0.18 + const hasPositiveEndBumper = bumpers.some((bumper) => bumper.center[0] >= frontLimit) + const hasNegativeEndBumper = bumpers.some((bumper) => bumper.center[0] <= rearLimit) + const namedFrontRear = + bumpers.some( + (bumper) => hasRole(bumper, ['front_bumper']) || factName(bumper).includes('front'), + ) && + bumpers.some( + (bumper) => hasRole(bumper, ['rear_bumper']) || factName(bumper).includes('rear'), + ) && + Math.max(...bumpers.map((bumper) => bumper.center[0])) - + Math.min(...bumpers.map((bumper) => bumper.center[0])) >= + (body.max[0] - body.min[0]) * 0.5 + if (!hasPositiveEndBumper && !namedFrontRear) { + issues.push('vehicle needs a front bumper at the positive length end.') + } + if (!hasNegativeEndBumper && !namedFrontRear) { + issues.push('vehicle needs a rear bumper at the negative length end.') + } + } + + if (body && requestedRed(options) && !isRedColor(body.materialColor)) { + issues.push('requested red vehicle body, but the main body material is not red.') + } +} + +function validateBicycle(facts: PrimitiveGeometryFacts, issues: string[], warnings: string[]) { + const tires = factsBy(facts, isBicycleTire) + if (tires.length !== 2) { + issues.push( + `bicycle requires exactly 2 tires from one bicycle_wheels wheelset, got ${tires.length}.`, + ) + } + + const requiredRoles = ['bicycle_frame', 'bicycle_fork', 'handlebar', 'saddle', 'chain_loop'] + for (const role of requiredRoles) { + if (!satisfiesRequiredRole(facts, role)) issues.push(`bicycle requires ${role}.`) + } + + if (tires.length === 2) { + const groundYs = tires.map((tire) => tire.min[1]) + const delta = Math.abs((groundYs[0] ?? 0) - (groundYs[1] ?? 0)) + if (delta > 0.03) issues.push('bicycle tires must share the same ground/contact height.') + const axleDistance = Math.abs((tires[0]?.center[0] ?? 0) - (tires[1]?.center[0] ?? 0)) + if ( + axleDistance < + Math.max(tires[0]?.halfExtents[1] ?? 0.1, tires[1]?.halfExtents[1] ?? 0.1) * 1.8 + ) { + warnings.push( + 'bicycle wheelbase is very short; the silhouette may read as a cart wheel pair.', + ) + } + } +} + +function validateMixer(facts: PrimitiveGeometryFacts, issues: string[], warnings: string[]) { + const shafts = factsBy(facts, (fact) => hasRole(fact, ['mixer_shaft', 'agitator_shaft'])) + const hubs = factsBy(facts, (fact) => + hasRole(fact, ['mixer_hub', 'agitator_hub', 'reactor_impeller_hub']), + ) + const blades = factsBy(facts, (fact) => hasRole(fact, ['mixer_blade', 'reactor_impeller'])) + + if (shafts.length < 1) issues.push('mixer requires one readable vertical shaft/rod.') + if (hubs.length < 1) issues.push('mixer requires a lower hub connecting blades to the shaft.') + if (blades.length < 3) { + issues.push(`mixer requires at least 3 radial flat blades, got ${blades.length}.`) + } + + const shaft = shafts[0] + if (shaft && shaft.min[1] > 0.04) warnings.push('mixer shaft is floating above the ground plane.') + + if (shaft && blades.length >= 3) { + const shaftRadius = Math.max(shaft.halfExtents[0], shaft.halfExtents[2]) + const radialCenters = blades.map((blade) => + Math.hypot(blade.center[0] - shaft.center[0], blade.center[2] - shaft.center[2]), + ) + if (Math.min(...radialCenters) < shaftRadius * 1.5) { + issues.push('mixer blades must extend radially outward from the shaft, not overlap the rod.') + } + if (blades.some((blade) => blade.center[1] > shaft.center[1])) { + warnings.push( + 'mixer blades are high on the shaft; common mud mixer paddles sit near the lower end.', + ) + } + } +} + +function hasAnyRole(facts: PrimitiveGeometryFacts, roles: string[]): boolean { + return roles.some((role) => { + if ((facts.roles[role] ?? 0) > 0 || (facts.sourcePartKinds[role] ?? 0) > 0) return true + if (role === 'inlet_port') { + return hasAnyRole(facts, [ + 'top_nozzle', + 'feed_nozzle', + 'inlet_nozzle', + 'suction_nozzle', + 'manway', + 'manway_flange', + ]) + } + if (role === 'outlet_port') { + return hasAnyRole(facts, ['drain_nozzle', 'discharge_nozzle', 'outlet_nozzle']) + } + return false + }) +} + +function validateIndustrialFamily( + family: SemanticFamily, + facts: PrimitiveGeometryFacts, + issues: string[], +) { + if (family === 'machine_tool') { + if (!hasAnyRole(facts, ['machine_base', 'machine_bed', 'cutting_table'])) { + issues.push('machine tool requires a readable base/bed/cutting table.') + } + if ( + !hasAnyRole(facts, [ + 'spindle_head', + 'spindle_chuck', + 'tool_head', + 'laser_head', + 'grinding_wheel', + 'milling_cutter', + 'drill_bit', + ]) + ) { + issues.push('machine tool requires a readable spindle/chuck/tool/laser head.') + } + if (!hasAnyRole(facts, ['control_panel'])) { + issues.push('machine tool requires a visible control panel.') + } + } + if (family === 'forming_machine') { + if (!hasAnyRole(facts, ['press_frame'])) + issues.push('forming machine requires a press/clamp frame.') + if (!hasAnyRole(facts, ['machine_base', 'press_bed'])) { + issues.push('forming machine requires a base or press bed.') + } + if (!hasAnyRole(facts, ['control_panel'])) { + issues.push('forming machine requires a visible control panel.') + } + } + if (family === 'material_handling') { + const grateCoolerIntent = facts.shapes.some((fact) => + /grate|cooler/.test( + `${fact.semanticRole ?? ''} ${fact.sourcePartKind ?? ''} ${fact.name ?? ''}`.toLowerCase(), + ), + ) + const isGrateCooler = + (grateCoolerIntent && satisfiesRequiredRole(facts, 'cooler_grate_bed')) || + (grateCoolerIntent && satisfiesRequiredRole(facts, 'cooler_housing')) + if (isGrateCooler) { + if (!satisfiesRequiredRole(facts, 'cooler_grate_bed')) { + issues.push('grate cooler requires a readable grate bed.') + } + if (!satisfiesRequiredRole(facts, 'cooling_air_box')) { + issues.push('grate cooler requires visible under-grate cooling air boxes.') + } + return + } + if (!hasAnyRole(facts, ['conveyor_frame', 'press_frame_rails', 'support_frame'])) { + issues.push('material handling equipment requires a conveyor/frame structure.') + } + if ( + !hasAnyRole(facts, ['belt_surface', 'roller_array', 'filter_plate_stack', 'screw_flight']) + ) { + issues.push('material handling equipment requires a belt surface or roller array.') + } + } + if (family === 'fluid_machine') { + if (!hasAnyRole(facts, ['pump_casing', 'compressor_casing', 'volute_casing'])) { + issues.push('fluid machine requires a readable pump/compressor casing.') + } + for (const role of ['inlet_port', 'outlet_port']) { + if (!hasAnyRole(facts, [role])) issues.push(`fluid machine requires ${role}.`) + } + } + if (family === 'process_equipment') { + if (!hasAnyRole(facts, ['heat_exchanger_shell', 'reactor_vessel_shell', 'vessel_shell'])) { + issues.push('process equipment requires a readable vessel/shell.') + } + if (!hasAnyRole(facts, ['inlet_port', 'outlet_port'])) { + issues.push('process equipment requires visible process ports.') + } + } + if (family === 'distillation_tower') { + if (!hasAnyRole(facts, ['distillation_column_shell'])) { + issues.push('distillation tower requires a tall vertical column shell.') + } + if (!hasAnyRole(facts, ['tray_level'])) { + issues.push('distillation tower requires readable tray/packing levels.') + } + if (!hasAnyRole(facts, ['inlet_port', 'outlet_port', 'overhead_vapor_outlet'])) { + issues.push('distillation tower requires visible process nozzles/ports.') + } + if (!hasAnyRole(facts, ['access_platform', 'ladder'])) { + issues.push('distillation tower requires readable access platform or ladder details.') + } + } +} + +function validateRobotArm(facts: PrimitiveGeometryFacts, issues: string[], warnings: string[]) { + const requiredRoles = [ + 'robot_base', + 'base_joint', + 'shoulder_joint', + 'upper_arm', + 'elbow_joint', + 'forearm', + 'end_effector', + ] + for (const role of requiredRoles) { + if ((facts.roles[role] ?? 0) === 0) issues.push(`robot arm requires ${role}.`) + } + + const base = facts.shapes.find((fact) => hasRole(fact, ['robot_base'])) + if (base && base.min[1] > 0.04) { + warnings.push('robot arm base is floating above the ground plane.') + } + + const joints = factsBy(facts, (fact) => + hasRole(fact, ['base_joint', 'shoulder_joint', 'elbow_joint', 'wrist_joint']), + ) + if (joints.length < 3) { + issues.push(`robot arm requires at least 3 readable joint housings, got ${joints.length}.`) + } + + const links = factsBy(facts, (fact) => hasRole(fact, ['upper_arm', 'forearm'])) + if (links.length < 2) { + issues.push('robot arm requires separate upper_arm and forearm links.') + } +} + +export function validatePrimitiveSemantics( + shapes: readonly PrimitiveShapeInput[], + transforms: readonly ResolvedPrimitiveTransform[] = [], + options: PrimitiveSemanticValidationOptions = {}, +): PrimitiveSemanticValidationResult { + const facts = buildPrimitiveGeometryFacts(shapes, transforms) + const family = detectFamily(facts, options) + const issues: string[] = [] + const warnings: string[] = [] + + validateRequiredRoles(facts, options, family, issues, warnings) + + if (facts.shapeCount === 0) issues.push('no primitive geometry facts were produced.') + if (facts.dimensions.some((dimension) => dimension > 50)) { + warnings.push( + 'generated object bounding box is unusually large for meter-based primitive output.', + ) + } + + switch (family) { + case 'vehicle': + validateVehicle(facts, options, issues, warnings) + break + case 'bicycle': + validateBicycle(facts, issues, warnings) + break + case 'robot_arm': + validateRobotArm(facts, issues, warnings) + break + case 'mixer': + validateMixer(facts, issues, warnings) + break + case 'machine_tool': + case 'distillation_tower': + case 'forming_machine': + case 'material_handling': + case 'fluid_machine': + case 'process_equipment': + validateIndustrialFamily(family, facts, issues) + break + } + + const score = Math.max(0, Number((1 - issues.length * 0.18 - warnings.length * 0.05).toFixed(4))) + return { + ok: issues.length === 0, + family, + score, + issues, + warnings, + recommendations: issues.map((issue) => `Repair geometry: ${issue}`), + facts, + } +} diff --git a/packages/core/src/lib/primitive-visual-quality.test.ts b/packages/core/src/lib/primitive-visual-quality.test.ts new file mode 100644 index 000000000..f6b2f2160 --- /dev/null +++ b/packages/core/src/lib/primitive-visual-quality.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test } from 'bun:test' +import { composeAssemblyPrimitives } from './assembly-compose' +import { composePartPrimitives } from './part-compose' +import type { PrimitiveShapeInput } from './primitive-compose' +import { resolvePrimitiveWorldTransforms } from './primitive-compose' +import { assessPrimitiveVisualQuality } from './primitive-visual-quality' +import { composeRobotArmPrimitives } from './robot-arm-compose' + +function assess(shapes: PrimitiveShapeInput[], prompt = 'car') { + return assessPrimitiveVisualQuality( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt, + geometryBrief: { category: 'vehicle' }, + }, + ) +} + +describe('assessPrimitiveVisualQuality', () => { + test('flags a semantic but blocky vehicle as low visual quality', () => { + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: 'block car body', + semanticRole: 'vehicle_body', + position: [0, 0.95, 0], + length: 4, + width: 1.8, + height: 1.5, + }, + ...[-1.35, 1.35].flatMap((x) => + [-0.82, 0.82].map( + (z): PrimitiveShapeInput => ({ + kind: 'torus', + name: 'block car tire', + semanticRole: 'vehicle_tire', + position: [x, 0.3, z], + axis: 'z', + majorRadius: 0.25, + tubeRadius: 0.06, + }), + ), + ), + { + kind: 'rounded-panel', + name: 'single car window sticker', + semanticRole: 'vehicle_window', + position: [0.2, 1.55, 0], + length: 0.7, + width: 1.1, + thickness: 0.02, + }, + { + kind: 'sphere', + name: 'left headlight', + semanticRole: 'headlight', + position: [1.95, 0.7, -0.45], + radius: 0.05, + }, + { + kind: 'sphere', + name: 'right headlight', + semanticRole: 'headlight', + position: [1.95, 0.7, 0.45], + radius: 0.05, + }, + { + kind: 'box', + name: 'front bumper', + semanticRole: 'front_bumper', + position: [2.05, 0.45, 0], + length: 0.06, + width: 1.5, + height: 0.08, + }, + { + kind: 'box', + name: 'rear bumper', + semanticRole: 'rear_bumper', + position: [-2.05, 0.45, 0], + length: 0.06, + width: 1.5, + height: 0.08, + }, + ] + + const result = assess(shapes) + + expect(result.family).toBe('vehicle') + expect(result.score).toBeLessThan(0.65) + expect(result.issues).toContain( + 'vehicle needs a separate cabin/roof mass, not one plain body block.', + ) + expect(result.issues).toContain('vehicle needs separated windshield/rear/side windows, got 1.') + }) + + test('accepts the default vehicle compose_parts silhouette as visually complete', () => { + const shapes = composePartPrimitives({ + name: 'Red sedan', + primaryColor: '#cc0000', + parts: [{ kind: 'vehicle_body', length: 4.4, width: 1.8, height: 1.35 }], + }) + + const result = assess(shapes, 'red sedan car') + + expect(result.family).toBe('vehicle') + expect(result.score).toBeGreaterThanOrEqual(0.85) + expect(result.issues).toEqual([]) + expect(result.metrics.wheelRadiusToLength).toBeGreaterThan(0.045) + }) + + test('accepts compose_robot_arm output as a readable editable robot arm', () => { + const shapes = composeRobotArmPrimitives({ + name: '3-axis robot arm', + axisCount: 3, + pose: 'work-ready', + endEffector: 'gripper', + detail: 'medium', + }) + + const result = assessPrimitiveVisualQuality( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'generate a 3-axis robot arm', + geometryBrief: { category: 'robot_arm' }, + }, + ) + + expect(result.family).toBe('robot_arm') + expect(result.score).toBeGreaterThanOrEqual(0.8) + expect(result.issues).toEqual([]) + expect(result.metrics.jointCount).toBeGreaterThanOrEqual(3) + }) + + test('composes robot arms with different axis counts without adding workcell clutter', () => { + const cases = [ + { axisCount: 4, expected: ['wrist_roll_joint'], forbidden: ['wrist_pitch_joint'] }, + { axisCount: 5, expected: ['wrist_roll_joint', 'wrist_pitch_joint'], forbidden: [] }, + { axisCount: 6, expected: ['wrist_roll_joint', 'wrist_pitch_joint'], forbidden: [] }, + { + axisCount: 7, + expected: ['redundant_axis_joint', 'wrist_roll_joint', 'wrist_pitch_joint'], + forbidden: [], + }, + ] + + for (const testCase of cases) { + const shapes = composeRobotArmPrimitives({ + name: `${testCase.axisCount}-axis robot arm`, + axisCount: testCase.axisCount, + pose: 'work-ready', + endEffector: 'tool-flange', + }) + const roles = new Set(shapes.map((shape) => shape.semanticRole)) + + expect(roles.has('robot_base')).toBe(true) + expect(roles.has('shoulder_joint')).toBe(true) + expect(roles.has('upper_arm')).toBe(true) + expect(roles.has('forearm')).toBe(true) + expect(roles.has('wrist_joint')).toBe(true) + expect(roles.has('work_table')).toBe(false) + for (const role of testCase.expected) expect(roles.has(role)).toBe(true) + for (const role of testCase.forbidden) expect(roles.has(role)).toBe(false) + } + }) + + test('scores standing fan grill depth and blade readability', () => { + const shapes = composePartPrimitives({ + name: 'Standing fan', + parts: [{ kind: 'protective_grill' }], + }) + + const result = assessPrimitiveVisualQuality( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'standing electric fan', + geometryBrief: { category: 'fan' }, + }, + ) + + expect(result.family).toBe('fan') + expect(result.score).toBeGreaterThanOrEqual(0.8) + expect(result.issues).toEqual([]) + expect(result.metrics.grillRingCount).toBeGreaterThanOrEqual(4) + expect(result.metrics.grillSideRibCount).toBeGreaterThanOrEqual(6) + }) + + test('flags under-specified industrial equipment and accepts industrial assembly', () => { + const boxOnly: PrimitiveShapeInput[] = [ + { + kind: 'box', + name: 'plain cnc machine block', + semanticRole: 'machine_base', + position: [0, 0.5, 0], + length: 2, + width: 1, + height: 1, + }, + ] + const poor = assessPrimitiveVisualQuality( + boxOnly, + resolvePrimitiveWorldTransforms(boxOnly, { positionMode: 'world-center' }), + { + prompt: 'cnc industrial machine', + geometryBrief: { category: 'industrial_equipment' }, + }, + ) + expect(poor.family).toBe('industrial_equipment') + expect(poor.score).toBeLessThan(0.7) + expect(poor.issues).toContain( + 'industrial equipment silhouette is under-specified with only 1 shapes.', + ) + + const shapes = composeAssemblyPrimitives({ + family: 'machine_tool', + object: 'machining center', + }) + const good = assessPrimitiveVisualQuality( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'cnc machining center', + geometryBrief: { category: 'industrial_equipment' }, + }, + ) + expect(good.family).toBe('industrial_equipment') + expect(good.score).toBeGreaterThanOrEqual(0.8) + expect(good.issues).toEqual([]) + }) + + test('treats reactor assemblies as industrial equipment even when prompt lists robot arm capabilities', () => { + const shapes = composeAssemblyPrimitives({ + family: 'reactor', + object: 'stirred reactor', + }) + const result = assessPrimitiveVisualQuality( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: + 'Factory geometry capabilities include conveyor, tank, reactor, and robot arm. User asks for 反应釜装置.', + geometryBrief: { category: 'industrial_process_equipment' }, + }, + ) + + expect(result.family).toBe('industrial_equipment') + expect(result.issues).not.toContain('robot arm visual quality requires robot_base.') + }) + + test('treats AGV material carts as industrial equipment instead of passenger cars', () => { + const shapes = composePartPrimitives({ + name: 'AGV material cart', + family: 'generic', + parts: [ + { + kind: 'generic_body', + semanticRole: 'vehicle_body', + length: 1.4, + width: 0.85, + height: 0.28, + }, + { + kind: 'generic_base', + semanticRole: 'cargo_platform', + length: 1.25, + width: 0.72, + height: 0.08, + }, + { kind: 'wheel_set', semanticRole: 'wheel', count: 4, radius: 0.13, wheelWidth: 0.06 }, + { kind: 'bar_pair', semanticRole: 'bumper', length: 1.25, height: 0.12 }, + { + kind: 'generic_detail_accent', + semanticRole: 'navigation_sensor', + length: 0.18, + width: 0.08, + height: 0.08, + }, + { kind: 'warning_label', semanticRole: 'safety_label' }, + ], + }) + + const result = assessPrimitiveVisualQuality( + shapes, + resolvePrimitiveWorldTransforms(shapes, { positionMode: 'world-center' }), + { + prompt: 'factory AGV material cart', + geometryBrief: { category: 'agv_material_cart' }, + }, + ) + + expect(result.family).toBe('industrial_equipment') + expect(result.score).toBeGreaterThanOrEqual(0.8) + expect(result.issues).not.toContain( + 'vehicle needs a separate cabin/roof mass, not one plain body block.', + ) + }) +}) diff --git a/packages/core/src/lib/primitive-visual-quality.ts b/packages/core/src/lib/primitive-visual-quality.ts new file mode 100644 index 000000000..99b896761 --- /dev/null +++ b/packages/core/src/lib/primitive-visual-quality.ts @@ -0,0 +1,606 @@ +import type { + PrimitiveGeometryBrief, + PrimitiveShapeInput, + ResolvedPrimitiveTransform, +} from './primitive-compose' +import { + buildPrimitiveGeometryFacts, + type PrimitiveGeometryFacts, + type PrimitiveShapeFact, +} from './primitive-facts' +import { hasComponentPartIntent } from './primitive-part-intent' + +export type PrimitiveVisualQualityFamily = + | 'vehicle' + | 'robot_arm' + | 'fan' + | 'aircraft' + | 'industrial_equipment' + | 'unknown' + +export interface PrimitiveVisualQualityOptions { + geometryBrief?: PrimitiveGeometryBrief + prompt?: string +} + +export interface PrimitiveVisualQualityResult { + family: PrimitiveVisualQualityFamily + score: number + issues: string[] + warnings: string[] + recommendations: string[] + metrics: Record +} + +function hasRole(fact: PrimitiveShapeFact, roles: string[]): boolean { + return fact.semanticRole != null && roles.includes(fact.semanticRole) +} + +function nameOf(fact: PrimitiveShapeFact): string { + return fact.name?.toLowerCase() ?? '' +} + +function factsBy( + facts: PrimitiveGeometryFacts, + predicate: (fact: PrimitiveShapeFact) => boolean, +): PrimitiveShapeFact[] { + return facts.shapes.filter(predicate) +} + +function detectFamily( + facts: PrimitiveGeometryFacts, + options: PrimitiveVisualQualityOptions, +): PrimitiveVisualQualityFamily { + const text = [ + options.geometryBrief?.category, + options.prompt, + Object.keys(facts.roles).join(' '), + Object.keys(facts.sourcePartKinds).join(' '), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + const promptIntentText = [options.geometryBrief?.category, options.prompt] + .filter(Boolean) + .join(' ') + .toLowerCase() + + if (hasComponentPartIntent(promptIntentText)) return 'unknown' + + if ( + /reactor|reaction[_\s-]?vessel|stirred[_\s-]?tank|pressure[_\s-]?vessel|process[_\s-]?equipment|reactor_vessel_shell|vessel_shell|agitator_motor|agitator_shaft|inlet_port|outlet_port|\u53cd\u5e94\u91dc|\u53cd\u5e94\u5668|\u6405\u62cc\u7f50|\u538b\u529b\u5bb9\u5668/.test( + text, + ) + ) { + return 'industrial_equipment' + } + + if (/robot|cobot|manipulator|robot_arm|\u673a\u5668\u81c2|\u673a\u68b0\u81c2/.test(text)) { + return 'robot_arm' + } + if ( + /aircraft|airplane|airliner|boeing|airbus|fuselage|t-tail|turbofan|jet[_\s-]?engine|aircraft_fuselage|aircraft_wing|aircraft_engine|vertical_stabilizer|horizontal_stabilizer|\u98de\u673a|\u5ba2\u673a|\u6ce2\u97f3|\u7a7a\u5ba2|\u673a\u7ffc|\u673a\u8eab/.test( + text, + ) + ) { + return 'aircraft' + } + if ( + /(?:^|[\s_-])fan(?:$|[\s_-])|standing fan|electric fan|protective_grill|radial_blades|\u98ce\u6247|\u7535\u98ce\u6247/.test( + text, + ) + ) { + return 'fan' + } + if ( + /tricycle|cargo[_\s-]?trike|cargo[_\s-]?bike|rickshaw|pushcart|handcart|\u4e09\u8f6e\u8f66|\u8d27\u8fd0\u81ea\u884c\u8f66/.test( + text, + ) + ) { + return 'unknown' + } + if ( + /\bagv\b|\bamr\b|\bvga[_\s-]?(cart|vehicle)?\b|automated[_\s-]?guided[_\s-]?vehicle|material[_\s-]?cart|navigation_sensor/.test( + text, + ) + ) { + return 'industrial_equipment' + } + if ( + /vehicle|sedan|suv|automobile|(?:^|[\s_-])(?:car|auto)(?:$|[\s_-])|\u6c7d\u8f66|\u8f7f\u8f66/.test( + text, + ) + ) { + return 'vehicle' + } + if (/chimney|smoke[_\s-]?stack|\u70df\u56f1/.test(text)) { + return 'unknown' + } + if ( + /industrial|factory|machine|cnc|lathe|machining|pump|conveyor|heat[_\s-]?exchanger|hydraulic|injection|laser|rounded_machine_body|vent_grill|vent_slats|volute_casing|ribbed_motor_body|skid_base|\u673a\u5e8a|\u6570\u63a7|\u6cf5|\u8f93\u9001\u673a|\u6362\u70ed\u5668|\u6db2\u538b|\u6ce8\u5851|\u6fc0\u5149/.test( + text, + ) + ) { + return 'industrial_equipment' + } + return 'unknown' +} + +function vehicleBody(facts: PrimitiveGeometryFacts): PrimitiveShapeFact | undefined { + return facts.shapes.find( + (fact) => + hasRole(fact, ['vehicle_body']) || + (fact.sourcePartKind === 'vehicle_body' && nameOf(fact).includes('body shell')), + ) +} + +function vehicleTires(facts: PrimitiveGeometryFacts): PrimitiveShapeFact[] { + return factsBy( + facts, + (fact) => + hasRole(fact, ['vehicle_tire']) || + (fact.sourcePartKind === 'vehicle_wheels' && nameOf(fact).includes('tire')), + ) +} + +function vehicleCabins(facts: PrimitiveGeometryFacts): PrimitiveShapeFact[] { + return factsBy( + facts, + (fact) => + hasRole(fact, ['vehicle_cabin']) || + (hasRole(fact, ['vehicle_glass', 'vehicle_window']) && nameOf(fact).includes('cabin')) || + (fact.sourcePartKind === 'vehicle_body' && nameOf(fact).includes('cabin')), + ) +} + +function vehicleWindows(facts: PrimitiveGeometryFacts): PrimitiveShapeFact[] { + return factsBy( + facts, + (fact) => + hasRole(fact, ['vehicle_window', 'vehicle_glass', 'glass']) || + fact.sourcePartKind === 'vehicle_windows' || + /windshield|window|glass/.test(nameOf(fact)), + ) +} + +function vehicleDecks(facts: PrimitiveGeometryFacts): PrimitiveShapeFact[] { + return factsBy( + facts, + (fact) => + hasRole(fact, ['vehicle_deck']) || + (fact.sourcePartKind === 'vehicle_body' && /deck|hood|trunk/.test(nameOf(fact))), + ) +} + +function hasVehicleDetail(facts: PrimitiveGeometryFacts, pattern: RegExp): boolean { + return facts.shapes.some((fact) => pattern.test(nameOf(fact))) +} + +function ratio(value: number, divisor: number): number { + return divisor > 0 && Number.isFinite(value) && Number.isFinite(divisor) ? value / divisor : 0 +} + +function average(values: number[]): number { + return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 +} + +function assessVehicleQuality(facts: PrimitiveGeometryFacts): PrimitiveVisualQualityResult { + const issues: string[] = [] + const warnings: string[] = [] + const recommendations: string[] = [] + const metrics: Record = {} + + const body = vehicleBody(facts) + const tires = vehicleTires(facts) + const cabins = vehicleCabins(facts) + const windows = vehicleWindows(facts) + const decks = vehicleDecks(facts) + const hasRocker = hasVehicleDetail(facts, /rocker|sill/) + const hasWheelArch = hasVehicleDetail(facts, /wheel arch|fender/) + const hasTaperedCabin = cabins.some((cabin) => cabin.kind === 'trapezoid-prism') + + if (!body) { + issues.push('vehicle visual quality requires a distinct main body shell.') + recommendations.push('Use compose_parts vehicle_body so the car has a shaped body shell.') + } + + const bodyLength = body ? body.max[0] - body.min[0] : facts.dimensions[0] + const bodyWidth = body ? body.max[2] - body.min[2] : facts.dimensions[2] + const bodyHeight = body ? body.max[1] - body.min[1] : facts.dimensions[1] + const overallHeight = facts.dimensions[1] + const wheelRadius = average( + tires.map((tire) => Math.max(tire.halfExtents[0], tire.halfExtents[1])), + ) + const wheelRadiusToLength = ratio(wheelRadius, bodyLength) + const wheelRadiusToHeight = ratio(wheelRadius, overallHeight) + const overallHeightToLength = ratio(overallHeight, bodyLength) + const bodyHeightToLength = ratio(bodyHeight, bodyLength) + const cabinHeightToOverall = ratio( + average(cabins.map((cabin) => cabin.max[1] - cabin.min[1])), + overallHeight, + ) + + metrics.bodyLength = Number(bodyLength.toFixed(4)) + metrics.bodyWidth = Number(bodyWidth.toFixed(4)) + metrics.bodyHeight = Number(bodyHeight.toFixed(4)) + metrics.overallHeight = Number(overallHeight.toFixed(4)) + metrics.wheelRadius = Number(wheelRadius.toFixed(4)) + metrics.wheelRadiusToLength = Number(wheelRadiusToLength.toFixed(4)) + metrics.wheelRadiusToHeight = Number(wheelRadiusToHeight.toFixed(4)) + metrics.overallHeightToLength = Number(overallHeightToLength.toFixed(4)) + metrics.bodyHeightToLength = Number(bodyHeightToLength.toFixed(4)) + metrics.cabinHeightToOverall = Number(cabinHeightToOverall.toFixed(4)) + + if (tires.length === 4) { + if (wheelRadiusToLength < 0.045) { + issues.push('vehicle wheels are too small relative to the body length.') + recommendations.push('Increase vehicle_wheels.radius or choose a sportier vehicle style.') + } + if (wheelRadiusToHeight < 0.14) { + warnings.push('vehicle wheels are visually small relative to the overall height.') + recommendations.push('Increase wheelRadius or lower the body/overallHeight.') + } + } + + if (overallHeightToLength > 0.46) { + issues.push('vehicle body is too tall for its length; it reads as a box instead of a car.') + recommendations.push('Lower vehicle_body.overallHeight or use a sedan/sports vehicleStyle.') + } + if (overallHeightToLength < 0.18) { + warnings.push('vehicle body is extremely low; check that the roof and windows remain readable.') + } + + if (cabins.length === 0) { + issues.push('vehicle needs a separate cabin/roof mass, not one plain body block.') + recommendations.push('Use vehicle_body from compose_parts so a cabin frame is generated.') + } else if (!hasTaperedCabin) { + warnings.push('vehicle cabin is boxy; a tapered cabin reads more like a real car.') + recommendations.push( + 'Set vehicle_body.cabinTopScale around 0.75-0.9 or roofCornerAngle below 90.', + ) + } + + if (windows.length < 3 && cabins.length > 0) { + warnings.push( + `vehicle has only ${windows.length} window/glass panel; separated windows improve readability.`, + ) + recommendations.push( + 'Use vehicle_windows or split the cabin glass into windshield, rear, and side panels.', + ) + } else if (windows.length < 3) { + issues.push(`vehicle needs separated windshield/rear/side windows, got ${windows.length}.`) + recommendations.push('Use vehicle_windows or add multiple window panels around the cabin.') + } + + if (decks.length < 2) { + warnings.push('vehicle lacks distinct front/rear deck layering.') + recommendations.push('Use compose_parts vehicle_body defaults with front and rear deck shapes.') + } + + if (!hasRocker) { + warnings.push('vehicle lacks a lower rocker/sill shadow, making the body read flat.') + recommendations.push('Add a dark rocker shadow or side sill detail below the doors.') + } + + if (!hasWheelArch) { + warnings.push('vehicle lacks wheel-arch/fender hints around the tires.') + recommendations.push( + 'Add subtle rounded fender or wheel-arch hints above each tire without blocky black side panels.', + ) + } + + const score = Math.max( + 0, + Number( + (1 - issues.length * 0.16 - warnings.length * 0.045 - (hasTaperedCabin ? 0 : 0.03)).toFixed( + 4, + ), + ), + ) + + return { + family: 'vehicle', + score, + issues, + warnings, + recommendations, + metrics, + } +} + +function assessRobotArmQuality(facts: PrimitiveGeometryFacts): PrimitiveVisualQualityResult { + const issues: string[] = [] + const warnings: string[] = [] + const recommendations: string[] = [] + const metrics: Record = {} + + const requiredRoles = [ + 'robot_base', + 'base_joint', + 'shoulder_joint', + 'upper_arm', + 'elbow_joint', + 'forearm', + 'end_effector', + ] + for (const role of requiredRoles) { + if ((facts.roles[role] ?? 0) === 0) { + issues.push(`robot arm visual quality requires ${role}.`) + recommendations.push(`Add a readable ${role} component via compose_robot_arm.`) + } + } + + const base = facts.shapes.find((fact) => hasRole(fact, ['robot_base'])) + const upperArm = facts.shapes.find((fact) => hasRole(fact, ['upper_arm'])) + const forearm = facts.shapes.find((fact) => hasRole(fact, ['forearm'])) + const shoulder = facts.shapes.find((fact) => hasRole(fact, ['shoulder_joint'])) + const elbow = facts.shapes.find((fact) => hasRole(fact, ['elbow_joint'])) + const endEffector = facts.shapes.find((fact) => hasRole(fact, ['end_effector'])) + const joints = factsBy(facts, (fact) => + hasRole(fact, ['base_joint', 'shoulder_joint', 'elbow_joint', 'wrist_joint']), + ) + + const upperLength = upperArm ? Math.max(...upperArm.halfExtents) * 2 : 0 + const forearmLength = forearm ? Math.max(...forearm.halfExtents) * 2 : 0 + const baseRadius = base ? Math.max(base.halfExtents[0], base.halfExtents[2]) : 0 + const reachEstimate = upperLength + forearmLength + const baseToReach = ratio(baseRadius, reachEstimate) + const linkBalance = + upperLength && forearmLength + ? Math.min(upperLength, forearmLength) / Math.max(upperLength, forearmLength) + : 0 + + metrics.upperArmLength = Number(upperLength.toFixed(4)) + metrics.forearmLength = Number(forearmLength.toFixed(4)) + metrics.baseRadius = Number(baseRadius.toFixed(4)) + metrics.baseToReach = Number(baseToReach.toFixed(4)) + metrics.linkBalance = Number(linkBalance.toFixed(4)) + metrics.jointCount = joints.length + + if (joints.length < 3) { + issues.push(`robot arm needs at least 3 visually distinct joints, got ${joints.length}.`) + recommendations.push('Use base, shoulder, and elbow joint housings at minimum.') + } + if (base && baseToReach < 0.06) { + warnings.push('robot arm base is visually small compared with reach.') + recommendations.push('Increase base radius or reduce reach.') + } + if (upperLength && forearmLength && linkBalance < 0.45) { + warnings.push('robot arm links are strongly imbalanced; the arm may read as a pole.') + recommendations.push('Keep upper_arm and forearm lengths within a roughly 2:1 ratio.') + } + if ( + shoulder && + elbow && + Math.abs(shoulder.center[1] - elbow.center[1]) < Math.max(0.08, upperLength * 0.12) + ) { + warnings.push( + 'robot arm shoulder and elbow are nearly level; use a posed chain for a clearer silhouette.', + ) + recommendations.push( + 'Use pose="work-ready" or reach-forward instead of a straight vertical stack.', + ) + } + if (endEffector && forearm && endEffector.center[2] < forearm.center[2]) { + warnings.push( + 'robot arm end effector does not appear beyond the forearm along the working direction.', + ) + } + + const score = Math.max(0, Number((1 - issues.length * 0.16 - warnings.length * 0.045).toFixed(4))) + return { + family: 'robot_arm', + score, + issues, + warnings, + recommendations, + metrics, + } +} + +function assessFanQuality(facts: PrimitiveGeometryFacts): PrimitiveVisualQualityResult { + const issues: string[] = [] + const warnings: string[] = [] + const recommendations: string[] = [] + const metrics: Record = {} + + const blades = factsBy( + facts, + (fact) => + hasRole(fact, ['fan_blade']) || + fact.sourcePartKind === 'radial_blades' || + fact.sourcePartKind === 'fan_blade', + ).filter((fact) => !/root/.test(nameOf(fact))) + const grill = factsBy( + facts, + (fact) => hasRole(fact, ['protective_grill']) || fact.sourcePartKind === 'protective_grill', + ) + const spokes = grill.filter((fact) => /spoke/.test(nameOf(fact))) + const rings = grill.filter((fact) => /ring/.test(nameOf(fact))) + const sideRibs = grill.filter((fact) => /side rib|rear/.test(nameOf(fact))) + const motor = factsBy(facts, (fact) => fact.sourcePartKind === 'motor_housing') + const support = factsBy( + facts, + (fact) => fact.sourcePartKind === 'vertical_pole' || fact.sourcePartKind === 'support_bracket', + ) + + metrics.bladeCount = blades.length + metrics.grillRingCount = rings.length + metrics.grillSpokeCount = spokes.length + metrics.grillSideRibCount = sideRibs.length + metrics.motorCount = motor.length + metrics.supportCount = support.length + + if (blades.length < 3) { + issues.push(`fan needs at least 3 readable blades, got ${blades.length}.`) + recommendations.push('Use fan_blade with count:3-6 for independent editable blades.') + } + if (rings.length < 3) { + issues.push(`fan protective grill needs multiple concentric rings, got ${rings.length}.`) + recommendations.push('Use protective_grill with ringCount:4-5 instead of a single torus.') + } + if (spokes.length < 12) { + warnings.push(`fan protective grill has few radial spokes, got ${spokes.length}.`) + recommendations.push('Increase protective_grill.spokeCount to 18-24 for a clearer cage.') + } + if (sideRibs.length < 6) { + warnings.push('fan grill lacks side/rear cage depth; it may read as a flat badge.') + recommendations.push( + 'Use protective_grill depth and domeDepth so the guard forms a shallow cage.', + ) + } + if (motor.length === 0) { + warnings.push('fan lacks a visible rear motor housing behind the blades.') + recommendations.push('Add motor_housing behind fan_blade or radial_blades.') + } + if (support.length === 0) { + warnings.push('fan lacks a pole/bracket support, so the assembly may float.') + recommendations.push('Add vertical_pole and support_bracket for standing fans.') + } + + const score = Math.max(0, Number((1 - issues.length * 0.18 - warnings.length * 0.04).toFixed(4))) + return { + family: 'fan', + score, + issues, + warnings, + recommendations, + metrics, + } +} + +function assessIndustrialEquipmentQuality( + facts: PrimitiveGeometryFacts, +): PrimitiveVisualQualityResult { + const issues: string[] = [] + const warnings: string[] = [] + const recommendations: string[] = [] + const metrics: Record = {} + + const bodyLike = factsBy( + facts, + (fact) => + /body|base|bed|frame|column|shell|casing|housing|press|machine|exchanger|conveyor/.test( + fact.semanticRole ?? '', + ) || + [ + 'rounded_machine_body', + 'ribbed_motor_body', + 'volute_casing', + 'skid_base', + 'conveyor_frame', + 'heat_exchanger', + 'cylindrical_tank', + ].includes(fact.sourcePartKind ?? ''), + ) + const controls = factsBy(facts, (fact) => + /control|panel|button|knob/.test(`${fact.semanticRole ?? ''} ${nameOf(fact)}`), + ) + const access = factsBy(facts, (fact) => + /door|guard|cover|hatch|window|transparent|access/.test( + `${fact.semanticRole ?? ''} ${nameOf(fact)}`, + ), + ) + const vents = factsBy( + facts, + (fact) => + /vent|slat|grill|louver|ribbed/.test(`${fact.semanticRole ?? ''} ${nameOf(fact)}`) || + fact.sourcePartKind === 'vent_slats' || + fact.sourcePartKind === 'vent_grill', + ) + const connectors = factsBy(facts, (fact) => + /port|flange|pipe|nozzle|inlet|outlet/.test(`${fact.semanticRole ?? ''} ${nameOf(fact)}`), + ) + const labels = factsBy(facts, (fact) => + /nameplate|warning|label/.test(`${fact.semanticRole ?? ''} ${nameOf(fact)}`), + ) + const roundedBodies = factsBy( + facts, + (fact) => + fact.sourcePartKind === 'rounded_machine_body' || + /rounded machine body|service hatch|shadow plinth|service seam/.test(nameOf(fact)), + ) + + metrics.bodyLikeCount = bodyLike.length + metrics.controlCount = controls.length + metrics.accessCount = access.length + metrics.ventCount = vents.length + metrics.connectorCount = connectors.length + metrics.labelCount = labels.length + metrics.roundedBodyDetailCount = roundedBodies.length + metrics.shapeCount = facts.shapeCount + + if (bodyLike.length === 0) { + issues.push('industrial equipment needs a readable main body/base/frame.') + recommendations.push( + 'Use rounded_machine_body, skid_base, machine bed/frame, or recipe body parts.', + ) + } + if (facts.shapeCount < 5) { + issues.push( + `industrial equipment silhouette is under-specified with only ${facts.shapeCount} shapes.`, + ) + recommendations.push( + 'Add 2-5 identifying modules such as control panel, guard, ports, rails, vents, or base.', + ) + } + if (facts.shapeCount < 3 && bodyLike.length <= 1) { + issues.push('industrial equipment needs separate modules, not one monolithic block.') + recommendations.push( + 'Split the object into base/body plus at least one functional module or panel.', + ) + } + if (controls.length === 0) { + warnings.push('industrial equipment lacks a visible control panel or operator interface.') + recommendations.push('Add control_box/control_panel or use recipe defaults for machine tools.') + } + if (access.length === 0 && connectors.length === 0) { + warnings.push('industrial equipment lacks access/guard/door or connection details.') + recommendations.push('Add access cover, transparent door, guard panel, pipe ports, or flanges.') + } + if (vents.length === 0 && connectors.length === 0) { + warnings.push('industrial equipment lacks vents, grilles, ribs, or pipe connection cues.') + recommendations.push( + 'Add vent_grill/vent_slats, ribbed_motor_body, inlet/outlet ports, or flanges.', + ) + } + if (roundedBodies.length === 1) { + warnings.push( + 'rounded_machine_body is present but lacks service hatches, seams, or base shadow.', + ) + recommendations.push( + 'Use the strengthened rounded_machine_body kernel or add visible service panels.', + ) + } + + const score = Math.max(0, Number((1 - issues.length * 0.16 - warnings.length * 0.04).toFixed(4))) + return { + family: 'industrial_equipment', + score, + issues, + warnings, + recommendations, + metrics, + } +} + +export function assessPrimitiveVisualQuality( + shapes: readonly PrimitiveShapeInput[], + transforms: readonly ResolvedPrimitiveTransform[] = [], + options: PrimitiveVisualQualityOptions = {}, +): PrimitiveVisualQualityResult { + const facts = buildPrimitiveGeometryFacts(shapes, transforms) + const family = detectFamily(facts, options) + if (family === 'vehicle') return assessVehicleQuality(facts) + if (family === 'robot_arm') return assessRobotArmQuality(facts) + if (family === 'fan') return assessFanQuality(facts) + if (family === 'industrial_equipment') return assessIndustrialEquipmentQuality(facts) + return { + family, + score: 1, + issues: [], + warnings: [], + recommendations: [], + metrics: {}, + } +} diff --git a/packages/core/src/lib/recipe-dimensions.ts b/packages/core/src/lib/recipe-dimensions.ts new file mode 100644 index 000000000..c3a9e0c0a --- /dev/null +++ b/packages/core/src/lib/recipe-dimensions.ts @@ -0,0 +1,89 @@ +import type { IndustrialArchetypeRecipeId } from './industrial-archetype-registry' + +export type RecipeDimensionSize = 'small' | 'medium' | 'large' + +export interface RecipeDimensions { + length: number + width: number + height: number + [key: string]: number +} + +export interface RecipeDimensionParams { + size?: string + sizeScale?: number + length?: number + width?: number + height?: number +} + +export const INDUSTRIAL_RECIPE_DIMENSIONS: Record< + IndustrialArchetypeRecipeId, + Record +> = { + 'machineTool.lathe': { + small: { length: 2.2, width: 1.35, height: 1.45 }, + medium: { length: 2.8, width: 1.8, height: 1.8 }, + large: { length: 3.6, width: 2.2, height: 2.1 }, + }, + 'machineTool.machiningCenter': { + small: { length: 1.9, width: 1.7, height: 2.0 }, + medium: { length: 2.4, width: 2.2, height: 2.4 }, + large: { length: 3.1, width: 2.7, height: 2.9 }, + }, + 'machineTool.laserCutter': { + small: { length: 2.2, width: 1.4, height: 1.1 }, + medium: { length: 3.0, width: 1.8, height: 1.3 }, + large: { length: 4.0, width: 2.2, height: 1.55 }, + }, + 'forming.injectionMolding': { + small: { length: 3.2, width: 1.1, height: 1.5 }, + medium: { length: 4.5, width: 1.4, height: 1.8 }, + large: { length: 6.0, width: 1.8, height: 2.2 }, + }, + 'forming.hydraulicPress': { + small: { length: 1.2, width: 0.95, height: 1.8 }, + medium: { length: 1.6, width: 1.2, height: 2.4 }, + large: { length: 2.2, width: 1.6, height: 3.2 }, + }, + 'materialHandling.beltConveyor': { + small: { length: 2.4, width: 0.55, height: 0.85 }, + medium: { length: 4.0, width: 0.8, height: 1.1 }, + large: { length: 5.8, width: 1.1, height: 1.35 }, + }, + 'fluidMachine.centrifugalPump': { + small: { length: 0.75, width: 0.36, height: 0.5 }, + medium: { length: 1.1, width: 0.5, height: 0.7 }, + large: { length: 1.6, width: 0.75, height: 1.0 }, + }, + 'process.heatExchanger': { + small: { length: 1.8, width: 0.65, height: 0.75 }, + medium: { length: 3.0, width: 1.0, height: 1.1 }, + large: { length: 4.8, width: 1.45, height: 1.55 }, + }, +} + +function finitePositive(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined +} + +export function resolveRecipeSizeKey(size: unknown): RecipeDimensionSize { + if (typeof size !== 'string') return 'medium' + const normalized = size.trim().toLowerCase() + if (/^(tiny|mini|compact|small|s|low)$/.test(normalized)) return 'small' + if (/^(large|big|xl|oversized|high)$/.test(normalized)) return 'large' + return 'medium' +} + +export function resolveRecipeDimensions( + recipeId: IndustrialArchetypeRecipeId, + params: RecipeDimensionParams = {}, +): RecipeDimensions { + const base = INDUSTRIAL_RECIPE_DIMENSIONS[recipeId][resolveRecipeSizeKey(params.size)] + const scale = finitePositive(params.sizeScale) ?? 1 + return { + length: finitePositive(params.length) ?? base.length * scale, + width: finitePositive(params.width) ?? base.width * scale, + height: finitePositive(params.height) ?? base.height * scale, + } +} diff --git a/packages/core/src/lib/robot-arm-compose.ts b/packages/core/src/lib/robot-arm-compose.ts new file mode 100644 index 000000000..51a33f37c --- /dev/null +++ b/packages/core/src/lib/robot-arm-compose.ts @@ -0,0 +1,427 @@ +import type { PrimitiveMaterialInput, PrimitiveShapeInput, Vec3 } from './primitive-compose' + +export type RobotArmStyle = 'industrial' | 'collaborative' | 'fanuc' +export type RobotArmPose = 'rest' | 'reach-forward' | 'work-ready' + +export interface RobotArmComposeInput { + name?: string + style?: RobotArmStyle | string + pose?: RobotArmPose | string + position?: Vec3 + axisCount?: 3 | 4 | 5 | 6 | 7 | number + baseShape?: 'round' | 'square' | 'pedestal' | string + endEffector?: 'gripper' | 'suction' | 'tool-flange' | string + reach?: number + baseHeight?: number + detail?: 'low' | 'medium' | 'high' | string + materialPreset?: string + primaryColor?: string + secondaryColor?: string + darkColor?: string + metalColor?: string + includeCableHarness?: boolean +} + +function clamp(value: unknown, fallback: number, min: number, max: number): number { + return Math.max( + min, + Math.min(max, typeof value === 'number' && Number.isFinite(value) ? value : fallback), + ) +} + +function roundSegments(detail: RobotArmComposeInput['detail']): number { + switch (detail) { + case 'high': + return 48 + case 'low': + return 20 + default: + return 32 + } +} + +function styleMaterial( + style: RobotArmComposeInput['style'], + materialPreset: string | undefined, +): string | undefined { + if (materialPreset) return materialPreset + if (style === 'fanuc') return 'safety-yellow' + return undefined +} + +function material( + color: string | undefined, + roughness = 0.82, + metalness = 0.04, +): PrimitiveMaterialInput | undefined { + return color ? { properties: { color, roughness, metalness } } : undefined +} + +function jointDisc(input: { + name: string + attachTo?: number + anchor?: string + childAnchor?: string + position?: Vec3 + rotation?: Vec3 + axis: 'x' | 'y' | 'z' + radius: number + height: number + segments: number + materialPreset?: string + material?: PrimitiveMaterialInput + semanticRole: string +}): PrimitiveShapeInput { + return { + kind: 'cylinder', + name: input.name, + attachTo: input.attachTo, + anchor: input.anchor, + childAnchor: input.childAnchor, + position: input.position ?? [0, 0, 0], + rotation: input.rotation, + axis: input.axis, + radius: input.radius, + height: input.height, + radialSegments: input.segments, + materialPreset: input.materialPreset, + material: input.material, + semanticRole: input.semanticRole, + } +} + +function armLink(input: { + name: string + attachTo?: number + position?: Vec3 + anchor?: string + childAnchor?: string + rotation: Vec3 + radius: number + length: number + segments: number + materialPreset?: string + material?: PrimitiveMaterialInput + semanticRole: string +}): PrimitiveShapeInput { + return { + kind: 'capsule', + name: input.name, + attachTo: input.attachTo, + anchor: input.anchor, + childAnchor: input.childAnchor, + position: input.position ?? [0, 0, 0], + rotation: input.rotation, + axis: 'y', + radius: input.radius, + height: input.length, + radialSegments: input.segments, + capSegments: 5, + materialPreset: input.materialPreset, + material: input.material, + semanticRole: input.semanticRole, + } +} + +function linkBetween(input: { + name: string + start: Vec3 + end: Vec3 + radius: number + segments: number + materialPreset?: string + material?: PrimitiveMaterialInput + semanticRole: string +}): PrimitiveShapeInput { + const dy = input.end[1] - input.start[1] + const dz = input.end[2] - input.start[2] + const length = Math.max(0.01, Math.hypot(dy, dz)) + return armLink({ + name: input.name, + position: [ + (input.start[0] + input.end[0]) / 2, + (input.start[1] + input.end[1]) / 2, + (input.start[2] + input.end[2]) / 2, + ], + rotation: [Math.atan2(dz, dy), 0, 0], + radius: input.radius, + length, + segments: input.segments, + materialPreset: input.materialPreset, + material: input.material, + semanticRole: input.semanticRole, + }) +} + +export function composeRobotArmPrimitives(input: RobotArmComposeInput = {}): PrimitiveShapeInput[] { + const reach = clamp(input.reach, 2.4, 0.8, 8) + const baseHeight = clamp(input.baseHeight, reach * 0.18, 0.12, reach * 0.35) + const turntableHeight = Math.max(0.055, baseHeight * 0.2) + const baseRadius = reach * 0.15 + const shoulderRadius = reach * 0.14 + const elbowRadius = reach * 0.112 + const wristRadius = reach * 0.056 + const upperArmLength = reach * 0.43 + const forearmLength = reach * 0.48 + const wristLength = reach * 0.12 + const toolLength = reach * 0.09 + const armRadius = reach * 0.072 + const segments = roundSegments(input.detail) + const position = input.position ?? [0, 0, 0] + const name = input.name ?? (input.style === 'fanuc' ? 'FANUC robot arm draft' : 'Robot arm draft') + const materialPreset = styleMaterial(input.style, input.materialPreset) + const primaryColor = input.primaryColor ?? (input.style === 'fanuc' ? '#facc15' : '#facc15') + const secondaryColor = input.secondaryColor ?? (input.style === 'fanuc' ? '#facc15' : '#111827') + const darkColor = input.darkColor ?? '#111827' + const metalColor = input.metalColor ?? '#cbd5e1' + const primaryMaterial = material(primaryColor) + const secondaryMaterial = material(secondaryColor) + const darkMaterial = material(darkColor, 0.78, 0.12) + const metalMaterial = material(metalColor, 0.42, 0.55) + const axisCount = Math.max( + 3, + Math.min(7, Math.round(typeof input.axisCount === 'number' ? input.axisCount : 6)), + ) + + const shoulderLean = input.pose === 'reach-forward' ? 0.42 : input.pose === 'rest' ? 0.18 : 0.28 + const elbowLift = input.pose === 'reach-forward' ? 0.05 : input.pose === 'rest' ? 0.3 : 0.18 + const wristLift = input.pose === 'rest' ? 0.05 : -0.02 + const baseTopY = position[1] + baseHeight + turntableHeight + const shoulder: Vec3 = [position[0], baseTopY + shoulderRadius * 0.42, position[2]] + const elbow: Vec3 = [ + position[0], + shoulder[1] + upperArmLength * Math.cos(shoulderLean), + shoulder[2] + upperArmLength * Math.sin(shoulderLean), + ] + const wristBase: Vec3 = [ + position[0], + elbow[1] + forearmLength * elbowLift, + elbow[2] + forearmLength * 0.92, + ] + const wristRoll: Vec3 = [position[0], wristBase[1] + wristLift, wristBase[2] + wristLength * 0.45] + const wristPitchPoint: Vec3 = [position[0], wristRoll[1], wristRoll[2] + wristLength * 0.55] + const wristYaw: Vec3 = [position[0], wristPitchPoint[1], wristPitchPoint[2] + wristLength * 0.5] + const flange: Vec3 = [position[0], wristYaw[1], wristYaw[2] + toolLength * 0.45] + const face: Vec3 = [position[0], flange[1], flange[2] + toolLength * 0.7] + + const shapes: PrimitiveShapeInput[] = [ + { + kind: 'cylinder', + name: `${name} base`, + position: [position[0], position[1] + baseHeight / 2, position[2]], + axis: 'y', + radius: baseRadius, + height: baseHeight, + radialSegments: segments, + materialPreset, + material: darkMaterial, + semanticRole: 'robot_base', + }, + { + kind: 'cylinder', + name: `${name} base turntable joint`, + position: [position[0], position[1] + baseHeight + turntableHeight / 2, position[2]], + axis: 'y', + radius: baseRadius * 0.78, + height: turntableHeight, + radialSegments: segments, + materialPreset, + material: secondaryMaterial, + semanticRole: 'base_joint', + }, + jointDisc({ + name: `${name} shoulder rotary housing`, + position: shoulder, + axis: 'x', + radius: shoulderRadius, + height: shoulderRadius * 0.95, + segments, + materialPreset, + material: primaryMaterial, + semanticRole: 'shoulder_joint', + }), + armLink({ + name: `${name} tapered upper arm shell`, + position: [ + (shoulder[0] + elbow[0]) / 2, + (shoulder[1] + elbow[1]) / 2, + (shoulder[2] + elbow[2]) / 2, + ], + rotation: [shoulderLean, 0, 0], + radius: armRadius, + length: upperArmLength, + segments, + materialPreset, + material: primaryMaterial, + semanticRole: 'upper_arm', + }), + jointDisc({ + name: `${name} elbow rotary housing`, + position: elbow, + axis: 'x', + radius: elbowRadius, + height: elbowRadius * 0.9, + segments, + materialPreset, + material: primaryMaterial, + semanticRole: 'elbow_joint', + }), + ] + + if (axisCount >= 7) { + const redundantJoint: Vec3 = [ + position[0], + elbow[1] + (wristBase[1] - elbow[1]) * 0.32, + elbow[2] + (wristBase[2] - elbow[2]) * 0.32, + ] + shapes.push( + jointDisc({ + name: `${name} J4 redundant forearm swivel`, + position: redundantJoint, + axis: 'z', + radius: elbowRadius * 0.78, + height: wristLength * 0.34, + segments, + materialPreset, + material: secondaryMaterial, + semanticRole: 'redundant_axis_joint', + }), + ) + } + + shapes.push( + linkBetween({ + name: `${name} tapered forearm shell`, + start: elbow, + end: wristBase, + radius: armRadius * 0.9, + segments, + materialPreset, + material: primaryMaterial, + semanticRole: 'forearm', + }), + ) + + if (axisCount >= 4) { + shapes.push( + jointDisc({ + name: `${name} J${axisCount >= 7 ? '5' : '4'} wrist roll module`, + position: wristRoll, + axis: 'z', + radius: wristRadius * 1.05, + height: wristLength * 0.44, + segments, + materialPreset, + material: secondaryMaterial, + semanticRole: 'wrist_roll_joint', + }), + ) + } + if (axisCount >= 5) { + shapes.push( + jointDisc({ + name: `${name} J${axisCount >= 7 ? '6' : '5'} wrist pitch module`, + position: wristPitchPoint, + axis: 'x', + radius: wristRadius * 0.92, + height: wristLength * 0.32, + segments, + materialPreset, + material: primaryMaterial, + semanticRole: 'wrist_pitch_joint', + }), + ) + } + + shapes.push( + jointDisc({ + name: `${name} J${axisCount} wrist yaw joint`, + position: wristYaw, + axis: 'z', + radius: wristRadius, + height: wristLength * 0.38, + segments, + materialPreset, + material: secondaryMaterial, + semanticRole: 'wrist_joint', + }), + ) + shapes.push( + jointDisc({ + name: `${name} ISO tool flange`, + position: flange, + axis: 'z', + radius: wristRadius * 0.72, + height: Math.max(0.035, wristLength * 0.26), + segments, + materialPreset, + material: metalMaterial, + semanticRole: 'tool_flange', + }), + ) + shapes.push( + jointDisc({ + name: `${name} end effector mounting face`, + position: face, + axis: 'z', + radius: wristRadius * 0.78, + height: Math.max(0.018, wristLength * 0.11), + segments, + materialPreset, + material: metalMaterial, + semanticRole: 'end_effector', + }), + ) + + if (input.includeCableHarness !== false) { + shapes.push( + linkBetween({ + name: `${name} upper arm cable harness`, + start: [shoulder[0] + armRadius * 1.15, shoulder[1] + armRadius * 0.45, shoulder[2]], + end: [elbow[0] + armRadius * 1.15, elbow[1] + armRadius * 0.35, elbow[2]], + radius: armRadius * 0.16, + segments: Math.max(12, Math.round(segments * 0.45)), + material: darkMaterial, + semanticRole: 'cable_harness', + }), + linkBetween({ + name: `${name} forearm cable harness`, + start: [elbow[0] + armRadius * 1.05, elbow[1] + armRadius * 0.25, elbow[2]], + end: [wristRoll[0] + armRadius * 0.85, wristRoll[1] + armRadius * 0.2, wristRoll[2]], + radius: armRadius * 0.14, + segments: Math.max(12, Math.round(segments * 0.45)), + material: darkMaterial, + semanticRole: 'cable_harness', + }), + ) + } + + if (input.endEffector !== 'tool-flange' && input.endEffector !== 'suction') { + shapes.push( + { + kind: 'box', + name: `${name} left gripper finger`, + position: [face[0] + armRadius * 0.72, face[1], face[2] + toolLength * 0.45], + length: armRadius * 0.45, + width: toolLength * 0.9, + height: armRadius * 1.15, + materialPreset, + material: metalMaterial, + semanticRole: 'gripper_finger', + }, + { + kind: 'box', + name: `${name} right gripper finger`, + position: [face[0] - armRadius * 0.72, face[1], face[2] + toolLength * 0.45], + length: armRadius * 0.45, + width: toolLength * 0.9, + height: armRadius * 1.15, + materialPreset, + material: metalMaterial, + semanticRole: 'gripper_finger', + }, + ) + } + + return shapes +} diff --git a/packages/core/src/lib/space-detection-pause.test.ts b/packages/core/src/lib/space-detection-pause.test.ts new file mode 100644 index 000000000..44df9a755 --- /dev/null +++ b/packages/core/src/lib/space-detection-pause.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + isSpaceDetectionPaused, + pauseSpaceDetection, + resumeSpaceDetection, +} from './space-detection' + +// The pause flag is module-level (matching the existing pauseSceneHistory +// refcount in store/history-control.ts). Reset it before/after each test so +// leftover depth from one case can't bleed into another. +function drain() { + for (let i = 0; i < 64 && isSpaceDetectionPaused(); i += 1) { + resumeSpaceDetection() + } +} + +beforeEach(drain) +afterEach(drain) + +describe('space-detection pause primitive', () => { + test('defaults to not paused', () => { + expect(isSpaceDetectionPaused()).toBe(false) + }) + + test('pauseSpaceDetection flips the flag, resumeSpaceDetection clears it', () => { + pauseSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(true) + resumeSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(false) + }) + + test('refcount — pause depth survives mismatched resumes from a second source', () => { + pauseSpaceDetection() + pauseSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(true) + + resumeSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(true) + + resumeSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(false) + }) + + test('resume is a no-op when not currently paused', () => { + expect(isSpaceDetectionPaused()).toBe(false) + resumeSpaceDetection() + resumeSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(false) + + pauseSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(true) + resumeSpaceDetection() + expect(isSpaceDetectionPaused()).toBe(false) + }) +}) diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts new file mode 100644 index 000000000..dd09e3169 --- /dev/null +++ b/packages/core/src/lib/space-detection.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test' +import { WallNode } from '../schema' +import { detectSpacesForLevel, wallClosesRoom } from './space-detection' + +function areaOf(polygon: Array<{ x: number; y: number }>) { + let area = 0 + for (let i = 0; i < polygon.length; i += 1) { + const a = polygon[i]! + const b = polygon[(i + 1) % polygon.length]! + area += a.x * b.y - b.x * a.y + } + return Math.abs(area / 2) +} + +function squareWalls() { + return [ + WallNode.parse({ start: [0, 0], end: [4, 0] }), + WallNode.parse({ start: [4, 0], end: [4, 3] }), + WallNode.parse({ start: [4, 3], end: [0, 3] }), + WallNode.parse({ start: [0, 3], end: [0, 0] }), + ] +} + +describe('detectSpacesForLevel', () => { + test('detects an isolated four-wall room', () => { + const { roomPolygons } = detectSpacesForLevel('level-1', squareWalls()) + expect(roomPolygons).toHaveLength(1) + }) + + test('detects a room closed against the middle of an existing wall', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [6, 0] }), + WallNode.parse({ start: [6, 0], end: [6, 5] }), + WallNode.parse({ start: [6, 5], end: [0, 5] }), + WallNode.parse({ start: [0, 5], end: [0, 0] }), + WallNode.parse({ start: [1, 0], end: [1, -2] }), + WallNode.parse({ start: [1, -2], end: [3, -2] }), + WallNode.parse({ start: [3, -2], end: [3, 0] }), + ] + + const { roomPolygons } = detectSpacesForLevel('level-1', walls) + const areas = roomPolygons.map((poly) => areaOf(poly)).sort((a, b) => a - b) + + expect(roomPolygons).toHaveLength(2) + expect(areas[0]).toBeCloseTo(4, 1) + expect(areas[1]).toBeCloseTo(30, 1) + }) +}) + +describe('wallClosesRoom', () => { + test('is false while a chain is open, true once it encloses a room', () => { + const open = [ + WallNode.parse({ start: [0, 0], end: [4, 0] }), + WallNode.parse({ start: [4, 0], end: [4, 3] }), + WallNode.parse({ start: [4, 3], end: [0, 3] }), + ] + const closing = WallNode.parse({ start: [0, 3], end: [0, 0] }) + + expect(wallClosesRoom(open, closing)).toBe(false) + expect(wallClosesRoom([...open, closing], closing)).toBe(true) + }) + + test('fires when a bay is sealed against the middle of an existing wall', () => { + const bigRoom = [ + WallNode.parse({ start: [0, 0], end: [6, 0] }), + WallNode.parse({ start: [6, 0], end: [6, 5] }), + WallNode.parse({ start: [6, 5], end: [0, 5] }), + WallNode.parse({ start: [0, 5], end: [0, 0] }), + ] + const bayLeft = WallNode.parse({ start: [1, 0], end: [1, -2] }) + const bayBottom = WallNode.parse({ start: [1, -2], end: [3, -2] }) + const bayRight = WallNode.parse({ start: [3, -2], end: [3, 0] }) + + expect(wallClosesRoom([...bigRoom, bayLeft, bayBottom], bayBottom)).toBe(false) + expect(wallClosesRoom([...bigRoom, bayLeft, bayBottom, bayRight], bayRight)).toBe(true) + }) +}) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 94a6e47b7..812165b1c 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -52,6 +52,8 @@ const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 +const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 +const WALL_JUNCTION_TOLERANCE = 0.08 function pointFromTuple(point: [number, number]): Point2D { return { x: point[0], y: point[1] } @@ -180,6 +182,43 @@ function bboxOverlapArea(a: ReturnType, b: ReturnType pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE, + ) + + return matchingPoints.length >= 2 +} + function getWallDirection(wall: Pick) { const dx = wall.end[0] - wall.start[0] const dy = wall.end[1] - wall.start[1] @@ -251,9 +290,44 @@ function sampleWallPointsForRoomDetection( return subdivide(0, start, 1, end, 0) } -function getDirectedWallBoundaryPoints(wall: WallNode, forward: boolean) { - const points = sampleWallPointsForRoomDetection(wall) - return forward ? points : [...points].reverse() +function segmentProjection(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + if (lengthSquared < 1e-12) { + return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) } + } + const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared + const clampedT = Math.max(0, Math.min(1, t)) + const projX = start.x + clampedT * dx + const projY = start.y + clampedT * dy + return { t, distance: Math.hypot(point.x - projX, point.y - projY) } +} + +function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Point2D[]) { + const length = Math.hypot(end.x - start.x, end.y - start.y) + if (length < 1e-9) return [start, end] + + const interior: Array<{ point: Point2D; t: number }> = [] + for (const vertex of vertices) { + const { t, distance } = segmentProjection(vertex, start, end) + if (distance > WALL_JUNCTION_TOLERANCE) continue + const along = t * length + if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE) continue + interior.push({ point: vertex, t }) + } + interior.sort((a, b) => a.t - b.t) + + const ordered: Point2D[] = [start] + let lastKey = pointKey(start) + for (const { point } of interior) { + const key = pointKey(point) + if (key === lastKey) continue + ordered.push(point) + lastKey = key + } + if (lastKey !== pointKey(end)) ordered.push(end) + return ordered } function extractRoomPolygons(walls: WallNode[]): Point2D[][] { @@ -280,38 +354,63 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] { return key } + const vertexByKey = new Map() + for (const wall of walls) { + for (const tuple of [wall.start, wall.end]) { + const point = pointFromTuple(tuple) + const key = pointKey(point) + if (!vertexByKey.has(key)) vertexByKey.set(key, point) + } + } + const vertices = [...vertexByKey.values()] + for (const wall of walls) { const start = pointFromTuple(wall.start) const end = pointFromTuple(wall.end) - const startKey = upsertNode(start) - const endKey = upsertNode(end) - if (startKey === endKey) continue - - const forwardDirection = getWallDirection(wall) - const reverseDirection = getWallDirection({ start: wall.end, end: wall.start }) - - const forwardId = `${wall.id}:f` - const reverseId = `${wall.id}:r` - - halfEdges.set(forwardId, { - id: forwardId, - reverseId, - fromKey: startKey, - toKey: endKey, - angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x), - points: getDirectedWallBoundaryPoints(wall, true), - }) - halfEdges.set(reverseId, { - id: reverseId, - reverseId: forwardId, - fromKey: endKey, - toKey: startKey, - angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x), - points: getDirectedWallBoundaryPoints(wall, false), + if (samePointWithinTolerance(start, end)) continue + + const subPolylines: Point2D[][] = isCurvedWall(wall) + ? [sampleWallPointsForRoomDetection(wall)] + : (() => { + const ordered = splitStraightWallAtVertices(start, end, vertices) + const parts: Point2D[][] = [] + for (let index = 0; index < ordered.length - 1; index += 1) { + parts.push([ordered[index]!, ordered[index + 1]!]) + } + return parts + })() + + subPolylines.forEach((points, subIndex) => { + const from = points[0]! + const to = points[points.length - 1]! + const fromKey = upsertNode(from) + const toKey = upsertNode(to) + if (fromKey === toKey) return + + const reversePoints = [...points].reverse() + const forwardId = `${wall.id}#${subIndex}:f` + const reverseId = `${wall.id}#${subIndex}:r` + + halfEdges.set(forwardId, { + id: forwardId, + reverseId, + fromKey, + toKey, + angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x), + points, + }) + halfEdges.set(reverseId, { + id: reverseId, + reverseId: forwardId, + fromKey: toKey, + toKey: fromKey, + angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x), + points: reversePoints, + }) + + graph.get(fromKey)?.outgoing.push(forwardId) + graph.get(toKey)?.outgoing.push(reverseId) }) - - graph.get(startKey)?.outgoing.push(forwardId) - graph.get(endKey)?.outgoing.push(reverseId) } const sortedOutgoing = new Map() @@ -337,7 +436,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] { const visitedDirected = new Set() const faces: Point2D[][] = [] - const maxSteps = Math.min(500, walls.length * 8 + 20) + const maxSteps = Math.min(2000, halfEdges.size + 10) for (const edgeId of halfEdges.keys()) { if (visitedDirected.has(edgeId)) continue @@ -391,6 +490,12 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] { return faces } +export function wallClosesRoom(walls: WallNode[], wall: WallNode): boolean { + const roomPolygons = extractRoomPolygons(walls) + if (roomPolygons.length === 0) return false + return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon)) +} + export function resolveWallSurfaceSides( wall: Pick, roomPolygons: Point2D[][], @@ -884,6 +989,29 @@ function runSpaceDetection( editorStore.getState().setSpaces(nextSpaces) } +// Refcount of outstanding pause requests, matching the pauseSceneHistory +// pattern. The community editor flips this off while the AI is actively +// mutating the scene so the wall-driven auto slab/ceiling sync doesn't race +// `create_room`'s explicit slabs/ceilings (see plan +// `ai-pause-space-detection`). +let spaceDetectionPauseDepth = 0 + +/** Pause the wall-driven auto slab/ceiling sync. Refcounted — pair with `resumeSpaceDetection`. */ +export function pauseSpaceDetection(): void { + spaceDetectionPauseDepth += 1 +} + +/** Resume the wall-driven auto slab/ceiling sync. No-op if not currently paused. */ +export function resumeSpaceDetection(): void { + if (spaceDetectionPauseDepth === 0) return + spaceDetectionPauseDepth -= 1 +} + +/** True iff the wall-driven auto slab/ceiling sync is currently paused. */ +export function isSpaceDetectionPaused(): boolean { + return spaceDetectionPauseDepth > 0 +} + export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void { const previousSnapshots = new Map() let isProcessing = false @@ -909,6 +1037,17 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => currentSnapshots.set(levelId, levelWallSnapshot(walls)) } + // Paused: roll the snapshot forward so we don't backfill (and re-duplicate) + // every paused change once detection resumes. Whatever the AI built while + // paused becomes the new baseline; only future changes will reconcile. + if (spaceDetectionPauseDepth > 0) { + previousSnapshots.clear() + for (const [levelId, snapshot] of currentSnapshots.entries()) { + previousSnapshots.set(levelId, snapshot) + } + return + } + const levelsToUpdate = new Set() for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) { diff --git a/packages/core/src/live-data/live-data-store.test.ts b/packages/core/src/live-data/live-data-store.test.ts new file mode 100644 index 000000000..7d2f49f33 --- /dev/null +++ b/packages/core/src/live-data/live-data-store.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { sanitizeLiveDataSnapshot, useLiveData } from './live-data-store' + +describe('live data store', () => { + beforeEach(() => { + useLiveData.getState().resetLiveData() + }) + + test('sanitizes snapshots before merging live values', () => { + const sanitized = sanitizeLiveDataSnapshot({ + seq: Number.POSITIVE_INFINITY, + timestamp: 123, + values: { + 'factory.temperature': 42, + 'factory.running': true, + 'factory.name': 'Pump A', + 'factory.badNumber': Number.NaN, + 'factory.object': { nested: 1 } as never, + 'factory.array': [1, 2] as never, + '': 99, + }, + }) + + expect(sanitized.rejectedCount).toBe(4) + expect(sanitized.snapshot).toEqual({ + timestamp: 123, + values: { + 'factory.temperature': 42, + 'factory.running': true, + 'factory.name': 'Pump A', + }, + }) + }) + + test('does not let invalid websocket values overwrite previous safe values', () => { + useLiveData.getState().setSnapshot({ + values: { + 'factory.temperature': 42, + }, + }) + + useLiveData.getState().setSnapshot({ + values: { + 'factory.temperature': Number.POSITIVE_INFINITY, + 'factory.status': { bad: true } as never, + }, + }) + + const state = useLiveData.getState() + expect(state.status).toBe('connected') + expect(state.values['factory.temperature']).toBe(42) + expect(state.values['factory.status']).toBeUndefined() + expect(state.error).toBe('Ignored 2 invalid live data values.') + }) +}) diff --git a/packages/core/src/live-data/live-data-store.ts b/packages/core/src/live-data/live-data-store.ts new file mode 100644 index 000000000..4e367965b --- /dev/null +++ b/packages/core/src/live-data/live-data-store.ts @@ -0,0 +1,145 @@ +'use client' + +import { create } from 'zustand' +import { + type LiveDataPath, + type LiveDataSnapshot, + type LiveDataValue, + STATIC_LIVE_DATA, + STATIC_LIVE_DATA_PATHS, +} from './static-live-data' + +export type LiveDataConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error' + +type LiveDataState = { + status: LiveDataConnectionStatus + httpEndpoint: string | null + wsEndpoint: string | null + endpoint: string | null + paths: LiveDataPath[] + values: Record + snapshot: LiveDataSnapshot | null + error: string | null + reconnectToken: number + setStatus: (status: LiveDataConnectionStatus, error?: string | null) => void + setEndpoint: (endpoint: string | null) => void + setSourceEndpoints: (endpoints: { + httpEndpoint?: string | null + wsEndpoint?: string | null + }) => void + setPaths: (paths: LiveDataPath[]) => void + setSnapshot: (snapshot: LiveDataSnapshot) => void + requestReconnect: () => void + resetLiveData: () => void +} + +const staticValues = Object.fromEntries( + Object.values(STATIC_LIVE_DATA).map((entry) => [entry.key, entry.value]), +) as Record + +const MAX_LIVE_DATA_STRING_LENGTH = 256 + +function mergeWithStaticPaths(paths: LiveDataPath[]) { + const merged: LiveDataPath[] = [] + const seen = new Set() + for (const path of [...paths, ...STATIC_LIVE_DATA_PATHS]) { + if (seen.has(path.path)) continue + seen.add(path.path) + merged.push(path) + } + return merged +} + +function sanitizeLiveDataValue(value: unknown): LiveDataValue | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined + if (typeof value === 'boolean') return value + if (typeof value === 'string') return value.slice(0, MAX_LIVE_DATA_STRING_LENGTH) + return undefined +} + +export function sanitizeLiveDataSnapshot(snapshot: LiveDataSnapshot): { + rejectedCount: number + snapshot: LiveDataSnapshot +} { + const values: Record = {} + let rejectedCount = 0 + + for (const [path, value] of Object.entries(snapshot.values ?? {})) { + if (!path.trim()) { + rejectedCount += 1 + continue + } + const sanitized = sanitizeLiveDataValue(value) + if (sanitized === undefined) { + rejectedCount += 1 + continue + } + values[path] = sanitized + } + + return { + rejectedCount, + snapshot: { + values, + ...(typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq) + ? { seq: snapshot.seq } + : {}), + ...(typeof snapshot.timestamp === 'number' && Number.isFinite(snapshot.timestamp) + ? { timestamp: snapshot.timestamp } + : {}), + }, + } +} + +export const useLiveData = create((set) => ({ + status: 'idle', + httpEndpoint: null, + wsEndpoint: null, + endpoint: null, + paths: STATIC_LIVE_DATA_PATHS, + values: staticValues, + snapshot: null, + error: null, + reconnectToken: 0, + setStatus: (status, error = null) => set({ status, error }), + setEndpoint: (endpoint) => set({ endpoint }), + setSourceEndpoints: (endpoints) => + set((state) => ({ + httpEndpoint: + endpoints.httpEndpoint === undefined ? state.httpEndpoint : endpoints.httpEndpoint, + wsEndpoint: endpoints.wsEndpoint === undefined ? state.wsEndpoint : endpoints.wsEndpoint, + reconnectToken: state.reconnectToken + 1, + })), + setPaths: (paths) => set({ paths: mergeWithStaticPaths(paths) }), + setSnapshot: (snapshot) => + set((state) => { + const sanitized = sanitizeLiveDataSnapshot(snapshot) + return { + snapshot: sanitized.snapshot, + values: { ...state.values, ...sanitized.snapshot.values }, + status: 'connected', + error: + sanitized.rejectedCount > 0 + ? `Ignored ${sanitized.rejectedCount} invalid live data value${sanitized.rejectedCount === 1 ? '' : 's'}.` + : null, + } + }), + requestReconnect: () => set((state) => ({ reconnectToken: state.reconnectToken + 1 })), + resetLiveData: () => + set({ + status: 'idle', + httpEndpoint: null, + wsEndpoint: null, + endpoint: null, + paths: STATIC_LIVE_DATA_PATHS, + values: staticValues, + snapshot: null, + error: null, + reconnectToken: 0, + }), +})) + +export function getLiveDataValue(path: string | null | undefined): LiveDataValue | undefined { + if (!path) return undefined + return useLiveData.getState().values[path] +} diff --git a/packages/core/src/live-data/static-live-data.ts b/packages/core/src/live-data/static-live-data.ts new file mode 100644 index 000000000..b05b7e54e --- /dev/null +++ b/packages/core/src/live-data/static-live-data.ts @@ -0,0 +1,142 @@ +export type StaticLiveDataKey = + | 'machine.status' + | 'machine.temperature' + | 'fan.speed' + | 'door.open' + | 'device.id' + | 'alarm.count' + +export type StaticLiveDataValue = string | number | boolean +export type LiveDataValue = StaticLiveDataValue + +export type LiveDataPath = { + path: string + label: string + valueType: 'number' | 'boolean' | 'string' + unit?: string + category?: string +} + +export type LiveDataSnapshot = { + values: Record + seq?: number + timestamp?: number +} + +export type StaticLiveDataEntry = { + key: StaticLiveDataKey + label: string + value: StaticLiveDataValue + unit?: string +} + +export const STATIC_LIVE_DATA: Record = { + 'machine.status': { key: 'machine.status', label: '设备状态', value: 1 }, + 'machine.temperature': { + key: 'machine.temperature', + label: '设备温度', + value: 28, + unit: '°C', + }, + 'fan.speed': { key: 'fan.speed', label: '风扇转速', value: 75, unit: '%' }, + 'door.open': { key: 'door.open', label: '门开启', value: 1 }, + 'device.id': { key: 'device.id', label: '设备 ID', value: 'A-001' }, + 'alarm.count': { key: 'alarm.count', label: '报警数量', value: 2 }, +} + +export const STATIC_LIVE_DATA_OPTIONS = Object.values(STATIC_LIVE_DATA).map((entry) => ({ + label: entry.label, + value: entry.key, +})) + +export const STATIC_LIVE_DATA_PATHS: LiveDataPath[] = Object.values(STATIC_LIVE_DATA).map((entry) => ({ + path: entry.key, + label: entry.label, + unit: entry.unit, + category: entry.key.split('.')[0], + valueType: + typeof entry.value === 'number' ? 'number' : typeof entry.value === 'boolean' ? 'boolean' : 'string', +})) + +export function getStaticLiveDataValue( + key: string | null | undefined, +): StaticLiveDataValue | undefined { + if (!key) return undefined + return STATIC_LIVE_DATA[key as StaticLiveDataKey]?.value +} + +export function formatStaticLiveDataValue(key: string | null | undefined): string { + if (!key) return '?' + const entry = STATIC_LIVE_DATA[key as StaticLiveDataKey] + if (!entry) return '?' + return `${entry.value}${entry.unit ? ` ${entry.unit}` : ''}` +} + +export function formatLiveDataValue(value: LiveDataValue | undefined, unit?: string): string { + if (value == null) return '?' + return `${value}${unit ? ` ${unit}` : ''}` +} + +export function getLiveDataPathLabel(path: string | null | undefined): string { + if (!path) return '' + return STATIC_LIVE_DATA_PATHS.find((entry) => entry.path === path)?.label ?? path +} + +export function renderLiveDataTemplate( + template: string | undefined, + key: string | undefined, +): string { + const entry = key ? STATIC_LIVE_DATA[key as StaticLiveDataKey] : undefined + if (!entry) return template?.replace('{value}', '?') ?? '?' + const value = `${entry.value}` + const unit = entry.unit ?? '' + return (template || '{label}: {value}{unit}') + .replaceAll('{label}', entry.label) + .replaceAll('{key}', entry.key) + .replaceAll('{value}', value) + .replaceAll('{unit}', unit ? ` ${unit}` : '') +} + +export type LiveDataBindingEffect = 'color' | 'rotation-y' | 'position-y' + +export type LiveDataBindingConfig = { + enabled?: boolean + dataKey: StaticLiveDataKey + effect: LiveDataBindingEffect +} + +export function isLiveDataBindingConfig(value: unknown): value is LiveDataBindingConfig { + if (!(value && typeof value === 'object')) return false + const record = value as Record + return typeof record.dataKey === 'string' && typeof record.effect === 'string' +} + +export function resolveBindingPreview(binding: LiveDataBindingConfig | null | undefined): string { + if (!binding?.enabled) return '未启用' + const value = formatStaticLiveDataValue(binding.dataKey) + if (binding.effect === 'color') return `${value} ? 颜色` + if (binding.effect === 'rotation-y') return `${value} ? Y 轴旋转` + return `${value} ? 高度偏移` +} + +export function resolveBindingColor(value: StaticLiveDataValue | undefined): string | null { + if (value === 0) return '#8a8a8a' + if (value === 1) return '#22c55e' + if (value === 2) return '#ef4444' + if (typeof value === 'number' && value > 0) return '#22c55e' + return null +} + +export function resolveBindingRotationYOffset(value: StaticLiveDataValue | undefined): number { + const numeric = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(numeric)) return 0 + return (Math.max(0, Math.min(100, numeric)) / 100) * Math.PI * 2 +} + +export function resolveBindingPositionYOffset(value: StaticLiveDataValue | undefined): number { + if (value === true) return 1 + if (value === false) return 0 + const numeric = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(numeric)) return 0 + return numeric > 0 ? 1 : 0 +} diff --git a/packages/core/src/material-library.test.ts b/packages/core/src/material-library.test.ts new file mode 100644 index 000000000..8f52dc7d2 --- /dev/null +++ b/packages/core/src/material-library.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from 'bun:test' +import { getMaterialSolidColorByRef, toLibraryMaterialRef } from './material-library' + +test('textured library fills resolve to a solid representative colour', () => { + expect(getMaterialSolidColorByRef(toLibraryMaterialRef('wood-finewood27'))).toBe('#a8794c') + expect(getMaterialSolidColorByRef(toLibraryMaterialRef('roof-claytiles'))).toBe('#b65f38') +}) + +test('paint colour presets keep their explicit preview colour', () => { + expect(getMaterialSolidColorByRef(toLibraryMaterialRef('preset-white'))).toBe('#ffffff') + expect(getMaterialSolidColorByRef(toLibraryMaterialRef('preset-forest'))).toBe('#4f6b57') +}) diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index e0974830a..1f24fac37 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -2411,6 +2411,46 @@ export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undef return MATERIAL_CATALOG.find((item) => item.id === id) } +function isNeutralWhite(color?: string | null): boolean { + if (!color) return false + const normalized = color.trim().toLowerCase() + return normalized === '#fff' || normalized === '#ffffff' || normalized === 'white' +} + +function inferTexturedMaterialSolidColor(item: MaterialCatalogItem): string | null { + const text = `${item.id} ${item.label} ${item.description ?? ''}`.toLowerCase() + + if (item.category === 'wood') { + if (text.includes('plank')) return '#8b5f3a' + if (text.includes('parquet')) return '#9b6a3f' + if (text.includes('fine')) return '#a8794c' + return '#93633c' + } + + if (item.category === 'flooring') { + if (text.includes('wood') || text.includes('parquet') || text.includes('plank')) { + return '#9b6a3f' + } + if (text.includes('marble')) return '#d7d0c4' + if (text.includes('concrete') || text.includes('cement')) return '#a8aaa4' + if (text.includes('stone')) return '#aaa296' + if (text.includes('tile') || text.includes('ceramic') || text.includes('porcelain')) { + return '#c8c0b4' + } + return '#b8b1a2' + } + + if (item.category === 'roof') { + if (text.includes('terracotta')) return '#a9522f' + if (text.includes('clay')) return '#b65f38' + if (text.includes('weathered')) return '#5f5b52' + if (text.includes('shingle')) return '#6f655a' + return '#8a6a52' + } + + return null +} + export const LIBRARY_MATERIAL_REF_PREFIX = 'library:' export function toLibraryMaterialRef(id: string) { @@ -2423,6 +2463,19 @@ export function getLibraryMaterialIdFromRef(materialRef?: string | null) { return materialRef.slice(LIBRARY_MATERIAL_REF_PREFIX.length) } +export function getMaterialSolidColorByRef(materialRef?: string | null): string | null { + const materialId = getLibraryMaterialIdFromRef(materialRef) + const item = getCatalogMaterialById(materialId ?? undefined) + if (!item) return null + + if (item.previewColor) return item.previewColor + + const color = item.preset.mapProperties.color + if (!isNeutralWhite(color)) return color + + return inferTexturedMaterialSolidColor(item) ?? color ?? null +} + export function getMaterialPresetByRef(materialRef?: string | null): MaterialPresetPayload | null { const materialId = getLibraryMaterialIdFromRef(materialRef) if (!materialId) return null diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts new file mode 100644 index 000000000..230a22040 --- /dev/null +++ b/packages/core/src/registry/handles.ts @@ -0,0 +1,385 @@ +// In-world resize / move arrow descriptors. Each `NodeDefinition` may +// declare a `handles` list (or a `(node) => list` function for shape- +// dependent affordances). The editor mounts a single generic component +// that reads these descriptors and renders the arrows / drag logic — no +// per-kind handles file needed. +// +// Pure data + small per-descriptor callbacks: no Three.js, React, or +// editor imports here so this stays in core. The descriptors are +// evaluated by the editor at drag time (`apply` etc.) so the callbacks +// run in the editor's context — they see the live node and the scene +// API but otherwise do not import 3D libraries. +// +// Layered intentionally: +// - axis-resize : symmetric scaling around center (column W/D, height) +// - edge-resize : anchored on one edge, the other follows the pointer +// (door width: drag right edge, left edge stays) +// - vertical-resize: linear-resize specialised for world-Y (height arrow +// anchored at bottom; window top-edge anchored at +// bottom; window bottom-edge anchored at top) +// - radial-resize : 1:1 outward growth of a radial field (column radius) +// - arc-resize : curved/spiral stair sweep / inner-radius / rise +// - endpoint-move : wall / fence endpoint drag (snapping is bespoke, +// so it delegates to a kind-supplied callback) + +import type { AnyNode, AnyNodeId } from '../schema/types' +import type { SceneApi } from './types' + +/** + * Editor-facing verbs that handle descriptors can invoke. + * + * Parallel to {@link SceneApi} but exposes EDITOR state mutations (move + * tools, endpoint dragging, etc.) instead of scene-data writes. Descriptors + * receive a concrete implementation from the editor at drag time — `core` + * only carries the interface so node definitions can call into editor + * affordances without importing the editor package. + * + * Minimal verb set today; grow it as new descriptor variants land + * (engageCurve for wall/fence curving, etc.). + */ +export type EditorApi = { + /** + * Hand the node to its registered move tool (the same path the floating + * menu's Move icon uses). Implementations clear any in-progress endpoint + * or curving state so the move starts from a clean slate. + */ + engageMove: (node: AnyNode) => void + /** + * Like {@link engageMove}, but for a press-drag gizmo: the move commits on + * pointer-release instead of waiting for a click, so the on-canvas move cross + * behaves as press-drag-release while still showing the placement preview. + */ + engageMoveDrag: (node: AnyNode) => void + /** + * Engage endpoint drag for kinds that own start / end anchors (walls, + * fences). No-ops for kinds without endpoints. + */ + engageEndpointMove: (node: AnyNode, endpoint: 'start' | 'end') => void + /** + * Engage a kind-owned curve affordance. No-ops for kinds without curve + * editing. + */ + engageCurve: (node: AnyNode) => void +} + +export type HandlePortal = 'self' | 'parent' | 'grandparent' + +export type HandleAxis = 'x' | 'y' | 'z' + +export type HandleAnchor = 'center' | 'min' | 'max' + +/** 3D position + rotation of the arrow in its portal target's local space. */ +export type HandlePlacement = { + /** + * `sceneApi` is supplied so descriptors that depend on cross-node state + * (elevator height resolving level entries, future cross-kind handles) + * can compute placement against the live scene. Existing descriptors + * that only need `node` can ignore the second argument. + */ + position: (node: N, sceneApi: SceneApi) => readonly [number, number, number] + /** Optional Y rotation (radians). Defaults to 0. */ + rotationY?: (node: N, sceneApi: SceneApi) => number +} + +export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing' + +/** + * Visual decoration shown alongside a handle while the user is hovering + * or dragging it. Today: a thin horizontal ring at a node-local radius — + * the curved-stair width / inner-radius arrows use this to trace the + * outer rim / inner pillar so the user sees what the drag affects. + * + * Pure data: the editor's arrow renderer reads it and mounts the visual. + */ +export type HandleDecoration = { + kind: 'ring' + /** Node-local radius of the ring (XZ plane). */ + radius: (node: N) => number + /** Node-local Y of the ring. Defaults to 0. */ + y?: (node: N) => number +} + +/** + * Linear resize along a single local axis. Covers width / depth / height + * arrows whose visible behaviour is "drag the +axis edge, the dimension + * grows." + * + * `anchor` controls which side stays fixed: + * - 'center' : symmetric — both edges move ±delta (column width/depth). + * - 'min' : the -axis edge is fixed; drag the +axis edge by `delta` + * grows the value by `delta` (column height with origin at + * base; door height with bottom anchored). + * - 'max' : the +axis edge is fixed; drag the -axis edge. + * + * `apply(node, newValue)` returns the partial patch. Use it to write + * sibling fields too (e.g. door 'max' anchor re-centers `position[0]`). + */ +export type LinearResizeHandle = { + kind: 'linear-resize' + /** Local axis. The arrow's chevron points along +axis. */ + axis: HandleAxis + anchor: HandleAnchor + currentValue: (node: N) => number + apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** + * Optional per-tick hook fired while this handle is being dragged, with the + * live (in-progress, override-merged) node. A pure side-channel for transient + * feedback — doors/windows use it to publish proximity / sill guides for the + * edge being resized. The return value is ignored; the resize itself is driven + * by `apply`. + */ + onDrag?: (node: N, sceneApi: SceneApi) => void + /** + * Cross-node redirect. By default the drag's live override + the + * committed write both land on the SELECTED node. When this returns + * another node's id, the editor publishes the override to / commits on + * THAT node instead (and `apply` should return that node's patch). + * Used when a node's handle edits a value owned by a sibling — e.g. a + * downspout's side-move arrows slide its outlet, which lives on the + * host gutter (`gutter.outlets[].offset`). The selected node is still + * what `currentValue` / `apply` receive, so the descriptor can read + * the downspout to find its gutter + outlet. + */ + overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined + min?: number | ((node: N, sceneApi: SceneApi) => number) + max?: number | ((node: N, sceneApi: SceneApi) => number) + /** Snap the resized scalar to the editor's active grid step before apply. */ + gridSnap?: boolean + placement: HandlePlacement + /** + * Dimension this handle steers (e.g. `'height'`). When set, the editor + * publishes it to `activeHandleDrag.label` for the duration of the drag + * so out-of-band overlays (the floating dimension pill) can react, and + * the handle's own in-world value chip is suppressed to avoid showing + * the same number twice. Leave unset for handles that keep their inline + * chip and don't drive any external overlay. + */ + measureLabel?: string + /** + * Defaults to 'self' (arrow lives in the selected node's own mesh). + * 'parent' uses the parent mesh — used by doors/windows whose handles + * need to ride the wall's rotation. + */ + portal?: HandlePortal + cursor?: Cursor + /** Optional visual guide shown while the arrow is hovered or dragging. */ + decoration?: HandleDecoration + /** + * Visual override. Defaults to the standard chevron arrow. + * + * `'tracker'` swaps the chevron for a dashed vertical leader + a small + * cube at `placement.position`. The leader runs from the floor (local + * y=0) up to the cube; the cube is the drag target and reuses the same + * linear-resize drag pipeline as the chevron. Intended for vertical + * height handles where the dashed leader makes the "this is the wall + * top" relationship readable at a glance — mirrors the `corner-picker` + * shape on `tap-action` handles but with a draggable cube instead of a + * one-tap hex disc. Use with `axis: 'y'`; horizontal axes will render + * the leader vertically and look wrong. + */ + shape?: 'arrow' | 'tracker' + /** + * Optional override for the bottom Y of the tracker leader. Defaults + * to 0 (floor of the rideObject's local frame). Use when the value + * being tracked spans a region that doesn't start at the floor — e.g. + * a chimney's body height runs from the roof deck up to the body top, + * so the leader should start at the deck plane and not climb through + * the roof shell below it. Only consulted when `shape === 'tracker'`. + */ + trackerBaseY?: (node: N, sceneApi: SceneApi) => number +} + +/** + * 1:1 outward growth — dragging the arrow outward by `delta` grows the + * value by `delta` (the visible edge follows the pointer). Use for radii + * and other fields where the conceptual model is "the +axis edge IS the + * thing being moved" rather than "the size IS being scaled." + */ +export type RadialResizeHandle = { + kind: 'radial-resize' + axis: HandleAxis + currentValue: (node: N) => number + apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + min?: number | ((node: N, sceneApi: SceneApi) => number) + max?: number | ((node: N, sceneApi: SceneApi) => number) + placement: HandlePlacement + portal?: HandlePortal + /** Optional visual guide shown while the arrow is hovered or dragging. */ + decoration?: HandleDecoration +} + +/** + * Curved / spiral stair sweep arrows. The renderer raycasts a horizontal + * plane through the arrow's Y and emits the angular delta (radians, + * signed, normalised to [-π, π]) around the node's local origin. + * + * Unlike the linear variants, `apply` receives the raw cursor delta + * (not a `newValue`) because sweep handles typically write multiple + * fields off the delta (`sweepAngle` AND `rotation` — re-orienting the + * arc so the opposite edge stays world-fixed). Descriptor-internal + * math handles the per-end sign and any clamping; the renderer stays + * out of it. + */ +export type ArcResizeHandle = { + kind: 'arc-resize' + /** + * Marks the drag mode. Only 'angular' uses the polar plane renderer; + * 'radial' and 'vertical' degenerate to `linear-resize` (axis 'x' / + * 'y') so descriptors should prefer that for those cases. + */ + axis: 'angular' + /** Optional metadata for descriptors that bundle two handles per kind. */ + end?: 'start' | 'end' + apply: (initialNode: N, delta: number, sceneApi: SceneApi) => Partial + placement: HandlePlacement + portal?: HandlePortal + /** Optional visual guide shown while the arrow is hovered or dragging. */ + decoration?: HandleDecoration + /** + * Visual override. Defaults to the standard chevron (used by the + * stair-sweep extend handles). 'rotate' renders a two-headed curved + * arrow icon, intended for whole-node rotation handles. + */ + shape?: 'chevron' | 'rotate' + /** + * Plane the angular drag is measured in: + * - 'horizontal' (default): cursor bearing around +Y — whole-node yaw + * (floor items, elevator, stair, roof-segment). + * - 'node-normal': cursor bearing around the node's local +Z axis, in + * the plane perpendicular to it — spins a wall-mounted item flat + * against its wall. The descriptor's `apply` writes the roll + * component (rotation[2]). The gizmo icon stands up into that plane. + */ + rotationPlane?: 'horizontal' | 'node-normal' + /** + * Pivot point for the angular drag, in the rideObject's local space. + * The renderer measures cursor angle (atan2 on the drag plane) around + * this point — descriptors that write `rotation` should anchor it to + * the node's visual center. Defaults to the rideObject's own origin, + * which is correct for nodes whose mesh origin coincides with the + * field they're rotating (roof-segment, elevator). Use this when the + * node's pose is baked into its geometry (chimney) so the mesh origin + * sits at the parent frame's origin rather than the rotating shape's + * center. + */ + rotationCenter?: (node: N, sceneApi: SceneApi) => readonly [number, number, number] +} + +/** + * Wall / fence endpoint drag. Snapping and adjacency belong to the kind, + * so the descriptor declares the placement and hands the world-space + * pointer position back to `apply`. The kind can splice walls, snap to + * a grid, merge with a neighbour, etc., and returns the partial patch. + */ +export type EndpointMoveHandle = { + kind: 'endpoint-move' + endpoint: 'start' | 'end' + placement: HandlePlacement + /** Called with the world-space hit on the ground plane. */ + apply: (node: N, worldPoint: readonly [number, number, number], sceneApi: SceneApi) => Partial + portal?: HandlePortal +} + +// Default to `any` so type-erased renderers can hold `HandleDescriptor[]` +// without each variant's contravariant `currentValue: (node: N) => ...` +// callback fighting the union widening. Per-kind defs supply a real N. +/** + * Click-to-engage affordance. The descriptor doesn't drive a drag — its + * single job is to mount a click target at `placement` and dispatch a + * verb on the editor API when the user clicks. Used by wall side-move + * (engage move tool) and wall corner pickers (engage endpoint move). + * + * The renderer picks the visual from `shape`. Default `'arrow'` reuses + * the chevron shape every resize handle uses. `'corner-picker'` renders + * a dashed vertical leader + billboarded hex disc + ring, anchored at + * `placement.position` and extending up to `nodeHeight(node)`. + */ +export type TapActionHandle = { + kind: 'tap-action' + placement: HandlePlacement + /** + * Dispatched on pointer-down. Use scene/editor APIs to read state + + * trigger the desired action. + */ + onActivate: (node: N, scene: SceneApi, editor: EditorApi) => void + /** + * Visual override; defaults to the standard chevron arrow. `'move-cross'` + * reuses the 4-way move cross — a tap-to-engage grip that hands the node to + * its move tool (via `onActivate`) instead of running the generic translate + * drag, so the move tool's own preview / ticker feedback shows up. + */ + shape?: 'arrow' | 'corner-picker' | 'move-cross' + /** + * Required when `shape: 'corner-picker'` — controls the dashed leader's + * vertical extent. Pure callback so the descriptor doesn't need to + * import 3D libs. + */ + nodeHeight?: (node: N) => number + /** + * `shape: 'move-cross'` only — tilts the flat cross to lie in the right + * plane. `'horizontal'` (default) leaves it flat on the floor; `'node-normal'` + * stands it up against the node's facing plane (a wall face). + */ + plane?: 'horizontal' | 'node-normal' + portal?: HandlePortal + cursor?: Cursor +} + +/** + * Free ground-plane move. Drag the handle and the node slides across the + * horizontal plane at its base — the renderer raycasts that plane, converts + * the hit into the node's parent-local frame, and reports the new local XZ + * (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release + * with the same live-override → commit-on-release flow as the resize / rotate + * handles. Rendered as a 4-way cross of double-headed arrows. Pure translation + * does not require geometry dirtying; renderers consume the live position + * override directly. + */ +export type TranslateHandle = { + kind: 'translate' + placement: HandlePlacement + /** + * Plane the drag is constrained to (through the node origin): + * - 'horizontal' (default): the ground plane (world-up normal) — slide + * across the floor. The free axes are parent-local X / Z. + * - 'node-normal': the plane perpendicular to the node's local +Z axis + * (its facing direction) — slide across a wall face. The free axes are + * parent-local X / Y; depth (Z) stays pinned to the surface. + */ + plane?: 'horizontal' | 'node-normal' + /** + * `localPos` is the dragged-to position in the node's PARENT-local frame, + * with the two in-plane axes already grid-snapped (if `snapExtents` is set) + * and the off-plane axis pinned to its drag-start value. Return the patch + * that writes it to the node's position field. + */ + apply: ( + initialNode: N, + localPos: readonly [number, number, number], + sceneApi: SceneApi, + ) => Partial + /** + * Optional grid-snap footprint for the two in-plane axes, in order + * `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and + * Y for 'node-normal'. Used to align the node's edges to the grid (rotation- + * aware: swap the pair at 90°). Omit / return null for free movement. + * `sceneApi` is supplied for composite nodes whose footprint depends on + * children, such as straight stairs. + */ + snapExtents?: (node: N, sceneApi: SceneApi) => readonly [number, number] | null + portal?: HandlePortal +} + +export type HandleDescriptor = + | LinearResizeHandle + | RadialResizeHandle + | ArcResizeHandle + | EndpointMoveHandle + | TapActionHandle + | TranslateHandle + +/** + * Static array, or a function for shape-dependent cases (column + * crossSection / supportStyle, stair-segment segmentType, etc.). + */ +export type HandleList = HandleDescriptor[] | ((node: N) => HandleDescriptor[]) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index d87e8b5dd..2478304e1 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,13 +1,59 @@ +export type { + ArcResizeHandle, + Cursor, + EditorApi, + EndpointMoveHandle, + HandleAnchor, + HandleAxis, + HandleDescriptor, + HandleList, + HandlePlacement, + HandlePortal, + LinearResizeHandle, + RadialResizeHandle, + TapActionHandle, + TranslateHandle, +} from './handles' export { + bakePolicyOf, discoverPlugins, + getSceneSelectionConfig, + getSceneSelectionKinds, getSelectableKinds, + hasRegistry3DMoveTool, + isRegistryMovable, isRegistrySelectable, + kindsWithBakePolicy, loadPlugin, nodeRegistry, type PluginDiscovery, + panelRegistry, registerNode, setPluginDiscovery, } from './registry' +export { + assertSemanticRecipeComposeResult, + assertSemanticRecipeDefinition, + registerSemanticRecipe, + semanticRecipeRegistry, + validateSemanticRecipeComposeResult, + validateSemanticRecipeDefinition, +} from './semantic-recipes' +export type { + SemanticRecipeComposeInput, + SemanticRecipeComposeResult, + SemanticRecipeDefinition, + SemanticRecipeEditableParam, + SemanticRecipeEditableParamEffect, + SemanticRecipeEditableParamKind, + SemanticRecipeEnvelope, + SemanticRecipeId, + SemanticRecipePart, + SemanticRecipePort, + SemanticRecipePortSide, + SemanticRecipeRegistry, + SemanticRecipeValidationIssue, +} from './semantic-recipes' export { type CascadeContext, type ChildQuery, @@ -16,10 +62,15 @@ export { type SpatialQuery, } from './relations-resolver' export { createSceneApi, type SceneStoreLike } from './scene-api' +export type { EquipmentNodeDescriptor, EquipmentPort } from '../equipment' export type { Affordance, AnyNodeDefinition, + ActionMenuLabel, + ActionMenuPlacementRule, AssetRef, + BakePolicy, + BakeReplaceRenderer, Capabilities, CapabilityCtx, CuttableConfig, @@ -40,22 +91,31 @@ export type { IconRef, Issue, LazyComponent, + MaterialTargetDescriptor, + MaterialTargetKind, McpOverrides, Modifiers, MovableConfig, NodeCategory, + NodeActionMenu, NodeDefinition, + NudgeDelta, NodeRegistry, + PanelWorkspace, ParametricDescriptor, ParamField, ParamGroup, Plugin, + PluginPanel, Presentation, Relations, RendererSource, RotatableConfig, ScalableConfig, SceneApi, + SceneSelectionConfig, + SceneSelectionFootprint, + SceneSelectionRole, SelectableConfig, SnapPointKind, SnappableConfig, diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index 0e1abc0f9..64517a8a9 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { z } from 'zod' -import { loadPlugin, nodeRegistry, registerNode } from './registry' +import { + getSceneSelectionConfig, + getSceneSelectionKinds, + loadPlugin, + nodeRegistry, + registerNode, +} from './registry' import type { AnyNodeDefinition, Plugin } from './types' function makeDefinition( @@ -68,6 +74,27 @@ describe('nodeRegistry', () => { registerNode(b) expect(nodeRegistry.schemas()).toEqual([a.schema, b.schema]) }) + + test('scene selection helpers expose only role-backed selection kinds', () => { + registerNode( + makeDefinition('wall', { + capabilities: { sceneSelection: { role: 'zone-content', zoneFootprint: 'segment' } }, + }), + ) + registerNode( + makeDefinition('data-widget', { + capabilities: { sceneSelection: { outline: false } }, + }), + ) + registerNode(makeDefinition('plain')) + + expect(getSceneSelectionKinds()).toEqual(['wall']) + expect(getSceneSelectionConfig('wall')).toEqual({ + role: 'zone-content', + zoneFootprint: 'segment', + }) + expect(getSceneSelectionConfig('missing')).toBeUndefined() + }) }) describe('loadPlugin', () => { diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 50021bee7..ceb6bb72b 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -1,8 +1,16 @@ import type { ZodObject } from 'zod' -import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types' +import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types' +import { registerSemanticRecipe, semanticRecipeRegistry } from './semantic-recipes' const HOST_API_VERSION = 1 as const +function isDevMode(): boolean { + if (typeof process !== 'undefined' && process.env?.NODE_ENV) { + return process.env.NODE_ENV !== 'production' + } + return false +} + class NodeRegistryImpl implements NodeRegistry { private readonly defs = new Map() @@ -57,6 +65,78 @@ export function registerNode(def: AnyNodeDefinition): void { nodeRegistry._register(def) } +/** + * Registry of left-rail panels contributed by plugins. Same add-only, + * duplicate-id-throws semantics as the node registry, with one difference: it + * is *observable*. Plugin discovery runs asynchronously after the first React + * render (see `loadExternalPlugins`), so the sidebar subscribes via + * {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot} + * (the `useSyncExternalStore` contract) and re-renders when a panel lands. + * `getSnapshot` returns a stable array reference between registrations so the + * subscribing component doesn't re-render in a loop. + */ +class PanelRegistryImpl { + private readonly panels = new Map() + private readonly listeners = new Set<() => void>() + private cached: PluginPanel[] = [] + // node kind → the (namespaced) panel id of the plugin that shipped it. + // Populated by `loadPlugin`; lets a host's "find in catalog" open the + // panel that places a kind without hardcoding per-plugin knowledge. + private readonly kindPanels = new Map() + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => { + this.listeners.delete(onChange) + } + } + + getSnapshot = (): PluginPanel[] => this.cached + + _register(panel: PluginPanel): void { + if (typeof panel.id !== 'string' || panel.id.length === 0) { + throw new Error('[registry] PluginPanel.id must be a non-empty string') + } + if (this.panels.has(panel.id)) { + if (isDevMode()) { + console.warn(`[registry] re-registering plugin panel "${panel.id}" (HMR)`) + } else { + throw new Error(`[registry] duplicate plugin panel id: "${panel.id}" already registered`) + } + } + this.panels.set(panel.id, panel) + this.emit() + } + + _associateKind(kind: string, panelId: string): void { + this.kindPanels.set(kind, panelId) + } + + /** The (namespaced) panel id of the plugin that registered `kind`, if any. */ + panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) + + // Test-only — clears the registry. Not exported from the package barrel. + _reset(): void { + this.panels.clear() + this.kindPanels.clear() + this.emit() + } + + private emit(): void { + this.cached = Array.from(this.panels.values()) + for (const fn of this.listeners) fn() + } +} + +export const panelRegistry: { + subscribe: (onChange: () => void) => () => void + getSnapshot: () => PluginPanel[] + panelForKind: (kind: string) => string | undefined + _register: (panel: PluginPanel) => void + _associateKind: (kind: string, panelId: string) => void + _reset: () => void +} = new PanelRegistryImpl() + /** * Returns the set of registered kinds whose definition declares the * `selectable` capability. Callers that maintain hardcoded "selectable kinds" @@ -85,6 +165,66 @@ export function isRegistrySelectable(kind: string): boolean { return nodeRegistry.get(kind)?.capabilities.selectable !== undefined } +export function getSceneSelectionKinds(): string[] { + const result: string[] = [] + for (const [kind, def] of nodeRegistry.entries()) { + if (def.capabilities.sceneSelection?.role !== undefined) { + result.push(kind) + } + } + return result +} + +export function getSceneSelectionConfig(kind: string) { + return nodeRegistry.get(kind)?.capabilities.sceneSelection +} + +/** + * A kind's {@link BakePolicy} from the registry, defaulting to `'static'` for + * kinds that don't declare one (or aren't registered). The bake and the baked + * `/viewer` consult this instead of hardcoding kind names — see + * plans/editor-plugin-trees-example.md → Part D. + */ +export function bakePolicyOf(kind: string): BakePolicy { + return nodeRegistry.get(kind)?.bake ?? 'static' +} + +/** Registered kinds whose {@link BakePolicy} matches `policy`. `'static'` is the + * default, so `kindsWithBakePolicy('static')` includes kinds that didn't set it. */ +export function kindsWithBakePolicy(policy: BakePolicy): string[] { + const result: string[] = [] + for (const [kind, def] of nodeRegistry.entries()) { + if ((def.bake ?? 'static') === policy) result.push(kind) + } + return result +} + +/** + * Returns true when the kind is movable from a 2D floor-plan handle — + * either via `capabilities.movable`, an explicit + * `def.floorplanMoveTarget`, or an `affordanceTools.move` 3D mover that + * the floating action menu can engage. Replaces the kind-name ternary + * chain in `floating-action-menu.tsx`. + */ +export function isRegistryMovable(kind: string): boolean { + const def = nodeRegistry.get(kind) + if (!def) return false + if (def.capabilities.movable !== undefined) return true + if (def.floorplanMoveTarget !== undefined) return true + if (def.affordanceTools?.move !== undefined) return true + return false +} + +/** + * Whether the kind has a move path that mounts in the 3D viewport. + * This gates Ctrl/Meta direct move and press-drag move handles. + */ +export function hasRegistry3DMoveTool(kind: string): boolean { + const def = nodeRegistry.get(kind) + if (!def) return false + return def.capabilities.movable !== undefined || def.affordanceTools?.move !== undefined +} + export async function loadPlugin(plugin: Plugin): Promise { if (plugin.apiVersion !== HOST_API_VERSION) { throw new Error( @@ -94,6 +234,25 @@ export async function loadPlugin(plugin: Plugin): Promise { for (const def of plugin.nodes ?? []) { registerNode(def) } + for (const panel of plugin.panels ?? []) { + // Namespace the panel id by its owning plugin so two plugins can each + // ship a panel id of `'main'`. The namespaced id is what the sidebar + // uses as `activeSidebarPanel`. + panelRegistry._register({ ...panel, id: `${plugin.id}:${panel.id}` }) + } + for (const recipe of plugin.semanticRecipes ?? []) { + if (!semanticRecipeRegistry.has(recipe.id)) { + registerSemanticRecipe(recipe) + } + } + // Associate every node kind with the plugin's first panel — the panel a + // host's "find in catalog" should open to place more of that kind. + const firstPanel = plugin.panels?.[0] + if (firstPanel) { + for (const def of plugin.nodes ?? []) { + panelRegistry._associateKind(def.kind, `${plugin.id}:${firstPanel.id}`) + } + } } /** @@ -104,8 +263,8 @@ export async function loadPlugin(plugin: Plugin): Promise { * {@link setPluginDiscovery} before the bootstrap module runs. * * Kept async so a future loader can fetch over the network without - * changing the contract. See `wiki/editor-plugin-authoring.md` for the - * plugin author surface this enables. + * changing the contract. See `wiki/architecture/plugin-authoring.md` for + * the plugin author surface this enables. */ export type PluginDiscovery = () => Promise diff --git a/packages/core/src/registry/semantic-recipes.test.ts b/packages/core/src/registry/semantic-recipes.test.ts new file mode 100644 index 000000000..fc78d60ea --- /dev/null +++ b/packages/core/src/registry/semantic-recipes.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + assertSemanticRecipeComposeResult, + registerSemanticRecipe, + semanticRecipeRegistry, + validateSemanticRecipeComposeResult, + validateSemanticRecipeDefinition, + type SemanticRecipeDefinition, +} from './semantic-recipes' + +function recipe(id: string): SemanticRecipeDefinition { + return { + id, + label: id, + family: 'test', + acceptsProfiles: [`${id}.profile`], + compose: () => ({ parts: [] }), + } +} + +describe('semantic recipe registry', () => { + beforeEach(() => { + semanticRecipeRegistry._reset() + }) + + test('registers and retrieves recipes', () => { + const def = recipe('test:recipe') + registerSemanticRecipe(def) + + expect(semanticRecipeRegistry.size).toBe(1) + expect(semanticRecipeRegistry.get('test:recipe')).toBe(def) + expect(semanticRecipeRegistry.has('test:recipe')).toBe(true) + }) + + test('rejects duplicate recipe ids', () => { + registerSemanticRecipe(recipe('test:recipe')) + + expect(() => registerSemanticRecipe(recipe('test:recipe'))).toThrow( + 'duplicate semantic recipe id', + ) + }) + + test('finds a recipe by accepted profile id', () => { + registerSemanticRecipe(recipe('test:recipe')) + + expect(semanticRecipeRegistry.findByProfile('TEST:RECIPE.PROFILE')?.id).toBe('test:recipe') + expect(semanticRecipeRegistry.findByProfile('missing.profile')).toBeUndefined() + }) + + test('rejects malformed editable param declarations during registration', () => { + const invalid: SemanticRecipeDefinition = { + ...recipe('test:invalid'), + editableParams: [ + { + key: 'opacity', + kind: 'number', + min: 1, + max: 0, + effects: [ + { + kind: 'set-part-material', + partRole: '', + property: 'opacity', + }, + ], + }, + ], + } + + expect(validateSemanticRecipeDefinition(invalid).map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'editable_param_range_invalid', + 'editable_effect_part_role_missing', + ]), + ) + expect(() => registerSemanticRecipe(invalid)).toThrow('invalid semantic recipe') + }) + + test('validates editable effect target roles against composed recipe parts', () => { + const def: SemanticRecipeDefinition = { + ...recipe('test:editable'), + editableParams: [ + { + key: 'shellOpacity', + kind: 'number', + effects: [ + { + kind: 'set-part-material', + partRole: 'missing_shell', + property: 'opacity', + }, + ], + }, + ], + editablePartRoles: ['missing_shell'], + corePartRoles: ['missing_core'], + compose: () => ({ + parts: [{ id: 'shell', kind: 'box', semanticRole: 'vessel_shell' }], + }), + } + + const result = def.compose({}) + const issues = validateSemanticRecipeComposeResult(def, result) + + expect(issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'editable_effect_part_role_missing_in_parts', + 'semantic_role_missing', + ]), + ) + expect(() => assertSemanticRecipeComposeResult(def, result)).toThrow( + 'missing semantic part role "missing_shell"', + ) + }) + + test('accepts editable params that target composed semantic parts', () => { + const def: SemanticRecipeDefinition = { + ...recipe('test:valid-editable'), + editableParams: [ + { + key: 'liquidLevel', + kind: 'number', + min: 0, + max: 1, + effects: [ + { kind: 'set-param' }, + { + kind: 'set-part-dynamic-level', + partRole: 'liquid_volume', + geometryRef: 'dynamicLevelGeometry', + }, + ], + }, + ], + editablePartRoles: ['vessel_shell', 'liquid_volume'], + corePartRoles: ['vessel_shell'], + compose: () => ({ + parts: [ + { id: 'shell', kind: 'box', semanticRole: 'vessel_shell' }, + { id: 'liquid', kind: 'box', semanticRole: 'liquid_volume' }, + ], + }), + } + + expect(validateSemanticRecipeDefinition(def)).toEqual([]) + expect(validateSemanticRecipeComposeResult(def, def.compose({}))).toEqual([]) + }) +}) diff --git a/packages/core/src/registry/semantic-recipes.ts b/packages/core/src/registry/semantic-recipes.ts new file mode 100644 index 000000000..624c0021e --- /dev/null +++ b/packages/core/src/registry/semantic-recipes.ts @@ -0,0 +1,346 @@ +import type { EquipmentParamValue } from '../equipment' +import type { PartComposePartInput } from '../lib/part-compose' +import type { Vec3 } from '../lib/primitive-compose' + +export type SemanticRecipeId = string + +export type SemanticRecipePortSide = 'left' | 'right' | 'front' | 'back' | 'top' | 'bottom' + +export type SemanticRecipePort = { + id: string + role?: string + medium?: string + side: SemanticRecipePortSide + height: number + offset?: number + direction?: Vec3 +} + +export type SemanticRecipeEnvelope = { + length: number + width: number + height: number + tolerance?: number +} + +export type SemanticRecipePart = PartComposePartInput & { + semanticRole?: string +} + +export type SemanticRecipeComposeInput = { + params?: Record + envelope?: Partial + profileId?: string + stationId?: string + medium?: string +} + +export type SemanticRecipeComposeResult = { + parts: SemanticRecipePart[] + ports?: SemanticRecipePort[] + envelope?: SemanticRecipeEnvelope + editableParams?: readonly SemanticRecipeEditableParam[] + editablePartRoles?: readonly string[] + corePartRoles?: readonly string[] + primarySemanticRole?: string +} + +export type SemanticRecipeEditableParamKind = 'number' | 'color' | 'boolean' | 'enum' + +export type SemanticRecipeEditableParamEffect = + | { + kind: 'set-param' + param?: string + } + | { + kind: 'set-part-material' + partRole: string + property: 'color' | 'opacity' | 'roughness' | 'metalness' | 'transparent' + transparentWhenBelowOne?: boolean + } + | { + kind: 'set-part-dynamic-level' + partRole: string + geometryRef: 'dynamicLevelGeometry' + minSize?: number + } + +export type SemanticRecipeEditableParam = { + key: string + label?: string + kind: SemanticRecipeEditableParamKind + min?: number + max?: number + step?: number + precision?: number + unit?: string + defaultValue?: EquipmentParamValue + options?: readonly string[] + effects?: readonly SemanticRecipeEditableParamEffect[] +} + +export type SemanticRecipeDefinition = { + id: SemanticRecipeId + label: string + family: string + acceptsProfiles?: readonly string[] + paramSchema?: unknown + defaultEnvelope?: SemanticRecipeEnvelope + editableParams?: readonly SemanticRecipeEditableParam[] + editablePartRoles?: readonly string[] + corePartRoles?: readonly string[] + compose: (input: SemanticRecipeComposeInput) => SemanticRecipeComposeResult +} + +export type SemanticRecipeRegistry = { + has: (id: SemanticRecipeId) => boolean + get: (id: SemanticRecipeId) => SemanticRecipeDefinition | undefined + entries: () => IterableIterator<[SemanticRecipeId, SemanticRecipeDefinition]> + findByProfile: (profileId: string) => SemanticRecipeDefinition | undefined + get size(): number +} + +export type SemanticRecipeValidationIssue = { + code: string + message: string + path: string +} + +const EDITABLE_MATERIAL_PROPERTIES = new Set([ + 'color', + 'opacity', + 'roughness', + 'metalness', + 'transparent', +]) + +function pushIssue( + issues: SemanticRecipeValidationIssue[], + code: string, + path: string, + message: string, +) { + issues.push({ code, path, message }) +} + +function validateEditableParam( + issues: SemanticRecipeValidationIssue[], + param: SemanticRecipeEditableParam, + index: number, +) { + const path = `editableParams.${index}` + if (typeof param.key !== 'string' || param.key.trim().length === 0) { + pushIssue(issues, 'editable_param_key_missing', `${path}.key`, 'Editable param key must be non-empty.') + } + if (param.kind === 'number') { + if (param.min != null && (!Number.isFinite(param.min) || typeof param.min !== 'number')) { + pushIssue(issues, 'editable_param_min_invalid', `${path}.min`, 'Number param min must be finite.') + } + if (param.max != null && (!Number.isFinite(param.max) || typeof param.max !== 'number')) { + pushIssue(issues, 'editable_param_max_invalid', `${path}.max`, 'Number param max must be finite.') + } + if ( + typeof param.min === 'number' && + typeof param.max === 'number' && + param.min > param.max + ) { + pushIssue(issues, 'editable_param_range_invalid', path, 'Number param min must not exceed max.') + } + if (param.step != null && (typeof param.step !== 'number' || param.step <= 0)) { + pushIssue(issues, 'editable_param_step_invalid', `${path}.step`, 'Number param step must be positive.') + } + } + if (param.kind === 'enum') { + if (!param.options?.length) { + pushIssue(issues, 'editable_param_enum_options_missing', `${path}.options`, 'Enum param must define options.') + } + if ( + typeof param.defaultValue === 'string' && + param.options?.length && + !param.options.includes(param.defaultValue) + ) { + pushIssue( + issues, + 'editable_param_enum_default_invalid', + `${path}.defaultValue`, + 'Enum defaultValue must be one of the options.', + ) + } + } + if (param.kind === 'boolean' && param.defaultValue != null && typeof param.defaultValue !== 'boolean') { + pushIssue(issues, 'editable_param_boolean_default_invalid', `${path}.defaultValue`, 'Boolean defaultValue must be boolean.') + } + const effects = param.effects ?? [{ kind: 'set-param' as const }] + effects.forEach((effect, effectIndex) => { + const effectPath = `${path}.effects.${effectIndex}` + if (effect.kind === 'set-param') { + if (effect.param != null && effect.param.trim().length === 0) { + pushIssue(issues, 'editable_effect_param_invalid', `${effectPath}.param`, 'set-param target must be non-empty.') + } + return + } + if (effect.kind === 'set-part-material') { + if (effect.partRole.trim().length === 0) { + pushIssue(issues, 'editable_effect_part_role_missing', `${effectPath}.partRole`, 'Material effect partRole must be non-empty.') + } + if (!EDITABLE_MATERIAL_PROPERTIES.has(effect.property)) { + pushIssue(issues, 'editable_effect_material_property_invalid', `${effectPath}.property`, `Unsupported material property: ${effect.property}.`) + } + return + } + if (effect.kind === 'set-part-dynamic-level') { + if (effect.partRole.trim().length === 0) { + pushIssue(issues, 'editable_effect_part_role_missing', `${effectPath}.partRole`, 'Dynamic level effect partRole must be non-empty.') + } + if (effect.geometryRef !== 'dynamicLevelGeometry') { + pushIssue(issues, 'editable_effect_geometry_ref_invalid', `${effectPath}.geometryRef`, 'Dynamic level effect must target dynamicLevelGeometry.') + } + if (effect.minSize != null && (typeof effect.minSize !== 'number' || effect.minSize <= 0)) { + pushIssue(issues, 'editable_effect_min_size_invalid', `${effectPath}.minSize`, 'Dynamic level minSize must be positive.') + } + return + } + pushIssue(issues, 'editable_effect_kind_invalid', `${effectPath}.kind`, 'Unsupported editable effect kind.') + }) +} + +export function validateSemanticRecipeDefinition( + recipe: SemanticRecipeDefinition, +): SemanticRecipeValidationIssue[] { + const issues: SemanticRecipeValidationIssue[] = [] + if (typeof recipe.id !== 'string' || recipe.id.length === 0) { + pushIssue(issues, 'recipe_id_missing', 'id', 'Semantic recipe id must be a non-empty string.') + } + if (typeof recipe.compose !== 'function') { + pushIssue(issues, 'recipe_compose_missing', 'compose', `Semantic recipe "${recipe.id}" must provide compose().`) + } + const keys = new Set() + ;(recipe.editableParams ?? []).forEach((param, index) => { + validateEditableParam(issues, param, index) + if (param.key && keys.has(param.key)) { + pushIssue(issues, 'editable_param_key_duplicate', `editableParams.${index}.key`, `Duplicate editable param key: ${param.key}.`) + } + if (param.key) keys.add(param.key) + }) + return issues +} + +function semanticPartRoles(parts: readonly SemanticRecipePart[]) { + return new Set( + parts + .map((part) => part.semanticRole) + .filter((role): role is string => typeof role === 'string' && role.length > 0), + ) +} + +function validateRoleList( + issues: SemanticRecipeValidationIssue[], + roles: Set, + list: readonly string[] | undefined, + path: string, +) { + ;(list ?? []).forEach((role, index) => { + if (!roles.has(role)) { + pushIssue(issues, 'semantic_role_missing', `${path}.${index}`, `Semantic role "${role}" is not produced by recipe parts.`) + } + }) +} + +export function validateSemanticRecipeComposeResult( + recipe: SemanticRecipeDefinition, + result: SemanticRecipeComposeResult, +): SemanticRecipeValidationIssue[] { + const issues: SemanticRecipeValidationIssue[] = [] + const roles = semanticPartRoles(result.parts) + const editableParams = result.editableParams ?? recipe.editableParams ?? [] + editableParams.forEach((param, paramIndex) => { + paramEffects(param).forEach((effect, effectIndex) => { + if (effect.kind !== 'set-part-material' && effect.kind !== 'set-part-dynamic-level') return + if (!roles.has(effect.partRole)) { + pushIssue( + issues, + 'editable_effect_part_role_missing_in_parts', + `editableParams.${paramIndex}.effects.${effectIndex}.partRole`, + `Editable param "${param.key}" targets missing semantic part role "${effect.partRole}".`, + ) + } + }) + }) + validateRoleList(issues, roles, result.corePartRoles ?? recipe.corePartRoles, 'corePartRoles') + return issues +} + +function paramEffects(param: SemanticRecipeEditableParam): readonly SemanticRecipeEditableParamEffect[] { + return param.effects?.length ? param.effects : [{ kind: 'set-param' as const }] +} + +function formatValidationIssues(recipeId: string, issues: readonly SemanticRecipeValidationIssue[]) { + return issues + .map((issue) => `${issue.code} at ${issue.path}: ${issue.message}`) + .join('; ') + .replace(/^/, `[registry] invalid semantic recipe "${recipeId}": `) +} + +export function assertSemanticRecipeDefinition(recipe: SemanticRecipeDefinition): void { + const issues = validateSemanticRecipeDefinition(recipe) + if (issues.length > 0) throw new Error(formatValidationIssues(recipe.id, issues)) +} + +export function assertSemanticRecipeComposeResult( + recipe: SemanticRecipeDefinition, + result: SemanticRecipeComposeResult, +): void { + const issues = validateSemanticRecipeComposeResult(recipe, result) + if (issues.length > 0) throw new Error(formatValidationIssues(recipe.id, issues)) +} + +class SemanticRecipeRegistryImpl implements SemanticRecipeRegistry { + private readonly recipes = new Map() + + has(id: SemanticRecipeId): boolean { + return this.recipes.has(id) + } + + get(id: SemanticRecipeId): SemanticRecipeDefinition | undefined { + return this.recipes.get(id) + } + + entries(): IterableIterator<[SemanticRecipeId, SemanticRecipeDefinition]> { + return this.recipes.entries() + } + + findByProfile(profileId: string): SemanticRecipeDefinition | undefined { + const normalized = profileId.trim().toLowerCase() + for (const recipe of this.recipes.values()) { + if (recipe.acceptsProfiles?.some((candidate) => candidate.toLowerCase() === normalized)) { + return recipe + } + } + return undefined + } + + get size(): number { + return this.recipes.size + } + + _register(recipe: SemanticRecipeDefinition): void { + assertSemanticRecipeDefinition(recipe) + if (this.recipes.has(recipe.id)) { + throw new Error(`[registry] duplicate semantic recipe id: "${recipe.id}" already registered`) + } + this.recipes.set(recipe.id, recipe) + } + + _reset(): void { + this.recipes.clear() + } +} + +export const semanticRecipeRegistry: SemanticRecipeRegistry & { + _register: (recipe: SemanticRecipeDefinition) => void + _reset: () => void +} = new SemanticRecipeRegistryImpl() + +export function registerSemanticRecipe(recipe: SemanticRecipeDefinition): void { + semanticRecipeRegistry._register(recipe) +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 515365e3a..7a910ee80 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,7 +1,10 @@ import type { ComponentType } from 'react' -import type { Object3D } from 'three' import type { ZodObject, z } from 'zod' +import type { EquipmentNodeDescriptor, EquipmentPort } from '../equipment' import type { AnyNode, AnyNodeId } from '../schema/types' +import type { SceneObjectRef } from '../types/scene-object' +import type { HandleList } from './handles' +import type { SemanticRecipeDefinition } from './semantic-recipes' // ─── GeometryContext ───────────────────────────────────────────────── // @@ -145,6 +148,8 @@ export type FloorplanStyle = { strokeLinejoin?: 'miter' | 'round' | 'bevel' strokeOpacity?: number fillOpacity?: number + pointerEvents?: 'none' | 'auto' | 'all' | 'stroke' | 'fill' | 'visible' | 'visiblePainted' + cursor?: string } // ─── ToolHint ──────────────────────────────────────────────────────── @@ -255,7 +260,12 @@ export type FloorplanGeometry = * effect from the legacy floor-plan panel. The 2D layer mounts a * shared `` in `` and references it via `fill=url(...)`. */ - | { kind: 'hatch'; points: readonly FloorplanPoint[]; color: string; opacity?: number } + | { + kind: 'hatch' + points: readonly FloorplanPoint[] + color: string + opacity?: number + } /** * Transparent click-detection segment. Sits on top of the kind's main * geometry with a wide stroke so the user doesn't need to pixel-hunt @@ -347,6 +357,21 @@ export type FloorplanGeometry = * down. Use this for simple "what length am I?" badges (fence, item * width, draft preview). */ + | { + kind: 'move-arrow' + point: FloorplanPoint + angle: number + affordance?: string + payload?: unknown + } + | { + kind: 'rotate-arrow' + point: FloorplanPoint + angle: number + affordance: string + payload?: unknown + pivot?: FloorplanPoint + } | { kind: 'dimension-label' cx: number @@ -355,6 +380,12 @@ export type FloorplanGeometry = /** Rotation in radians. The renderer auto-flips to keep text upright. */ angle: number } + | { + kind: 'equal-spacing-badge' + point: FloorplanPoint + text: string + angle: number + } /** * Architect's dimension overlay — extension lines from the edge * endpoints out past the dimension line, two dimension line halves @@ -492,6 +523,7 @@ export type FloorplanMoveTargetSession = { * area, overlap detected, ...). */ canCommit(): boolean + commit?(): void } export type FloorplanMoveTarget = (args: { @@ -499,23 +531,119 @@ export type FloorplanMoveTarget = (args: { nodes: Record }) => FloorplanMoveTargetSession +export type NudgeDelta = readonly [number, number, number] + +export type ActionMenuPlacementRule = + | 'bbox' + | 'bbox-tall' + | 'flat-structure' + | 'html-compact' + | 'html-panel' + | 'linear' + +export type ActionMenuLabel = { + key?: string + fallback: string +} + +export type NodeEditActions = { + addHole?: (node: N) => Partial | null + nudgePlan?: (node: N, delta: NudgeDelta) => Partial | null +} + +export type NodeActionMenu = { + placement?: ActionMenuPlacementRule + curve?: { + affordance?: string + isAvailable?: (node: N, ctx: { nodes: Record }) => boolean + } + endpointMove?: { + affordance?: string + canDetach?: boolean + label: (endpoint: 'start' | 'end', ctx: { detachHint: boolean }) => ActionMenuLabel + localPosition: (node: N, endpoint: 'start' | 'end') => readonly [number, number, number] + } +} + // ─── Plugin manifest ───────────────────────────────────────────────── +/** + * A left-rail panel contributed by a plugin. The host adds `icon` to the + * sidebar icon rail; clicking it mounts `component` (lazy-loaded, behind a + * per-panel error boundary) in the sidebar content area. `id` is namespaced + * by the owning plugin id at load time, so two plugins may each declare a + * panel id of `'main'` without colliding. `icon` reuses {@link IconRef} so a + * plugin contributes a mark the same way a node's presentation does, with no + * React rendering in core. + */ +/** Host workspace a panel belongs to — `edit` (the build workspace) or + * `studio` (the render workspace). Manifest metadata, not core logic: the + * host's sidebar filters by its current workspace mode. */ +export type PanelWorkspace = 'edit' | 'studio' + +export type PluginPanel = { + id: string + label: string + icon: IconRef + component: LazyComponent + /** Workspaces that surface this panel. Default `['edit']` — a placement / + * authoring panel has no business in the clean render workspace; a plugin + * that ships studio tooling opts in explicitly. */ + workspaces?: readonly PanelWorkspace[] +} + export type Plugin = { id: string apiVersion: 1 nodes?: AnyNodeDefinition[] + panels?: PluginPanel[] + semanticRecipes?: SemanticRecipeDefinition[] } // ─── NodeDefinition ────────────────────────────────────────────────── export type AnyNodeDefinition = NodeDefinition> +export type SurfaceRole = + | 'wall' + | 'floor' + | 'ceiling' + | 'roof' + | 'joinery' + | 'glazing' + | 'furnishing' + +export type MaterialTargetKind = 'whole' | 'face' | 'part' + +export type MaterialTargetDescriptor = { + key: string + label: string + kind: MaterialTargetKind + description?: string + materialKey?: string + materialPresetKey?: string +} + +/** + * How a kind is treated by the GLB bake and the baked `/viewer`. See + * plans/editor-plugin-trees-example.md → Part D. + * - `'static'` (default) — baked as geometry; the viewer shows the baked mesh. + * - `'strip'` — excluded from the bake; the viewer rebuilds it live from + * `scene_graph` via the registry renderer (heavy reference assets: scans, guides). + * - `'replace'` — baked as *static* geometry (a plain glTF viewer still shows it), + * but our viewer removes the baked meshes for this kind and re-renders the node + * live from `scene_graph` — for dynamic content whose runtime look differs from a + * frozen snapshot (shader wind, interactivity), rendered through its own path. + */ +export type BakePolicy = 'static' | 'strip' | 'replace' + export type NodeDefinition> = { kind: string schemaVersion: number schema: S category: NodeCategory + surfaceRole?: SurfaceRole + snapProfile?: string defaults: () => Omit, 'id' | 'type'> migrate?: Record unknown> @@ -523,6 +651,18 @@ export type NodeDefinition> = { capabilities: Capabilities relations?: Relations parametrics?: ParametricDescriptor> + equipment?: EquipmentNodeDescriptor + materialTargets?: readonly MaterialTargetDescriptor[] + + /** + * Whether scene mutations add this kind to `dirtyNodes`. Default true. + * Set false for structural/organizational kinds that no dirty consumer + * rebuilds; otherwise their marks can accumulate for the whole session. + */ + dirtyTracking?: boolean + + /** GLB bake treatment for this kind (default `'static'`). See {@link BakePolicy}. */ + bake?: BakePolicy /** * Renderer for this kind. Optional under the three-checkbox composition @@ -536,6 +676,17 @@ export type NodeDefinition> = { * already null-guard on `def.renderer` so omitting it is safe. */ renderer?: RendererSource> + /** + * Collective renderer the baked `/viewer` uses to re-render this kind live when + * `bake === 'replace'`. It receives every node of this kind under one baked + * level and is portaled into that level's `Object3D`, so an instanced kind can + * draw them as instanced meshes in level-local space (riding level stacking for + * free) instead of the frozen baked meshes (which the viewer hides). Needed when + * the normal per-node `renderer` can't stand alone in a baked scene (e.g. an + * instanced kind whose `renderer` is an invisible selection proxy and whose real + * geometry comes from a `system`). See plans/editor-plugin-trees-example.md → Part D. + */ + bakeReplaceRenderer?: BakeReplaceRenderer> /** * Pure geometry builder. When set, the framework's generic * `` calls this on every dirty mark — `nodes` keyed by @@ -548,7 +699,7 @@ export type NodeDefinition> = { * rebuilds; combine with `system` if you also need per-frame imperative * work (animations, named-mesh material poking). */ - geometry?: (node: z.infer, ctx: GeometryContext) => Object3D + geometry?: (node: z.infer, ctx: GeometryContext) => SceneObjectRef /** * Level-batch precompute hook. Called by `` once per * level per frame, **before** the per-node `def.geometry` calls in @@ -581,6 +732,7 @@ export type NodeDefinition> = { * the legacy `floorplan-panel.tsx` monolith. */ floorplan?: (node: z.infer, ctx: GeometryContext) => FloorplanGeometry | null + ports?: (node: z.infer, ctx: GeometryContext) => EquipmentPort[] /** * 2D drag affordances keyed by the string identifier emitted on * `endpoint-handle` (and similar interactive floor-plan primitives) via @@ -596,6 +748,18 @@ export type NodeDefinition> = { * both 3D and 2D affordances expose both fields — they're independent. */ floorplanAffordances?: Record>> + /** + * Optional editor actions exposed by a kind without hardcoding kind names + * in editor shells. Actions return a scene patch; the editor owns sounds, + * selection, and the actual store mutation. + */ + editActions?: NodeEditActions> + /** + * Contextual 3D floating-menu metadata supplied by the kind. The editor + * renders the chrome and owns store mutations; the definition contributes + * pure labels, placement hints, and local anchor math. + */ + actionMenu?: NodeActionMenu> /** * Kind-specific 2D move handler for `useEditor.movingNode`-driven * placement in the floor plan. When set, `FloorplanRegistryMove @@ -611,6 +775,12 @@ export type NodeDefinition> = { * unset and rely on the generic overlay path. */ floorplanMoveTarget?: FloorplanMoveTarget> + /** + * In-world editor handles rendered by the generic 3D handle rig. + * Kinds keep the descriptor data here while editor-owned components + * handle raycasting, drag lifecycle, and scene writes. + */ + handles?: HandleList> system?: SystemContribution tool?: LazyComponent /** @@ -705,6 +875,15 @@ export type RendererSource = | { kind: 'glb'; getAsset: (n: N) => AssetRef } | { kind: 'instanced-glb'; getAsset: (n: N) => AssetRef } +/** + * A collective renderer for the baked `/viewer` (see `NodeDefinition.bakeReplaceRenderer`): + * a lazy module whose default export takes all of one level's `replace` nodes and + * is portaled into that baked level. Three-free indirection, same as `system`. + */ +export type BakeReplaceRenderer = { + module: () => Promise<{ default: ComponentType<{ nodes: N[] }> }> +} + export type AssetRef = { id: string src: string @@ -734,8 +913,11 @@ export type Capabilities = { deletable?: boolean groupable?: boolean selectable?: SelectableConfig + sceneSelection?: SceneSelectionConfig interactive?: boolean floorPlaced?: FloorPlacedConfig + alignmentFootprint?: AlignmentFootprintConfig + dragBounds?: (node: AnyNode) => { size: [number, number, number] } | null } export type CapabilityCtx = { node: AnyNode } @@ -796,6 +978,20 @@ export type SelectableConfig = { override?: (ctx: CapabilityCtx) => SelectableConfig | null } +export type SceneSelectionRole = 'building' | 'level' | 'zone' | 'zone-content' + +export type SceneSelectionFootprint = 'position' | 'segment' | 'polygon' | 'always' + +export type SceneSelectionConfig = { + role?: SceneSelectionRole + zoneFootprint?: SceneSelectionFootprint + levelParentKinds?: readonly string[] + selectParentKind?: string + outline?: boolean + hover?: boolean + click?: boolean +} + /** * Floor-placed kinds rest directly on a level and need their Y lifted by * any slab the footprint overlaps. The generic `` @@ -805,14 +1001,46 @@ export type SelectableConfig = { * `applies` is an optional predicate to skip nodes that share a kind but * are mounted off-floor (items attached to a wall / ceiling). */ +export type FloorPlacedFootprint = { + dimensions: [number, number, number] + rotation: [number, number, number] + position?: [number, number, number] +} + +export type FloorPlacedFootprintContext = { + nodes: Readonly> +} + +export type FloorPlacedFootprintResolver = ( + node: AnyNode, + ctx?: FloorPlacedFootprintContext, +) => FloorPlacedFootprint + +export type FloorPlacedFootprintsResolver = ( + node: AnyNode, + ctx?: FloorPlacedFootprintContext, +) => readonly FloorPlacedFootprint[] + export type FloorPlacedConfig = { - footprint: (node: AnyNode) => { - dimensions: [number, number, number] - rotation: [number, number, number] - } + footprint?: FloorPlacedFootprintResolver + footprints?: FloorPlacedFootprintsResolver applies?: (node: AnyNode) => boolean + collides?: boolean } +export type AlignmentFootprint = + | { + shape: 'box' + dimensions: [number, number, number] + rotation: [number, number, number] + } + | { shape: 'aabb'; minX: number; minZ: number; maxX: number; maxZ: number } + +export type AlignmentFootprintConfig = ( + node: AnyNode, + nodes?: Readonly>, +) => AlignmentFootprint | null + // ─── Relations ─────────────────────────────────────────────────────── export type Relations = { @@ -860,7 +1088,12 @@ export type ParamField = | { key: keyof N; kind: 'vec3'; visibleIf?: (n: N) => boolean } | { key: keyof N; kind: 'color'; visibleIf?: (n: N) => boolean } | { key: keyof N; kind: 'material'; visibleIf?: (n: N) => boolean } - | { key: keyof N; kind: 'ref'; refKind: string; visibleIf?: (n: N) => boolean } + | { + key: keyof N + kind: 'ref' + refKind: string + visibleIf?: (n: N) => boolean + } /** Escape hatch for fields that don't map to a single node key — * derived values (`length` from `start`/`end`), sliders with * dynamic min/max (curve sagitta bounded by chord length), @@ -869,11 +1102,18 @@ export type ParamField = | { key: string kind: 'custom' - component: ComponentType<{ node: N; onUpdate: (patch: Partial) => void }> + component: ComponentType<{ + node: N + onUpdate: (patch: Partial) => void + }> visibleIf?: (n: N) => boolean } -export type Issue = { field?: string; msg: string; severity?: 'error' | 'warning' } +export type Issue = { + field?: string + msg: string + severity?: 'error' | 'warning' +} // ─── Affordance ────────────────────────────────────────────────────── @@ -891,7 +1131,12 @@ export type EditorCtx = { // ─── DragAction primitive ──────────────────────────────────────────── export type Vec2 = readonly [number, number] -export type Modifiers = { shift: boolean; alt: boolean; ctrl: boolean; meta: boolean } +export type Modifiers = { + shift: boolean + alt: boolean + ctrl: boolean + meta: boolean +} export type DragAction = { begin: (input: { node?: AnyNode; point: Vec2; handleId?: string; modifiers?: Modifiers }) => Ctx diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 5d5428678..cd435c171 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -14,6 +14,8 @@ export type { // Material export { DEFAULT_MATERIALS, + MaterialGradient, + MaterialGradientStop, MaterialMapPropertiesSchema, MaterialMapsSchema, MaterialPreset, @@ -24,7 +26,11 @@ export { resolveMaterial, TextureWrapMode, } from './material' +export { AssemblyNode } from './nodes/assembly' +export { BoxNode } from './nodes/box' export { BuildingNode } from './nodes/building' +export { CableTrayNode } from './nodes/cable-tray' +export { CapsuleNode } from './nodes/capsule' export { CeilingNode } from './nodes/ceiling' export { COLUMN_PRESETS, @@ -41,6 +47,13 @@ export { ColumnStyle, ColumnSupportStyle, } from './nodes/column' +export { ConeNode } from './nodes/cone' +export { ConformalStripNode } from './nodes/conformal-strip' +export { ConveyorBeltDirection, ConveyorBeltNode, ConveyorBeltPoint } from './nodes/conveyor-belt' +export { CylinderNode } from './nodes/cylinder' +export { DataChartKind, DataChartNode } from './nodes/data-chart' +export { DataTableNode, DataTableRow } from './nodes/data-table' +export { DataWidgetKind, DataWidgetNode } from './nodes/data-widget' export { DoorNode, DoorSegment } from './nodes/door' export { ElevatorDoorPanelStyle, @@ -48,8 +61,12 @@ export { ElevatorNode, ElevatorShaftStyle, } from './nodes/elevator' +export { ExtrudeNode } from './nodes/extrude' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' +export { FrustumNode } from './nodes/frustum' export { GuideNode, GuideScaleReference } from './nodes/guide' +export { HalfCylinderNode } from './nodes/half-cylinder' +export { HemisphereNode } from './nodes/hemisphere' export type { AnimationEffect, Asset, @@ -65,22 +82,37 @@ export type { export { getScaledDimensions, ItemNode, + isDirectFloorPlacedItem, + isFloorAttachedItem, isLowProfileItemSurface, + isPlanDragMovableItem, LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, } from './nodes/item' +export { LadderNode } from './nodes/ladder' +export { LatheNode } from './nodes/lathe' export { LevelNode } from './nodes/level' +export { PipeMedium, PipeNode } from './nodes/pipe' +export { + PipeFittingKind, + PipeFittingNode, + PipeValveStyle, +} from './nodes/pipe-fitting' +export { RoadNode, RoadSurfaceKind } from './nodes/road' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export { RoofSegmentNode, RoofType } from './nodes/roof-segment' +export { RoundedPanelNode } from './nodes/rounded-panel' export { ScanNode } from './nodes/scan' // Nodes export { ShelfNode } from './nodes/shelf' export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' export { SpawnNode } from './nodes/spawn' +export { SphereNode } from './nodes/sphere' export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { getEffectiveStairSurfaceMaterial, + StairCenterColumnShape, StairNode, StairRailingMode, StairSlabOpeningMode, @@ -88,13 +120,20 @@ export { StairType, } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' +export { SteelBeamNode, SteelBeamProfile } from './nodes/steel-beam' +export { SteelFrameBraceStyle, SteelFrameNode, SteelFrameStyle } from './nodes/steel-frame' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' +export { SweepNode } from './nodes/sweep' +export { TankKind, TankNode } from './nodes/tank' +export { TorusNode } from './nodes/torus' +export { TrapezoidPrismNode } from './nodes/trapezoid-prism' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' export { getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, WallNode, } from './nodes/wall' +export { WedgeNode } from './nodes/wedge' export { WindowNode, WindowType } from './nodes/window' export { ZoneNode } from './nodes/zone' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/material.test.ts b/packages/core/src/schema/material.test.ts new file mode 100644 index 000000000..3c33e2653 --- /dev/null +++ b/packages/core/src/schema/material.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from 'bun:test' +import { MaterialSchema, MaterialTarget } from './material' + +test('material schema accepts serialized three.js side values from material map presets', () => { + expect( + MaterialSchema.parse({ + preset: 'custom', + properties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 0, + }, + }).properties?.side, + ).toBe('front') + + expect( + MaterialSchema.parse({ + preset: 'custom', + properties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 2, + }, + }).properties?.side, + ).toBe('double') +}) + +test('material schema accepts linear gradients with per-stop opacity', () => { + const material = MaterialSchema.parse({ + preset: 'custom', + properties: { + color: '#ffffff', + opacity: 0.8, + transparent: true, + }, + gradient: { + space: 'uv', + axis: 'y', + stops: [ + { offset: 0, color: '#ffffff', opacity: 1 }, + { offset: 1, color: '#1d4ed8', opacity: 0.35 }, + ], + }, + }) + + expect(material.gradient?.type).toBe('linear') + expect(material.gradient?.stops[1]?.opacity).toBe(0.35) +}) + +test('material gradients require at least two stops', () => { + expect(() => + MaterialSchema.parse({ + preset: 'custom', + gradient: { + stops: [{ offset: 0, color: '#ffffff', opacity: 1 }], + }, + }), + ).toThrow() +}) + +test('material targets include catalog items', () => { + expect(MaterialTarget.parse('item')).toBe('item') +}) diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 5e5f71e32..b6b2cd294 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -21,14 +21,41 @@ export const MaterialProperties = z.object({ metalness: z.number().min(0).max(1).default(0), opacity: z.number().min(0).max(1).default(1), transparent: z.boolean().default(false), - side: z.enum(['front', 'back', 'double']).default('front'), + side: z + .preprocess( + (value) => { + if (value === 0) return 'front' + if (value === 1) return 'back' + if (value === 2) return 'double' + return value + }, + z.enum(['front', 'back', 'double']), + ) + .default('front'), }) export type MaterialProperties = z.infer +export const MaterialGradientStop = z.object({ + offset: z.number().min(0).max(1), + color: z.string().default('#ffffff'), + opacity: z.number().min(0).max(1).default(1), +}) +export type MaterialGradientStop = z.infer + +export const MaterialGradient = z.object({ + type: z.enum(['linear']).default('linear'), + space: z.enum(['uv', 'local', 'world']).default('uv'), + axis: z.enum(['x', 'y', 'z']).default('y'), + angle: z.number().default(0), + stops: z.array(MaterialGradientStop).min(2).max(8), +}) +export type MaterialGradient = z.infer + export const MaterialSchema = z.object({ id: z.string().optional(), preset: MaterialPreset.optional(), properties: MaterialProperties.optional(), + gradient: MaterialGradient.optional(), texture: z .object({ url: AssetUrl, @@ -46,12 +73,30 @@ export const MaterialTarget = z.enum([ 'stair', 'stair-segment', 'fence', + 'road', 'column', 'slab', 'ceiling', 'door', 'window', 'shelf', + 'item', + 'box', + 'cylinder', + 'cone', + 'frustum', + 'hemisphere', + 'torus', + 'wedge', + 'trapezoid-prism', + 'sphere', + 'lathe', + 'capsule', + 'half-cylinder', + 'rounded-panel', + 'extrude', + 'sweep', + 'elevator', ]) export type MaterialTarget = z.infer diff --git a/packages/core/src/schema/nodes/assembly.test.ts b/packages/core/src/schema/nodes/assembly.test.ts new file mode 100644 index 000000000..2fdc0c321 --- /dev/null +++ b/packages/core/src/schema/nodes/assembly.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { AssemblyNode } from './assembly' + +describe('AssemblyNode', () => { + test('parses a static generated assembly', () => { + const node = AssemblyNode.parse({ + id: 'assembly_static', + position: [1, 0, 2], + children: ['box_child'], + }) + + expect(node.position).toEqual([1, 0, 2]) + expect(node.scale).toEqual([1, 1, 1]) + expect(node.children).toEqual(['box_child']) + }) + + test('defaults children to an empty array', () => { + const node = AssemblyNode.parse({ id: 'assembly_empty' }) + + expect(node.children).toEqual([]) + }) +}) diff --git a/packages/core/src/schema/nodes/assembly.ts b/packages/core/src/schema/nodes/assembly.ts new file mode 100644 index 000000000..79fcc2b66 --- /dev/null +++ b/packages/core/src/schema/nodes/assembly.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const AssemblyNode = BaseNode.extend({ + id: objectId('assembly'), + type: nodeType('assembly'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), + children: z.array(z.string()).default([]), +}).describe('Assembly node - transformable parent group for generated multi-part objects.') + +export type AssemblyNode = z.infer diff --git a/packages/core/src/schema/nodes/box.ts b/packages/core/src/schema/nodes/box.ts new file mode 100644 index 000000000..953998b2c --- /dev/null +++ b/packages/core/src/schema/nodes/box.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const BoxNode = BaseNode.extend({ + id: objectId('box'), + type: nodeType('box'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + length: z.number().min(0.01).max(20).default(1.0), + width: z.number().min(0.01).max(20).default(1.0), + height: z.number().min(0.01).max(20).default(1.0), + cornerRadius: z.number().min(0).max(2).default(0), + cornerSegments: z.number().int().min(1).max(12).default(4), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Box (cuboid) primitive - configurable rectangular volume. Set cornerRadius for rounded manufactured housings, vehicle bodies, appliance shells, and softened furniture.', +) + +export type BoxNode = z.infer diff --git a/packages/core/src/schema/nodes/cable-tray.ts b/packages/core/src/schema/nodes/cable-tray.ts new file mode 100644 index 000000000..1ae105b4d --- /dev/null +++ b/packages/core/src/schema/nodes/cable-tray.ts @@ -0,0 +1,30 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const CableTrayNode = BaseNode.extend({ + id: objectId('cable-tray'), + type: nodeType('cable-tray'), + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + curveOffset: z.number().optional(), + width: z.number().default(0.45), + sideHeight: z.number().default(0.18), + thickness: z.number().default(0.035), + elevation: z.number().default(2.4), + rungSpacing: z.number().default(0.35), + showRungs: z.boolean().default(true), + color: z.string().default('#9aa3ad'), +}).describe( + dedent` + Cable tray node - editable industrial cable tray route in level coordinates. + - start/end: plan centerline endpoints. + - curveOffset: optional sagitta offset used to bend the tray into an arc. + - width/sideHeight/thickness: tray cross-section dimensions. + - elevation: tray bottom height above level origin. + - rungSpacing/showRungs: ladder tray crossbar controls. + `, +) + +export type CableTrayNode = z.infer + diff --git a/packages/core/src/schema/nodes/capsule.ts b/packages/core/src/schema/nodes/capsule.ts new file mode 100644 index 000000000..94078733c --- /dev/null +++ b/packages/core/src/schema/nodes/capsule.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const CapsuleNode = BaseNode.extend({ + id: objectId('capsule'), + type: nodeType('capsule'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + radius: z.number().min(0.01).max(10).default(0.25), + height: z.number().min(0.02).max(20).default(1.0), + capSegments: z.number().int().min(1).max(16).default(6), + radialSegments: z.number().int().min(8).max(64).default(32), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Capsule primitive - rounded-ended cylinder for pillows, bolsters, handles, rounded bars, and soft furniture forms.', +) + +export type CapsuleNode = z.infer diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index 78a521264..6b42480c1 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -68,6 +68,7 @@ export const ColumnSupportStyle = z.enum([ 'trestle', 'portal-frame', 'box-frame', + 'pipe-saddle', ]) export type ColumnStyle = z.infer @@ -985,6 +986,62 @@ export const COLUMN_PRESETS = { braceTopSpread: 1, bracePlateEnabled: false, }, + pipeSaddleSupport: { + label: 'Pipe Saddle', + supportStyle: 'pipe-saddle', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 1.2, + braceTopSpread: 0.6, + bracePlateEnabled: true, + }, } as const satisfies Record>> export type ColumnPresetId = keyof typeof COLUMN_PRESETS diff --git a/packages/core/src/schema/nodes/cone-frustum.test.ts b/packages/core/src/schema/nodes/cone-frustum.test.ts new file mode 100644 index 000000000..cb5c57b87 --- /dev/null +++ b/packages/core/src/schema/nodes/cone-frustum.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { ConeNode } from './cone' +import { FrustumNode } from './frustum' + +describe('cone and frustum node schemas', () => { + test('allow four radial segments for pyramid geometry', () => { + const cone = ConeNode.parse({ + radius: 1, + height: 1.5, + radialSegments: 4, + }) + const frustum = FrustumNode.parse({ + radiusBottom: 1, + radiusTop: 0.35, + height: 1.5, + radialSegments: 4, + }) + + expect(cone.radialSegments).toBe(4) + expect(frustum.radialSegments).toBe(4) + }) +}) diff --git a/packages/core/src/schema/nodes/cone.ts b/packages/core/src/schema/nodes/cone.ts new file mode 100644 index 000000000..37255b148 --- /dev/null +++ b/packages/core/src/schema/nodes/cone.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ConeNode = BaseNode.extend({ + id: objectId('cone'), + type: nodeType('cone'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + radius: z.number().min(0.01).max(10).default(0.5), + height: z.number().min(0.01).max(20).default(1), + radialSegments: z.number().int().min(3).max(64).default(32), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Cone primitive - circular cone for traffic cones, tapered tips, lamp shades, roofs, and pointed mechanical parts.', +) + +export type ConeNode = z.infer diff --git a/packages/core/src/schema/nodes/conformal-strip.ts b/packages/core/src/schema/nodes/conformal-strip.ts new file mode 100644 index 000000000..738b6220f --- /dev/null +++ b/packages/core/src/schema/nodes/conformal-strip.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ConformalStripNode = BaseNode.extend({ + id: objectId('conformal-strip'), + type: nodeType('conformal-strip'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + surface: z.enum(['ellipsoid-cylinder']).default('ellipsoid-cylinder'), + side: z.enum(['left', 'right']).default('left'), + xStart: z.number().min(-50).max(50).default(-0.5), + xEnd: z.number().min(-50).max(50).default(0.5), + verticalOffset: z.number().min(-20).max(20).default(0), + width: z.number().min(0.001).max(20).default(0.04), + thickness: z.number().min(0.0005).max(1).default(0.003), + surfaceRadiusY: z.number().min(0.001).max(20).default(0.25), + surfaceRadiusZ: z.number().min(0.001).max(20).default(0.25), + surfaceLength: z.number().min(0.001).max(100).optional(), + endTaper: z.number().min(0).max(0.95).default(0.28), + segments: z.number().int().min(1).max(128).default(16), + widthSegments: z.number().int().min(1).max(16).default(2), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Conformal strip primitive - a thin curved rectangular decal/stripe that follows an ellipsoid-cylinder surface such as an aircraft fuselage.', +) + +export type ConformalStripNode = z.infer diff --git a/packages/core/src/schema/nodes/conveyor-belt.test.ts b/packages/core/src/schema/nodes/conveyor-belt.test.ts new file mode 100644 index 000000000..2c7aa3e1f --- /dev/null +++ b/packages/core/src/schema/nodes/conveyor-belt.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { ConveyorBeltNode } from './conveyor-belt' + +describe('ConveyorBeltNode', () => { + test('parses a multi-segment conveyor route', () => { + const node = ConveyorBeltNode.parse({ + name: 'Line A', + points: [ + [0, 0, 0], + [2, 0, 0], + [2, 0, 3], + ], + }) + + expect(node.type).toBe('conveyor-belt') + expect(node.points).toHaveLength(3) + expect(node.color).toBe('#111827') + expect(node.edgeColor).toBe('#94a3b8') + expect(node.rollerColor).toBe('#cbd5e1') + expect(node.direction).toBe('forward') + }) + + test('requires at least two route points', () => { + expect(() => + ConveyorBeltNode.parse({ + name: 'Invalid', + points: [[0, 0, 0]], + }), + ).toThrow() + }) +}) diff --git a/packages/core/src/schema/nodes/conveyor-belt.ts b/packages/core/src/schema/nodes/conveyor-belt.ts new file mode 100644 index 000000000..33daee739 --- /dev/null +++ b/packages/core/src/schema/nodes/conveyor-belt.ts @@ -0,0 +1,39 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const ConveyorBeltDirection = z.enum(['forward', 'backward']) + +export const ConveyorBeltPoint = z.tuple([z.number(), z.number(), z.number()]) + +export const ConveyorBeltNode = BaseNode.extend({ + id: objectId('conveyor-belt'), + type: nodeType('conveyor-belt'), + points: z + .array(ConveyorBeltPoint) + .min(2) + .default([ + [0, 0, 0], + [4, 0, 0], + ]), + width: z.number().min(0.1).max(5).default(0.8), + thickness: z.number().min(0.02).max(0.5).default(0.08), + elevation: z.number().min(-2).max(20).default(0.8), + color: z.string().default('#111827'), + edgeColor: z.string().default('#94a3b8'), + rollerColor: z.string().default('#cbd5e1'), + showFrame: z.boolean().default(true), + showRollers: z.boolean().default(true), + rollerSpacing: z.number().min(0.2).max(5).default(1), + direction: ConveyorBeltDirection.default('forward'), +}).describe( + dedent` + Conveyor belt node - editable industrial transfer path. + - points: multi-segment centerline in level coordinates. + - width/thickness/elevation: belt cross-section and height. + - direction: preview/runtime transfer direction along the point route. + `, +) + +export type ConveyorBeltNode = z.infer +export type ConveyorBeltDirection = z.infer diff --git a/packages/core/src/schema/nodes/cylinder.ts b/packages/core/src/schema/nodes/cylinder.ts new file mode 100644 index 000000000..b1e2fbeac --- /dev/null +++ b/packages/core/src/schema/nodes/cylinder.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const CylinderNode = BaseNode.extend({ + id: objectId('cylinder'), + type: nodeType('cylinder'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + radius: z.number().min(0.01).max(10).default(0.5), + height: z.number().min(0.01).max(20).default(1.0), + radialSegments: z.number().int().min(8).max(64).default(32), + wallThickness: z.number().min(0.001).max(10).optional(), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Cylinder primitive — configurable cylindrical volume. Set wallThickness for a hollow tube.', +) + +export type CylinderNode = z.infer diff --git a/packages/core/src/schema/nodes/data-chart.ts b/packages/core/src/schema/nodes/data-chart.ts new file mode 100644 index 000000000..c605c7b4a --- /dev/null +++ b/packages/core/src/schema/nodes/data-chart.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const DataChartKind = z.enum(['bar', 'line']) + +export const DataChartNode = BaseNode.extend({ + id: objectId('data-chart'), + type: nodeType('data-chart'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 2, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + chartType: DataChartKind.default('bar'), + title: z.string().default('Trend'), + dataKeys: z + .array(z.string()) + .min(1) + .max(8) + .default(['machine.temperature', 'fan.speed', 'alarm.count']), + foreground: z.string().default('#ffffff'), + background: z.string().default('#111827'), + backgroundOpacity: z.number().min(0).max(1).default(1), + accent: z.string().default('#38bdf8'), + fontSize: z.number().min(10).max(32).default(13), +}).describe('Data chart widget - bar or line chart backed by static/live data.') + +export type DataChartKind = z.infer +export type DataChartNode = z.infer diff --git a/packages/core/src/schema/nodes/data-display.test.ts b/packages/core/src/schema/nodes/data-display.test.ts new file mode 100644 index 000000000..6972d782d --- /dev/null +++ b/packages/core/src/schema/nodes/data-display.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' +import { DataChartNode } from './data-chart' +import { DataTableNode } from './data-table' +import { DataWidgetNode } from './data-widget' + +describe('data display nodes', () => { + test('default background opacity remains opaque', () => { + expect(DataWidgetNode.parse({}).backgroundOpacity).toBe(1) + expect(DataChartNode.parse({}).backgroundOpacity).toBe(1) + expect(DataTableNode.parse({}).backgroundOpacity).toBe(1) + }) + + test('accepts transparent backgrounds', () => { + expect(DataWidgetNode.parse({ backgroundOpacity: 0 }).backgroundOpacity).toBe(0) + expect(DataChartNode.parse({ backgroundOpacity: 0.35 }).backgroundOpacity).toBe(0.35) + expect(DataTableNode.parse({ backgroundOpacity: 0.8 }).backgroundOpacity).toBe(0.8) + }) +}) diff --git a/packages/core/src/schema/nodes/data-table.ts b/packages/core/src/schema/nodes/data-table.ts new file mode 100644 index 000000000..7e67f9e34 --- /dev/null +++ b/packages/core/src/schema/nodes/data-table.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const DataTableRow = z.object({ + label: z.string().default('Metric'), + dataKey: z.string().default('machine.temperature'), +}) + +export const DataTableNode = BaseNode.extend({ + id: objectId('data-table'), + type: nodeType('data-table'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 2, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + title: z.string().default('Live Data'), + rows: z + .array(DataTableRow) + .min(1) + .max(8) + .default([ + { label: 'Temperature', dataKey: 'machine.temperature' }, + { label: 'Fan speed', dataKey: 'fan.speed' }, + { label: 'Alarm count', dataKey: 'alarm.count' }, + ]), + foreground: z.string().default('#ffffff'), + background: z.string().default('#111827'), + backgroundOpacity: z.number().min(0).max(1).default(1), + accent: z.string().default('#38bdf8'), + fontSize: z.number().min(10).max(24).default(12), +}).describe('Data table widget - compact tabular display for multiple live data values.') + +export type DataTableRow = z.infer +export type DataTableNode = z.infer diff --git a/packages/core/src/schema/nodes/data-widget.ts b/packages/core/src/schema/nodes/data-widget.ts new file mode 100644 index 000000000..fa78d9612 --- /dev/null +++ b/packages/core/src/schema/nodes/data-widget.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const DataWidgetKind = z.enum(['label', 'badge', 'card', 'chart']) + +export const DataWidgetNode = BaseNode.extend({ + id: objectId('data-widget'), + type: nodeType('data-widget'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 2, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + widgetType: DataWidgetKind.default('label'), + dataKey: z.string().default('machine.temperature'), + template: z.string().default('{label}: {value}{unit}'), + title: z.string().default('Live Data'), + foreground: z.string().default('#ffffff'), + background: z.string().default('#111827'), + backgroundOpacity: z.number().min(0).max(1).default(1), + fontSize: z.number().min(10).max(48).default(14), +}).describe('Data widget — static/live data label, badge, card, or chart placed on the canvas.') + +export type DataWidgetKind = z.infer +export type DataWidgetNode = z.infer diff --git a/packages/core/src/schema/nodes/extrude.ts b/packages/core/src/schema/nodes/extrude.ts new file mode 100644 index 000000000..6e69438d3 --- /dev/null +++ b/packages/core/src/schema/nodes/extrude.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ExtrudeNode = BaseNode.extend({ + id: objectId('extrude'), + type: nodeType('extrude'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + profile: z + .array(z.tuple([z.number(), z.number()])) + .min(3) + .max(256) + .default([ + [-0.5, -0.25], + [0.5, -0.25], + [0.5, 0.25], + [-0.5, 0.25], + ]), + holes: z + .array( + z + .array(z.tuple([z.number(), z.number()])) + .min(3) + .max(128), + ) + .max(16) + .default([]), + depth: z.number().min(0.005).max(10).default(0.1), + bevelSize: z.number().min(0).max(1).default(0.01), + bevelThickness: z.number().min(0).max(1).default(0.01), + bevelSegments: z.number().int().min(0).max(12).default(2), + curveSegments: z.number().int().min(1).max(32).default(8), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Extrude primitive - custom 2D profile extruded through depth for logos, brackets, handles, silhouettes, and non-rectangular panels.', +) + +export type ExtrudeNode = z.infer diff --git a/packages/core/src/schema/nodes/frustum.ts b/packages/core/src/schema/nodes/frustum.ts new file mode 100644 index 000000000..83f7b287a --- /dev/null +++ b/packages/core/src/schema/nodes/frustum.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const FrustumNode = BaseNode.extend({ + id: objectId('frustum'), + type: nodeType('frustum'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + radiusTop: z.number().min(0.001).max(10).default(0.25), + radiusBottom: z.number().min(0.001).max(10).default(0.5), + height: z.number().min(0.01).max(20).default(1), + radialSegments: z.number().int().min(3).max(64).default(32), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Frustum primitive - truncated cone / circular taper for cups, flower pots, lamp bases, table legs, and industrial fittings.', +) + +export type FrustumNode = z.infer diff --git a/packages/core/src/schema/nodes/half-cylinder.ts b/packages/core/src/schema/nodes/half-cylinder.ts new file mode 100644 index 000000000..6e1d0612e --- /dev/null +++ b/packages/core/src/schema/nodes/half-cylinder.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const HalfCylinderNode = BaseNode.extend({ + id: objectId('half-cylinder'), + type: nodeType('half-cylinder'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + radius: z.number().min(0.01).max(10).default(0.5), + height: z.number().min(0.01).max(20).default(1.0), + radialSegments: z.number().int().min(8).max(64).default(24), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Half-cylinder primitive - semicircular extrusion with a flat cut face for arched covers, fenders, half pipes, and rounded housings.', +) + +export type HalfCylinderNode = z.infer diff --git a/packages/core/src/schema/nodes/hemisphere.ts b/packages/core/src/schema/nodes/hemisphere.ts new file mode 100644 index 000000000..ee32b72fe --- /dev/null +++ b/packages/core/src/schema/nodes/hemisphere.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const HemisphereNode = BaseNode.extend({ + id: objectId('hemisphere'), + type: nodeType('hemisphere'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), + radius: z.number().min(0.01).max(10).default(0.5), + widthSegments: z.number().int().min(8).max(64).default(32), + heightSegments: z.number().int().min(4).max(32).default(16), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Hemisphere primitive - closed half-sphere dome for buttons, camera covers, lamp covers, domes, and rounded housings.', +) + +export type HemisphereNode = z.infer diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index f8434d35e..e1384174b 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' import type { CollectionId } from '../collections' +import { MaterialSchema } from '../material' // --- Control descriptors --- @@ -109,6 +110,7 @@ const assetSchema = z.object({ }) .optional(), // undefined = can't place things on it interactive: interactiveSchema.optional(), + articraft: z.json().optional(), }) export type AssetInput = z.input @@ -130,6 +132,9 @@ export const ItemNode = BaseNode.extend({ // Denormalized references to collections this node belongs to collectionIds: z.array(z.custom()).optional(), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + asset: assetSchema, }).describe(dedent`Item node - used to represent a item in the building - position: position in level coordinate system (or parent coordinate system if attached) @@ -147,6 +152,32 @@ export const ItemNode = BaseNode.extend({ export type ItemNode = z.infer +/** Catalog floor item — no wall / ceiling attachTo. */ +export function isFloorAttachedItem(item: Pick): boolean { + return !item.asset.attachTo +} + +/** Floor item placed directly on a level (not on a table / shelf parent). */ +export function isDirectFloorPlacedItem( + item: Pick, + nodes: Record, +): boolean { + if (!isFloorAttachedItem(item)) return false + if (!item.parentId) return true + const parent = nodes[item.parentId] + return parent?.type === 'level' +} + +/** Existing scene item that supports unified plan (XZ) drag-move. */ +export function isPlanDragMovableItem(item: ItemNode): boolean { + if (item.asset.category === 'door' || item.asset.category === 'window') return false + const meta = + typeof item.metadata === 'object' && item.metadata !== null + ? (item.metadata as Record) + : {} + return !meta.isNew +} + export const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 /** diff --git a/packages/core/src/schema/nodes/ladder.ts b/packages/core/src/schema/nodes/ladder.ts new file mode 100644 index 000000000..dc4c6a940 --- /dev/null +++ b/packages/core/src/schema/nodes/ladder.ts @@ -0,0 +1,32 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const LadderNode = BaseNode.extend({ + id: objectId('ladder'), + type: nodeType('ladder'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + height: z.number().default(3), + width: z.number().default(0.55), + railDiameter: z.number().default(0.04), + rungDiameter: z.number().default(0.03), + rungSpacing: z.number().default(0.3), + standoffDepth: z.number().default(0.16), + cageEnabled: z.boolean().default(false), + cageRadius: z.number().default(0.42), + cageStartHeight: z.number().default(1.8), + color: z.string().default('#8a9098'), +}).describe( + dedent` + Ladder node - editable vertical industrial access ladder. + - position/rotation: floor anchor and facing. + - height/width: ladder envelope. + - railDiameter/rungDiameter/rungSpacing: rail and rung sizing. + - standoffDepth: wall or equipment offset brackets. + - cageEnabled/cageRadius/cageStartHeight: optional safety cage. + `, +) + +export type LadderNode = z.infer + diff --git a/packages/core/src/schema/nodes/lathe.ts b/packages/core/src/schema/nodes/lathe.ts new file mode 100644 index 000000000..34024454d --- /dev/null +++ b/packages/core/src/schema/nodes/lathe.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const LatheNode = BaseNode.extend({ + id: objectId('lathe'), + type: nodeType('lathe'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + profile: z.array(z.tuple([z.number(), z.number()])).min(2).max(64).default([[0, 0], [0.5, 1]]), + segments: z.number().int().min(8).max(128).default(32), + arc: z.number().min(0.01).max(Math.PI * 2).default(Math.PI * 2), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Lathe primitive — a 2D profile revolved around the Y axis. Use for vases, bowls, bottles, lamp shades, bell shapes, and radially symmetric curved surfaces.', +) + +export type LatheNode = z.infer diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts new file mode 100644 index 000000000..130aedef2 --- /dev/null +++ b/packages/core/src/schema/nodes/level.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { AnyNode } from '../types' +import { BoxNode } from './box' +import { CableTrayNode } from './cable-tray' +import { ConveyorBeltNode } from './conveyor-belt' +import { DataChartNode } from './data-chart' +import { DataTableNode } from './data-table' +import { LevelNode } from './level' +import { SteelBeamNode } from './steel-beam' +import { SteelFrameNode } from './steel-frame' + +describe('LevelNode', () => { + test('accepts industrial route children', () => { + const cableTray = CableTrayNode.parse({ + start: [0, 0], + end: [1, 0], + }) + const steelBeam = SteelBeamNode.parse({ + start: [0, 1], + end: [1, 1], + }) + const steelFrame = SteelFrameNode.parse({ + levels: 3, + columns: 5, + }) + const conveyorBelt = ConveyorBeltNode.parse({ + points: [ + [0, 0, 2], + [1, 0, 2], + ], + }) + + const level = LevelNode.parse({ + children: [cableTray.id, steelBeam.id, steelFrame.id, conveyorBelt.id], + }) + + expect(level.children).toEqual([cableTray.id, steelBeam.id, steelFrame.id, conveyorBelt.id]) + expect(AnyNode.safeParse(level).success).toBe(true) + }) + + test('accepts generated primitive and assembly children', () => { + const box = BoxNode.parse({ id: 'box_generated', parentId: 'level_main' }) + const level = LevelNode.parse({ + id: 'level_main', + children: ['assembly_generated', box.id], + }) + + expect(level.children).toEqual(['assembly_generated', 'box_generated']) + expect(AnyNode.safeParse(level).success).toBe(true) + }) + + test('accepts registered plugin node children by id', () => { + const level = LevelNode.parse({ + id: 'level_main', + children: ['factory-pump_generated'], + }) + + expect(level.children as string[]).toEqual(['factory-pump_generated']) + expect(AnyNode.safeParse(level).success).toBe(true) + }) + + test('accepts data display children', () => { + const chart = DataChartNode.parse({}) + const table = DataTableNode.parse({}) + + const level = LevelNode.parse({ + children: [chart.id, table.id], + }) + + expect(level.children).toEqual([chart.id, table.id]) + expect(AnyNode.safeParse(level).success).toBe(true) + }) +}) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index d0bd589de..6730b8e4a 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -1,18 +1,47 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { AssemblyNode } from './assembly' +import { BoxNode } from './box' +import { CableTrayNode } from './cable-tray' +import { CapsuleNode } from './capsule' import { CeilingNode } from './ceiling' import { ColumnNode } from './column' +import { ConeNode } from './cone' +import { ConformalStripNode } from './conformal-strip' +import { ConveyorBeltNode } from './conveyor-belt' +import { CylinderNode } from './cylinder' +import { DataChartNode } from './data-chart' +import { DataTableNode } from './data-table' +import { DataWidgetNode } from './data-widget' +import { ExtrudeNode } from './extrude' import { FenceNode } from './fence' +import { FrustumNode } from './frustum' import { GuideNode } from './guide' +import { HalfCylinderNode } from './half-cylinder' +import { HemisphereNode } from './hemisphere' import { ItemNode } from './item' +import { LadderNode } from './ladder' +import { LatheNode } from './lathe' +import { PipeNode } from './pipe' +import { PipeFittingNode } from './pipe-fitting' +import { RoadNode } from './road' import { RoofNode } from './roof' +import { RoundedPanelNode } from './rounded-panel' import { ScanNode } from './scan' import { ShelfNode } from './shelf' import { SlabNode } from './slab' import { SpawnNode } from './spawn' +import { SphereNode } from './sphere' import { StairNode } from './stair' +import { SteelBeamNode } from './steel-beam' +import { SteelFrameNode } from './steel-frame' +import { SweepNode } from './sweep' +import { TankNode } from './tank' +import { TorusNode } from './torus' +import { TrapezoidPrismNode } from './trapezoid-prism' import { WallNode } from './wall' +import { WedgeNode } from './wedge' import { ZoneNode } from './zone' export const LevelNode = BaseNode.extend({ @@ -21,19 +50,50 @@ export const LevelNode = BaseNode.extend({ children: z .array( z.union([ + AssemblyNode.shape.id, + BoxNode.shape.id, + CapsuleNode.shape.id, WallNode.shape.id, FenceNode.shape.id, + CableTrayNode.shape.id, + ConveyorBeltNode.shape.id, + ConeNode.shape.id, + ConformalStripNode.shape.id, + CylinderNode.shape.id, + DataChartNode.shape.id, + DataTableNode.shape.id, + DataWidgetNode.shape.id, + ExtrudeNode.shape.id, + FrustumNode.shape.id, + HalfCylinderNode.shape.id, + HemisphereNode.shape.id, + PipeFittingNode.shape.id, + PipeNode.shape.id, + RoadNode.shape.id, + SteelBeamNode.shape.id, + SteelFrameNode.shape.id, ColumnNode.shape.id, ItemNode.shape.id, + LadderNode.shape.id, + LatheNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, RoofNode.shape.id, + RoundedPanelNode.shape.id, StairNode.shape.id, ScanNode.shape.id, GuideNode.shape.id, SpawnNode.shape.id, + SphereNode.shape.id, ShelfNode.shape.id, + SweepNode.shape.id, + TankNode.shape.id, + TorusNode.shape.id, + TrapezoidPrismNode.shape.id, + WedgeNode.shape.id, + objectId('factory-pump') as unknown as typeof AssemblyNode.shape.id, + objectId('factory-tank') as unknown as typeof AssemblyNode.shape.id, ]), ) .default([]), diff --git a/packages/core/src/schema/nodes/pipe-fitting.ts b/packages/core/src/schema/nodes/pipe-fitting.ts new file mode 100644 index 000000000..bc443eaed --- /dev/null +++ b/packages/core/src/schema/nodes/pipe-fitting.ts @@ -0,0 +1,50 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { PipeMedium } from './pipe' + +export const PipeFittingKind = z.enum(['elbow', 'tee', 'cross', 'flange', 'valve']) +export const PipeValveStyle = z.enum(['placeholder', 'gate', 'ball', 'butterfly']) + +export const PipeFittingNode = BaseNode.extend({ + id: objectId('pipe-fitting'), + type: nodeType('pipe-fitting'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 1, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + fittingKind: PipeFittingKind.default('elbow'), + /** Elbow sweep angle in degrees. Common field values are 45, 60, and 90. */ + angleDegrees: z.number().min(15).max(180).default(90), + diameter: z.number().min(0.02).max(2).default(0.15), + bendRadiusMultiplier: z.number().min(1).max(8).default(3), + branchLength: z.number().min(0.1).max(5).default(0.8), + length: z.number().min(0.05).max(5).default(0.4), + flangeOuterDiameter: z.number().min(0.03).max(4).optional(), + flangeThickness: z.number().min(0.01).max(1).default(0.04), + boltCount: z.number().int().min(0).max(32).default(8), + boltDiameter: z.number().min(0.005).max(0.2).default(0.02), + valveStyle: PipeValveStyle.default('placeholder'), + connectedPipeId: z.string().optional(), + connectionPoint: z.enum(['start', 'end', 'center']).default('center'), + pipeStation: z.number().min(0).max(1).optional(), + insulated: z.boolean().default(true), + insulationThickness: z.number().min(0).max(1).default(0.05), + pressureKpa: z.number().default(100), + temperatureC: z.number().default(180), + medium: PipeMedium.default('steam'), + color: z.string().default('#b0b8c0'), + opacity: z.number().min(0).max(1).default(1), +}).describe( + dedent` + Pipe fitting node - industrial pipe fittings such as elbows, tees, and crosses. + - elbow: angled bend with editable sweep angle, e.g. 90° or 60°. + - tee: one inlet with two outlets / one-to-two branch fitting. + - cross: one-to-three branch fitting. + `, +) + +export type PipeFittingNode = z.infer +export type PipeFittingKind = z.infer +export type PipeValveStyle = z.infer diff --git a/packages/core/src/schema/nodes/pipe.ts b/packages/core/src/schema/nodes/pipe.ts new file mode 100644 index 000000000..15a683c09 --- /dev/null +++ b/packages/core/src/schema/nodes/pipe.ts @@ -0,0 +1,39 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const PipeMedium = z.enum(['steam', 'condensate', 'water']) + +export const PipeNode = BaseNode.extend({ + id: objectId('pipe'), + type: nodeType('pipe'), + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + curveOffset: z.number().optional(), + diameter: z.number().default(0.15), + /** Start height of the pipe centerline (meters above level origin). */ + elevation: z.number().default(1), + /** Tilt from horizontal in degrees. 0 = horizontal run, 90 = vertical. */ + rotate: z.number().default(0), + insulated: z.boolean().default(true), + insulationThickness: z.number().default(0.05), + pressureKpa: z.number().default(100), + temperatureC: z.number().default(180), + medium: PipeMedium.default('steam'), + showHangers: z.boolean().default(true), + hangerSpacing: z.number().default(2), + color: z.string().default('#b0b8c0'), + opacity: z.number().min(0).max(1).default(1), +}).describe( + dedent` + Pipe node — steam / utility routing segment in level coordinates. + - Plan path start→end with optional curveOffset; 3D centerline tilts by \`rotate\` (0° horizontal, 90° vertical). + - elevation: start height of the centerline. + - diameter: outer pipe diameter in meters. + - insulated / insulationThickness: visual insulation jacket. + - pressureKpa / temperatureC / medium: process metadata (display + MCP). + - showHangers / hangerSpacing: visual support rings along runs. + `, +) + +export type PipeNode = z.infer diff --git a/packages/core/src/schema/nodes/road.test.ts b/packages/core/src/schema/nodes/road.test.ts new file mode 100644 index 000000000..057222d0a --- /dev/null +++ b/packages/core/src/schema/nodes/road.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'bun:test' +import { RoadNode } from './road' + +test('road node defaults to road surface kind', () => { + const node = RoadNode.parse({ start: [0, 0], end: [5, 0] }) + expect(node.surfaceKind).toBe('road') +}) + +test('road node accepts ground strip surface kinds', () => { + for (const surfaceKind of ['road', 'river', 'walkway', 'greenbelt'] as const) { + const node = RoadNode.parse({ start: [0, 0], end: [5, 0], surfaceKind }) + expect(node.surfaceKind).toBe(surfaceKind) + } +}) diff --git a/packages/core/src/schema/nodes/road.ts b/packages/core/src/schema/nodes/road.ts new file mode 100644 index 000000000..f7747a924 --- /dev/null +++ b/packages/core/src/schema/nodes/road.ts @@ -0,0 +1,37 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const RoadSurfaceKind = z.enum(['road', 'river', 'walkway', 'greenbelt']) +export type RoadSurfaceKind = z.infer + +export const RoadNode = BaseNode.extend({ + id: objectId('road'), + type: nodeType('road'), + surfaceKind: RoadSurfaceKind.default('road'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + curveOffset: z.number().optional(), + width: z.number().default(3.5), + thickness: z.number().default(0.04), + elevation: z.number().default(0.01), + laneCount: z.number().int().min(1).max(8).default(2), + showLaneMarkings: z.boolean().default(true), + asphaltColor: z.string().default('#2f3338'), + markingColor: z.string().default('#f8fafc'), +}).describe( + dedent` + Ground strip node - a flat linear surface strip drawn on a level. + - surfaceKind: road, river, walkway, or greenbelt presentation + - start/end: centerline endpoints in level coordinate system + - curveOffset: midpoint sagitta offset used to bend the road into an arc + - width/thickness/elevation: strip dimensions and offset above or below the level plane + - laneCount/showLaneMarkings: visual lane stripe controls for road-like strips + - asphaltColor/markingColor: default surface and stripe colours + `, +) + +export type RoadNode = z.infer diff --git a/packages/core/src/schema/nodes/rounded-panel.ts b/packages/core/src/schema/nodes/rounded-panel.ts new file mode 100644 index 000000000..3398901df --- /dev/null +++ b/packages/core/src/schema/nodes/rounded-panel.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const RoundedPanelNode = BaseNode.extend({ + id: objectId('rounded-panel'), + type: nodeType('rounded-panel'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + length: z.number().min(0.01).max(20).default(1.0), + width: z.number().min(0.01).max(20).default(0.5), + thickness: z.number().min(0.005).max(2).default(0.04), + cornerRadius: z.number().min(0).max(2).default(0.04), + cornerSegments: z.number().int().min(1).max(12).default(4), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Rounded rectangular panel primitive - thin bevelled panel/keycap/screen/cushion with rounded rectangular footprint.', +) + +export type RoundedPanelNode = z.infer diff --git a/packages/core/src/schema/nodes/sphere.ts b/packages/core/src/schema/nodes/sphere.ts new file mode 100644 index 000000000..2fd61b38a --- /dev/null +++ b/packages/core/src/schema/nodes/sphere.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const SphereNode = BaseNode.extend({ + id: objectId('sphere'), + type: nodeType('sphere'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), + radius: z.number().min(0.01).max(10).default(0.5), + widthSegments: z.number().int().min(8).max(64).default(32), + heightSegments: z.number().int().min(8).max(64).default(32), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Sphere primitive — configurable solid spherical volume. Use scale [sx, sy, sz] for ellipsoids (e.g. [2, 0.3, 1] for a flattened engine-hood dome).', +) + +export type SphereNode = z.infer diff --git a/packages/core/src/schema/nodes/stair.test.ts b/packages/core/src/schema/nodes/stair.test.ts new file mode 100644 index 000000000..ef08ed49e --- /dev/null +++ b/packages/core/src/schema/nodes/stair.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' +import { StairNode } from './stair' + +describe('StairNode schema', () => { + test('defaults spiral center columns to round', () => { + const stair = StairNode.parse({ stairType: 'spiral' }) + expect(stair.centerColumnShape).toBe('round') + }) + + test('accepts square spiral center columns', () => { + const stair = StairNode.parse({ stairType: 'spiral', centerColumnShape: 'square' }) + expect(stair.centerColumnShape).toBe('square') + }) + + test('rejects unknown center column shapes', () => { + expect(() => StairNode.parse({ centerColumnShape: 'triangle' })).toThrow() + }) +}) diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index a0c68fe18..75b1a8d80 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -9,11 +9,13 @@ export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) export const StairType = z.enum(['straight', 'curved', 'spiral']) export const StairTopLandingMode = z.enum(['none', 'integrated']) export const StairSlabOpeningMode = z.enum(['none', 'destination']) +export const StairCenterColumnShape = z.enum(['round', 'square']) export type StairRailingMode = z.infer export type StairType = z.infer export type StairTopLandingMode = z.infer export type StairSlabOpeningMode = z.infer +export type StairCenterColumnShape = z.infer export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side' export type StairSurfaceMaterialSpec = { material?: MaterialSchemaType @@ -49,6 +51,7 @@ export const StairNode = BaseNode.extend({ topLandingMode: StairTopLandingMode.default('none'), topLandingDepth: z.number().default(0.9), showCenterColumn: z.boolean().default(true), + centerColumnShape: StairCenterColumnShape.default('round'), showStepSupports: z.boolean().default(true), railingMode: StairRailingMode.default('none'), railingHeight: z.number().default(0.92), @@ -75,6 +78,7 @@ export const StairNode = BaseNode.extend({ - topLandingMode: optional integrated top landing for spiral stairs - topLandingDepth: depth used to size the integrated spiral top landing - showCenterColumn: whether spiral stairs render a center column + - centerColumnShape: round or square center column shape for spiral stairs - showStepSupports: whether spiral stairs render step support brackets - railingMode: whether to render railings and on which side(s) - railingHeight: top height of the railing above the stair surface diff --git a/packages/core/src/schema/nodes/steel-beam.ts b/packages/core/src/schema/nodes/steel-beam.ts new file mode 100644 index 000000000..9122fe8e2 --- /dev/null +++ b/packages/core/src/schema/nodes/steel-beam.ts @@ -0,0 +1,34 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const SteelBeamProfile = z.enum(['i-beam', 'box', 'channel', 'concave']) + +export const SteelBeamNode = BaseNode.extend({ + id: objectId('steel-beam'), + type: nodeType('steel-beam'), + start: z.tuple([z.number(), z.number()]).default([0, 0]), + end: z.tuple([z.number(), z.number()]).default([3, 0]), + curveOffset: z.number().optional(), + elevation: z.number().default(0), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + profile: SteelBeamProfile.default('i-beam'), + length: z.number().default(3), + height: z.number().default(0.32), + width: z.number().default(0.18), + flangeThickness: z.number().default(0.045), + webThickness: z.number().default(0.035), + color: z.string().default('#7f8792'), +}).describe( + dedent` + Steel beam node - editable structural beam route. + - start/end/curveOffset: plan centerline, matching pipe-like route editing. + - elevation: bottom height above level origin. + - position/rotation/length are retained for legacy scenes; new tools write start/end. + - profile: i-beam, box (hollow rectangular tube), channel, or concave. + - height/width/flangeThickness/webThickness: section dimensions. + `, +) + +export type SteelBeamNode = z.infer diff --git a/packages/core/src/schema/nodes/steel-frame.ts b/packages/core/src/schema/nodes/steel-frame.ts new file mode 100644 index 000000000..f105ec796 --- /dev/null +++ b/packages/core/src/schema/nodes/steel-frame.ts @@ -0,0 +1,46 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const SteelFrameStyle = z.enum([ + 'pipe-rack', + 'equipment-platform', + 'portal-frame', + 'tower-frame', +]) + +export const SteelFrameBraceStyle = z.enum(['single-diagonal', 'knee', 'none']) + +export const SteelFrameNode = BaseNode.extend({ + id: objectId('steel-frame'), + type: nodeType('steel-frame'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + style: SteelFrameStyle.default('pipe-rack'), + braceStyle: z.preprocess( + (value) => (value === 'x' ? 'single-diagonal' : value), + SteelFrameBraceStyle.default('single-diagonal'), + ), + length: z.number().default(6), + width: z.number().default(2.4), + height: z.number().default(4.5), + levels: z.number().int().min(1).max(8).default(2), + columns: z.number().int().min(2).max(12).default(4), + rows: z.number().int().min(2).max(6).default(2), + memberSize: z.number().default(0.14), + braceSize: z.number().default(0.06), + deckThickness: z.number().default(0.08), + color: z.string().default('#ffffff'), + deckColor: z.string().default('#f8fafc'), +}).describe( + dedent` + Steel frame node - editable industrial outdoor steel frame. + - style: pipe rack, equipment platform, portal frame, or tower frame. + - levels/columns/rows control the repeated vertical tiers and column grids. + - length/width/height/memberSize/braceSize/deckThickness reuse the positioned parametric-item editing pattern. + `, +) + +export type SteelFrameNode = z.infer +export type SteelFrameStyle = z.infer +export type SteelFrameBraceStyle = z.infer diff --git a/packages/core/src/schema/nodes/sweep.ts b/packages/core/src/schema/nodes/sweep.ts new file mode 100644 index 000000000..35103e7c8 --- /dev/null +++ b/packages/core/src/schema/nodes/sweep.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const SweepNode = BaseNode.extend({ + id: objectId('sweep'), + type: nodeType('sweep'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + path: z + .array(z.tuple([z.number(), z.number(), z.number()])) + .min(2) + .max(64) + .default([ + [-0.5, 0, 0], + [0.5, 0, 0], + ]), + radius: z.number().min(0.005).max(2).default(0.03), + tubularSegments: z.number().int().min(2).max(128).default(24), + radialSegments: z.number().int().min(3).max(32).default(12), + closed: z.boolean().default(false), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Sweep primitive - circular tube swept along a 3D path for cables, hoses, curved handles, rails, and piping.', +) + +export type SweepNode = z.infer diff --git a/packages/core/src/schema/nodes/tank.ts b/packages/core/src/schema/nodes/tank.ts new file mode 100644 index 000000000..477605208 --- /dev/null +++ b/packages/core/src/schema/nodes/tank.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const TankKind = z.enum(['vertical', 'horizontal', 'spherical']) + +export const TankNode = BaseNode.extend({ + id: objectId('tank'), + type: nodeType('tank'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + kind: TankKind.default('vertical'), + diameter: z.number().min(0.1).max(20).default(1.6), + height: z.number().min(0.1).max(40).default(3), + length: z.number().min(0.1).max(40).default(3), + liquidLevel: z.number().min(0).max(1).default(0.5), + shellColor: z.string().default('#94a3b8'), + liquidColor: z.string().default('#38bdf8'), + shellOpacity: z.number().min(0.05).max(1).default(0.24), +}).describe( + 'Tank node - industrial vertical, horizontal, or spherical storage tank with editable liquid level.', +) + +export type TankNode = z.infer +export type TankKind = z.infer diff --git a/packages/core/src/schema/nodes/torus.ts b/packages/core/src/schema/nodes/torus.ts new file mode 100644 index 000000000..3351dcbb7 --- /dev/null +++ b/packages/core/src/schema/nodes/torus.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const TorusNode = BaseNode.extend({ + id: objectId('torus'), + type: nodeType('torus'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + majorRadius: z.number().min(0.01).max(10).default(0.5), + tubeRadius: z.number().min(0.001).max(5).default(0.08), + radialSegments: z.number().int().min(3).max(64).default(16), + tubularSegments: z.number().int().min(8).max(128).default(48), + arc: z + .number() + .min(0.01) + .max(Math.PI * 2) + .default(Math.PI * 2), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Torus primitive - donut/ring tube for tires, steering wheels, seals, fan rims, rings, and handles.', +) + +export type TorusNode = z.infer diff --git a/packages/core/src/schema/nodes/trapezoid-prism.ts b/packages/core/src/schema/nodes/trapezoid-prism.ts new file mode 100644 index 000000000..5ac3c9f25 --- /dev/null +++ b/packages/core/src/schema/nodes/trapezoid-prism.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const TrapezoidPrismNode = BaseNode.extend({ + id: objectId('trapezoid-prism'), + type: nodeType('trapezoid-prism'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + length: z.number().min(0.01).max(50).default(1), + width: z.number().min(0.01).max(50).default(1), + height: z.number().min(0.01).max(20).default(0.5), + topLengthScale: z.number().min(0.01).max(3).default(0.7), + topWidthScale: z.number().min(0.01).max(3).default(0.7), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Trapezoid-prism primitive - tapered rectangular block with smaller/larger top face for appliance shells, plinths, tapered cushions, and stylized housings.', +) + +export type TrapezoidPrismNode = z.infer diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts new file mode 100644 index 000000000..a59a3a6dd --- /dev/null +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { getEffectiveWallSurfaceMaterial, type WallNode } from './wall' + +describe('wall surface material resolution', () => { + test('prefers a configured custom surface material over a stale surface preset', () => { + const material = { + preset: 'custom', + properties: { + color: '#d4af37', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + } satisfies NonNullable + + const wall = { + type: 'wall', + interiorMaterial: material, + interiorMaterialPreset: 'library:wood-oak', + } satisfies Partial + + expect(getEffectiveWallSurfaceMaterial(wall, 'interior')).toEqual({ + material, + }) + }) +}) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ebce121c2..56d77d216 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -81,19 +81,23 @@ function hasSurfaceMaterial(spec: WallSurfaceMaterialSpec): boolean { return spec.material !== undefined || typeof spec.materialPreset === 'string' } +function normalizeWallSurfaceMaterial(spec: WallSurfaceMaterialSpec): WallSurfaceMaterialSpec { + return spec.material ? { material: spec.material } : spec +} + export function getEffectiveWallSurfaceMaterial( wall: WallSurfaceMaterialSource, side: WallSurfaceSide, ): WallSurfaceMaterialSpec { - const configured = getConfiguredWallSurfaceMaterial(wall, side) + const configured = normalizeWallSurfaceMaterial(getConfiguredWallSurfaceMaterial(wall, side)) if (hasSurfaceMaterial(configured)) { return configured } - return { + return normalizeWallSurfaceMaterial({ material: wall.material, materialPreset: wall.materialPreset, - } + }) } export function getWallSurfaceMaterialSignature(spec: WallSurfaceMaterialSpec): string { diff --git a/packages/core/src/schema/nodes/wedge.ts b/packages/core/src/schema/nodes/wedge.ts new file mode 100644 index 000000000..9b2cb6dec --- /dev/null +++ b/packages/core/src/schema/nodes/wedge.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const WedgeNode = BaseNode.extend({ + id: objectId('wedge'), + type: nodeType('wedge'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.preprocess( + (val) => (typeof val === 'number' ? [0, val, 0] : val), + z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + ), + length: z.number().min(0.01).max(50).default(1), + width: z.number().min(0.01).max(50).default(1), + height: z.number().min(0.01).max(20).default(0.5), + slopeAxis: z.enum(['x', 'z']).default('z'), + slopeDirection: z.enum(['positive', 'negative']).default('positive'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe( + 'Wedge primitive - sloped triangular prism for ramps, car hoods, keyboard side blocks, angled backs, and tapered covers.', +) + +export type WedgeNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a683fb120..ed039e333 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,41 +1,99 @@ import z from 'zod' +import { AssemblyNode } from './nodes/assembly' +import { BoxNode } from './nodes/box' import { BuildingNode } from './nodes/building' +import { CableTrayNode } from './nodes/cable-tray' +import { CapsuleNode } from './nodes/capsule' import { CeilingNode } from './nodes/ceiling' import { ColumnNode } from './nodes/column' +import { ConeNode } from './nodes/cone' +import { ConformalStripNode } from './nodes/conformal-strip' +import { ConveyorBeltNode } from './nodes/conveyor-belt' +import { CylinderNode } from './nodes/cylinder' +import { DataChartNode } from './nodes/data-chart' +import { DataTableNode } from './nodes/data-table' +import { DataWidgetNode } from './nodes/data-widget' import { DoorNode } from './nodes/door' import { ElevatorNode } from './nodes/elevator' +import { ExtrudeNode } from './nodes/extrude' import { FenceNode } from './nodes/fence' +import { FrustumNode } from './nodes/frustum' import { GuideNode } from './nodes/guide' +import { HalfCylinderNode } from './nodes/half-cylinder' +import { HemisphereNode } from './nodes/hemisphere' import { ItemNode } from './nodes/item' +import { LadderNode } from './nodes/ladder' +import { LatheNode } from './nodes/lathe' import { LevelNode } from './nodes/level' +import { PipeNode } from './nodes/pipe' +import { PipeFittingNode } from './nodes/pipe-fitting' +import { RoadNode } from './nodes/road' import { RoofNode } from './nodes/roof' import { RoofSegmentNode } from './nodes/roof-segment' +import { RoundedPanelNode } from './nodes/rounded-panel' import { ScanNode } from './nodes/scan' import { ShelfNode } from './nodes/shelf' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' import { SpawnNode } from './nodes/spawn' +import { SphereNode } from './nodes/sphere' import { StairNode } from './nodes/stair' import { StairSegmentNode } from './nodes/stair-segment' +import { SteelBeamNode } from './nodes/steel-beam' +import { SteelFrameNode } from './nodes/steel-frame' +import { SweepNode } from './nodes/sweep' +import { TankNode } from './nodes/tank' +import { TorusNode } from './nodes/torus' +import { TrapezoidPrismNode } from './nodes/trapezoid-prism' import { WallNode } from './nodes/wall' +import { WedgeNode } from './nodes/wedge' import { WindowNode } from './nodes/window' import { ZoneNode } from './nodes/zone' export const AnyNode = z.discriminatedUnion('type', [ SiteNode, BuildingNode, + AssemblyNode, + CableTrayNode, + BoxNode, + CylinderNode, + DataChartNode, + DataTableNode, + DataWidgetNode, + ConeNode, + ConformalStripNode, + ConveyorBeltNode, + FrustumNode, + HemisphereNode, + TorusNode, + WedgeNode, + TrapezoidPrismNode, + SphereNode, + LatheNode, + CapsuleNode, + HalfCylinderNode, + RoundedPanelNode, + ExtrudeNode, + SweepNode, + TankNode, ElevatorNode, LevelNode, ColumnNode, WallNode, FenceNode, + PipeFittingNode, + PipeNode, + RoadNode, ItemNode, + LadderNode, ZoneNode, SlabNode, CeilingNode, RoofNode, RoofSegmentNode, ShelfNode, + SteelBeamNode, + SteelFrameNode, StairNode, StairSegmentNode, ScanNode, diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts new file mode 100644 index 000000000..721baff1e --- /dev/null +++ b/packages/core/src/services/alignment-anchors.ts @@ -0,0 +1,345 @@ +/** + * Node → alignment-anchor adapters. + * + * `alignment.ts` is pure geometry and knows nothing about nodes. This + * module bridges the scene graph to it: it reads a floor-placed kind's + * footprint from the registry and turns it into the bbox anchors the + * resolver matches against. Kept out of `alignment.ts` so that file stays + * registry-free. + * + * All coordinates are XZ meters in the same frame as `node.position` + * (building-local for nodes inside a building). The 3D move producer works + * entirely in that frame, so the resulting guides line up with the cursor. + */ + +import { nodeRegistry } from '../registry' +import type { AnyNode } from '../schema/types' +import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { type AlignmentAnchor, bboxCornerAnchors } from './alignment' + +export type FootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number } + +/** + * Axis-aligned XZ bounding box of a rotated rectangle centred at + * `position`. Mirrors the rotated-corner math the spatial-grid manager + * uses (`getItemFootprint`) so alignment anchors coincide with the + * footprint used for collision / slab elevation. + */ +export function footprintAABBFrom( + position: readonly [number, number, number], + dimensions: readonly [number, number, number], + rotationY: number, +): FootprintAABB { + const [x, , z] = position + const [w, , d] = dimensions + const halfW = w / 2 + const halfD = d / 2 + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [lx, lz] of [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] as const) { + const wx = x + (lx * cos - lz * sin) + const wz = z + (lx * sin + lz * cos) + if (wx < minX) minX = wx + if (wx > maxX) maxX = wx + if (wz < minZ) minZ = wz + if (wz > maxZ) maxZ = wz + } + + return { minX, minZ, maxX, maxZ } +} + +/** The relocatable box footprint for a node, or null when it has none + * (walls / slabs / polygon kinds) or the kind's predicate excludes it + * (e.g. a wall-attached item that doesn't rest on the floor). + * + * Box footprints come from one of two capabilities: `floorPlaced` (kinds + * whose Y is also slab-lifted — columns, items) or `alignmentFootprint` + * with a `box` shape (kinds that align by their footprint but aren't + * floor-coupled — the elevator's outer shaft). A kind whose + * `alignmentFootprint` is an `aabb` (stair) has no centred box, so it's + * resolved directly in `nodeAlignmentAnchors`, not here. */ +function floorFootprint( + node: AnyNode, +): { dimensions: [number, number, number]; rotation: [number, number, number] } | null { + const capabilities = nodeRegistry.get(node.type)?.capabilities + const floorPlaced = capabilities?.floorPlaced + // `footprint` is optional now that floor-placed kinds may instead declare + // composite `footprints` (e.g. stairs); those have no single centred box + // here, so fall through to `alignmentFootprint`. + if (floorPlaced?.footprint) { + if (floorPlaced.applies && !floorPlaced.applies(node)) return null + return floorPlaced.footprint(node) + } + const alignment = capabilities?.alignmentFootprint?.(node) + if (alignment?.shape === 'box') { + return { dimensions: alignment.dimensions, rotation: alignment.rotation } + } + return null +} + +/** + * XZ bounding box a node occupies in plan, unifying the two non-structural + * sources: a relocatable box (`floorFootprint`, covering floor-placed kinds + * and the elevator's alignment box) and a kind that hands back an explicit + * `aabb` because its plan shape isn't a centred rectangle (stair). Returns + * null for kinds with neither. + */ +function alignmentAABB( + node: AnyNode, + nodes?: Readonly>, +): FootprintAABB | null { + const box = footprintAABB(node) + if (box) return box + const alignment = nodeRegistry.get(node.type)?.capabilities?.alignmentFootprint?.(node, nodes) + if (alignment?.shape === 'aabb') { + return { + minX: alignment.minX, + minZ: alignment.minZ, + maxX: alignment.maxX, + maxZ: alignment.maxZ, + } + } + return null +} + +/** XZ footprint AABB of a floor-placed node at its current position, or + * null for kinds without a usable footprint. */ +export function footprintAABB(node: AnyNode): FootprintAABB | null { + const fp = floorFootprint(node) + if (!fp) return null + const position = (node as { position?: [number, number, number] }).position ?? [0, 0, 0] + return footprintAABBFrom(position, fp.dimensions, fp.rotation[1] ?? 0) +} + +/** XZ footprint AABB of a floor-placed node relocated so its centre sits at + * the proposed (x, z). `rotationY` overrides the node's footprint rotation + * (R/T bumps it before the scene commit lands). Null when no footprint. */ +export function footprintAABBAt( + node: AnyNode, + x: number, + z: number, + rotationY?: number, +): FootprintAABB | null { + const fp = floorFootprint(node) + if (!fp) return null + return footprintAABBFrom([x, 0, z], fp.dimensions, rotationY ?? fp.rotation[1] ?? 0) +} + +/** + * Corner anchors for the moving node's footprint relocated so its centre + * sits at the proposed (x, z). Corners only — the moving item aligns by its + * edges, never its centreline. Returns [] when the kind has no footprint. + */ +export function movingFootprintAnchors( + node: AnyNode, + x: number, + z: number, + rotationY?: number, +): AlignmentAnchor[] { + const aabb = footprintAABBAt(node, x, z, rotationY) + if (!aabb) return [] + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) +} + +function relocatedPlanNode(node: AnyNode, x: number, z: number, rotationY?: number): AnyNode { + const position = (node as { position?: unknown }).position + const y = Array.isArray(position) && typeof position[1] === 'number' ? position[1] : 0 + const relocated: Record = { + ...(node as Record), + position: [x, y, z], + } + + if (rotationY !== undefined && 'rotation' in node) { + const rotation = (node as { rotation?: unknown }).rotation + relocated.rotation = Array.isArray(rotation) + ? [rotation[0] ?? 0, rotationY, rotation[2] ?? 0] + : rotationY + } + + return relocated as AnyNode +} + +export function movingAlignmentAnchors( + node: AnyNode, + nodes: Readonly> | undefined, + x: number, + z: number, + rotationY?: number, +): AlignmentAnchor[] { + const box = footprintAABBAt(node, x, z, rotationY) + if (box) return bboxCornerAnchors(node.id, box.minX, box.minZ, box.maxX, box.maxZ) + + const alignment = nodeRegistry + .get(node.type) + ?.capabilities?.alignmentFootprint?.(relocatedPlanNode(node, x, z, rotationY), nodes) + + if (alignment?.shape === 'box') { + const aabb = footprintAABBFrom([x, 0, z], alignment.dimensions, alignment.rotation[1] ?? 0) + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) + } + if (alignment?.shape === 'aabb') { + return bboxCornerAnchors( + node.id, + alignment.minX, + alignment.minZ, + alignment.maxX, + alignment.maxZ, + ) + } + + return [] +} + +/** + * Alignment anchors for a wall segment: the two centerline endpoints + chord + * midpoint, plus — when `thickness` is known — four **face** corner anchors, + * each endpoint offset by ±thickness/2 perpendicular to the wall axis. + * + * The face anchors are what let a footprint align to a wall's *face* rather + * than its centerline: for an axis-aligned wall the two same-side face + * anchors share a constant X (vertical wall) or Z (horizontal wall) running + * the wall's full length, so the point-to-point resolver snaps a moving + * corner flush to the face anywhere along the wall (the perpendicular + * tie-break connects the guide to the nearer face endpoint). A diagonal wall + * gets only its face/centerline endpoints — point-to-point can't represent a + * sloped face line; that's an accepted v1 limitation. + * + * Curve offset is ignored — endpoints are exact and the chord midpoint is + * good enough for v1. Coordinates are the wall's `start` / `end` + * (building-local XZ meters). + */ +export function wallSegmentAnchors( + id: string, + start: readonly [number, number], + end: readonly [number, number], + thickness?: number, +): AlignmentAnchor[] { + const anchors: AlignmentAnchor[] = [ + { nodeId: id, kind: 'corner', x: start[0], z: start[1] }, + { nodeId: id, kind: 'corner', x: end[0], z: end[1] }, + { nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 }, + ] + + if (thickness && thickness > 0) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.hypot(dx, dz) + if (len > 1e-6) { + // Perpendicular to the wall axis, scaled to half-thickness. + const half = thickness / 2 + const px = (-dz / len) * half + const pz = (dx / len) * half + for (const [bx, bz] of [start, end] as const) { + anchors.push({ nodeId: id, kind: 'corner', x: bx + px, z: bz + pz }) + anchors.push({ nodeId: id, kind: 'corner', x: bx - px, z: bz - pz }) + } + } + } + + return anchors +} + +/** Each vertex of a polygon (slab / ceiling footprint) as a `corner` anchor. */ +export function polygonAnchors( + id: string, + points: readonly (readonly [number, number])[], +): AlignmentAnchor[] { + return points.map(([x, z]) => ({ nodeId: id, kind: 'corner' as const, x, z })) +} + +/** + * Alignment anchors a node contributes to the candidate pool, dispatched by + * kind: walls / fences → segment endpoints + midpoint; slabs / ceilings → + * polygon vertices; everything else → the corners of its plan bounding box + * (`alignmentAABB`, which covers floor-placed kinds, the elevator's + * alignment box, and the stair's chain / sector footprint). Kinds with no + * usable footprint contribute nothing. + * + * `nodes` is needed only by kinds whose footprint walks siblings / children + * (a straight stair's `stair-segment` chain); every other kind derives its + * anchors from `node` alone. + */ +export function nodeAlignmentAnchors( + node: AnyNode, + nodes?: Readonly>, +): AlignmentAnchor[] { + if (node.type === 'wall' || node.type === 'fence') { + const seg = node as { + id: string + start: [number, number] + end: [number, number] + thickness?: number + } + // Wall thickness is schema-optional (falls back to the geometry default); + // fence always carries one. Either way, pass it through so faces align. + return wallSegmentAnchors(seg.id, seg.start, seg.end, seg.thickness ?? DEFAULT_WALL_THICKNESS) + } + if (node.type === 'slab' || node.type === 'ceiling') { + const poly = (node as { polygon?: [number, number][] }).polygon + return poly ? polygonAnchors(node.id, poly) : [] + } + const aabb = alignmentAABB(node, nodes) + return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : [] +} + +/** + * Resolve the level a node belongs to by walking its `parentId` chain, or + * null when it isn't under a level. Inlined here (rather than importing the + * spatial-grid `resolveLevelId`) to keep this services module free of + * hook / store dependencies. + */ +function resolveNodeLevelId( + node: AnyNode, + nodes: Readonly>, +): string | null { + let current: AnyNode | undefined = node + while (current) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + } + return null +} + +/** + * Anchors from every alignable node except `excludeId` — the unified + * candidate pool every move / placement tool resolves against, so any + * draggable object can align to any other (items, walls, fences, slabs, + * ceilings, columns). + * + * When `levelId` is given, nodes that belong to a *different* level are + * dropped. Alignment is XZ-only, so without this a node directly below on + * another floor (e.g. the ground floor while you place on the first) would + * snap and draw a guide even though the two sit at different heights. + * Building-/site-scoped nodes with no level ancestor (e.g. an elevator + * shaft, which is parented to the building and spans every floor) resolve to + * null and stay in the pool so they align on any floor. The 2D floor-plan + * deliberately omits the filter — aligning a wall to the one directly below + * in plan is the whole point of the reference floor. + */ +export function collectAlignmentAnchors( + nodes: Readonly>, + excludeId: string, + levelId?: string | null, +): AlignmentAnchor[] { + const anchors: AlignmentAnchor[] = [] + for (const node of Object.values(nodes)) { + if (!node || node.id === excludeId) continue + if (levelId) { + const nodeLevelId = resolveNodeLevelId(node, nodes) + if (nodeLevelId !== null && nodeLevelId !== levelId) continue + } + anchors.push(...nodeAlignmentAnchors(node, nodes)) + } + return anchors +} diff --git a/packages/core/src/services/alignment.ts b/packages/core/src/services/alignment.ts new file mode 100644 index 000000000..869760dff --- /dev/null +++ b/packages/core/src/services/alignment.ts @@ -0,0 +1,211 @@ +/** + * Pure alignment-guide resolver — no React, no DOM, no scene access. + * + * Given a moving object's anchor points at its proposed position and a + * pool of candidate anchors from nearby static objects, returns: + * - the best per-axis matches as `Guide` rendering primitives, and + * - an optional `{ dx, dz }` snap delta the caller can apply. + * + * Anchors are 2D points on the floor plane (XZ, in world meters). The + * resolver picks at most one match per axis: the smallest |Δx| match + * snaps X; the smallest |Δz| match snaps Z. This mirrors Figma's + * behaviour — guides appear along the matched axes, regardless of how + * many neighbours could have matched. + * + * Two guides max per call keeps the visual signal sharp at the cost of + * not surfacing every possible alignment at once. Multi-guide ("this + * lines up with three things") is intentionally out of scope for v1. + */ + +export type AnchorKind = 'corner' | 'edge-mid' | 'center' + +export type AlignmentAnchor = { + /** Owning node id — informational; resolver does not use it. */ + nodeId: string + kind: AnchorKind + x: number + z: number +} + +export type AlignmentGuideAxis = 'x' | 'z' + +/** + * Rendering primitive — a guide line on the floor plane. + * + * `axis === 'x'`: vertical guide. Both endpoints share `coord` as their X. + * `axis === 'z'`: horizontal guide. Both endpoints share `coord` as their Z. + * + * The line spans from the matched candidate anchor to the moving anchor + * after snap. Renderers extend visually beyond the endpoints if they want + * Figma-style "infinite line" feel. + */ +export type AlignmentGuide = { + axis: AlignmentGuideAxis + coord: number + from: { x: number; z: number } + to: { x: number; z: number } + movingAnchorKind: AnchorKind + candidateAnchorKind: AnchorKind + candidateNodeId: string + /** Perpendicular distance between the two anchors (used by the distance pill). */ + distance: number +} + +export type ResolveAlignmentInput = { + /** Anchors of the moving node, positioned at the proposed (pre-snap) location. */ + moving: readonly AlignmentAnchor[] + /** Anchors from every other candidate node the caller has already filtered. */ + candidates: readonly AlignmentAnchor[] + /** + * Max |Δ| (meters) for an anchor pair to count as a match. Typically + * derived from a screen-pixel budget × current units-per-pixel so the + * snap feel is zoom-invariant. + */ + threshold: number +} + +export type ResolveAlignmentResult = { + guides: AlignmentGuide[] + /** + * Delta the caller should add to the moving node's planar position so + * its anchors land on the matched axes. `null` when no axis matched. + */ + snap: { dx: number; dz: number } | null +} + +const EMPTY: ResolveAlignmentResult = { guides: [], snap: null } + +export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult { + const { moving, candidates, threshold } = input + if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY + + // Best match per axis: smallest |Δ| on the matched axis (tightest + // alignment), then — crucially — tie-break to the candidate anchor NEAREST + // on the perpendicular axis. Anchors are real points (corners / endpoints / + // midpoints), so the guide always connects to the closest actual point of + // the candidate, never a far one that merely shares the same coordinate. + type Best = { + delta: number + primary: number + perp: number + m: AlignmentAnchor + c: AlignmentAnchor + } + let bestX: Best | null = null + let bestZ: Best | null = null + + for (const m of moving) { + for (const c of candidates) { + const dx = c.x - m.x + const dz = c.z - m.z + const adx = Math.abs(dx) + const adz = Math.abs(dz) + if ( + adx <= threshold && + (bestX === null || adx < bestX.primary || (adx === bestX.primary && adz < bestX.perp)) + ) { + bestX = { delta: dx, primary: adx, perp: adz, m, c } + } + if ( + adz <= threshold && + (bestZ === null || adz < bestZ.primary || (adz === bestZ.primary && adx < bestZ.perp)) + ) { + bestZ = { delta: dz, primary: adz, perp: adx, m, c } + } + } + } + + if (!bestX && !bestZ) return EMPTY + + const dxSnap = bestX?.delta ?? 0 + const dzSnap = bestZ?.delta ?? 0 + const guides: AlignmentGuide[] = [] + + if (bestX) { + // X-axis match: vertical guide at x = bestX.c.x. The moving anchor + // ends up at (c.x, m.z + dzSnap). Span the line between them. + const snappedMz = bestX.m.z + dzSnap + const z1 = Math.min(bestX.c.z, snappedMz) + const z2 = Math.max(bestX.c.z, snappedMz) + guides.push({ + axis: 'x', + coord: bestX.c.x, + from: { x: bestX.c.x, z: z1 }, + to: { x: bestX.c.x, z: z2 }, + movingAnchorKind: bestX.m.kind, + candidateAnchorKind: bestX.c.kind, + candidateNodeId: bestX.c.nodeId, + distance: Math.abs(snappedMz - bestX.c.z), + }) + } + + if (bestZ) { + const snappedMx = bestZ.m.x + dxSnap + const x1 = Math.min(bestZ.c.x, snappedMx) + const x2 = Math.max(bestZ.c.x, snappedMx) + guides.push({ + axis: 'z', + coord: bestZ.c.z, + from: { x: x1, z: bestZ.c.z }, + to: { x: x2, z: bestZ.c.z }, + movingAnchorKind: bestZ.m.kind, + candidateAnchorKind: bestZ.c.kind, + candidateNodeId: bestZ.c.nodeId, + distance: Math.abs(snappedMx - bestZ.c.x), + }) + } + + return { guides, snap: { dx: dxSnap, dz: dzSnap } } +} + +// ─── Anchor extractors (pure) ───────────────────────────────────────── + +/** + * Produces the 9 standard anchors for an axis-aligned bounding box on the + * floor plane: 4 corners, 4 edge midpoints, 1 center. Suitable for any + * floor-plan entity whose footprint can be expressed as a bbox. + * + * Caller is responsible for computing the bbox — the resolver doesn't + * care how (per-kind dimensions, SVG getBBox(), etc.). + */ +export function bboxAnchors( + nodeId: string, + minX: number, + minZ: number, + maxX: number, + maxZ: number, +): AlignmentAnchor[] { + const cx = (minX + maxX) / 2 + const cz = (minZ + maxZ) / 2 + return [ + { nodeId, kind: 'corner', x: minX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: maxZ }, + { nodeId, kind: 'corner', x: minX, z: maxZ }, + { nodeId, kind: 'edge-mid', x: cx, z: minZ }, + { nodeId, kind: 'edge-mid', x: maxX, z: cz }, + { nodeId, kind: 'edge-mid', x: cx, z: maxZ }, + { nodeId, kind: 'edge-mid', x: minX, z: cz }, + { nodeId, kind: 'center', x: cx, z: cz }, + ] +} + +/** + * The 4 corner anchors of a bbox — edges only, no edge-midpoints or center. + * Used where alignment should lock to an object's edges (left/right/front/ + * back), never its centreline. + */ +export function bboxCornerAnchors( + nodeId: string, + minX: number, + minZ: number, + maxX: number, + maxZ: number, +): AlignmentAnchor[] { + return [ + { nodeId, kind: 'corner', x: minX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: maxZ }, + { nodeId, kind: 'corner', x: minX, z: maxZ }, + ] +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index ac4826f80..46489e342 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,3 +1,26 @@ +export { + type AlignmentAnchor, + type AlignmentGuide, + type AlignmentGuideAxis, + type AnchorKind, + bboxAnchors, + bboxCornerAnchors, + type ResolveAlignmentInput, + type ResolveAlignmentResult, + resolveAlignment, +} from './alignment' +export { + collectAlignmentAnchors, + type FootprintAABB, + footprintAABB, + footprintAABBAt, + footprintAABBFrom, + movingAlignmentAnchors, + movingFootprintAnchors, + nodeAlignmentAnchors, + polygonAnchors, + wallSegmentAnchors, +} from './alignment-anchors' export { createDragSession, type DragSession, @@ -23,6 +46,26 @@ export { moveToward, resolveMovable, } from './movement' +export { + type AlongWallAlignment, + type AlongWallFeature, + computeEdgeGaps, + computeOpeningGuides, + DEFAULT_OPENING_GUIDE_TOLERANCES, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type EdgeGap, + type EqualSpacingRun, + type OpeningGuideInput, + type OpeningGuides, + type OpeningGuideTolerances, + type OpeningSpan, + type SillHeadGuide, + type VerticalAlignment, + type VerticalFeature, + type WallExtent, +} from './opening-guides' export { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, diff --git a/packages/core/src/services/opening-guides.test.ts b/packages/core/src/services/opening-guides.test.ts new file mode 100644 index 000000000..648800b42 --- /dev/null +++ b/packages/core/src/services/opening-guides.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test' +import { + computeEdgeGaps, + computeOpeningGuides, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type OpeningSpan, + type WallExtent, +} from './opening-guides' + +function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan { + return { id, centerS, width, centerY, height } +} + +const WALL: WallExtent = { length: 10, height: 2.5 } + +describe('detectEqualSpacing', () => { + test('returns null for fewer than three openings', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull() + }) + + test('detects a run of equal gaps across three openings', () => { + // width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m. + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) + const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02) + expect(run).not.toBeNull() + expect(run?.gap).toBeCloseTo(1) + expect(run?.segments).toHaveLength(2) + expect(run?.openingIds).toEqual(['a', 'b', 'c']) + expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 }) + expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 }) + }) + + test('extends a run across four openings (three gaps)', () => { + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)] + const run = detectEqualSpacing(openings, 'c', 0.03, 0.02) + expect(run?.segments).toHaveLength(3) + expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd']) + }) + + test('returns null when gaps differ beyond tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1 + const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('returns null when the moving opening is not part of the equal run', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1 + const d = span('d', 10, 1) // far right, breaks the run + expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull() + }) + + test('a near-zero (touching) gap breaks a run', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap + const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('honours the equal-spacing tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1.0 + const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2) + expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull() + }) +}) + +describe('computeEdgeGaps', () => { + test('measures clearance to the nearest neighbour on each side', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const left = span('l', 2, 1) // [1.5,2.5] + const right = span('r', 8, 1) // [7.5,8.5] + const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.distance).toBeCloseTo(2) + expect(byside.left?.target).toBe('opening') + expect(byside.left?.targetId).toBe('l') + expect(byside.right?.distance).toBeCloseTo(2) + expect(byside.right?.targetId).toBe('r') + }) + + test('falls back to wall ends with no neighbour', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const gaps = computeEdgeGaps(moving, [], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.target).toBe('wall-start') + expect(byside.left?.distance).toBeCloseTo(4.5) + expect(byside.right?.target).toBe('wall-end') + expect(byside.right?.distance).toBeCloseTo(4.5) + }) + + test('omits a side that is flush / overlapping (below minGap)', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left + const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02) + expect(gaps.find((g) => g.side === 'left')).toBeUndefined() + expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end') + }) +}) + +describe('detectAlongWallAlignment', () => { + test('detects edge-to-edge alignment within tolerance', () => { + const moving = span('m', 5, 2) // [4,6] + const sib = span('s', 7.05, 2) // left edge 6.05 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('right') + expect(a?.targetFeature).toBe('left') + expect(a?.snap).toBeCloseTo(0.05) + expect(a?.s).toBeCloseTo(6.05) + }) + + test('detects centre alignment', () => { + const moving = span('m', 5, 2) + const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.targetFeature).toBe('center') + expect(a?.snap).toBeCloseTo(0.03) + }) + + test('returns null when nothing is within tolerance', () => { + const moving = span('m', 5, 2) + const sib = span('s', 9, 2) + expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('detectVerticalAlignment', () => { + test('detects a shared sill within tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1.0 + const sib = span('s', 8, 1, 2.04, 2) // sill 1.04 + const a = detectVerticalAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('sill') + expect(a?.targetFeature).toBe('sill') + expect(a?.snap).toBeCloseTo(0.04) + expect(a?.y).toBeCloseTo(1.04) + }) + + test('returns null when sills/tops differ beyond tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5 + const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4 + expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('computeOpeningGuides', () => { + test('includes sill/head for windows', () => { + const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2 + const guides = computeOpeningGuides({ + moving, + siblings: [], + wall: WALL, + includeVertical: true, + }) + expect(guides.sillHead?.sill).toBeCloseTo(1) + expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2 + expect(guides.sillHead?.bottomY).toBeCloseTo(1) + expect(guides.sillHead?.topY).toBeCloseTo(2) + }) + + test('omits vertical guides for doors (sit on the floor)', () => { + const moving = span('m', 5, 1, 1, 2) + const sib = span('s', 8, 1, 1, 2) + const guides = computeOpeningGuides({ + moving, + siblings: [sib], + wall: WALL, + includeVertical: false, + }) + expect(guides.sillHead).toBeNull() + expect(guides.vertical).toBeNull() + // along-wall + proximity still computed for doors + expect(guides.gaps.length).toBeGreaterThan(0) + }) + + test('combines proximity and equal-spacing in one pass', () => { + const moving = span('b', 2.5, 1) + const guides = computeOpeningGuides({ + moving, + siblings: [span('a', 0.5, 1), span('c', 4.5, 1)], + wall: WALL, + includeVertical: true, + }) + expect(guides.gaps).toHaveLength(2) + expect(guides.equalSpacing?.gap).toBeCloseTo(1) + expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c']) + }) +}) + +describe('opening-guides — review regressions', () => { + test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => { + // gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the + // moving opening; a first-gap-anchored greedy scan used to drop it. + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)] + const run = detectEqualSpacing(openings, 'd', 0.03, 0.02) + expect(run?.openingIds).toEqual(['b', 'c', 'd']) + expect(run?.gap).toBeCloseTo(1.03) + expect(run?.segments).toHaveLength(2) + }) + + test('detectEqualSpacing prefers the leftmost run on a length tie', () => { + // gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs. + const openings = [ + span('a', 0.5, 1), + span('b', 2.5, 1), + span('c', 4.5, 1), + span('d', 7.5, 1), + span('e', 10.5, 1), + ] + expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c']) + }) + + test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const containing = span('s', 5, 2) // [4,6] straddles both edges + expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([]) + }) + + test('alignment detectors ignore the moving opening if present in siblings', () => { + const moving = span('m', 5, 2, 1.5, 1) + expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull() + expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull() + }) + + test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => { + const moving = span('m', 5, 2) // centre 5 + const sib = span('s', 4.96, 0.5) // centre 4.96 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.snap).toBeCloseTo(-0.04) + }) +}) diff --git a/packages/core/src/services/opening-guides.ts b/packages/core/src/services/opening-guides.ts new file mode 100644 index 000000000..5f0da29ed --- /dev/null +++ b/packages/core/src/services/opening-guides.ts @@ -0,0 +1,404 @@ +// Proximity / alignment guides for wall-hosted openings (doors, windows). +// +// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene +// store, no React — so it runs identically for the 3D viewport and the 2D +// floor plan and is unit-testable in isolation. Callers extract the spans from +// the scene graph (an opening's `position[0]` is its along-wall centre, its +// `position[1]` its vertical centre with the wall base at y=0) and feed them in; +// the renderers transform the returned wall-local coordinates back to world +// (3D) or plan (2D). +// +// What it produces, mirroring the affordances architects expect (and Figma's +// smart guides): +// - sill/head : a window's bottom edge → floor and top edge → wall top. +// - edge gaps : along-wall clearance to the nearest neighbour opening (or +// the wall end) on each side. +// - alongWall : the moving opening's edge/centre lining up with a +// neighbour's edge/centre along the wall. +// - vertical : two openings sharing a sill / head / vertical centre. +// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them. +// +// Detection is passive — it reports what currently coincides within tolerance +// and the snap delta that would make it exact, leaving the snap decision to the +// caller's manipulation policy (grid vs. alignment vs. Shift bypass). + +/** An opening's footprint in its host wall's local frame. */ +export type OpeningSpan = { + id: string + /** Centre along the wall, measured from `wall.start` (m). */ + centerS: number + /** Along-wall extent (m). */ + width: number + /** Vertical centre above the wall base (floor at y=0) (m). */ + centerY: number + /** Vertical extent (m). */ + height: number +} + +export type WallExtent = { + /** Wall length (m). */ + length: number + /** Wall height (m). */ + height: number +} + +export type OpeningGuideTolerances = { + /** Max distance for an edge/centre to count as aligned with a neighbour (m). */ + align: number + /** Max difference between two gaps for them to count as equal (m). */ + equalSpacing: number + /** Gaps below this are treated as touching/overlap noise and ignored (m). */ + minGap: number +} + +export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = { + // Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`). + align: 0.08, + equalSpacing: 0.03, + minGap: 0.02, +} + +/** Which along-wall feature of an opening a guide references. */ +export type AlongWallFeature = 'left' | 'center' | 'right' +/** Which vertical feature of an opening a guide references. */ +export type VerticalFeature = 'sill' | 'center' | 'top' + +export type SillHeadGuide = { + /** Floor (y=0) → the opening's bottom edge (m). */ + sill: number + /** Wall-local y of the bottom edge. */ + bottomY: number + /** The opening's top edge → the wall top (m). */ + head: number + /** Wall-local y of the top edge. */ + topY: number +} + +export type EdgeGap = { + side: 'left' | 'right' + /** Clearance along the wall (m). */ + distance: number + /** Wall-local s of the moving opening's edge. */ + fromS: number + /** Wall-local s of the neighbour edge / wall end. */ + toS: number + target: 'opening' | 'wall-start' | 'wall-end' + /** Set when `target === 'opening'`. */ + targetId?: string +} + +export type AlongWallAlignment = { + /** Wall-local s the two features share. */ + s: number + movingFeature: AlongWallFeature + targetId: string + targetFeature: AlongWallFeature + /** Delta to add to the moving opening's `centerS` to make them coincide. */ + snap: number +} + +export type VerticalAlignment = { + /** Wall-local y the two features share. */ + y: number + movingFeature: VerticalFeature + targetId: string + targetFeature: VerticalFeature + /** Delta to add to the moving opening's `centerY` to make them coincide. */ + snap: number +} + +export type EqualSpacingRun = { + /** The repeated gap value (average of the run's gaps) (m). */ + gap: number + /** The equal-gap segments along the wall, in order (left → right). */ + segments: { fromS: number; toS: number }[] + /** Participating opening ids, ordered along the wall, including the moving one. */ + openingIds: string[] +} + +export type OpeningGuides = { + sillHead: SillHeadGuide | null + gaps: EdgeGap[] + alongWall: AlongWallAlignment | null + vertical: VerticalAlignment | null + equalSpacing: EqualSpacingRun | null +} + +export type OpeningGuideInput = { + moving: OpeningSpan + /** Other openings on the SAME wall (the moving opening excluded). */ + siblings: readonly OpeningSpan[] + wall: WallExtent + /** + * Whether to compute vertical (sill/head/vertical-alignment) guides. True for + * windows; false for doors, which sit on the floor so their sill is always 0. + */ + includeVertical: boolean + tolerances?: Partial +} + +const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2 +const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2 +const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2 +const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2 + +function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number { + if (feature === 'left') return leftEdge(s) + if (feature === 'right') return rightEdge(s) + return s.centerS +} + +function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number { + if (feature === 'sill') return bottomEdge(s) + if (feature === 'top') return topEdge(s) + return s.centerY +} + +const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right'] +const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top'] + +/** + * Edge-to-edge clearance from the moving opening to the nearest neighbour on + * each side, falling back to the wall ends when there is no neighbour — the + * "how much wall is left here" reading. Returns 0–2 gaps (one per side); a side + * is omitted when its clearance is below `minGap` (the opening is flush against + * or overlapping that neighbour). + */ +export function computeEdgeGaps( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + wall: WallExtent, + minGap: number, +): EdgeGap[] { + const movingLeft = leftEdge(moving) + const movingRight = rightEdge(moving) + + // A sibling that straddles one of the moving opening's edges is an OVERLAP, + // not a neighbour: there is no clearance on that side, and we must not fall + // back to the wall end (which would report a misleading distance measured + // "through" the overlapping opening). + const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft) + const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight) + + let leftNeighbour: { s: number; id: string } | null = null + let rightNeighbour: { s: number; id: string } | null = null + for (const sib of siblings) { + const sibRight = rightEdge(sib) + const sibLeft = leftEdge(sib) + // Entirely to the left of the moving opening → candidate left neighbour. + if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) { + leftNeighbour = { s: sibRight, id: sib.id } + } + // Entirely to the right → candidate right neighbour. + if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) { + rightNeighbour = { s: sibLeft, id: sib.id } + } + } + + const gaps: EdgeGap[] = [] + + if (!leftCrossed) { + const leftToS = leftNeighbour ? leftNeighbour.s : 0 + const leftDistance = movingLeft - leftToS + if (leftDistance >= minGap) { + gaps.push({ + side: 'left', + distance: leftDistance, + fromS: movingLeft, + toS: leftToS, + target: leftNeighbour ? 'opening' : 'wall-start', + targetId: leftNeighbour?.id, + }) + } + } + + if (!rightCrossed) { + const rightToS = rightNeighbour ? rightNeighbour.s : wall.length + const rightDistance = rightToS - movingRight + if (rightDistance >= minGap) { + gaps.push({ + side: 'right', + distance: rightDistance, + fromS: movingRight, + toS: rightToS, + target: rightNeighbour ? 'opening' : 'wall-end', + targetId: rightNeighbour?.id, + }) + } + } + + return gaps +} + +/** + * The closest coincidence between any of the moving opening's edges/centre and + * any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge + * and centre-to-centre are weighed equally; the single closest pair wins + * (matching the one-guide-per-axis behaviour of the floor-plane resolver). + */ +export function detectAlongWallAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): AlongWallAlignment | null { + let best: AlongWallAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of ALONG_WALL_FEATURES) { + const movingCoord = alongWallFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of ALONG_WALL_FEATURES) { + const targetCoord = alongWallFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + s: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * The closest coincidence between the moving opening's sill/centre/top and any + * sibling's sill/centre/top, within `tolerance` — the "these two windows share + * a sill height" detector. Same single-best-match policy as the along-wall + * variant. + */ +export function detectVerticalAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): VerticalAlignment | null { + let best: VerticalAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of VERTICAL_FEATURES) { + const movingCoord = verticalFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of VERTICAL_FEATURES) { + const targetCoord = verticalFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + y: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * Figma-style equal-spacing detection: order all openings along the wall, look + * at the clearances BETWEEN consecutive openings, and return the longest run of + * ≥2 consecutive gaps that are equal within `tolerance` and that the moving + * opening participates in (so the badges only appear while the drag is actually + * forming or extending a series). Returns null when no such run exists. + * + * Gaps below `minGap` (touching/overlapping openings) break a run — a row of + * flush openings is not "equally spaced". + */ +export function detectEqualSpacing( + allOpenings: readonly OpeningSpan[], + movingId: string, + tolerance: number, + minGap: number, +): EqualSpacingRun | null { + if (allOpenings.length < 3) return null + const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS) + const movingIndex = sorted.findIndex((s) => s.id === movingId) + if (movingIndex < 0) return null + + // Clearance between opening i and i+1. + const gaps: { value: number; fromS: number; toS: number }[] = [] + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i] + const b = sorted[i + 1] + if (!a || !b) continue + const fromS = rightEdge(a) + const toS = leftEdge(b) + gaps.push({ value: toS - fromS, fromS, toS }) + } + + // Longest contiguous window of gaps that are (a) each ≥ minGap and (b) + // mutually equal within tolerance (window max − min ≤ tolerance), spanning at + // least 2 gaps and including the moving opening. Brute force over windows + // (openings per wall are few). A first-gap-anchored greedy scan is NOT + // equivalent: it drops a valid run that begins partway through a drifting + // sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end, + // where [1.02, 1.04] is a real run. On a length tie the leftmost window wins, + // for determinism. + let best: EqualSpacingRun | null = null + for (let lo = 0; lo < gaps.length; lo++) { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + for (let hi = lo; hi < gaps.length; hi++) { + const gap = gaps[hi] + if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run + min = Math.min(min, gap.value) + max = Math.max(max, gap.value) + if (max - min > tolerance) break // extending only widens the spread + const gapCount = hi - lo + 1 + if (gapCount < 2) continue + const firstOpening = lo // gap i sits between openings i and i+1 + const lastOpening = hi + 1 + if (movingIndex < firstOpening || movingIndex > lastOpening) continue + if (best !== null && gapCount <= best.segments.length) continue + const windowGaps = gaps.slice(lo, hi + 1) + best = { + gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length, + segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })), + openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id), + } + } + } + return best +} + +/** + * Compute every proximity/alignment guide for the moving opening in one pass. + * Pure: feed it the moving opening's wall-local span, its same-wall siblings, + * and the wall extent; render the result in whichever view. + */ +export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides { + const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances } + const { moving, siblings, wall, includeVertical } = input + + const sillHead: SillHeadGuide | null = includeVertical + ? { + sill: bottomEdge(moving), + bottomY: bottomEdge(moving), + head: wall.height - topEdge(moving), + topY: topEdge(moving), + } + : null + + return { + sillHead, + gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap), + alongWall: detectAlongWallAlignment(moving, siblings, tol.align), + vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null, + equalSpacing: detectEqualSpacing( + [moving, ...siblings], + moving.id, + tol.equalSpacing, + tol.minGap, + ), + } +} diff --git a/packages/core/src/store/actions/node-actions.test.ts b/packages/core/src/store/actions/node-actions.test.ts new file mode 100644 index 000000000..21d65b679 --- /dev/null +++ b/packages/core/src/store/actions/node-actions.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, AssemblyNode, BoxNode } from '../../schema' +import useScene from '../use-scene' + +describe('node delete actions', () => { + beforeEach(() => { + useScene.setState({ nodes: {}, rootNodeIds: [], collections: {} } as never) + useScene.temporal.getState().clear() + }) + + test('does not delete a child id referenced by a non-owning container', () => { + const owner = AssemblyNode.parse({ id: 'assembly_owner', children: ['box_shared'] }) + const corruptReference = AssemblyNode.parse({ + id: 'assembly_corrupt', + children: ['box_shared'], + }) + const sharedBox = BoxNode.parse({ id: 'box_shared', parentId: owner.id }) + + useScene.setState({ + nodes: { + [owner.id]: owner, + [corruptReference.id]: corruptReference, + [sharedBox.id]: sharedBox, + } as Record, + rootNodeIds: [owner.id, corruptReference.id] as AnyNodeId[], + collections: {}, + } as never) + + useScene.getState().deleteNode(corruptReference.id as AnyNodeId) + + expect(useScene.getState().nodes[corruptReference.id as AnyNodeId]).toBeUndefined() + expect(useScene.getState().nodes[owner.id as AnyNodeId]).toBeDefined() + expect(useScene.getState().nodes[sharedBox.id as AnyNodeId]).toBeDefined() + }) +}) diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 2c5768a36..9fa73a22e 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -370,8 +370,11 @@ export const applyNodeChangesAction = ( allIdsToDelete.add(id) const node = nextNodes[id] if (node && 'children' in node && Array.isArray(node.children)) { - for (const childId of node.children) { - collectDelete(childId as AnyNodeId) + for (const childId of node.children as AnyNodeId[]) { + const child = nextNodes[childId] + if (child?.parentId === id) { + collectDelete(childId) + } } } } @@ -416,7 +419,9 @@ export const applyNodeChangesAction = ( return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections } }) - nodesToMarkDirty.forEach((id) => get().markDirty(id)) + nodesToMarkDirty.forEach((id) => { + get().markDirty(id) + }) parentsToMarkDirty.forEach((id) => { get().markDirty(id) const parent = get().nodes[id] @@ -515,6 +520,7 @@ export const deleteNodesAction = ( if (get().readOnly) return const parentsToMarkDirty = new Set() const nodesToMarkDirty = new Set() + const deletedIds = new Set() const mergePlans = buildWallMergePlans(get().nodes, ids) set((state) => { @@ -530,13 +536,19 @@ export const deleteNodesAction = ( allIds.add(id) const node = nextNodes[id] if (node && 'children' in node) { - for (const cid of node.children as AnyNodeId[]) collect(cid) + for (const cid of node.children as AnyNodeId[]) { + const child = nextNodes[cid] + if (child?.parentId === id) collect(cid) + } } } for (const id of ids) collect(id) for (const plan of mergePlans) { allIds.add(plan.secondaryWallId) } + for (const id of allIds) { + deletedIds.add(id) + } for (const plan of mergePlans) { const primaryWall = nextNodes[plan.primaryWallId] @@ -598,6 +610,10 @@ export const deleteNodesAction = ( return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections } }) + for (const id of deletedIds) { + get().clearDirty(id) + } + // Mark affected nodes dirty: parents of deleted nodes and their remaining children // (e.g. deleting a slab affects sibling walls via level elevation changes) parentsToMarkDirty.forEach((parentId) => { diff --git a/packages/core/src/store/use-alignment-guides.ts b/packages/core/src/store/use-alignment-guides.ts new file mode 100644 index 000000000..dbb368dd6 --- /dev/null +++ b/packages/core/src/store/use-alignment-guides.ts @@ -0,0 +1,21 @@ +// Ephemeral store for Figma-style alignment guides published during a +// move / placement drag. The producer (a tool or move overlay) writes +// guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes +// and draws them. Both sides clear on commit, cancel, and unmount. + +import { create } from 'zustand' +import type { AlignmentGuide } from '../services/alignment' + +type AlignmentGuidesState = { + guides: AlignmentGuide[] + set(guides: AlignmentGuide[]): void + clear(): void +} + +const useAlignmentGuides = create((set) => ({ + guides: [], + set: (guides) => set({ guides }), + clear: () => set({ guides: [] }), +})) + +export default useAlignmentGuides diff --git a/packages/core/src/store/use-live-node-overrides.ts b/packages/core/src/store/use-live-node-overrides.ts index 04f952727..958e88877 100644 --- a/packages/core/src/store/use-live-node-overrides.ts +++ b/packages/core/src/store/use-live-node-overrides.ts @@ -7,6 +7,7 @@ type LiveNodeOverrideState = { set(nodeId: string, values: LiveNodeOverrides): void get(nodeId: string): LiveNodeOverrides | undefined clear(nodeId: string): void + clearFields(nodeId: string, keys: readonly string[]): void clearAll(): void } @@ -25,7 +26,31 @@ const useLiveNodeOverrides = create((set, get) => ({ next.delete(nodeId) return { overrides: next } }), + clearFields: (nodeId, keys) => + set((state) => { + const current = state.overrides.get(nodeId) + if (!current) return state + + const nextValues = { ...current } + for (const key of keys) { + delete nextValues[key] + } + + const next = new Map(state.overrides) + if (Object.keys(nextValues).length === 0) { + next.delete(nodeId) + } else { + next.set(nodeId, nextValues) + } + return { overrides: next } + }), clearAll: () => set({ overrides: new Map() }), })) +export function getEffectiveNode(node: T): T { + const override = useLiveNodeOverrides.getState().overrides.get(node.id) + if (!override || Object.keys(override).length === 0) return node + return { ...node, ...override } as T +} + export default useLiveNodeOverrides diff --git a/packages/core/src/store/use-live-transforms.ts b/packages/core/src/store/use-live-transforms.ts index b2aef7dd0..c1c2d07fd 100644 --- a/packages/core/src/store/use-live-transforms.ts +++ b/packages/core/src/store/use-live-transforms.ts @@ -17,10 +17,21 @@ type LiveTransformState = { clearAll(): void } +function sameLiveTransform(a: LiveTransform | undefined, b: LiveTransform): boolean { + return ( + !!a && + a.rotation === b.rotation && + a.position[0] === b.position[0] && + a.position[1] === b.position[1] && + a.position[2] === b.position[2] + ) +} + const useLiveTransforms = create((set, get) => ({ transforms: new Map(), set: (nodeId, transform) => set((state) => { + if (sameLiveTransform(state.transforms.get(nodeId), transform)) return state const next = new Map(state.transforms) next.set(nodeId, transform) return { transforms: next } @@ -28,6 +39,7 @@ const useLiveTransforms = create((set, get) => ({ get: (nodeId) => get().transforms.get(nodeId), clear: (nodeId) => set((state) => { + if (!state.transforms.has(nodeId)) return state const next = new Map(state.transforms) next.delete(nodeId) return { transforms: next } diff --git a/packages/core/src/store/use-scene-default.test.ts b/packages/core/src/store/use-scene-default.test.ts new file mode 100644 index 000000000..110940a77 --- /dev/null +++ b/packages/core/src/store/use-scene-default.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import useScene from './use-scene' + +describe('default scene skeleton', () => { + beforeEach(() => { + useScene.getState().unloadScene() + useScene.temporal.getState().clear() + }) + + test('creates bidirectional site building level parent links', () => { + useScene.getState().loadScene() + + const { rootNodeIds } = useScene.getState() + const nodes = useScene.getState().nodes as Record + const site = nodes[rootNodeIds[0]!] as { children: string[]; id: string; type: string } + const building = nodes[site.children[0]!] as { children: string[]; id: string; parentId: string; type: string } + const level = nodes[building.children[0]!] as { id: string; parentId: string; type: string } + + expect(site.type).toBe('site') + expect(building.type).toBe('building') + expect(building.parentId).toBe(site.id) + expect(level.type).toBe('level') + expect(level.parentId).toBe(building.id) + }) +}) diff --git a/packages/core/src/store/use-scene-dirty-tracking.test.ts b/packages/core/src/store/use-scene-dirty-tracking.test.ts new file mode 100644 index 000000000..d0e83cec7 --- /dev/null +++ b/packages/core/src/store/use-scene-dirty-tracking.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry } from '../registry/registry' +import type { AnyNodeDefinition } from '../registry/types' +import type { AnyNode, AnyNodeId } from '../schema/types' +import useScene from './use-scene' + +const untrackedDef = { + kind: 'test-untracked', + schemaVersion: 1, + schema: {} as never, + category: 'furnishing', + defaults: () => ({}), + capabilities: {}, + dirtyTracking: false, +} as unknown as AnyNodeDefinition + +const trackedDef = { + ...untrackedDef, + kind: 'test-tracked', + dirtyTracking: undefined, +} as unknown as AnyNodeDefinition + +const UNTRACKED = 'item_untracked' as AnyNodeId +const TRACKED = 'item_tracked' as AnyNodeId +const UNREGISTERED = 'item_unregistered' as AnyNodeId + +const makeNode = (id: AnyNodeId, type: string): AnyNode => + ({ + object: 'node', + id, + type, + parentId: null, + visible: true, + metadata: {}, + children: [], + }) as unknown as AnyNode + +describe('dirty tracking', () => { + beforeEach(() => { + if (!nodeRegistry.has(untrackedDef.kind)) nodeRegistry._register(untrackedDef) + if (!nodeRegistry.has(trackedDef.kind)) nodeRegistry._register(trackedDef) + useScene.setState({ + nodes: { + [UNTRACKED]: makeNode(UNTRACKED, 'test-untracked'), + [TRACKED]: makeNode(TRACKED, 'test-tracked'), + [UNREGISTERED]: makeNode(UNREGISTERED, 'unregistered-kind'), + }, + rootNodeIds: [UNTRACKED, TRACKED, UNREGISTERED], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('markDirty skips kinds whose definition opts out', () => { + useScene.getState().markDirty(UNTRACKED) + expect(useScene.getState().dirtyNodes.has(UNTRACKED)).toBe(false) + }) + + test('markDirty tracks kinds without the opt-out, registered or not', () => { + useScene.getState().markDirty(TRACKED) + useScene.getState().markDirty(UNREGISTERED) + expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(true) + expect(useScene.getState().dirtyNodes.has(UNREGISTERED)).toBe(true) + }) + + test('deleteNodes removes deleted ids from the dirty set', () => { + useScene.getState().markDirty(TRACKED) + expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(true) + useScene.getState().deleteNodes([TRACKED]) + expect(useScene.getState().nodes[TRACKED]).toBeUndefined() + expect(useScene.getState().dirtyNodes.has(TRACKED)).toBe(false) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 9301dad82..459ab0c82 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -3,14 +3,17 @@ import type { TemporalState } from 'zundo' import { temporal } from 'zundo' import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { nodeRegistry } from '../registry/registry' import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections' +import { DoorNode as DoorNodeSchema } from '../schema/nodes/door' import { LevelNode } from '../schema/nodes/level' import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import type { AnyNode, AnyNodeId } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' import { resetSceneHistoryPauseDepth } from './history-control' @@ -31,7 +34,7 @@ function getEnumValue( } function getNullableString(value: unknown) { - return typeof value === 'string' ? value : null + return typeof value === 'string' && value.length > 0 ? value : null } function getStringArray(value: unknown) { @@ -72,6 +75,7 @@ function normalizeStairNode(node: Record) { topLandingMode: getEnumValue(node.topLandingMode, ['none', 'integrated'] as const, 'none'), topLandingDepth: getFiniteNumber(node.topLandingDepth, 0.9), showCenterColumn: getBoolean(node.showCenterColumn, true), + centerColumnShape: getEnumValue(node.centerColumnShape, ['round', 'square'] as const, 'round'), showStepSupports: getBoolean(node.showStepSupports, true), railingMode: getEnumValue(node.railingMode, ['none', 'left', 'right', 'both'] as const, 'none'), railingHeight: getFiniteNumber(node.railingHeight, 0.92), @@ -101,6 +105,11 @@ function normalizeStairSegmentNode(node: Record) { return parsed.success ? parsed.data : null } +function normalizeDoorNode(node: Record) { + const parsed = DoorNodeSchema.safeParse(node) + return parsed.success ? { ...node, ...parsed.data } : null +} + function migrateWallSurfaceMaterials(node: Record) { const hasInterior = node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' @@ -285,7 +294,8 @@ function migrateRoofSurfaceMaterials(node: Record) { } function migrateNodes(nodes: Record): Record { - const patchedNodes = { ...nodes } + const { nodes: healed } = healSceneNodes(nodes) + const patchedNodes = { ...healed } as Record for (const [id, node] of Object.entries(patchedNodes)) { // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { @@ -338,6 +348,13 @@ function migrateNodes(nodes: Record): Record { } } + if (node.type === 'door') { + const normalized = normalizeDoorNode(node) + if (normalized) { + patchedNodes[id] = normalized + } + } + if (node.type === 'wall') { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) } @@ -605,8 +622,8 @@ const useScene: UseSceneStore = create()( // Define all nodes flat const nodes: Record = { [site.id]: site, - [building.id]: building, - [level0.id]: level0, + [building.id]: { ...building, parentId: site.id }, + [level0.id]: { ...level0, parentId: building.id }, } // Site is the root @@ -616,6 +633,9 @@ const useScene: UseSceneStore = create()( }, markDirty: (id) => { + const node = get().nodes[id] + const def = node ? nodeRegistry.get(node.type) : undefined + if (def?.dirtyTracking === false) return get().dirtyNodes.add(id) }, @@ -735,7 +755,7 @@ const useScene: UseSceneStore = create()( const { nodes, rootNodeIds, collections } = state return { nodes, rootNodeIds, collections } }, - limit: 50, // Limit to last 50 actions + limit: 50, }, ), ) diff --git a/packages/core/src/systems/pipe/pipe-centerline.ts b/packages/core/src/systems/pipe/pipe-centerline.ts new file mode 100644 index 000000000..a0dc2ab4b --- /dev/null +++ b/packages/core/src/systems/pipe/pipe-centerline.ts @@ -0,0 +1,81 @@ +import { getWallCurveLength, sampleWallCenterline } from '../wall/wall-curve' + +export type PipeCenterlineLike = { + start: [number, number] + end: [number, number] + curveOffset?: number + elevation: number + rotate?: number +} + +export type PipeCenterlinePoint3D = { + x: number + y: number + z: number +} + +export function clampPipeRotateDegrees(rotate: number | undefined) { + return Math.max(0, Math.min(90, rotate ?? 0)) +} + +export function getPipeRotateRadians(pipe: Pick) { + return (clampPipeRotateDegrees(pipe.rotate) * Math.PI) / 180 +} + +export function samplePipeCenterline3D( + node: PipeCenterlineLike, + segments = 32, +): PipeCenterlinePoint3D[] { + const planSamples = sampleWallCenterline(node, segments) + const totalLength = getWallCurveLength(node, segments) + if (planSamples.length < 2 || totalLength < 1e-6) { + return [{ x: node.start[0], y: node.elevation, z: node.start[1] }] + } + + const rotateRad = getPipeRotateRadians(node) + const cosR = Math.cos(rotateRad) + const sinR = Math.sin(rotateRad) + const sx = node.start[0] + const sz = node.start[1] + + const points: PipeCenterlinePoint3D[] = [] + let accumulated = 0 + + for (let index = 0; index < planSamples.length; index += 1) { + const plan = planSamples[index]! + if (index > 0) { + const previous = planSamples[index - 1]! + accumulated += Math.hypot(plan.x - previous.x, plan.y - previous.y) + } + const t = accumulated / totalLength + const dx = plan.x - sx + const dz = plan.y - sz + points.push({ + x: sx + dx * cosR, + y: node.elevation + t * totalLength * sinR, + z: sz + dz * cosR, + }) + } + + return points +} + +export function getPipeEndpoint3D( + node: PipeCenterlineLike, + endpoint: 'start' | 'end', +): PipeCenterlinePoint3D { + const samples = samplePipeCenterline3D(node, 24) + if (samples.length === 0) { + return { x: node.start[0], y: node.elevation, z: node.start[1] } + } + return endpoint === 'start' ? samples[0]! : samples[samples.length - 1]! +} + +export function getPipeMidpoint3D(node: PipeCenterlineLike): PipeCenterlinePoint3D { + const samples = samplePipeCenterline3D(node, 24) + return samples[Math.floor((samples.length - 1) / 2)] ?? samples[0]! +} + +export function isPipeNearlyVertical(node: Pick) { + return clampPipeRotateDegrees(node.rotate) >= 89.5 +} diff --git a/packages/core/src/systems/stair/stair-footprint.ts b/packages/core/src/systems/stair/stair-footprint.ts new file mode 100644 index 000000000..9a6e88ad4 --- /dev/null +++ b/packages/core/src/systems/stair/stair-footprint.ts @@ -0,0 +1,209 @@ +import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' + +/** + * Stair footprint geometry shared by the slab-opening sync and the + * alignment-anchor adapters. A stair has no single box footprint: straight + * stairs are a cumulative chain of `stair-segment` children, curved / spiral + * stairs are an annular sector stored entirely on the parent. Both reduce to + * an XZ bounding box here so callers that only need "where does the stair sit + * in plan" (alignment guides) don't have to re-walk the geometry. + * + * All math is in the building-local XZ frame, matching `node.position`. + */ + +export type StairFootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number } + +type SegmentTransform = { + position: [number, number, number] + rotation: number +} + +/** + * XZ rotation in the stair geometry convention (equivalent to rotating by + * `-angle` in standard math): positive `angle` turns local +Z toward +X. Every + * stair helper — slab openings, floor-plan emitter, this footprint — shares it, + * so anchors line up with the rendered stair. + */ +export function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +/** + * Cumulative per-segment transforms for a straight (segment-chained) stair. + * Each flight attaches to the previous segment's end; `attachmentSide` rotates + * the chain ±90° (left / right) or continues straight (front). Positions are in + * the stair's local frame (before the stair's own `position` / `rotation`). + */ +export function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] { + const transforms: SegmentTransform[] = [] + let currentX = 0 + let currentY = 0 + let currentZ = 0 + let currentRot = 0 + + for (let index = 0; index < segments.length; index++) { + const segment = segments[index] + if (!segment) continue + + if (index === 0) { + transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot }) + continue + } + + const previous = segments[index - 1] + if (!previous) continue + + let attachX = 0 + let attachZ = 0 + let rotationDelta = 0 + + switch (segment.attachmentSide) { + case 'front': + attachX = 0 + attachZ = previous.length + break + case 'left': + attachX = previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = Math.PI / 2 + break + case 'right': + attachX = -previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = -Math.PI / 2 + break + } + + const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot) + currentX += deltaX + currentY += previous.height + currentZ += deltaZ + currentRot += rotationDelta + + transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot }) + } + + return transforms +} + +function emptyBox(): StairFootprintAABB { + return { + minX: Number.POSITIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY, + } +} + +/** Grow `box` to include the world-plan point produced by rotating the + * stair-local point by the stair's rotation and offsetting by its position. */ +function extendByLocal(box: StairFootprintAABB, stair: StairNode, localX: number, localZ: number) { + const [wx, wz] = rotateXZ(localX, localZ, stair.rotation ?? 0) + const x = stair.position[0] + wx + const z = stair.position[2] + wz + if (x < box.minX) box.minX = x + if (x > box.maxX) box.maxX = x + if (z < box.minZ) box.minZ = z + if (z > box.maxZ) box.maxZ = z +} + +function finiteBox(box: StairFootprintAABB): StairFootprintAABB | null { + return Number.isFinite(box.minX) && Number.isFinite(box.minZ) ? box : null +} + +/** Bounding box of a straight stair's segment chain, walking the children. */ +function straightStairAABB( + stair: StairNode, + nodes: Readonly>, +): StairFootprintAABB | null { + const segments = (stair.children ?? []) + .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) + .filter( + (segment): segment is StairSegmentNode => + segment?.type === 'stair-segment' && segment.visible !== false, + ) + if (segments.length === 0) return null + + const transforms = computeSegmentTransforms(segments) + const box = emptyBox() + segments.forEach((segment, index) => { + const transform = transforms[index] + if (!transform) return + const halfWidth = segment.width / 2 + // Segment-local footprint: X across the flight, Z along the run from the + // attachment edge (0) to the far edge (length). + for (const [cornerX, cornerZ] of [ + [-halfWidth, 0], + [halfWidth, 0], + [halfWidth, segment.length], + [-halfWidth, segment.length], + ] as const) { + const [offsetX, offsetZ] = rotateXZ(cornerX, cornerZ, transform.rotation) + extendByLocal(box, stair, transform.position[0] + offsetX, transform.position[2] + offsetZ) + } + }) + return finiteBox(box) +} + +const ARC_SAMPLES = 48 + +/** Bounding box of a curved / spiral stair's annular sector (plus the + * integrated spiral top landing when present). */ +function arcStairAABB(stair: StairNode): StairFootprintAABB | null { + const isSpiral = stair.stairType === 'spiral' + const minInnerRadius = isSpiral ? 0.05 : 0.2 + const innerRadius = Math.max(minInnerRadius, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9)) + const width = Math.max(stair.width ?? 1, 0.4) + const outerRadius = innerRadius + width + + let sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2) + // A full revolution would make the arc degenerate; clamp just under 2π the + // same way the floor-plan emitter does so the sampled box stays correct. + if (Math.abs(sweep) >= Math.PI * 2) sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001) + const half = sweep / 2 + + const box = emptyBox() + // Sample both rims across the sweep — the extremes can fall on either the + // arc ends or an axis crossing in between, so we need the full sweep. + for (let step = 0; step <= ARC_SAMPLES; step += 1) { + const angle = -half + (sweep * step) / ARC_SAMPLES + const cos = Math.cos(angle) + const sin = Math.sin(angle) + extendByLocal(box, stair, cos * innerRadius, sin * innerRadius) + extendByLocal(box, stair, cos * outerRadius, sin * outerRadius) + } + + // Integrated spiral top landing — a rectangle hung off the outer rim. + if (isSpiral && stair.topLandingMode === 'integrated') { + const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1) + const halfWidth = width / 2 + for (const [cornerX, cornerZ] of [ + [outerRadius, -halfWidth], + [outerRadius + depth, -halfWidth], + [outerRadius + depth, halfWidth], + [outerRadius, halfWidth], + ] as const) { + extendByLocal(box, stair, cornerX, cornerZ) + } + } + + return finiteBox(box) +} + +/** + * XZ bounding box of a stair's plan footprint, or null when it can't be + * determined (a straight stair whose segment children aren't in `nodes`). + * Straight stairs need the children to walk the flight chain; curved / spiral + * stairs are derived from the parent alone, so `nodes` is optional for them. + */ +export function stairFootprintAABB( + stair: StairNode, + nodes?: Readonly>, +): StairFootprintAABB | null { + if ((stair.stairType ?? 'straight') === 'straight') { + return nodes ? straightStairAABB(stair, nodes) : null + } + return arcStairAABB(stair) +} diff --git a/packages/core/src/systems/stair/stair-opening-preview.test.ts b/packages/core/src/systems/stair/stair-opening-preview.test.ts new file mode 100644 index 000000000..94aa55dd0 --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-preview.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { + getNodesWithLiveStairOpeningInputs, + hasLiveStairOpeningInputs, +} from './stair-opening-preview' +import { syncAutoStairOpenings } from './stair-opening-sync' + +describe('stair opening previews', () => { + test('computes auto openings from live stair transforms', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const slab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_live', + width: 1, + length: 3, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_live', + name: 'Live Stair', + parentId: ground.id, + position: [1, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, slab, stair, { ...segment, parentId: stair.id }].map((node) => [ + node.id, + node, + ]), + ) as Record + const liveTransforms = new Map([ + [stair.id, { position: [3, 0, 0.2] as [number, number, number], rotation: 0 }], + ]) + const liveOverrides = new Map>() + + expect(hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, new Set())).toBe(true) + + const previewNodes = getNodesWithLiveStairOpeningInputs( + nodes, + liveTransforms, + liveOverrides, + new Set(), + ) + const updates = syncAutoStairOpenings(previewNodes) + const hole = updates.find((update) => update.id === slab.id)?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.max(...hole!.map(([x]) => x))).toBeGreaterThan(3.4) + }) + + test('ignores its own live surface overrides as preview inputs', () => { + const slab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + const nodes = { [slab.id]: slab } as Record + const liveOverrides = new Map>([ + [ + slab.id, + { + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + }, + ], + ]) + + expect(hasLiveStairOpeningInputs(nodes, new Map(), liveOverrides, new Set([slab.id]))).toBe( + false, + ) + }) +}) diff --git a/packages/core/src/systems/stair/stair-opening-preview.ts b/packages/core/src/systems/stair/stair-opening-preview.ts new file mode 100644 index 000000000..24b464189 --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-preview.ts @@ -0,0 +1,120 @@ +import type { AnyNode, AnyNodeId, CeilingNode, SlabNode } from '../../schema' +import useLiveNodeOverrides, { type LiveNodeOverrides } from '../../store/use-live-node-overrides' +import type { LiveTransform } from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' + +type SurfaceOpeningUpdate = { + id: AnyNodeId + data: Partial +} + +const SURFACE_OPENING_FIELDS = ['holes', 'holeMetadata'] as const + +function isSurface(node: AnyNode | undefined): node is SlabNode | CeilingNode { + return node?.type === 'slab' || node?.type === 'ceiling' +} + +function isStairOpeningInputNode(node: AnyNode | undefined) { + return node?.type === 'stair' || node?.type === 'stair-segment' +} + +function omitPreviewSurfaceFields(override: LiveNodeOverrides) { + const next = { ...override } + for (const field of SURFACE_OPENING_FIELDS) { + delete next[field] + } + return next +} + +export function hasLiveStairOpeningInputs( + nodes: Record, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + for (const nodeId of liveTransforms.keys()) { + if (nodes[nodeId]?.type === 'stair') return true + } + + for (const [nodeId, override] of liveOverrides) { + if (previewSurfaceIds.has(nodeId)) continue + if (Object.keys(override).length > 0 && isStairOpeningInputNode(nodes[nodeId])) return true + } + + return false +} + +export function getNodesWithLiveStairOpeningInputs( + nodes: Record, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + const nextNodes: Record = { ...nodes } + + for (const [nodeId, override] of liveOverrides) { + const node = nextNodes[nodeId] + if (!node) continue + + const values = previewSurfaceIds.has(nodeId) ? omitPreviewSurfaceFields(override) : override + if (Object.keys(values).length === 0) continue + nextNodes[nodeId] = { ...node, ...values } as AnyNode + } + + for (const [nodeId, transform] of liveTransforms) { + const node = nextNodes[nodeId] + if (node?.type !== 'stair') continue + nextNodes[nodeId] = { + ...node, + position: transform.position, + rotation: transform.rotation, + } + } + + return nextNodes +} + +export function createSurfaceOpeningPreviewController() { + const previewSurfaceIds = new Set() + + const clearSurface = (id: AnyNodeId) => { + useLiveNodeOverrides.getState().clearFields(id, SURFACE_OPENING_FIELDS) + useScene.getState().markDirty(id) + } + + return { + previewSurfaceIds, + apply(updates: SurfaceOpeningUpdate[]) { + const scene = useScene.getState() + const nextSurfaceIds = new Set() + + for (const update of updates) { + const node = scene.nodes[update.id] + if (!isSurface(node)) continue + if (!('holes' in update.data || 'holeMetadata' in update.data)) continue + + nextSurfaceIds.add(update.id) + useLiveNodeOverrides.getState().set(update.id, { + holes: update.data.holes ?? [], + holeMetadata: update.data.holeMetadata ?? [], + }) + scene.markDirty(update.id) + } + + for (const id of previewSurfaceIds) { + if (!nextSurfaceIds.has(id)) clearSurface(id) + } + + previewSurfaceIds.clear() + for (const id of nextSurfaceIds) { + previewSurfaceIds.add(id) + } + }, + clear() { + for (const id of previewSurfaceIds) { + clearSurface(id) + } + previewSurfaceIds.clear() + }, + } +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index f3a194f63..2ba5169cc 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -11,7 +11,7 @@ import { import { syncAutoStairOpenings } from './stair-opening-sync' describe('syncAutoStairOpenings', () => { - test('only applies stair holes to destination slabs that contain the opening', () => { + test('only applies stair holes to destination slabs that overlap the opening', () => { const building = BuildingNode.parse({ name: 'Building' }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) @@ -74,15 +74,264 @@ describe('syncAutoStairOpenings', () => { expect(bedroomUpdate).toBeUndefined() }) + test('applies stair holes to a later destination slab when the configured offset overhangs the slab edge', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_edge', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_edge', + name: 'Edge Stair', + parentId: ground.id, + position: [2, 0, 0], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + openingOffset: 0.08, + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const hole = landingUpdate?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.min(...hole!.map(([, z]) => z))).toBeCloseTo(-0.08) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('does not apply stair holes to slabs on another building with a matching level number', () => { + const buildingA = BuildingNode.parse({ name: 'Building A' }) + const groundA = LevelNode.parse({ name: 'Ground A', level: 0, parentId: buildingA.id }) + const upperA = LevelNode.parse({ name: 'Upper A', level: 1, parentId: buildingA.id }) + const buildingB = BuildingNode.parse({ name: 'Building B' }) + const upperB = LevelNode.parse({ name: 'Upper B', level: 1, parentId: buildingB.id }) + const slabA = SlabNode.parse({ + name: 'Upper A Slab', + parentId: upperA.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const slabB = SlabNode.parse({ + name: 'Upper B Slab', + parentId: upperB.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_scoped', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_scoped', + name: 'Scoped Stair', + parentId: groundA.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: groundA.id, + toLevelId: upperA.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + buildingA, + groundA, + upperA, + buildingB, + upperB, + slabA, + slabB, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + + expect(updates.find((update) => update.id === slabA.id)?.data.holes).toHaveLength(1) + expect(updates.find((update) => update.id === slabB.id)).toBeUndefined() + }) + + test('uses the parent level when a stair has stale from-level data', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_stale_from', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_stale_from', + name: 'Stale From Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: 'default', + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const hole = landingUpdate?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.min(...hole!.map(([, z]) => z))).toBeGreaterThan(0.9) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('infers the destination level when a destination stair has blank level fields', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_blank_levels', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_blank_levels', + name: 'Blank Level Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: '', + toLevelId: '', + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('infers the destination level when a destination stair targets its source level', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_self_target', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_self_target', + name: 'Self Target Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: ground.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + test('does not add stair holes when a manual surface hole already covers them', () => { const building = BuildingNode.parse({ name: 'Building' }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) const manualOpening: Array<[number, number]> = [ - [1.2, 0.8], - [2.8, 0.8], - [2.8, 2.9], - [1.2, 2.9], + [1.0, 0.0], + [3.0, 0.0], + [3.0, 3.0], + [1.0, 3.0], ] const sourceCeiling = CeilingNode.parse({ name: 'Source Ceiling', @@ -206,10 +455,10 @@ describe('syncAutoStairOpenings', () => { const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) const manualOpening: Array<[number, number]> = [ - [1.2, 0.8], - [2.8, 0.8], - [2.8, 2.9], - [1.2, 2.9], + [1.0, 0.0], + [3.0, 0.0], + [3.0, 3.0], + [1.0, 3.0], ] const staleAutoOpening: Array<[number, number]> = [ [1.5, 1], diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 5c9b296f2..def28ce64 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -1,4 +1,5 @@ -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import { resolveBuildingForLevel, resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import { type Point2D, polygonContainsPolygon, polygonsOverlap } from '../../lib/polygon-relations' import type { AnyNode, AnyNodeId, @@ -9,8 +10,7 @@ import type { SurfaceHoleMetadata, } from '../../schema' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' - -type Point2D = [number, number] +import { computeSegmentTransforms, rotateXZ } from './stair-footprint' type SegmentTransform = { position: [number, number, number] @@ -75,81 +75,115 @@ function normalizeExistingMetadata( } // (Removing expandPolygonRadially in favor of geometric expansion inside the polygon generators) +// `rotateXZ` + `computeSegmentTransforms` are shared with the alignment-anchor +// footprint via `./stair-footprint` so both derive the chain identically. -function rotateXZ(x: number, z: number, angle: number): [number, number] { - const cos = Math.cos(angle) - const sin = Math.sin(angle) - return [x * cos + z * sin, -x * sin + z * cos] +function getLevelNumber(levelId: string | null, nodes: Record) { + if (!levelId) return undefined + const node = nodes[levelId as AnyNodeId] + return node?.type === 'level' ? node.level : undefined } -function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] { - const transforms: SegmentTransform[] = [] - let currentX = 0 - let currentY = 0 - let currentZ = 0 - let currentRot = 0 +function getLevelBuildingId(levelId: string | null, nodes: Record) { + if (!levelId) return null + return resolveBuildingForLevel(levelId as AnyNodeId, nodes as Record) +} - for (let index = 0; index < segments.length; index++) { - const segment = segments[index] - if (!segment) continue +function normalizeLevelId(levelId: string | null | undefined, nodes: Record) { + if (!levelId) return null + return nodes[levelId as AnyNodeId]?.type === 'level' ? levelId : null +} - if (index === 0) { - transforms.push({ - position: [currentX, currentY, currentZ], - rotation: currentRot, - }) - continue +function getBuildingLevels(buildingId: string | null, nodes: Record) { + const building = buildingId ? nodes[buildingId as AnyNodeId] : null + if (building?.type !== 'building') return [] + + const levels = new Map>() + for (const childId of building.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'level') levels.set(child.id, child) + } + for (const candidate of Object.values(nodes)) { + if (candidate?.type === 'level' && candidate.parentId === building.id) { + levels.set(candidate.id, candidate) } + } - const previous = segments[index - 1] - if (!previous) continue + return Array.from(levels.values()).sort((left, right) => left.level - right.level) +} - let attachX = 0 - let attachZ = 0 - let rotationDelta = 0 +function inferSourceLevelForDestination( + destinationLevelId: string | null, + nodes: Record, +) { + if (!destinationLevelId) return null + const destination = nodes[destinationLevelId as AnyNodeId] + if (destination?.type !== 'level') return null - switch (segment.attachmentSide) { - case 'front': - attachX = 0 - attachZ = previous.length - break - case 'left': - attachX = previous.width / 2 - attachZ = previous.length / 2 - rotationDelta = Math.PI / 2 - break - case 'right': - attachX = -previous.width / 2 - attachZ = previous.length / 2 - rotationDelta = -Math.PI / 2 - break - } + const buildingId = getLevelBuildingId(destinationLevelId, nodes) + return ( + getBuildingLevels(buildingId, nodes) + .filter((level) => level.level < destination.level) + .at(-1)?.id ?? null + ) +} - const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot) - currentX += deltaX - currentY += previous.height - currentZ += deltaZ - currentRot += rotationDelta +function inferDestinationLevelForSource( + sourceLevelId: string | null, + nodes: Record, +) { + if (!sourceLevelId) return null + const source = nodes[sourceLevelId as AnyNodeId] + if (source?.type !== 'level') return null - transforms.push({ - position: [currentX, currentY, currentZ], - rotation: currentRot, - }) - } + const buildingId = getLevelBuildingId(sourceLevelId, nodes) + return ( + getBuildingLevels(buildingId, nodes).find((level) => level.level > source.level)?.id ?? null + ) +} - return transforms +function levelsShareBuilding( + leftLevelId: string | null, + rightLevelId: string | null, + nodes: Record, +) { + if (!(leftLevelId && rightLevelId)) return true + const leftBuildingId = getLevelBuildingId(leftLevelId, nodes) + const rightBuildingId = getLevelBuildingId(rightLevelId, nodes) + return !(leftBuildingId && rightBuildingId && leftBuildingId !== rightBuildingId) } -function getLevelNumber(levelId: string | null, nodes: Record) { - if (!levelId) return undefined - const node = nodes[levelId as AnyNodeId] - return node?.type === 'level' ? node.level : undefined +function isInStairBuildingScope( + stair: StairNode, + surfaceLevelId: string, + nodes: Record, +) { + const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) + const fromBuildingId = getLevelBuildingId(fromLevelId, nodes) + const toBuildingId = getLevelBuildingId(toLevelId, nodes) + const surfaceBuildingId = getLevelBuildingId(surfaceLevelId, nodes) + + if (fromBuildingId && toBuildingId && fromBuildingId !== toBuildingId) return false + if (fromBuildingId && surfaceBuildingId && fromBuildingId !== surfaceBuildingId) return false + if (toBuildingId && surfaceBuildingId && toBuildingId !== surfaceBuildingId) return false + + return true } function getResolvedStairLevelIds(stair: StairNode, nodes: Record) { - const parentLevelId = resolveLevelId(stair, nodes) - const fromLevelId = stair.fromLevelId ?? parentLevelId - const toLevelId = stair.toLevelId ?? fromLevelId + const parentLevelId = normalizeLevelId(resolveLevelId(stair, nodes), nodes) + const explicitToLevelId = normalizeLevelId(stair.toLevelId, nodes) + const fromLevelId = + normalizeLevelId(stair.fromLevelId, nodes) ?? + parentLevelId ?? + inferSourceLevelForDestination(explicitToLevelId, nodes) + const explicitToLevelIsUsable = + explicitToLevelId && + explicitToLevelId !== fromLevelId && + levelsShareBuilding(fromLevelId, explicitToLevelId, nodes) + const toLevelId = explicitToLevelIsUsable + ? explicitToLevelId + : inferDestinationLevelForSource(fromLevelId, nodes) return { fromLevelId, toLevelId } } @@ -253,36 +287,6 @@ function polygonArea(points: Point2D[]) { return area / 2 } -function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) { - const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) - if (Math.abs(cross) > tolerance) return false - const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) - if (dot < -tolerance) return false - const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 - return dot <= lenSq + tolerance -} - -function pointInPolygon(point: Point2D, polygon: Point2D[]) { - if (polygon.length < 3) return false - let inside = false - const [x, z] = point - - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const a = polygon[i]! - const b = polygon[j]! - if (pointOnSegment(point, a, b)) return true - const intersects = - a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] - if (intersects) inside = !inside - } - - return inside -} - -function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { - return inner.every((point) => pointInPolygon(point, outer)) -} - function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) } @@ -470,13 +474,14 @@ function getStraightOpeningPolygonsForSurface( stair: StairNode, nodes: Record, targetElevation: number, + openingOffsetOverride?: number, ) { const layouts = getStraightStairLayouts(stair, nodes) if (layouts.length === 0) return [] const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) - const openingOffset = Math.max(stair.openingOffset ?? 0, 0.15) + const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) const openingRects: AxisAlignedRect[] = [] for (let index = 0; index < layouts.length; index += 1) { @@ -554,22 +559,22 @@ function getStairOpeningPolygons( stair: StairNode, nodes: Record, targetElevation?: number, + openingOffsetOverride?: number, ) { if ((stair.slabOpeningMode ?? 'none') !== 'destination') { return [] } + const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) + if (stair.stairType === 'curved') { return [ - getCurvedOpeningPolygon( - stair, - Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15), - ), + getCurvedOpeningPolygon(stair, Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)), ] } if (stair.stairType === 'spiral') { - const offset = Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15) + const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0) const polygons = [getSpiralOpeningPolygon(stair, offset)] if (stair.topLandingMode === 'integrated') { polygons.push(getSpiralLandingPolygon(stair, offset)) @@ -578,14 +583,39 @@ function getStairOpeningPolygons( } if (typeof targetElevation === 'number') { - return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation) + return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation, openingOffset) } return getStraightOpeningPolygonsForSurface( stair, nodes, Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0), + openingOffset, + ) +} + +function getApplicableStairOpeningPolygons( + stair: StairNode, + nodes: Record, + targetElevation: number, + surfacePolygon: Point2D[], +) { + const configuredOffset = Math.max(stair.openingOffset ?? 0, 0) + const polygons = getStairOpeningPolygons(stair, nodes, targetElevation, configuredOffset) + const overlappingPolygons = polygons.filter((polygon) => polygonsOverlap(surfacePolygon, polygon)) + + if (overlappingPolygons.length === polygons.length || configuredOffset <= 1e-6) { + return overlappingPolygons + } + + const fallbackPolygons = getStairOpeningPolygons(stair, nodes, targetElevation, 0) + const overlappingFallbackPolygons = fallbackPolygons.filter((polygon) => + polygonsOverlap(surfacePolygon, polygon), ) + + return overlappingFallbackPolygons.length === fallbackPolygons.length + ? overlappingFallbackPolygons + : overlappingPolygons } function getTargetSlabElevationForStair( @@ -640,6 +670,8 @@ function shouldApplyStairToSlab( const toLevel = getLevelNumber(toLevelId, nodes) const slabLevel = getLevelNumber(slabLevelId, nodes) + if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false + if (slabLevel === undefined) { return toLevelId === slabLevelId } @@ -663,6 +695,8 @@ function shouldApplyStairToCeiling( const toLevel = getLevelNumber(toLevelId, nodes) const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + if (!isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false + if (ceilingLevel === undefined) { return fromLevelId === ceilingLevelId } @@ -698,10 +732,11 @@ export function syncAutoStairOpenings(nodes: Record) { const stairHoles = stairs .filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes)) .flatMap((stair) => - getStairOpeningPolygons( + getApplicableStairOpeningPolygons( stair, nodes, getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes), + slab.polygon, ).map((polygon) => ({ polygon, metadata: { @@ -710,7 +745,6 @@ export function syncAutoStairOpenings(nodes: Record) { }, })), ) - .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) const nextHoles = [ @@ -747,10 +781,11 @@ export function syncAutoStairOpenings(nodes: Record) { const stairHoles = stairs .filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes)) .flatMap((stair) => - getStairOpeningPolygons( + getApplicableStairOpeningPolygons( stair, nodes, getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes), + ceiling.polygon, ).map((polygon) => ({ polygon, metadata: { @@ -759,7 +794,6 @@ export function syncAutoStairOpenings(nodes: Record) { }, })), ) - .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) const nextHoles = [ diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index 2558fcc5a..b78224cf9 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -2,7 +2,15 @@ import { useEffect, useRef } from 'react' import type { AnyNode } from '../../schema' +import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' +import useLiveNodeOverrides from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' import useScene from '../../store/use-scene' +import { + createSurfaceOpeningPreviewController, + getNodesWithLiveStairOpeningInputs, + hasLiveStairOpeningInputs, +} from './stair-opening-preview' import { syncAutoStairOpenings } from './stair-opening-sync' function isOpeningRelevantNode(node: AnyNode | undefined) { @@ -35,24 +43,90 @@ function hasOpeningRelevantNodeChange( export const StairOpeningSystem = () => { const syncingAutoOpeningsRef = useRef(false) + const syncingPreviewOpeningsRef = useRef(false) + const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) useEffect(() => { const applyUpdates = (updates: ReturnType) => { if (updates.length === 0) return syncingAutoOpeningsRef.current = true - useScene.getState().updateNodes(updates) + pauseSceneHistory(useScene) + try { + useScene.getState().updateNodes(updates) + } finally { + resumeSceneHistory(useScene) + } queueMicrotask(() => { syncingAutoOpeningsRef.current = false }) } + const applyPreviewUpdates = (updates: ReturnType) => { + syncingPreviewOpeningsRef.current = true + previewControllerRef.current.apply(updates) + queueMicrotask(() => { + syncingPreviewOpeningsRef.current = false + }) + } + + const clearPreviewUpdates = () => { + if (previewControllerRef.current.previewSurfaceIds.size === 0) return + syncingPreviewOpeningsRef.current = true + previewControllerRef.current.clear() + queueMicrotask(() => { + syncingPreviewOpeningsRef.current = false + }) + } + + const refreshLivePreview = () => { + if (syncingPreviewOpeningsRef.current) return + + const nodes = useScene.getState().nodes + const liveTransforms = useLiveTransforms.getState().transforms + const liveOverrides = useLiveNodeOverrides.getState().overrides + const previewSurfaceIds = previewControllerRef.current.previewSurfaceIds + + if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) { + clearPreviewUpdates() + return + } + + applyPreviewUpdates( + syncAutoStairOpenings( + getNodesWithLiveStairOpeningInputs( + nodes, + liveTransforms, + liveOverrides, + previewSurfaceIds, + ), + ), + ) + } + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + refreshLivePreview() - return useScene.subscribe((state, prevState) => { + const unsubscribeScene = useScene.subscribe((state, prevState) => { if (syncingAutoOpeningsRef.current) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return applyUpdates(syncAutoStairOpenings(state.nodes)) + refreshLivePreview() + }) + + const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { + refreshLivePreview() }) + + const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => { + refreshLivePreview() + }) + + return () => { + unsubscribeScene() + unsubscribeLiveTransforms() + unsubscribeLiveOverrides() + previewControllerRef.current.clear() + } }, []) return null diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts index 743f14cac..27bc30fd2 100644 --- a/packages/core/src/systems/wall/wall-curve.ts +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -1,10 +1,13 @@ -import type { FenceNode, WallNode } from '../../schema' +import type { FenceNode, PipeNode, RoadNode, WallNode } from '../../schema' import type { Point2D } from './wall-mitering' const CURVE_EPSILON = 1e-6 const DEFAULT_SAMPLE_SEGMENTS = 24 -type WallCurveLike = Pick +type WallCurveLike = Pick< + WallNode | FenceNode | PipeNode | RoadNode, + 'start' | 'end' | 'curveOffset' +> type CurveFrame = { point: Point2D @@ -201,7 +204,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL } export function getWallSurfacePolygon( - wall: Pick, + wall: Pick, segments = DEFAULT_SAMPLE_SEGMENTS, miterOverrides?: WallSurfaceMiterOverrides, ) { diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts new file mode 100644 index 000000000..349850ed0 --- /dev/null +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import type { WallNode } from '../../schema' +import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering' + +function wall(id: string, start: [number, number], end: [number, number]): WallNode { + return { + id, + type: 'wall', + object: 'node', + visible: true, + parentId: 'level_test', + children: [], + start, + end, + thickness: 0.1, + height: 2.5, + frontSide: 'interior', + backSide: 'exterior', + metadata: {}, + } as WallNode +} + +function maxBoundaryCoord(walls: WallNode[]): number { + const miter = calculateLevelMiters(walls) + let max = 0 + for (const w of walls) { + const bp = getWallMiterBoundaryPoints(w, miter) + expect(bp).not.toBeNull() + if (!bp) continue + for (const p of [bp.startLeft, bp.startRight, bp.endLeft, bp.endRight]) { + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + max = Math.max(max, Math.abs(p.x), Math.abs(p.y)) + } + } + return max +} + +describe('wall mitering miter limit', () => { + // Two 3 m walls sharing the origin, meeting at decreasing angles. Without a + // miter limit the joint point runs to infinity as the angle → 0 (∝ 1/sin θ), + // which is the "infinite wall" seen when a room-preset preview lands on top of + // an existing wall. The boundary must stay bounded near the wall length. + test.each([90, 30, 10, 5, 1, 0.1, 0.01])('stays bounded at a %s° junction', (deg) => { + const rad = (deg * Math.PI) / 180 + const walls = [ + wall('A', [0, 0], [3, 0]), + wall('B', [0, 0], [3 * Math.cos(rad), 3 * Math.sin(rad)]), + ] + // 3 m walls + a few cm of joint: anything past ~4 m is a runaway spike. + expect(maxBoundaryCoord(walls)).toBeLessThan(4) + }) + + test('still miters a normal 90° corner', () => { + const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])] + const miter = calculateLevelMiters(walls) + const bpA = getWallMiterBoundaryPoints(walls[0]!, miter) + expect(bpA).not.toBeNull() + if (!bpA) throw new Error('expected miter boundary points') + // The shared corner is pulled to the mitred intersection, offset from the + // raw butt position (halfThickness 0.05) by the diagonal of the joint. + const startSideX = Math.min(bpA.startLeft.x, bpA.startRight.x) + expect(startSideX).toBeLessThan(-0.001) + expect(startSideX).toBeGreaterThan(-0.5) + }) +}) diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index e6a2f2953..193f11be3 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -35,6 +35,17 @@ type JunctionData = Map const TOLERANCE = 0.001 +// Miter joints are line-line intersections, so the joint point sits a distance +// ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the +// two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview +// dragged on top of an existing wall, or a freshly-drawn wall almost parallel +// to its neighbour) that distance runs away to infinity and the wall renders as +// an infinite spike. Cap the joint at this multiple of the wall half-thickness; +// beyond it we fall back to a square (butt) joint, exactly like the existing +// parallel-walls guard. 10× preserves every realistic corner (a 0.1 m wall keeps +// mitering down to ~11°) while bounding the pathological near-collinear case. +const MITER_LIMIT = 10 + function pointToKey(p: Point2D, tolerance = TOLERANCE): string { const snap = 1 / tolerance return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}` @@ -198,6 +209,7 @@ interface ProcessedWall { edgeA: LineEquation // Left edge edgeB: LineEquation // Right edge isPassthrough: boolean // True if wall passes through junction (T-junction) + halfThickness: number // Used to bound the miter joint against runaway spikes } function calculateJunctionIntersections( @@ -228,7 +240,14 @@ function calculateJunctionIntersections( const edgeB = createLineFromPointAndVector(pB, v) const angle = Math.atan2(v.y, v.x) - processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true }) + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: true, + halfThickness: halfT, + }) } } else { // Normal wall endpoint (start or end) @@ -245,7 +264,14 @@ function calculateJunctionIntersections( const edgeB = createLineFromPointAndVector(pB, v) const angle = Math.atan2(v.y, v.x) - processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false }) + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: false, + halfThickness: halfT, + }) } } @@ -275,6 +301,21 @@ function calculateJunctionIntersections( y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det, } + // Miter limit: `det` only catches walls that are *exactly* parallel. Two + // walls meeting at a shallow angle have a small-but-nonzero `det`, so `p` + // lands far from the junction (∝ 1/sin θ) and the wall renders as an + // infinite spike. Reject any joint farther than MITER_LIMIT half-thicknesses + // from the meeting point — those walls fall back to a square joint. + const maxMiter = MITER_LIMIT * Math.max(wall1.halfThickness, wall2.halfThickness) + const dx = p.x - meetingPoint.x + const dy = p.y - meetingPoint.y + if ( + !(Number.isFinite(p.x) && Number.isFinite(p.y)) || + dx * dx + dy * dy > maxMiter * maxMiter + ) { + continue + } + // Only assign intersection to non-passthrough walls // Passthrough walls don't receive junction data (their geometry doesn't change) if (!wall1.isPassthrough) { diff --git a/packages/core/src/transfer-network/conveyor.test.ts b/packages/core/src/transfer-network/conveyor.test.ts new file mode 100644 index 000000000..f12023367 --- /dev/null +++ b/packages/core/src/transfer-network/conveyor.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import { ConveyorBeltNode } from '../schema/nodes/conveyor-belt' +import { + addTransferConnectionToMetadata, + createConveyorEndpointConnection, + getConveyorPortPoint, + getTransferConnections, + removeTransferConnectionsFromMetadata, + resolveConveyorEndpointSnap, +} from './conveyor' + +function belt(id: string, points: Array<[number, number, number]>) { + return ConveyorBeltNode.parse({ id, name: id, points }) +} + +describe('conveyor transfer network', () => { + test('resolves nearby conveyor endpoints with compatible port preference', () => { + const existing = belt('conveyor-belt_a', [ + [0, 0, 0], + [4, 0, 0], + ]) + const nodes = { [existing.id]: existing } as Record + + const snap = resolveConveyorEndpointSnap({ + point: [4.08, 0, 0.03], + nodes, + preferredTargetPort: 'out', + }) + + expect(snap?.targetNodeId).toBe(existing.id) + expect(snap?.targetPort).toBe('out') + expect(snap?.point).toEqual([4, 0, 0]) + }) + + test('ignores endpoints outside the snap threshold', () => { + const existing = belt('conveyor-belt_a', [ + [0, 0, 0], + [4, 0, 0], + ]) + + expect( + resolveConveyorEndpointSnap({ + point: [4.3, 0, 0], + nodes: { [existing.id]: existing } as Record, + }), + ).toBeNull() + }) + + test('stores transfer connections without duplicates', () => { + const connection = createConveyorEndpointConnection({ + selfNodeId: 'conveyor-belt_b', + selfPort: 'in', + targetNodeId: 'conveyor-belt_a', + targetPort: 'out', + }) + + const metadata = addTransferConnectionToMetadata( + addTransferConnectionToMetadata({}, connection), + connection, + ) + + expect(getTransferConnections({ metadata } as AnyNode)).toEqual([ + { + fromNodeId: 'conveyor-belt_a', + fromPort: 'out', + toNodeId: 'conveyor-belt_b', + toPort: 'in', + }, + ]) + }) + + test('reads input and output port coordinates', () => { + const node = belt('conveyor-belt_a', [ + [1, 0, 2], + [3, 0, 4], + ]) + + expect(getConveyorPortPoint(node, 'in')).toEqual([1, 0, 2]) + expect(getConveyorPortPoint(node, 'out')).toEqual([3, 0, 4]) + }) + + test('removes existing connections for a moved port', () => { + const metadata = addTransferConnectionToMetadata( + {}, + { + fromNodeId: 'conveyor-belt_a', + fromPort: 'out', + toNodeId: 'conveyor-belt_b', + toPort: 'in', + }, + ) + + expect( + getTransferConnections({ + metadata: removeTransferConnectionsFromMetadata(metadata, { + nodeId: 'conveyor-belt_a', + port: 'out', + }), + } as AnyNode), + ).toEqual([]) + }) +}) diff --git a/packages/core/src/transfer-network/conveyor.ts b/packages/core/src/transfer-network/conveyor.ts new file mode 100644 index 000000000..6b81a0b17 --- /dev/null +++ b/packages/core/src/transfer-network/conveyor.ts @@ -0,0 +1,196 @@ +import type { AnyNode, AnyNodeId } from '../schema' + +export type TransferPort = 'in' | 'out' + +export type TransferConnection = { + fromNodeId: string + fromPort: TransferPort + toNodeId: string + toPort: TransferPort +} + +export type ConveyorBeltRouteNode = AnyNode & { + type: 'conveyor-belt' + points: Array<[number, number, number]> + direction?: 'forward' | 'backward' + elevation?: number + thickness?: number +} + +export type ConveyorEndpointSnap = { + point: [number, number, number] + targetNodeId: AnyNodeId + targetPort: TransferPort + distance: number +} + +export const TRANSFER_ENDPOINT_SNAP_THRESHOLD = 0.15 + +export function isConveyorBeltRouteNode(node: AnyNode | null | undefined): node is ConveyorBeltRouteNode { + return node?.type === 'conveyor-belt' && Array.isArray((node as ConveyorBeltRouteNode).points) +} + +export function getConveyorPortPoint( + node: ConveyorBeltRouteNode, + port: TransferPort, +): [number, number, number] | null { + const point = port === 'in' ? node.points[0] : node.points[node.points.length - 1] + return point ? [point[0], point[1], point[2]] : null +} + +export function distance3D(a: [number, number, number], b: [number, number, number]) { + return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) +} + +export function resolveConveyorEndpointSnap(args: { + point: [number, number, number] + nodes: Record + selfId?: string + preferredTargetPort?: TransferPort + threshold?: number +}): ConveyorEndpointSnap | null { + const threshold = args.threshold ?? TRANSFER_ENDPOINT_SNAP_THRESHOLD + let best: ConveyorEndpointSnap | null = null + let bestPreferred: ConveyorEndpointSnap | null = null + + for (const node of Object.values(args.nodes)) { + if (!isConveyorBeltRouteNode(node)) continue + if (node.id === args.selfId) continue + for (const port of ['in', 'out'] as const) { + const target = getConveyorPortPoint(node, port) + if (!target) continue + const distance = distance3D(args.point, target) + if (distance > threshold) continue + if (!best || distance < best.distance) { + best = { + point: target, + targetNodeId: node.id as AnyNodeId, + targetPort: port, + distance, + } + } + if (args.preferredTargetPort === port && (!bestPreferred || distance < bestPreferred.distance)) { + bestPreferred = { + point: target, + targetNodeId: node.id as AnyNodeId, + targetPort: port, + distance, + } + } + } + } + + return bestPreferred ?? best +} + +export function createConveyorEndpointConnection(args: { + selfNodeId: string + selfPort: TransferPort + targetNodeId: string + targetPort: TransferPort +}): TransferConnection { + if (args.selfPort === 'in') { + return { + fromNodeId: args.targetNodeId, + fromPort: args.targetPort, + toNodeId: args.selfNodeId, + toPort: 'in', + } + } + return { + fromNodeId: args.selfNodeId, + fromPort: 'out', + toNodeId: args.targetNodeId, + toPort: args.targetPort, + } +} + +function connectionKey(connection: TransferConnection) { + return `${connection.fromNodeId}:${connection.fromPort}->${connection.toNodeId}:${connection.toPort}` +} + +function readConnections(metadata: unknown): TransferConnection[] { + if (!metadata || typeof metadata !== 'object') return [] + const connections = (metadata as { transferConnections?: unknown }).transferConnections + if (!Array.isArray(connections)) return [] + return connections.filter((connection): connection is TransferConnection => { + if (!connection || typeof connection !== 'object') return false + const candidate = connection as TransferConnection + return ( + typeof candidate.fromNodeId === 'string' && + typeof candidate.toNodeId === 'string' && + (candidate.fromPort === 'in' || candidate.fromPort === 'out') && + (candidate.toPort === 'in' || candidate.toPort === 'out') + ) + }) +} + +export function addTransferConnectionToMetadata( + metadata: unknown, + connection: TransferConnection, +): Record { + const base = metadata && typeof metadata === 'object' ? { ...(metadata as Record) } : {} + const existing = readConnections(base) + const keys = new Set(existing.map(connectionKey)) + if (!keys.has(connectionKey(connection))) existing.push(connection) + base.transferConnections = existing + return base +} + +export function removeTransferConnectionsFromMetadata( + metadata: unknown, + args: { nodeId: string; port?: TransferPort }, +): Record { + const base = metadata && typeof metadata === 'object' ? { ...(metadata as Record) } : {} + const existing = readConnections(base) + const filtered = existing.filter((connection) => { + const matchesFrom = + connection.fromNodeId === args.nodeId && (!args.port || connection.fromPort === args.port) + const matchesTo = + connection.toNodeId === args.nodeId && (!args.port || connection.toPort === args.port) + return !(matchesFrom || matchesTo) + }) + if (filtered.length === existing.length) return base + if (filtered.length > 0) { + base.transferConnections = filtered + } else { + delete base.transferConnections + } + return base +} + +export function removeTransferConnectionsReferencingNodesFromMetadata( + metadata: unknown, + nodeIds: Iterable, +): Record { + const base = metadata && typeof metadata === 'object' ? { ...(metadata as Record) } : {} + const deleted = new Set(nodeIds) + if (deleted.size === 0) return base + const existing = readConnections(base) + const filtered = existing.filter( + (connection) => !deleted.has(connection.fromNodeId) && !deleted.has(connection.toNodeId), + ) + if (filtered.length === existing.length) return base + if (filtered.length > 0) { + base.transferConnections = filtered + } else { + delete base.transferConnections + } + return base +} + +export function getTransferConnections(node: AnyNode): TransferConnection[] { + return readConnections(node.metadata) +} + +export function areConveyorPortsTouching( + a: ConveyorBeltRouteNode, + aPort: TransferPort, + b: ConveyorBeltRouteNode, + bPort: TransferPort, + threshold = 0.001, +) { + const aPoint = getConveyorPortPoint(a, aPort) + const bPoint = getConveyorPortPoint(b, bPort) + return !!(aPoint && bPoint && distance3D(aPoint, bPoint) <= threshold) +} diff --git a/packages/core/src/transfer-network/endpoints.test.ts b/packages/core/src/transfer-network/endpoints.test.ts new file mode 100644 index 000000000..03f303ceb --- /dev/null +++ b/packages/core/src/transfer-network/endpoints.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import { PipeNode } from '../schema/nodes/pipe' +import { + createTransferEndpointConnection, + getTransferPortPoint, + resolveTransferEndpointSnap, +} from './endpoints' + +function pipe(id: string, start: [number, number], end: [number, number]) { + return PipeNode.parse({ id, name: id, start, end, elevation: 1, rotate: 0 }) +} + +describe('transfer endpoints', () => { + test('resolves pipe endpoints through the generic transfer snapper', () => { + const existing = pipe('pipe_existing', [0, 0], [4, 0]) + const snap = resolveTransferEndpointSnap({ + point: [4.08, 1, 0.03], + nodes: { [existing.id]: existing } as Record, + preferredTargetPort: 'out', + nodeTypes: ['pipe'], + }) + + expect(snap?.targetNodeId).toBe(existing.id) + expect(snap?.targetPort).toBe('out') + expect(snap?.point).toEqual([4, 1, 0]) + }) + + test('reads pipe port coordinates from the 3D centerline', () => { + const node = pipe('pipe_existing', [1, 2], [3, 4]) + + expect(getTransferPortPoint(node, 'in')).toEqual([1, 1, 2]) + expect(getTransferPortPoint(node, 'out')).toEqual([3, 1, 4]) + }) + + test('creates directional endpoint connections', () => { + expect( + createTransferEndpointConnection({ + selfNodeId: 'pipe_b', + selfPort: 'in', + targetNodeId: 'pipe_a', + targetPort: 'out', + }), + ).toEqual({ + fromNodeId: 'pipe_a', + fromPort: 'out', + toNodeId: 'pipe_b', + toPort: 'in', + }) + }) +}) diff --git a/packages/core/src/transfer-network/endpoints.ts b/packages/core/src/transfer-network/endpoints.ts new file mode 100644 index 000000000..46a83c5ed --- /dev/null +++ b/packages/core/src/transfer-network/endpoints.ts @@ -0,0 +1,98 @@ +import type { AnyNode, AnyNodeId, PipeNode } from '../schema' +import { getPipeEndpoint3D } from '../systems/pipe/pipe-centerline' +import type { + ConveyorBeltRouteNode, + TransferConnection, + TransferPort, +} from './conveyor' +import { distance3D, isConveyorBeltRouteNode } from './conveyor' + +export type TransferEndpointNode = ConveyorBeltRouteNode | PipeNode + +export type TransferEndpointSnap = { + point: [number, number, number] + targetNodeId: AnyNodeId + targetPort: TransferPort + distance: number +} + +export const TRANSFER_ENDPOINT_SNAP_DISTANCE = 0.28 + +export function isTransferEndpointNode(node: AnyNode | null | undefined): node is TransferEndpointNode { + return isConveyorBeltRouteNode(node) || node?.type === 'pipe' +} + +export function getTransferPortPoint( + node: TransferEndpointNode, + port: TransferPort, +): [number, number, number] | null { + if (isConveyorBeltRouteNode(node)) { + const point = port === 'in' ? node.points[0] : node.points[node.points.length - 1] + return point ? [point[0], point[1], point[2]] : null + } + if (node.type === 'pipe') { + const endpoint = getPipeEndpoint3D(node, port === 'in' ? 'start' : 'end') + return [endpoint.x, endpoint.y, endpoint.z] + } + return null +} + +export function resolveTransferEndpointSnap(args: { + point: [number, number, number] + nodes: Record + selfId?: string + preferredTargetPort?: TransferPort + nodeTypes?: ReadonlyArray + threshold?: number +}): TransferEndpointSnap | null { + const threshold = args.threshold ?? TRANSFER_ENDPOINT_SNAP_DISTANCE + const nodeTypes = args.nodeTypes ? new Set(args.nodeTypes) : null + let best: TransferEndpointSnap | null = null + let bestPreferred: TransferEndpointSnap | null = null + + for (const node of Object.values(args.nodes)) { + if (!isTransferEndpointNode(node)) continue + if (node.id === args.selfId) continue + if (nodeTypes && !nodeTypes.has(node.type)) continue + for (const port of ['in', 'out'] as const) { + const target = getTransferPortPoint(node, port) + if (!target) continue + const distance = distance3D(args.point, target) + if (distance > threshold) continue + const candidate: TransferEndpointSnap = { + point: target, + targetNodeId: node.id as AnyNodeId, + targetPort: port, + distance, + } + if (!best || distance < best.distance) best = candidate + if (args.preferredTargetPort === port && (!bestPreferred || distance < bestPreferred.distance)) { + bestPreferred = candidate + } + } + } + + return bestPreferred ?? best +} + +export function createTransferEndpointConnection(args: { + selfNodeId: string + selfPort: TransferPort + targetNodeId: string + targetPort: TransferPort +}): TransferConnection { + if (args.selfPort === 'in') { + return { + fromNodeId: args.targetNodeId, + fromPort: args.targetPort, + toNodeId: args.selfNodeId, + toPort: 'in', + } + } + return { + fromNodeId: args.selfNodeId, + fromPort: 'out', + toNodeId: args.targetNodeId, + toPort: args.targetPort, + } +} diff --git a/packages/core/src/types/scene-object.ts b/packages/core/src/types/scene-object.ts new file mode 100644 index 000000000..28268f77e --- /dev/null +++ b/packages/core/src/types/scene-object.ts @@ -0,0 +1,2 @@ +export type SceneObjectRef = any +export type ScenePointerEvent = any diff --git a/packages/core/src/utils/heal-scene-graph.test.ts b/packages/core/src/utils/heal-scene-graph.test.ts new file mode 100644 index 000000000..0b1dc21a1 --- /dev/null +++ b/packages/core/src/utils/heal-scene-graph.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' +import { healSceneNodes } from './heal-scene-graph' + +describe('healSceneNodes', () => { + test('strips non-string (null) children entries', () => { + const { nodes, strippedChildRefs } = healSceneNodes({ + wall_a: { + id: 'wall_a', + type: 'wall', + start: [0, 0], + end: [1, 0], + children: [null, 'item_x'], + }, + item_x: { id: 'item_x', type: 'item' }, + }) + expect(strippedChildRefs).toBe(1) + expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x']) + }) + + test('drops childless zero-length walls and removes their parent reference', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] }, + wall_zero: { id: 'wall_zero', type: 'wall', start: [5, 5], end: [5, 5], children: [] }, + wall_real: { id: 'wall_real', type: 'wall', start: [0, 0], end: [3, 0], children: [] }, + }) + expect(droppedWallIds).toEqual(['wall_zero']) + expect('wall_zero' in nodes).toBe(false) + expect((nodes.level_0 as { children: string[] }).children).toEqual(['wall_real']) + }) + + + test('restores missing child parentId links from parent children arrays', () => { + const { nodes, restoredParentLinks } = healSceneNodes({ + building_a: { id: 'building_a', type: 'building', children: ['level_0'] }, + level_0: { id: 'level_0', type: 'level', parentId: null, children: [] }, + level_other: { id: 'level_other', type: 'level', parentId: 'building_other', children: [] }, + }) + + expect(restoredParentLinks).toBe(1) + expect((nodes.level_0 as { parentId: string }).parentId).toBe('building_a') + expect((nodes.level_other as { parentId: string }).parentId).toBe('building_other') + }) + + test('keeps a zero-length wall that still hosts a door/window', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + wall_z: { id: 'wall_z', type: 'wall', start: [1, 1], end: [1, 1], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + }) + expect(droppedWallIds).toEqual([]) + expect('wall_z' in nodes).toBe(true) + }) + + test('passes a clean scene through untouched', () => { + const input = { + wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + } + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(input) + expect(droppedWallIds).toEqual([]) + expect(strippedChildRefs).toBe(0) + expect(nodes.wall_a).toBe(input.wall_a) + }) +}) diff --git a/packages/core/src/utils/heal-scene-graph.ts b/packages/core/src/utils/heal-scene-graph.ts new file mode 100644 index 000000000..22b7dc871 --- /dev/null +++ b/packages/core/src/utils/heal-scene-graph.ts @@ -0,0 +1,102 @@ +// Repairs scene-graph corruption that pre-dates the source fixes, so existing +// saved scenes still load. Two known kinds of damage, both produced by the +// capture wall-merge before it was fixed: +// +// 1. A `children` array containing a non-string entry. The merge re-attached a +// wall-hosted item without minting an id, so `undefined` was pushed into the +// wall's children — which serializes to `[null]`. The wall schema rejects +// `null` children, so the whole scene fails to load. +// 2. A zero-length wall (start === end). It renders nothing, but lingers as a +// junk node and is a foot-gun for snapping/mitering. +// +// Both are also prevented at the source now (see merge-walls.ts and the wall +// miter limit); this is the load-time safety net for already-saved scenes. + +const ZERO_LENGTH_EPS = 1e-6 + +export interface HealSceneResult { + nodes: Record + /** Ids of zero-length walls that were dropped. */ + droppedWallIds: string[] + /** Count of non-string (e.g. null) entries removed from `children` arrays. */ + strippedChildRefs: number + /** Count of missing child parentId links restored from parent children arrays. */ + restoredParentLinks: number +} + +function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } { + if (!node || typeof node !== 'object') return false + const n = node as Record + return ( + n.type === 'wall' && + Array.isArray(n.start) && + Array.isArray(n.end) && + typeof n.start[0] === 'number' && + typeof n.start[1] === 'number' && + typeof n.end[0] === 'number' && + typeof n.end[1] === 'number' + ) +} + +/** + * Returns a healed copy of a `nodes` map. Pure — does not mutate `input`. + * Nodes that need no repair are passed through by reference. + */ +export function healSceneNodes(input: Record): HealSceneResult { + const droppedWallIds: string[] = [] + + // Pass 1: drop childless zero-length walls. (Only childless ones — a wall + // carrying a door/window must keep its hosts, degenerate or not.) + const kept: Record = {} + for (const [id, node] of Object.entries(input)) { + if (isWallLike(node)) { + const children = (node as { children?: unknown }).children + const childless = !Array.isArray(children) || children.length === 0 + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + if (childless && Math.hypot(dx, dz) <= ZERO_LENGTH_EPS) { + droppedWallIds.push(id) + continue + } + } + kept[id] = node + } + + const dropped = new Set(droppedWallIds) + let strippedChildRefs = 0 + + // Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug) + // and references to walls we just removed. + const nodes: Record = {} + for (const [id, node] of Object.entries(kept)) { + const children = (node as { children?: unknown })?.children + if (Array.isArray(children)) { + const cleaned = children.filter((c): c is string => typeof c === 'string' && !dropped.has(c)) + if (cleaned.length !== children.length) { + strippedChildRefs += children.length - cleaned.length + nodes[id] = { ...(node as Record), children: cleaned } + continue + } + } + nodes[id] = node + } + + let restoredParentLinks = 0 + for (const [parentId, parent] of Object.entries(nodes)) { + const children = (parent as { children?: unknown })?.children + if (!Array.isArray(children)) continue + + for (const childId of children) { + const child = nodes[childId] + if (!child || typeof child !== 'object') continue + + const childRecord = child as Record + if (childRecord.parentId != null) continue + + nodes[childId] = { ...childRecord, parentId } + restoredParentLinks += 1 + } + } + + return { nodes, droppedWallIds, strippedChildRefs, restoredParentLinks } +} diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index 23873c3ed..5caba974e 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,4 +1,5 @@ import { AnyNode, type AnyNodeType } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' export type ValidationSeverity = 'error' | 'warning' @@ -127,9 +128,19 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { } } - const nodes = nodesRaw as Record + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes( + nodesRaw as Record, + ) const rootNodeIds = rootNodeIdsRaw as string[] + if (strippedChildRefs > 0 || droppedWallIds.length > 0) { + warnings.push({ + severity: 'warning', + code: 'auto_repaired', + message: `Repaired on import: removed ${strippedChildRefs} invalid child reference${strippedChildRefs === 1 ? '' : 's'} and ${droppedWallIds.length} zero-length wall${droppedWallIds.length === 1 ? '' : 's'}.`, + }) + } + if (rootNodeIds.length === 0) { errors.push({ severity: 'error', diff --git a/packages/editor/package.json b/packages/editor/package.json index 1e18e3640..c52f95490 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -5,7 +5,18 @@ "type": "module", "exports": { ".": "./src/index.tsx", - "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx" + "./i18n": "./src/i18n/index.ts", + "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx", + "./components/editor": "./src/components/editor/index.tsx", + "./components/sidebar/ai-chat-panel": "./src/components/ui/sidebar/panels/ai-chat-panel/index.tsx", + "./components/sidebar/items-panel": "./src/components/ui/sidebar/panels/items-panel/index.tsx", + "./components/sidebar/store": "./src/components/ui/primitives/sidebar.tsx", + "./components/sidebar/types": "./src/components/ui/sidebar/tab-bar.tsx", + "./components/ui/dropdown-menu": "./src/components/ui/primitives/dropdown-menu.tsx", + "./floorplan": "./src/lib/floorplan/index.ts", + "./scene": "./src/lib/scene.ts", + "./store": "./src/store/use-editor.tsx", + "./svg-paths": "./src/components/editor-2d/svg-paths.ts" }, "scripts": { "check-types": "tsc --noEmit" @@ -21,6 +32,7 @@ "three": "^0.184" }, "dependencies": { + "@pascal-app/articraft-bridge": "*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx b/packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx index 42de33cec..105eb2b84 100644 --- a/packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx @@ -3,6 +3,11 @@ import { memo, useEffect } from 'react' import useEditor from '../../store/use-editor' +function hasBrowserTextSelection() { + const selection = window.getSelection() + return Boolean(selection && !selection.isCollapsed && selection.toString().trim()) +} + type FloorplanSiteKeyHandlerProps = { onRestoreGroundLevel: () => void } @@ -64,6 +69,10 @@ export const FloorplanDuplicateHotkey = memo(function FloorplanDuplicateHotkey({ return } + if (hasBrowserTextSelection()) { + return + } + if (!(isFloorplanHovered && hasDuplicatable)) { return } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 988fb2a58..ee321fbbf 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -1,17 +1,12 @@ 'use client' -import { - type AnyNode, - type AnyNodeId, - type CeilingNode, - nodeRegistry, - type SlabNode, - useScene, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { type AnyNode, type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core' +import useViewer from '@pascal-app/viewer/store' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' +import { isPlanDragMovableNode } from '../../lib/plan-drag' import { sfxEmitter } from '../../lib/sfx-bus' +import { duplicateNodeSubtree } from '../../lib/subtree-duplication' import useEditor from '../../store/use-editor' import { NodeActionMenu } from '../editor/node-action-menu' @@ -29,9 +24,7 @@ import { NodeActionMenu } from '../editor/node-action-menu' * `capabilities.movable`, `def.floorplanMoveTarget`, OR * `def.affordanceTools.move` (slab / ceiling). The * `` / dispatcher picks the right path. - * - Add hole (slab + ceiling only): inserts a small default-square - * hole at the polygon centroid via `updateNode`. Mirrors the legacy - * `handleAddHole` in `floating-action-menu.tsx`. + * - Add hole: offered when the selected kind exposes `def.editActions.addHole`. * - Duplicate: deep-clones the node, marks it new, sets it as the * movingNode (placement cursor) — same UX pattern as 3D duplicate. * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's @@ -89,9 +82,10 @@ export function FloorplanRegistryActionMenu() { // the floor plan." The `MoveTool` dispatcher resolves the right path. const canMove = !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move + const isDirectPlanDraggable = isPlanDragMovableNode(node) const canDuplicate = def.capabilities.duplicable !== false const canDelete = def.capabilities.deletable !== false - const canAddHole = node.type === 'slab' || node.type === 'ceiling' + const addHoleAction = def.editActions?.addHole const handleMove = () => { sfxEmitter.emit('sfx:item-pick') @@ -108,56 +102,37 @@ export function FloorplanRegistryActionMenu() { } const handleAddHole = () => { - if (!canAddHole) return - const surfaceNode = node as SlabNode | CeilingNode - const polygon = surfaceNode.polygon - if (!polygon || polygon.length < 3) return - - let cx = 0 - let cz = 0 - for (const [x, z] of polygon) { - cx += x - cz += z - } - cx /= polygon.length - cz /= polygon.length - - const holeSize = 0.5 - const newHole: Array<[number, number]> = [ - [cx - holeSize, cz - holeSize], - [cx + holeSize, cz - holeSize], - [cx + holeSize, cz + holeSize], - [cx - holeSize, cz + holeSize], - ] - const currentHoles = surfaceNode.holes ?? [] - const currentMetadata = currentHoles.map( - (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const }, - ) + const patch = addHoleAction?.(node as never) + if (!patch) return sfxEmitter.emit('sfx:structure-build') - useScene.getState().updateNode( - selectedId as AnyNodeId, - { - holes: [...currentHoles, newHole], - holeMetadata: [...currentMetadata, { source: 'manual' as const }], - } as Partial, - ) + useScene.getState().updateNode(selectedId as AnyNodeId, patch as Partial) } const handleDuplicate = () => { if (!node.parentId) return sfxEmitter.emit('sfx:item-pick') useScene.temporal.getState().pause() - const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId } - delete (cloned as { id?: AnyNodeId }).id - const prevMeta = - cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata) - ? (cloned.metadata as Record) - : {} - cloned.metadata = { ...prevMeta, isNew: true } - const parsed = def.schema.parse(cloned) as AnyNode - useScene.getState().createNode(parsed, node.parentId as AnyNodeId) - setMovingNode(parsed as never) - useScene.temporal.getState().resume() + try { + if ('children' in node && Array.isArray(node.children) && node.children.length > 0) { + const { root } = duplicateNodeSubtree(selectedId, { markRootNew: true }) + setMovingNode(root as never) + } else { + const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId } + delete (cloned as { id?: AnyNodeId }).id + const prevMeta = + cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata) + ? (cloned.metadata as Record) + : {} + cloned.metadata = { ...prevMeta, isNew: true } + const parsed = def.schema.parse(cloned) as AnyNode + useScene.getState().createNode(parsed, node.parentId as AnyNodeId) + setMovingNode(parsed as never) + } + } catch (error) { + console.error('Failed to duplicate registry node', error) + } finally { + useScene.temporal.getState().resume() + } } const handleDelete = () => { @@ -176,10 +151,10 @@ export function FloorplanRegistryActionMenu() { }} > event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} /> diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 29e71cb64..42a63f4cc 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -4,15 +4,31 @@ import { type AnyNode, type AnyNodeId, type FloorplanMoveTargetSession, + type FenceNode, + type LevelNode, + type PipeNode, + type RoadNode, nodeRegistry, pauseSceneHistory, resumeSceneHistory, snapPointToGrid, + type WallNode, useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import useViewer from '@pascal-app/viewer/store' import { useEffect } from 'react' +import { snapFenceDraftPoint } from '../../components/tools/fence/fence-drafting' +import { snapPipeDraftPoint } from '../../components/tools/pipe/pipe-drafting' +import { getWallGridStep, snapScalarToGrid } from '../../components/tools/wall/wall-drafting' +import { floorItemDragSuppressClickRef } from '../../lib/floor-item-drag' +import { getLinkedPipeSnapshots } from '../../lib/pipe-plan-move' +import { + applySegmentEndpointPreview, + computeSegmentDragEndpoints, + getLinkedSegmentSnapshots, + getSegmentPlanMidpoint, +} from '../../lib/segment-plan-move' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' @@ -341,6 +357,172 @@ export function FloorplanRegistryMoveOverlay() { } } + // ── Path 1b — whole-segment translate (pipe / wall / fence) ───── + if ( + movingNode.type === 'pipe' || + movingNode.type === 'road' || + movingNode.type === 'wall' || + movingNode.type === 'fence' + ) { + type SegmentNode = PipeNode | RoadNode | WallNode | FenceNode + const segment = movingNode as SegmentNode + const originalStart = [...segment.start] as [number, number] + const originalEnd = [...segment.end] as [number, number] + const linkedSegments = + segment.type === 'pipe' + ? getLinkedPipeSnapshots({ + pipeId: segment.id, + pipeParentId: segment.parentId ?? null, + originalStart, + originalEnd, + }) + : getLinkedSegmentSnapshots({ + segmentId: segment.id, + segmentParentId: segment.parentId ?? null, + segmentType: segment.type, + originalStart, + originalEnd, + }) + let dragAnchor: [number, number] | null = null + let preview: { start: [number, number]; end: [number, number] } | null = null + + const levelNode = + segment.parentId && + useScene.getState().nodes[segment.parentId as AnyNodeId]?.type === 'level' + ? (useScene.getState().nodes[segment.parentId as AnyNodeId] as LevelNode) + : null + const levelChildren = levelNode?.children ?? [] + const levelWalls = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is WallNode => child?.type === 'wall') + const levelPipes = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is PipeNode => child?.type === 'pipe') + const levelFences = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is FenceNode => child?.type === 'fence') + + pauseSceneHistory(useScene) + let historyPaused = true + + const restoreOriginal = () => { + applySegmentEndpointPreview( + segment.id, + linkedSegments, + originalStart, + originalEnd, + originalStart, + originalEnd, + ) + } + + const snapCursor = (planPoint: [number, number]): [number, number] => { + if (segment.type === 'pipe') { + return snapPipeDraftPoint({ + point: planPoint, + walls: levelWalls, + pipes: levelPipes, + ignorePipeIds: [segment.id], + }) + } + if (segment.type === 'fence') { + return snapFenceDraftPoint({ + point: planPoint, + walls: levelWalls, + fences: levelFences, + ignoreFenceIds: [segment.id], + }) + } + const step = getWallGridStep() + return [snapScalarToGrid(planPoint[0], step), snapScalarToGrid(planPoint[1], step)] + } + + const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => { + const svg = scene.ownerSVGElement + if (!svg) return false + const rect = svg.getBoundingClientRect() + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) + } + + const onMove = (event: PointerEvent) => { + if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return + const m = toMeters(event.clientX, event.clientY) + if (!m) return + const [localX, localZ] = snapCursor(m) + const anchor = dragAnchor ?? getSegmentPlanMidpoint(segment) + dragAnchor = anchor + const { start, end } = computeSegmentDragEndpoints({ + originalStart, + originalEnd, + dragAnchor: anchor, + cursorPlan: [localX, localZ], + }) + preview = { start, end } + applySegmentEndpointPreview( + segment.id, + linkedSegments, + originalStart, + originalEnd, + start, + end, + ) + } + + const onPointerUp = (event: PointerEvent) => { + if (event.button !== 0) return + if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return + if (!preview) { + setMovingNode(null) + return + } + + restoreOriginal() + resumeSceneHistory(useScene) + historyPaused = false + applySegmentEndpointPreview( + segment.id, + linkedSegments, + originalStart, + originalEnd, + preview.start, + preview.end, + ) + floorItemDragSuppressClickRef.current = true + sfxEmitter.emit('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [segment.id] }) + setMovingNode(null) + } + + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + restoreOriginal() + if (historyPaused) { + resumeSceneHistory(useScene) + historyPaused = false + } + useViewer.getState().setSelection({ selectedIds: [segment.id] }) + setMovingNode(null) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('keydown', onKey) + if (historyPaused) { + restoreOriginal() + resumeSceneHistory(useScene) + } + } + } + // ── Path 2 — generic free-floating translate ──────────────────── const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null if (!entry) return diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 661ae1ac2..b0018379a 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -8,6 +8,7 @@ import { type FloorplanGeometry, type FloorplanPalette, type GeometryContext, + type LiveTransform, nodeRegistry, pauseSceneHistory, resumeSceneHistory, @@ -16,7 +17,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import useViewer from '@pascal-app/viewer/store' import { memo, type PointerEvent as ReactPointerEvent, @@ -26,6 +27,7 @@ import { useRef, useState, } from 'react' +import { isPlanDragMovableNode, PLAN_DRAG_THRESHOLD_PX } from '../../../lib/plan-drag' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { useFloorplanRender } from '../floorplan-render-context' @@ -85,6 +87,15 @@ type ActiveDrag = { historyPaused: boolean } +type FloorplanEntry = { + id: AnyNodeId + node: AnyNode + base: FloorplanGeometry | null + overlay: FloorplanGeometry | null + selected: boolean + highlighted: boolean +} + function snapshotNode(node: AnyNode): NodeSnapshot { // Shallow-clone every non-id, non-type field. Arrays / vec tuples are // deep-cloned to detach from the live store reference. @@ -100,6 +111,95 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) { return snapshots.map((s) => ({ id: s.id, data: s.data })) } +function applyFloorplanLiveTransform(node: AnyNode, live: LiveTransform): AnyNode { + if (node.type === 'item' || node.type === 'shelf') { + return { + ...node, + position: live.position, + rotation: [0, live.rotation, 0] as [number, number, number], + parentId: null, + } as AnyNode + } + + if (node.type === 'stair') { + return { + ...node, + position: live.position, + } as AnyNode + } + + if (node.type === 'slab' || node.type === 'ceiling') { + const dx = live.position[0] + const dz = live.position[2] + if (dx === 0 && dz === 0) return node + + const surface = node as { + polygon: Array<[number, number]> + holes?: Array> + } + return { + ...node, + polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + holes: (surface.holes ?? []).map((h) => + h.map(([x, z]) => [x + dx, z + dz] as [number, number]), + ), + } as AnyNode + } + + return node +} + +function buildFloorplanEntry( + node: AnyNode, + nodes: Record, + viewState: { + selected: boolean + highlighted: boolean + hovered: boolean + moving: boolean + palette: FloorplanPalette | undefined + }, +): FloorplanEntry | null { + const def = nodeRegistry.get(node.type) + const builder = def?.floorplan + if (!builder) return null + + const ctx = buildContext(node, nodes, viewState) + const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( + node, + ctx, + ) + if (!geometry) return null + + const { base, overlay } = splitFloorplanOverlay(geometry) + return { + id: node.id, + node, + base, + overlay, + selected: viewState.selected, + highlighted: viewState.highlighted, + } +} + +function mergeMovingEntry(entries: FloorplanEntry[], movingEntry: FloorplanEntry | null) { + if (!movingEntry) return entries + + const movingRank = floorplanLayerRank(movingEntry.node.type) + const next: FloorplanEntry[] = [] + let inserted = false + for (const entry of entries) { + if (entry.id === movingEntry.id) continue + if (!inserted && floorplanLayerRank(entry.node.type) > movingRank) { + next.push(movingEntry) + inserted = true + } + next.push(entry) + } + if (!inserted) next.push(movingEntry) + return next +} + export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = useViewer((s) => s.selection.levelId) const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -111,11 +211,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const renderCtx = useFloorplanRender() const movingNode = useEditor((s) => s.movingNode) const setMovingNode = useEditor((s) => s.setMovingNode) - // Subscribe to the live-transforms map ref so the layer re-renders - // whenever a 3D mover publishes a per-frame position (see - // `usePlacementCoordinator`). Without this the 2D floor plan only - // updates after 3D commit — the 3D drag would look frozen in 2D. - const liveTransforms = useLiveTransforms((s) => s.transforms) + // Subscribe only to the active moving node's live transform. Subscribing + // to the full map makes every drag frame rebuild the whole floorplan. + const movingNodeId = movingNode?.id as AnyNodeId | undefined + const movingLiveTransform = useLiveTransforms((s) => + movingNodeId ? s.transforms.get(movingNodeId) : undefined, + ) // Same reactivity hook for elevator runtime state — `useInteractive` // tracks the current / fallback level + cab travel, `useLiveNode // Overrides` carries live-edit overrides from the inspector. Builders @@ -132,6 +233,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // Interactive state lives in refs; only the visible feedback bits go // into React state to keep re-renders cheap during drag. const dragRef = useRef(null) + const pendingItemDragRef = useRef<{ + nodeId: AnyNodeId + startX: number + startY: number + } | null>(null) const [hoveredHandleId, setHoveredHandleId] = useState(null) const [activeDragId, setActiveDragId] = useState(null) @@ -140,10 +246,55 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (event.button !== 0) return event.stopPropagation() setSelection({ selectedIds: [id] }) + + const node = useScene.getState().nodes[id] + if (node && isPlanDragMovableNode(node) && !movingNode) { + pendingItemDragRef.current = { + nodeId: id, + startX: event.clientX, + startY: event.clientY, + } + } }, - [setSelection], + [movingNode, setSelection], ) + useEffect(() => { + const tryStartItemDrag = (clientX: number, clientY: number) => { + const pending = pendingItemDragRef.current + if (!pending || movingNode) return + const dx = clientX - pending.startX + const dy = clientY - pending.startY + if (Math.hypot(dx, dy) < PLAN_DRAG_THRESHOLD_PX) return + + const node = useScene.getState().nodes[pending.nodeId] + if (!node || !isPlanDragMovableNode(node)) return + + pendingItemDragRef.current = null + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node as never) + } + + const clearPendingItemDrag = () => { + pendingItemDragRef.current = null + } + + const onPointerMove = (event: PointerEvent) => { + tryStartItemDrag(event.clientX, event.clientY) + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', clearPendingItemDrag) + window.addEventListener('pointercancel', clearPendingItemDrag) + + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', clearPendingItemDrag) + window.removeEventListener('pointercancel', clearPendingItemDrag) + pendingItemDragRef.current = null + } + }, [movingNode, setMovingNode]) + const handleClickStop = useCallback((event: React.MouseEvent) => { event.stopPropagation() }, []) @@ -164,14 +315,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // partition. const entries = useMemo(() => { if (!levelId) return [] - const out: { - id: AnyNodeId - node: AnyNode - base: FloorplanGeometry | null - overlay: FloorplanGeometry | null - selected: boolean - highlighted: boolean - }[] = [] + const out: FloorplanEntry[] = [] const visit = (id: AnyNodeId) => { const node = nodes[id] @@ -182,56 +326,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const selected = selectedIdSet.has(id) const highlighted = highlightedIdSet.has(id) const hovered = hoveredId === id - const moving = movingNode?.id === id - // Live-transform override — when a mover is publishing per-frame - // position/rotation, render that here instead of the committed - // scene state. Without this the 2D floor plan would only update - // after commit, making the drag look frozen. - // - // The live-transform contract varies per kind (see - // wiki/architecture/tools.md "useLiveTransforms contract is - // per-kind, not generic"); we narrow per kind here: - // - item: world-plan position frame. Override `position` + - // `rotation` and force `parentId: null` so the resolver - // treats them as world coords directly. - // - slab / ceiling: position is a translation **delta** - // (`[Δx, 0, Δz]`). Translate the polygon + holes by the - // delta — the floor-plan builder draws the polygon at its - // new location, mirroring the 3D `` - // visual without forcing per-tick CSG scene writes. - const live = liveTransforms.get(id) - let effectiveNode: AnyNode = node - if (live) { - if (node.type === 'item' || node.type === 'shelf') { - // World-plan position kinds: the live transform carries the - // node's intended position/rotation in level-local coords. - // Override both and force `parentId: null` so the floor-plan - // resolver treats `position` as world plan coords directly - // (skipping the parent-chain transform composition). - effectiveNode = { - ...node, - position: live.position, - rotation: [0, live.rotation, 0] as [number, number, number], - parentId: null, - } as AnyNode - } else if (node.type === 'slab' || node.type === 'ceiling') { - const dx = live.position[0] - const dz = live.position[2] - if (dx !== 0 || dz !== 0) { - const surface = node as { - polygon: Array<[number, number]> - holes?: Array> - } - effectiveNode = { - ...node, - polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), - holes: (surface.holes ?? []).map((h) => - h.map(([x, z]) => [x + dx, z + dz] as [number, number]), - ), - } as AnyNode - } - } - } + const moving = movingNodeId === id + // Live-transform frames are handled by movingEntry below. + const effectiveNode: AnyNode = node const ctx = buildContext(effectiveNode, nodes, { selected, highlighted, @@ -265,16 +362,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // priority. out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) return out - }, [ - levelId, - nodes, - liveTransforms, - selectedIdSet, - highlightedIdSet, - hoveredId, - movingNode?.id, - renderCtx?.palette, - ]) + }, [levelId, nodes, selectedIdSet, highlightedIdSet, hoveredId, movingNodeId, renderCtx?.palette]) // ── Generic 2D affordance dispatch ───────────────────────────────── // @@ -283,6 +371,33 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // dispatcher then owns: history pause/resume, snapshot capture, // pointer-move/up/cancel routing, and the single-undo dance on // commit. Each kind owns the actual mutation logic inside `apply`. + const movingEntry = useMemo(() => { + if (!movingNodeId || !movingLiveTransform) return null + const node = nodes[movingNodeId] + if (!node) return null + + return buildFloorplanEntry(applyFloorplanLiveTransform(node, movingLiveTransform), nodes, { + selected: selectedIdSet.has(movingNodeId), + highlighted: highlightedIdSet.has(movingNodeId), + hovered: hoveredId === movingNodeId, + moving: true, + palette: renderCtx?.palette, + }) + }, [ + movingNodeId, + movingLiveTransform, + nodes, + selectedIdSet, + highlightedIdSet, + hoveredId, + renderCtx?.palette, + ]) + + const displayEntries = useMemo( + () => mergeMovingEntry(entries, movingEntry), + [entries, movingEntry], + ) + const startAffordanceDrag = useCallback( ( nodeId: AnyNodeId, @@ -442,7 +557,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } }, []) - if (entries.length === 0) return null + if (displayEntries.length === 0) return null const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1 const palette = renderCtx?.palette @@ -516,7 +631,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { higher-rank kinds (slabs, then walls / items / shelves) layer on top in the expected document-order z-stack. */} - {entries.map(({ id, base }) => (base ? renderEntry(id, base, `base-${id}`) : null))} + {displayEntries.map(({ id, base }) => (base ? renderEntry(id, base, `base-${id}`) : null))} {/* Overlay pass — interactive handles (vertex / midpoint / edge / move) and labels (text / dimensions). Painted after every base @@ -526,7 +641,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { still routes through the same selection-handling `` so a click on a zone's name selects the zone. */} - {entries.map(({ id, overlay }) => + {displayEntries.map(({ id, overlay }) => overlay ? renderEntry(id, overlay, `overlay-${id}`) : null, )} @@ -986,6 +1101,53 @@ function InteractiveGeometry({ ) } + case 'equal-spacing-badge': { + const accent = '#ec4899' + let degrees = (g.angle * 180) / Math.PI + if (degrees > 90) degrees -= 180 + else if (degrees <= -90) degrees += 180 + + const label = `= ${g.text}` + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = Math.max(unitsPerPixel * 10, 0.08) + const textWidth = label.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + return ( + + + + {label} + + + ) + } case 'dimension': { if (!palette) return <> const stroke = g.stroke ?? palette.measurementStroke @@ -1222,6 +1384,7 @@ const OVERLAY_KINDS = new Set([ 'move-handle', 'dimension', 'dimension-label', + 'equal-spacing-badge', ]) /** diff --git a/packages/editor/src/components/editor/action-menu-placement.test.ts b/packages/editor/src/components/editor/action-menu-placement.test.ts new file mode 100644 index 000000000..04f6605f1 --- /dev/null +++ b/packages/editor/src/components/editor/action-menu-placement.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import * as THREE from 'three' +import { getActionMenuAnchor } from './action-menu-placement' + +function boxFromSize(width: number, height: number, depth: number) { + return new THREE.Box3( + new THREE.Vector3(-width / 2, -height / 2, -depth / 2), + new THREE.Vector3(width / 2, height / 2, depth / 2), + ) +} + +describe('getActionMenuAnchor', () => { + test('keeps compact data labels close to the label center', () => { + const anchor = getActionMenuAnchor( + { type: 'data-widget' }, + boxFromSize(1.6, 0.5, 0.08), + new THREE.Vector3(), + ) + + expect(anchor.y).toBeCloseTo(0.24) + }) + + test('places data chart menus above the full html panel footprint', () => { + const anchor = getActionMenuAnchor( + { type: 'data-chart' }, + boxFromSize(1.65, 0.7, 0.08), + new THREE.Vector3(), + ) + + expect(anchor.y).toBeCloseTo(0.85) + }) + + test('treats card-style data widgets as html panels', () => { + const anchor = getActionMenuAnchor( + { type: 'data-widget', widgetType: 'card' }, + boxFromSize(2.8, 1, 0.08), + new THREE.Vector3(), + ) + + expect(anchor.y).toBeCloseTo(1) + }) +}) diff --git a/packages/editor/src/components/editor/action-menu-placement.ts b/packages/editor/src/components/editor/action-menu-placement.ts new file mode 100644 index 000000000..28d358fa4 --- /dev/null +++ b/packages/editor/src/components/editor/action-menu-placement.ts @@ -0,0 +1,63 @@ +'use client' + +import { nodeRegistry, type ActionMenuPlacementRule } from '@pascal-app/core' +import * as THREE from 'three' + +export const ACTION_MENU_DISTANCE_FACTOR = 6 + +type ActionMenuPlacementNode = { type: string; widgetType?: string } + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) + +const RULE_BY_NODE_TYPE = new Map([ + ['data-widget', 'html-compact'], + ['data-chart', 'html-panel'], + ['data-table', 'html-panel'], + ['slab', 'flat-structure'], + ['ceiling', 'flat-structure'], +]) + +function getPlacementRule( + node: ActionMenuPlacementNode, + size: THREE.Vector3, +): ActionMenuPlacementRule { + if (node.type === 'data-widget' && (node.widgetType === 'card' || node.widgetType === 'chart')) { + return 'html-panel' + } + + const explicitRule = + nodeRegistry.get(node.type)?.actionMenu?.placement ?? RULE_BY_NODE_TYPE.get(node.type) + if (explicitRule) return explicitRule + return size.y > 4 ? 'bbox-tall' : 'bbox' +} + +function getAnchorGap(rule: ActionMenuPlacementRule, size: THREE.Vector3) { + switch (rule) { + case 'html-compact': + return 0.24 + case 'html-panel': + return 0.5 + case 'bbox-tall': + return 0.24 + case 'flat-structure': + return 0.5 + case 'linear': + return 0.22 + case 'bbox': + return clamp(size.y * 0.08, 0.18, 0.32) + } +} + +export function getActionMenuAnchor( + node: ActionMenuPlacementNode, + box: THREE.Box3, + target: THREE.Vector3, + sizeTarget = new THREE.Vector3(), +) { + const size = box.getSize(sizeTarget) + const center = box.getCenter(target) + const rule = getPlacementRule(node, size) + const yBase = rule === 'html-compact' ? center.y : box.max.y + + return target.set(center.x, yBase + getAnchorGap(rule, size), center.z) +} diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx new file mode 100644 index 000000000..1a188af93 --- /dev/null +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { exportSceneToGlb } from '../../lib/glb-export' + +/** Resolve after the next couple of animation frames, giving React/R3F time to + * commit and mount export-only geometry (e.g. instanced kinds' real meshes) + * before the exporter clones the scene graph. */ +function nextFrames(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) +} + +export function BakeExporter({ + active, + onComplete, + onError, +}: { + active: boolean + onComplete: (buffer: ArrayBuffer) => void + onError: (message: string) => void +}) { + const scene = useThree((s) => s.scene) + const doneRef = useRef(false) + useEffect(() => { + if (!(active && !doneRef.current)) return + doneRef.current = true + const run = async () => { + try { + // Signal export so instanced kinds (trees/flowers/grass) swap their + // invisible proxy for real, exportable geometry, then wait for the + // commit before cloning the scene graph. + useViewer.getState().setExporting(true) + await nextFrames() + const sceneGroup = scene.getObjectByName('scene-renderer') + if (!sceneGroup) throw new Error('scene-renderer group not found') + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + onComplete(buffer) + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } finally { + useViewer.getState().setExporting(false) + } + } + void run() + }, [active, scene, onComplete, onError]) + return null +} diff --git a/packages/editor/src/components/editor/canvas-lens/canvas-lens-helpers.ts b/packages/editor/src/components/editor/canvas-lens/canvas-lens-helpers.ts new file mode 100644 index 000000000..68704d578 --- /dev/null +++ b/packages/editor/src/components/editor/canvas-lens/canvas-lens-helpers.ts @@ -0,0 +1,81 @@ +import type { AnyNode } from '@pascal-app/core' +import type { ObjectCapabilityProfile } from '../../../lib/object-capabilities' + +export type AnyRecord = Record +export type LensNodeMap = Record + +export function isRecord(value: unknown): value is AnyRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function vector3(value: unknown): [number, number, number] | undefined { + if ( + Array.isArray(value) && + value.length >= 3 && + typeof value[0] === 'number' && + typeof value[1] === 'number' && + typeof value[2] === 'number' + ) { + return [value[0], value[1], value[2]] + } + return undefined +} + +export function numberValue(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +export function stringValue(value: unknown) { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +export function metadataOf(node: AnyNode | undefined) { + const metadata = (node as unknown as { metadata?: unknown })?.metadata + return isRecord(metadata) ? metadata : {} +} + +export function equipmentAssemblyOf(metadata: AnyRecord) { + return isRecord(metadata.equipmentAssembly) ? metadata.equipmentAssembly : undefined +} + +export function stationIdOf(node: AnyNode | undefined) { + const metadata = metadataOf(node) + const assembly = equipmentAssemblyOf(metadata) + return stringValue(metadata.stationId) ?? stringValue(assembly?.stationId) +} + +export function nodeBasePosition(node: AnyNode | undefined): [number, number, number] { + if (!node) return [0, 0, 0] + return vector3((node as unknown as AnyRecord).position) ?? [0, 0, 0] +} + +export function compactId(value: string | undefined) { + if (!value) return undefined + const parts = value.split(':') + return parts[parts.length - 1] || value +} + +export function uniqueStrings(values: readonly string[], limit = values.length) { + return values.filter((value, index) => values.indexOf(value) === index).slice(0, limit) +} + +export function isEquipmentProfile(profile: ObjectCapabilityProfile) { + return ( + profile.sources.includes('semantic-assembly') || + profile.sources.includes('factory-equipment') || + Boolean(profile.recipeId || profile.equipmentFamily) + ) +} + +export function estimateEquipmentHeight( + node: AnyNode | undefined, + profile: ObjectCapabilityProfile, + fallback = 2.4, +) { + if (!node) return fallback + const record = node as unknown as AnyRecord + if (profile.equipmentFamily === 'column') return 7.5 + if (profile.equipmentFamily === 'tank') return 3.2 + if (profile.equipmentFamily === 'pump') return 1.6 + return numberValue(record.height) ?? fallback +} diff --git a/packages/editor/src/components/editor/canvas-lens/data-lens-overlay.tsx b/packages/editor/src/components/editor/canvas-lens/data-lens-overlay.tsx new file mode 100644 index 000000000..3655ab80b --- /dev/null +++ b/packages/editor/src/components/editor/canvas-lens/data-lens-overlay.tsx @@ -0,0 +1,266 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + formatLiveDataValue, + isDynamicBinding, + isLiveDataBindingConfig, + type LiveDataPath, + type LiveDataValue, + useLiveData, + useScene, +} from '@pascal-app/core' +import useViewer from '@pascal-app/viewer/store' +import { Html } from '@react-three/drei' +import { Database, Radio, RadioTower } from 'lucide-react' +import type { DragEvent } from 'react' +import { memo, useMemo } from 'react' +import { resolveObjectCapabilities } from '../../../lib/object-capabilities' +import { planSemanticLiveDataBindingForPath } from '../../../lib/semantic-live-data-bindings' +import { cn } from '../../../lib/utils' +import useEditor from '../../../store/use-editor' +import { + type AnyRecord, + estimateEquipmentHeight, + isEquipmentProfile, + type LensNodeMap, + metadataOf, + nodeBasePosition, + stringValue, +} from './canvas-lens-helpers' + +type DataLensStatus = 'bound' | 'ready' + +type DataLensItem = { + nodeId: string + label: string + status: DataLensStatus + position: [number, number, number] + sourceLabel: string + bindingLabels: string[] + valueLabels: string[] +} + +type LiveDataLensContext = { + paths: LiveDataPath[] + values: Record +} + +const LIVE_DATA_PATH_DRAG_MIME = 'application/x-pascal-live-data-path' + +function dynamicBindingsFrom(metadata: AnyRecord) { + return Array.isArray(metadata.dynamicBindings) + ? metadata.dynamicBindings.filter(isDynamicBinding) + : [] +} + +function legacyBindingFrom(metadata: AnyRecord) { + const binding = metadata.liveDataBinding + return isLiveDataBindingConfig(binding) && binding.enabled !== false ? binding : undefined +} + +function hasLooseBindingMetadata(metadata: AnyRecord) { + return Boolean( + metadata.liveDataBindings || + metadata.dataBinding || + metadata.dataBindings || + metadata.telemetry, + ) +} + +function compactBindingLabel(label: string) { + return label.length > 40 ? `${label.slice(0, 37)}...` : label +} + +function livePathMeta(context: LiveDataLensContext, path: string | null | undefined) { + if (!path) return undefined + return context.paths.find((entry) => entry.path === path) +} + +function liveValueLabel(context: LiveDataLensContext, path: string | null | undefined) { + if (!path) return undefined + const meta = livePathMeta(context, path) + const value = context.values[path] + const formatted = formatLiveDataValue(value, meta?.unit) + const label = meta?.label ?? stringValue(path) ?? path + return `${label}: ${formatted}` +} + +function dataLensItems(nodes: LensNodeMap, context: LiveDataLensContext) { + const items: DataLensItem[] = [] + for (const node of Object.values(nodes)) { + const profile = resolveObjectCapabilities(node, nodes) + if (!profile || !isEquipmentProfile(profile)) continue + + const metadata = metadataOf(node) + const base = nodeBasePosition(node) + const dynamicBindings = dynamicBindingsFrom(metadata) + const legacyBinding = legacyBindingFrom(metadata) + const looseBinding = hasLooseBindingMetadata(metadata) + const bound = Boolean(legacyBinding || dynamicBindings.length || looseBinding) + const bindingLabels = [ + legacyBinding ? `${legacyBinding.effect}: ${legacyBinding.dataKey}` : undefined, + ...dynamicBindings.map((binding) => `${binding.type}: ${binding.path}`), + looseBinding && !legacyBinding && dynamicBindings.length === 0 + ? 'external telemetry' + : undefined, + ] + .filter((value): value is string => Boolean(value)) + .map(compactBindingLabel) + .slice(0, 4) + const valueLabels = [ + legacyBinding ? liveValueLabel(context, legacyBinding.dataKey) : undefined, + ...dynamicBindings + .map((binding) => liveValueLabel(context, binding.path) ?? stringValue(binding.path)) + .filter((value): value is string => Boolean(value)) + .slice(0, 2), + ].filter((value): value is string => Boolean(value)) + + items.push({ + nodeId: profile.nodeId, + label: profile.label ?? String(node?.id ?? 'Equipment'), + status: bound ? 'bound' : 'ready', + position: [base[0], base[1] + estimateEquipmentHeight(node, profile, 2.6) + 1.2, base[2]], + sourceLabel: bound + ? `${bindingLabels.length} binding${bindingLabels.length === 1 ? '' : 's'}` + : 'Ready to bind', + bindingLabels, + valueLabels, + }) + } + + return items.slice(0, 64) +} + +export const DataLensOverlay = memo(function DataLensOverlay() { + const showDataBindingOverlay = useEditor((state) => state.showDataBindingOverlay) + const nodes = useScene((state) => state.nodes) + const updateNode = useScene((state) => state.updateNode) + const markDirty = useScene((state) => state.markDirty) + const paths = useLiveData((state) => state.paths) + const values = useLiveData((state) => state.values) + const selectedIds = useViewer((state) => state.selection.selectedIds) + const setSelection = useViewer((state) => state.setSelection) + const items = useMemo( + () => (showDataBindingOverlay ? dataLensItems(nodes, { paths, values }) : []), + [showDataBindingOverlay, nodes, paths, values], + ) + const selectedIdSet = useMemo(() => new Set(selectedIds.map(String)), [selectedIds]) + + if (!showDataBindingOverlay || items.length === 0) return null + + return ( + + {items.map((item) => { + const selected = selectedIdSet.has(item.nodeId) + const bound = item.status === 'bound' + const Icon = bound ? RadioTower : Database + const handleDrop = (event: DragEvent) => { + const path = + event.dataTransfer.getData(LIVE_DATA_PATH_DRAG_MIME) || + event.dataTransfer.getData('text/plain') + if (!path) return + event.preventDefault() + event.stopPropagation() + const nodeMap = nodes as Record + const node = nodeMap[item.nodeId] + const profile = resolveObjectCapabilities(node, nodeMap) + if (!profile) return + const plan = planSemanticLiveDataBindingForPath({ + path, + profile, + node: node as AnyNode | undefined, + }) + if (!plan) return + updateNode(plan.nodeId as AnyNodeId, plan.patch) + markDirty(plan.nodeId as AnyNodeId) + setSelection({ selectedIds: [plan.nodeId as AnyNodeId] }) + } + return ( + + + + ) + })} + + ) +}) diff --git a/packages/editor/src/components/editor/canvas-lens/equipment-lens-overlay.tsx b/packages/editor/src/components/editor/canvas-lens/equipment-lens-overlay.tsx new file mode 100644 index 000000000..bbcfc8539 --- /dev/null +++ b/packages/editor/src/components/editor/canvas-lens/equipment-lens-overlay.tsx @@ -0,0 +1,182 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import useViewer from '@pascal-app/viewer/store' +import { Html } from '@react-three/drei' +import { Box, Wrench } from 'lucide-react' +import { memo, useMemo } from 'react' +import { + type ObjectCapabilityProfile, + resolveObjectCapabilities, +} from '../../../lib/object-capabilities' +import { cn } from '../../../lib/utils' +import useEditor from '../../../store/use-editor' +import { + type AnyRecord, + compactId, + estimateEquipmentHeight, + isEquipmentProfile, + type LensNodeMap, + metadataOf, + nodeBasePosition, + numberValue, + uniqueStrings, +} from './canvas-lens-helpers' + +type EquipmentLensItem = { + nodeId: string + label: string + family?: string + recipeId?: string + position: [number, number, number] + footprint: [number, number, number] + editableParts: string[] + editableCapabilityLabels: string[] + portCount: number +} + +function estimateFootprint(node: AnyNode | undefined, profile: ObjectCapabilityProfile) { + const record = (node ?? {}) as unknown as AnyRecord + if (profile.equipmentFamily === 'column') return [2.3, 0.04, 2.3] as [number, number, number] + if (profile.equipmentFamily === 'tank') return [3.6, 0.04, 3.6] as [number, number, number] + if (profile.equipmentFamily === 'pump') return [3.0, 0.04, 1.6] as [number, number, number] + return [ + numberValue(record.width) ?? 2.6, + 0.04, + numberValue(record.depth) ?? numberValue(record.length) ?? 2.0, + ] as [number, number, number] +} + +function roleLabel(role: string | undefined) { + return role?.trim() || 'part' +} + +function equipmentLensItems(nodes: LensNodeMap) { + const items: EquipmentLensItem[] = [] + for (const node of Object.values(nodes)) { + const profile = resolveObjectCapabilities(node, nodes) + if (!profile) continue + if (!isEquipmentProfile(profile)) continue + + const metadata = metadataOf(node) + const base = nodeBasePosition(node) + const height = estimateEquipmentHeight(node, profile, 2.2) + const editableParts = uniqueStrings( + profile.editableParts + .filter((part) => part.editable) + .map((part) => roleLabel(part.semanticRole ?? part.sourcePartKind)), + 5, + ) + const editableCapabilityLabels = profile.capabilities + .filter((capability) => capability.editable) + .map((capability) => capability.label) + .slice(0, 3) + + items.push({ + nodeId: profile.nodeId, + label: profile.label ?? String(node?.id ?? 'Equipment'), + family: + profile.equipmentFamily ?? + (typeof metadata.equipmentRole === 'string' ? metadata.equipmentRole : undefined), + recipeId: compactId(profile.recipeId), + position: [base[0], base[1] + height + 0.6, base[2]], + footprint: estimateFootprint(node, profile), + editableParts, + editableCapabilityLabels, + portCount: profile.ports.length, + }) + } + + return items.slice(0, 64) +} + +export const EquipmentLensOverlay = memo(function EquipmentLensOverlay() { + const showEquipmentOverlay = useEditor((state) => state.showEquipmentOverlay) + const nodes = useScene((state) => state.nodes) + const selectedIds = useViewer((state) => state.selection.selectedIds) + const setSelection = useViewer((state) => state.setSelection) + const items = useMemo( + () => (showEquipmentOverlay ? equipmentLensItems(nodes) : []), + [showEquipmentOverlay, nodes], + ) + const selectedIdSet = useMemo(() => new Set(selectedIds.map(String)), [selectedIds]) + + if (!showEquipmentOverlay || items.length === 0) return null + + return ( + + {items.map((item) => { + const selected = selectedIdSet.has(item.nodeId) + const basePosition: [number, number, number] = [item.position[0], 0.08, item.position[2]] + return ( + + + + + + + + + + ) + })} + + ) +}) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 5050ee4a0..3a4b2ca33 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -7,12 +7,14 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' +import { GRID_LAYER, ZONE_LAYER } from '@pascal-app/viewer/layers' +import useViewer from '@pascal-app/viewer/store' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' import { Box3, Vector3 } from 'three' import { EDITOR_LAYER } from '../../lib/constants' +import { computeSceneBoundsXZ, pickSceneCameraFocusBounds } from '../../lib/scene-bounds' import useEditor from '../../store/use-editor' const currentTarget = new Vector3() @@ -37,9 +39,12 @@ export const CustomCameraControls = () => { !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE const camera = useThree((state) => state.camera) + const gl = useThree((state) => state.gl) const raycaster = useThree((state) => state.raycaster) + const ignoreLeftSelectControlStartRef = useRef(false) useEffect(() => { camera.layers.enable(EDITOR_LAYER) + camera.layers.enable(GRID_LAYER) raycaster.layers.enable(EDITOR_LAYER) raycaster.layers.enable(ZONE_LAYER) }, [camera, raycaster]) @@ -118,6 +123,26 @@ export const CustomCameraControls = () => { } }, [cameraMode, isPreviewMode]) + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || isPreviewMode || useViewer.getState().spacePanning) return + ignoreLeftSelectControlStartRef.current = true + window.addEventListener( + 'pointerup', + () => { + ignoreLeftSelectControlStartRef.current = false + }, + { once: true }, + ) + } + + gl.domElement.addEventListener('pointerdown', onPointerDown, { capture: true }) + return () => { + gl.domElement.removeEventListener('pointerdown', onPointerDown, { capture: true }) + ignoreLeftSelectControlStartRef.current = false + } + }, [gl.domElement, isPreviewMode]) + // Touch gestures (mobile / trackpad). // - One finger drag → rotate by default (much easier on a phone), but // falls back to NONE while the user is actively @@ -138,9 +163,23 @@ export const CustomCameraControls = () => { const movingNode = useEditor((s) => s.movingNode) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) + const movingPipeEndpoint = useEditor((s) => s.movingPipeEndpoint) + const movingCableTrayEndpoint = useEditor((s) => s.movingCableTrayEndpoint) + const movingConveyorBeltEndpoint = useEditor((s) => s.movingConveyorBeltEndpoint) + const movingRoadEndpoint = useEditor((s) => s.movingRoadEndpoint) + const movingSteelBeamEndpoint = useEditor((s) => s.movingSteelBeamEndpoint) const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee' const isInteracting = Boolean( - tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive, + tool || + movingNode || + movingWallEndpoint || + movingFenceEndpoint || + movingPipeEndpoint || + movingCableTrayEndpoint || + movingConveyorBeltEndpoint || + movingRoadEndpoint || + movingSteelBeamEndpoint || + isBoxSelectActive, ) const touches = useMemo(() => { const twoFingerAction = @@ -170,6 +209,12 @@ export const CustomCameraControls = () => { space: false, } + const setSpacePanning = (panning: boolean) => { + keyState.space = panning + useViewer.getState().setSpacePanning(panning) + document.body.style.cursor = panning ? 'grab' : '' + } + const updateConfig = () => { if (!controls.current) return @@ -196,8 +241,7 @@ export const CustomCameraControls = () => { const onKeyDown = (event: KeyboardEvent) => { if (event.code === 'Space') { - keyState.space = true - document.body.style.cursor = 'grab' + setSpacePanning(true) } if (event.code === 'ShiftRight') { keyState.shiftRight = true @@ -216,8 +260,7 @@ export const CustomCameraControls = () => { const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') { - keyState.space = false - document.body.style.cursor = '' + setSpacePanning(false) } if (event.code === 'ShiftRight') { keyState.shiftRight = false @@ -234,13 +277,21 @@ export const CustomCameraControls = () => { updateConfig() } + const onBlur = () => { + setSpacePanning(false) + updateConfig() + } + document.addEventListener('keydown', onKeyDown) document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) updateConfig() return () => { + setSpacePanning(false) document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) } }, [cameraMode, isPreviewMode]) @@ -389,7 +440,7 @@ export const CustomCameraControls = () => { focusNode(nodeId) } - const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => { + const handleFitScene = ({ bounds, reason }: CameraControlFitSceneEvent) => { if (!controls.current || isPreviewMode) return if (!bounds) { // Restore default framing pose when no bounds were computed. @@ -401,8 +452,9 @@ export const CustomCameraControls = () => { // Use the longer horizontal extent to size the orbit radius so the whole // footprint sits in view regardless of aspect ratio. const maxExtent = Math.max(w, d) - const distance = Math.max(maxExtent * 1.4, 15) - const height = Math.max(maxExtent * 0.8, 10) + const isFactoryFocus = reason === 'factory-key-process' + const distance = Math.max(maxExtent * (isFactoryFocus ? 1.05 : 1.4), 15) + const height = Math.max(maxExtent * (isFactoryFocus ? 0.55 : 0.8), 10) controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true) } @@ -414,7 +466,15 @@ export const CustomCameraControls = () => { emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) emitter.on('camera-controls:fit-scene', handleFitScene) + const initialFitFrame = requestAnimationFrame(() => { + const nodes = useScene.getState().nodes as Parameters[0] + const focus = pickSceneCameraFocusBounds(nodes) + const bounds = focus?.bounds ?? computeSceneBoundsXZ(nodes) + if (bounds) handleFitScene({ bounds, reason: focus?.reason ?? 'scene-bounds' }) + }) + return () => { + cancelAnimationFrame(initialFitFrame) emitter.off('camera-controls:capture', handleNodeCapture) emitter.off('camera-controls:focus', handleNodeFocus) emitter.off('camera-controls:view', handleNodeView) @@ -426,6 +486,7 @@ export const CustomCameraControls = () => { }, [focusNode, isPreviewMode]) const onTransitionStart = useCallback(() => { + if (ignoreLeftSelectControlStartRef.current) return useViewer.getState().setCameraDragging(true) }, []) @@ -442,11 +503,15 @@ export const CustomCameraControls = () => { makeDefault maxDistance={100} maxPolarAngle={maxPolarAngle} - minDistance={10} + minDistance={0.5} minPolarAngle={0} mouseButtons={mouseButtons} + onControlEnd={onRest} + onControlStart={onTransitionStart} + onEnd={onRest} onRest={onRest} onSleep={onRest} + onStart={onTransitionStart} onTransitionStart={onTransitionStart} ref={controls} restThreshold={0.01} diff --git a/packages/editor/src/components/editor/editor-layout-mobile.tsx b/packages/editor/src/components/editor/editor-layout-mobile.tsx index 5a364ea02..5e193ab05 100644 --- a/packages/editor/src/components/editor/editor-layout-mobile.tsx +++ b/packages/editor/src/components/editor/editor-layout-mobile.tsx @@ -1,6 +1,6 @@ 'use client' -import { useViewer } from '@pascal-app/viewer' +import useViewer from '@pascal-app/viewer/store' import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import useEditor from '../../store/use-editor' import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar' @@ -69,6 +69,7 @@ export function EditorLayoutMobile({ const [committedSheetH, setCommittedSheetH] = useState(0) const currentTab = sidebarTabs.find((t) => t.id === activePanel) + const prevPanelRef = useRef(activePanel) // Keep active panel valid useEffect(() => { @@ -83,17 +84,21 @@ export function EditorLayoutMobile({ // desktop "Furnish" action which itself opens the Items panel). // - Leaving Items while still furnishing exits the build mode. useEffect(() => { - const { phase, mode, setMode, setPhase } = useEditor.getState() + const prev = prevPanelRef.current + prevPanelRef.current = activePanel + const { phase, mode, setMode, enterFurnishBuildMode } = useEditor.getState() + if (activePanel === 'ai' && mode === 'build') { setMode('select') return } if (activePanel === 'items') { - if (phase !== 'furnish') setPhase('furnish') - if (mode !== 'build') setMode('build') + if (phase !== 'furnish' || mode !== 'build') { + enterFurnishBuildMode({ openItemsPanel: false }) + } return } - if (phase === 'furnish' && mode === 'build') { + if (prev === 'items' && activePanel !== 'items' && phase === 'furnish' && mode === 'build') { setMode('select') } }, [activePanel]) diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index 3a794308a..fbab9fe17 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -34,6 +34,7 @@ function LeftColumn({ const isResizing = useRef(false) const isExpanding = useRef(false) + const prevPanelRef = useRef(activePanel) // Ensure active panel is a valid tab useEffect(() => { @@ -42,12 +43,17 @@ function LeftColumn({ } }, [tabs, activePanel, setActivePanel]) - // Leaving the items tab while furnishing should drop back to select mode + // Leaving the items tab while furnishing should drop back to select mode. + // Only react to explicit tab transitions — not mount/rehydrate while still + // on another tab, which used to race with enterFurnishBuildMode(). useEffect(() => { - if (activePanel === 'items') return - const { phase, mode, setMode } = useEditor.getState() - if (phase === 'furnish' && mode === 'build') { - setMode('select') + const prev = prevPanelRef.current + prevPanelRef.current = activePanel + if (prev === 'items' && activePanel !== 'items') { + const { phase, mode, setMode } = useEditor.getState() + if (phase === 'furnish' && mode === 'build') { + setMode('select') + } } }, [activePanel]) @@ -129,7 +135,7 @@ function LeftColumn({ {/* Resize handle + hit area */}
@@ -230,13 +236,15 @@ export function EditorLayoutV2({ {/* Main content: left column + right column */}
- {!isCaptureMode && sidebarTabs.length > 0 && ( - - )} +
0 ? 'contents' : 'hidden'}> + {!isCaptureMode && sidebarTabs.length > 0 && ( + + )} +
state.scene) @@ -23,39 +25,26 @@ export function ExportManager() { const date = new Date().toISOString().split('T')[0] if (format === 'stl') { + const exportScene = prepareSceneForExport(sceneGroup) const exporter = new STLExporter() - const result = exporter.parse(sceneGroup, { binary: true }) + const result = exporter.parse(exportScene, { binary: true }) const blob = new Blob([result], { type: 'model/stl' }) downloadBlob(blob, `model_${date}.stl`) return } if (format === 'obj') { + const exportScene = prepareSceneForExport(sceneGroup) const exporter = new OBJExporter() - const result = exporter.parse(sceneGroup) + const result = exporter.parse(exportScene) const blob = new Blob([result], { type: 'model/obj' }) downloadBlob(blob, `model_${date}.obj`) return } - // Default: GLB export (existing behavior) - const exporter = new GLTFExporter() - - return new Promise((resolve, reject) => { - exporter.parse( - sceneGroup, - (gltf) => { - const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' }) - downloadBlob(blob, `model_${date}.glb`) - resolve() - }, - (error) => { - console.error('Export error:', error) - reject(error) - }, - { binary: true }, - ) - }) + const glb = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + const blob = new Blob([glb], { type: 'model/gltf-binary' }) + downloadBlob(blob, `model_${date}.glb`) } setExportScene(exportFn) @@ -68,6 +57,30 @@ export function ExportManager() { return null } +function isMeshWithInvalidGeometry(object: Object3D): object is Mesh { + if (!('isMesh' in object) || !(object as Mesh).isMesh) return false + const geometry = (object as Mesh).geometry + const position = geometry?.getAttribute?.('position') + return !position || position.count === 0 +} + +function prepareSceneForExport(sceneGroup: Object3D): Object3D { + const exportScene = sceneGroup.clone(true) + const invalidMeshes: Object3D[] = [] + + exportScene.traverse((object) => { + if (isMeshWithInvalidGeometry(object)) { + invalidMeshes.push(object) + } + }) + + for (const mesh of invalidMeshes) { + mesh.parent?.remove(mesh) + } + + return exportScene +} + function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const link = document.createElement('a') diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index e1793c5d5..e31d9f686 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -25,7 +25,7 @@ import { useInteractive, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import useViewer from '@pascal-app/viewer/store' import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -78,6 +78,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 +const VOID_FALL_RESPAWN_DEPTH = 12 const keyboardMap = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, @@ -1217,6 +1218,25 @@ export const FirstPersonControls = () => { if (!controllerRef.current?.group) return const group = controllerRef.current.group + + const worldBounds = worldRef.current?.bounds + if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) { + const respawnPosition: [number, number, number] | undefined = placedSpawn + ? [ + placedSpawn.position[0], + placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE, + placedSpawn.position[2], + ] + : controllerStart?.position + + if (respawnPosition) { + group.position.set(respawnPosition[0], respawnPosition[1], respawnPosition[2]) + controllerRef.current.resetLinVel() + ridingElevatorRef.current = null + setElevatorRideLocked(false) + } + } + group.rotation.y = 0 camera.position.copy(group.position).add(cameraOffset) cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index f977aab87..864ca430e 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -1,8 +1,10 @@ import { + type AnyNode, type AnyNodeId, type DoorNode, getGarageVisibleOpeningRatio, isOperationDoorType, + nodeRegistry, sceneRegistry, useInteractive, useScene, @@ -10,21 +12,11 @@ import { import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' - -const COLLIDER_NODE_TYPES = [ - 'wall', - 'fence', - 'slab', - 'stair', - 'stair-segment', - 'roof', - 'roof-segment', - 'door', - 'window', - 'item', -] as const +import { computeSceneBoundsXZ } from '../../../lib/scene-bounds' const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']) +const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish']) +const DEDICATED_COLLIDER_NODE_TYPES = new Set(['elevator']) const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial() const DOWN = new THREE.Vector3(0, -1, 0) const UP = new THREE.Vector3(0, 1, 0) @@ -32,6 +24,10 @@ const SPAWN_EYE_HEIGHT = 1.65 const RAYCAST_CLEARANCE = 25 const DOOR_LEAF_COLLIDER_DEPTH = 0.06 const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85 +const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08 +const LEVEL_FALLBACK_FLOOR_PADDING = 2 +const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30 +const SITE_GROUND_COLLIDER_MIN_SIZE = 2000 export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT @@ -46,16 +42,174 @@ export type FirstPersonSpawn = { yaw: number } -type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number] +type LevelNode = Extract +type SiteNode = Extract +type SceneNodes = ReturnType['nodes'] function isMesh(object: THREE.Object3D): object is THREE.Mesh { return 'isMesh' in object && (object as THREE.Mesh).isMesh } +// Renderer-effective visibility: an invisible ancestor hides the whole +// subtree at render time even when the object's own flag is true. The +// collider world must match what's rendered — the roof keeps stale, +// UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit +// exit hides the wrapper without stripping geometry), and cloning those +// meshes would block the walkthrough player at openings the visible +// merged shell has cut through. +function isEffectivelyVisible(object: THREE.Object3D) { + let current: THREE.Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) { return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible } +function isGenericColliderNode(node: AnyNode) { + if (node.visible === false) return false + if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false + const def = nodeRegistry.get(node.type) + // Ceilings are a transparent mount surface for fixtures (lights, fans), not a + // walkable or blocking structure — the walkthrough player must pass through + // them rather than be held up as if standing on a floor slab. + if (node.type === 'ceiling') return false + return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '') +} + +function createBoxColliderGeometry(width: number, height: number, depth: number) { + const sourceGeometry = new THREE.BoxGeometry(width, height, depth).toNonIndexed() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) + geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) + sourceGeometry.dispose() + return geometry +} + +function getVisibleLevelChildren(level: LevelNode, nodes: SceneNodes) { + return level.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => Boolean(child && child.visible !== false)) +} + +function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) { + if (level.visible === false) return null + + const children = getVisibleLevelChildren(level, nodes) + if (children.some((child) => child.type === 'slab')) return null + + const levelObject = sceneRegistry.nodes.get(level.id) + if (!levelObject?.visible) return null + + const bounds = computeSceneBoundsXZ(children) + const [centerX, centerZ] = bounds?.center ?? [0, 0] + const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0] + const width = Math.max( + boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + const depth = Math.max( + boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + + const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth) + + levelObject.updateWorldMatrix(true, false) + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ), + ) + geometry.applyMatrix4(levelObject.matrixWorld) + return geometry +} + +function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { + const geometries: THREE.BufferGeometry[] = [] + + for (const levelId of sceneRegistry.byType.level!) { + const node = nodes[levelId as AnyNodeId] + if (node?.type !== 'level') continue + + const geometry = createLevelFallbackFloorGeometry(node, nodes) + if (geometry) geometries.push(geometry) + } + + return geometries +} + +// The visible ground is the site node's ground mesh, but `site` is a `site` +// category node and therefore excluded from the generic collider sweep. Without +// a dedicated collider, a spawn on the bare ground (no slab, or not parented to +// a level that triggers the per-level fallback) has no floor to stand on and the +// walkthrough player falls through. Derive a thin ground slab from node data (not +// the rendered mesh) so it exists regardless of geometry-mount timing. The slab +// is effectively unbounded (not sized to the site polygon): the ground plane must +// keep holding the player up even after they step past the site boundary, +// otherwise they fall below the ground plane into the void. +function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) { + if (site.visible === false) return null + + const siteObject = sceneRegistry.nodes.get(site.id) + if (!siteObject?.visible) return null + + const bounds = computeSceneBoundsXZ(nodes) + const [centerX, centerZ] = bounds?.center ?? [0, 0] + const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0] + const width = Math.max( + boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + SITE_GROUND_COLLIDER_MIN_SIZE, + ) + const depth = Math.max( + boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + SITE_GROUND_COLLIDER_MIN_SIZE, + ) + + const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth) + + siteObject.updateWorldMatrix(true, false) + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ), + ) + geometry.applyMatrix4(siteObject.matrixWorld) + return geometry +} + +function collectSiteGroundColliderGeometries(nodes: SceneNodes) { + const geometries: THREE.BufferGeometry[] = [] + + for (const siteId of sceneRegistry.byType.site ?? []) { + const node = nodes[siteId as AnyNodeId] + if (node?.type !== 'site') continue + + const geometry = createSiteGroundColliderGeometry(node, nodes) + if (geometry) geometries.push(geometry) + } + + return geometries +} + +// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a +// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every +// merged geometry to share the same typed-array constructor for matching attributes, so +// imported item GLBs using KHR_mesh_quantization or interleaved buffers must be coerced +// to Float32 to match wall/slab geometry. +function toFloat32Attribute(source: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) { + const itemSize = source.itemSize + const array = new Float32Array(source.count * itemSize) + for (let i = 0; i < source.count; i++) { + const offset = i * itemSize + array[offset] = source.getX(i) + if (itemSize > 1) array[offset + 1] = source.getY(i) + if (itemSize > 2) array[offset + 2] = source.getZ(i) + if (itemSize > 3) array[offset + 3] = source.getW(i) + } + return new THREE.BufferAttribute(array, itemSize) +} + function cloneWorldGeometry(mesh: THREE.Mesh) { const sourceGeometry = mesh.geometry const position = sourceGeometry.getAttribute('position') @@ -65,11 +219,14 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() const cleanGeometry = new THREE.BufferGeometry() - cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) + cleanGeometry.setAttribute( + 'position', + toFloat32Attribute(workingGeometry.getAttribute('position')), + ) const normal = workingGeometry.getAttribute('normal') if (normal) { - cleanGeometry.setAttribute('normal', normal.clone()) + cleanGeometry.setAttribute('normal', toFloat32Attribute(normal)) } else { cleanGeometry.computeVertexNormals() } @@ -86,16 +243,12 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { return cleanGeometry } -function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { - if (type === 'window') { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - return node?.type === 'window' && node.openingKind === 'opening' +function shouldSkipColliderNode(node: AnyNode) { + if (node.type === 'window') { + return node.openingKind === 'opening' } - if (type !== 'door') return false - - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if (!node || node.type !== 'door') return false + if (node.type !== 'door') return false if (!node.segments.length) return true @@ -124,15 +277,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { const visibleHeight = leafH * (1 - openAmount) if (visibleHeight <= 0.12) return null - const sourceGeometry = new THREE.BoxGeometry( - leafW, - visibleHeight, - DOOR_LEAF_COLLIDER_DEPTH, - ).toNonIndexed() - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) - geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) - sourceGeometry.dispose() + const geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH) const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2 geometry.applyMatrix4( root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)), @@ -153,15 +298,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0)) const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - const sourceGeometry = new THREE.BoxGeometry( - leafW, - leafH, - DOOR_LEAF_COLLIDER_DEPTH, - ).toNonIndexed() - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) - geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) - sourceGeometry.dispose() + const geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH) const matrix = root.matrixWorld .clone() .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0)) @@ -172,16 +309,17 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { return geometry } -function buildRegisteredNodeTypeLookup() { - const nodeTypes = new Map() +function buildRegisteredColliderNodeIds(nodes: SceneNodes) { + const nodeIds = new Set() - for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]!) { - nodeTypes.set(nodeId, type) - } + for (const nodeId of sceneRegistry.nodes.keys()) { + const node = nodes[nodeId as AnyNodeId] + if (!node || !isGenericColliderNode(node)) continue + if (shouldSkipColliderNode(node)) continue + nodeIds.add(nodeId) } - return nodeTypes + return nodeIds } function collectColliderGeometriesFromNode( @@ -189,7 +327,7 @@ function collectColliderGeometriesFromNode( rootNodeId: string, visitedMeshes: WeakSet, registeredObjectIds: Map, - registeredNodeTypes: Map, + registeredColliderNodeIds: Set, ): THREE.BufferGeometry[] { const geometries: THREE.BufferGeometry[] = [] @@ -197,9 +335,12 @@ function collectColliderGeometriesFromNode( if (visitedMeshes.has(object)) return visitedMeshes.add(object) + // Prune hidden subtrees — children of an invisible group never render, + // so they must not collide either (see isEffectivelyVisible). + if (!object.visible) return + if ( isMesh(object) && - object.visible && isColliderMaterialVisible(object.material) && !SKIPPED_MESH_NAMES.has(object.name) ) { @@ -211,11 +352,8 @@ function collectColliderGeometriesFromNode( for (const child of object.children) { const childNodeId = registeredObjectIds.get(child) - if (childNodeId && childNodeId !== rootNodeId) { - const childType = registeredNodeTypes.get(childNodeId) - if (childType && COLLIDER_NODE_TYPES.includes(childType)) { - continue - } + if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) { + continue } visit(child) @@ -228,52 +366,59 @@ function collectColliderGeometriesFromNode( } export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null { + const nodes = useScene.getState().nodes const geometries: THREE.BufferGeometry[] = [] const visitedMeshes = new WeakSet() - const registeredNodeTypes = buildRegisteredNodeTypeLookup() + const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes) const registeredObjectIds = new Map() for (const [nodeId, object] of sceneRegistry.nodes) { registeredObjectIds.set(object, nodeId) } - for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]!) { - if (shouldSkipColliderNode(nodeId, type)) continue + for (const nodeId of registeredColliderNodeIds) { + const node = nodes[nodeId as AnyNodeId] + if (!node) continue - const root = sceneRegistry.nodes.get(nodeId) - if (!root) continue + const root = sceneRegistry.nodes.get(nodeId) + if (!root) continue - if (type === 'door') { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if (node?.type !== 'door') continue + // Registered objects can sit inside a hidden wrapper (roof segments + // under `segments-wrapper`) — the per-node traversal starts AT the + // object, so the ancestor chain must be checked here. + if (!isEffectivelyVisible(root)) continue - const doorGeometry = createDoorLeafColliderGeometry(root, node) - if (doorGeometry) { - geometries.push(doorGeometry) - } - continue + if (node.type === 'door') { + const doorGeometry = createDoorLeafColliderGeometry(root, node) + if (doorGeometry) { + geometries.push(doorGeometry) } - - root.updateMatrixWorld(true) - geometries.push( - ...collectColliderGeometriesFromNode( - root, - nodeId, - visitedMeshes, - registeredObjectIds, - registeredNodeTypes, - ), - ) + continue } + + root.updateMatrixWorld(true) + geometries.push( + ...collectColliderGeometriesFromNode( + root, + nodeId, + visitedMeshes, + registeredObjectIds, + registeredColliderNodeIds, + ), + ) } + geometries.push(...collectLevelFallbackFloorGeometries(nodes)) + geometries.push(...collectSiteGroundColliderGeometries(nodes)) + if (geometries.length === 0) { return null } const mergedGeometry = mergeGeometries(geometries, false) - geometries.forEach((geometry) => geometry.dispose()) + for (const geometry of geometries) { + geometry.dispose() + } if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { mergedGeometry?.dispose() @@ -288,7 +433,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree bvhGeometry.computeBoundsTree?.({ - maxLeafTris: 12, + maxLeafSize: 12, strategy: 0, } as never) bvhGeometry.computeBoundingBox() diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 7e21d81af..0b82fad19 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - type CeilingNode, ColumnNode, DoorNode, ElevatorNode, @@ -12,8 +11,8 @@ import { ItemNode, isRegistrySelectable, nodeRegistry, + PipeNode, RoofSegmentNode, - type SlabNode, SpawnNode, StairNode, StairSegmentNode, @@ -22,16 +21,21 @@ import { WallNode, WindowNode, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import useViewer from '@pascal-app/viewer/store' import { Html } from '@react-three/drei' -import { useFrame } from '@react-three/fiber' +import { useFrame, useThree } from '@react-three/fiber' import { Move } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' +import { t } from '../../i18n' +import { createEditorApi } from '../../lib/editor-api' +import { isPlanDragMovableNode } from '../../lib/plan-drag' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' +import { duplicateNodeSubtree } from '../../lib/subtree-duplication' import useEditor from '../../store/use-editor' +import { ACTION_MENU_DISTANCE_FACTOR, getActionMenuAnchor } from './action-menu-placement' import { NodeActionMenu } from './node-action-menu' const ALLOWED_TYPES = [ @@ -45,34 +49,64 @@ const ALLOWED_TYPES = [ 'stair-segment', 'wall', 'fence', + 'pipe', 'column', 'slab', 'ceiling', 'spawn', ] const DELETE_ONLY_TYPES: string[] = [] -const HOLE_TYPES = ['slab', 'ceiling'] +const ENDPOINT_BUTTON_BASE_CLASS = + 'pointer-events-auto flex h-6 w-6 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors' +const ENDPOINT_BUTTON_WALL_CLASS = + 'border-violet-400/60 bg-violet-500/10 text-violet-400 hover:border-violet-300/80 hover:bg-violet-500/20 hover:text-violet-200' +const ENDPOINT_BUTTON_DETACH_CLASS = + 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white' + +function getEndpointMoveLabel( + actionMenu: NonNullable>['actionMenu']>, + endpoint: 'start' | 'end', + detachHint: boolean, +): string { + const label = actionMenu.endpointMove?.label(endpoint, { detachHint }) + if (!label) return endpoint === 'start' ? 'Move start' : 'Move end' + return label.key ? t(label.key, label.fallback) : label.fallback +} export function FloatingActionMenu() { + const { camera } = useThree() const selectedIds = useViewer((s) => s.selection.selectedIds) const updateNode = useScene((s) => s.updateNode) const mode = useEditor((s) => s.mode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) + const movingNode = useEditor((s) => s.movingNode) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) + const movingPipeEndpoint = useEditor((s) => s.movingPipeEndpoint) + const movingCableTrayEndpoint = useEditor((s) => s.movingCableTrayEndpoint) + const movingConveyorBeltEndpoint = useEditor((s) => s.movingConveyorBeltEndpoint) + const movingRoadEndpoint = useEditor((s) => s.movingRoadEndpoint) + const movingSteelBeamEndpoint = useEditor((s) => s.movingSteelBeamEndpoint) + const activeAffordance = useEditor((s) => s.activeAffordance) + const curvingWall = useEditor((s) => s.curvingWall) const curvingFence = useEditor((s) => s.curvingFence) + const curvingPipe = useEditor((s) => s.curvingPipe) + const curvingCableTray = useEditor((s) => s.curvingCableTray) + const curvingRoad = useEditor((s) => s.curvingRoad) + const curvingSteelBeam = useEditor((s) => s.curvingSteelBeam) const setMovingNode = useEditor((s) => s.setMovingNode) - const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint) - const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint) - const setCurvingWall = useEditor((s) => s.setCurvingWall) - const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) - const groupRef = useRef(null) const startEndpointGroupRef = useRef(null) const endEndpointGroupRef = useRef(null) + const menuGroupRef = useRef(null) + const boxRef = useRef(new THREE.Box3()) + const sizeRef = useRef(new THREE.Vector3()) + const menuAnchorRef = useRef(new THREE.Vector3()) + const projectedAnchorRef = useRef(new THREE.Vector3()) const [altPressed, setAltPressed] = useState(false) + const [menuVisible, setMenuVisible] = useState(false) // Only show for single selection of specific types const selectedId = selectedIds.length === 1 ? selectedIds[0] : null @@ -80,28 +114,23 @@ export function FloatingActionMenu() { // Subscribe just to the selected node so unrelated scene updates do not // re-render this menu. const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null)) + const actionMenu = node ? nodeRegistry.get(node.type)?.actionMenu : undefined // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any // NodeDefinition with `capabilities.selectable`) get the floating menu // by default too. Phase 4 collapses these into a single registry check. const isValidType = node ? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type) : false - - // Boolean selector, only re-renders when curving availability actually flips. - const canCurveSelectedWall = useScene((s) => { - if (!selectedId) return false - const selectedNode = s.nodes[selectedId as AnyNodeId] - if (selectedNode?.type !== 'wall') return false - return !(selectedNode.children ?? []).some((childId) => { - const child = s.nodes[childId as AnyNodeId] - if (!child) return false - if (child.type === 'door' || child.type === 'window') return true - if (child.type === 'item') { - const attachTo = child.asset?.attachTo - return attachTo === 'wall' || attachTo === 'wall-side' - } - return false - }) + const isDirectPlanDraggable = node ? isPlanDragMovableNode(node) : false + const addHoleAction = node ? nodeRegistry.get(node.type)?.editActions?.addHole : undefined + const canDetachEndpoint = actionMenu?.endpointMove?.canDetach === true + const endpointButtonClass = `${ENDPOINT_BUTTON_BASE_CLASS} ${ + altPressed && canDetachEndpoint ? ENDPOINT_BUTTON_DETACH_CLASS : ENDPOINT_BUTTON_WALL_CLASS + }` + + const canCurveSelectedNode = useScene((s) => { + if (!(node && actionMenu?.curve)) return false + return actionMenu.curve.isAvailable?.(node as never, { nodes: s.nodes }) ?? true }) useEffect(() => { @@ -133,53 +162,41 @@ export function FloatingActionMenu() { }, []) useFrame(() => { - if (!(selectedId && isValidType && groupRef.current)) return + if (!(selectedId && isValidType)) { + if (menuVisible) setMenuVisible(false) + return + } const obj = sceneRegistry.nodes.get(selectedId) if (obj) { - // Calculate bounding box in world space - const box = new THREE.Box3().setFromObject(obj) - if (!box.isEmpty()) { - const center = box.getCenter(new THREE.Vector3()) - // Position above the object, with extra offset for walls/slabs to avoid covering measurement labels - const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type) - const yOffset = isStructural ? 0.8 : 0.3 - groupRef.current.position.set(center.x, box.max.y + yOffset, center.z) + obj.updateWorldMatrix(true, false) + + const box = boxRef.current.setFromObject(obj) + if (!box.isEmpty() && node) { + const anchor = getActionMenuAnchor(node, box, menuAnchorRef.current, sizeRef.current) + menuGroupRef.current?.position.copy(anchor) + const projected = projectedAnchorRef.current.copy(anchor).project(camera) + const nextVisible = projected.z >= -1 && projected.z <= 1 + if (menuVisible !== nextVisible) setMenuVisible(nextVisible) + } else if (menuVisible) { + setMenuVisible(false) } - if (node?.type === 'wall' || node?.type === 'fence') { - const segment = node as WallNode | FenceNode - const endpointYOffset = 0.35 - const startWorld = - node.type === 'wall' - ? obj.localToWorld(new THREE.Vector3(0, 0, 0)) - : obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1])) - const endWorld = - node.type === 'wall' - ? obj.localToWorld( - new THREE.Vector3( - Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]), - 0, - 0, - ), - ) - : obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1])) + if (node && actionMenu?.endpointMove) { + const startLocal = actionMenu.endpointMove.localPosition(node as never, 'start') + const endLocal = actionMenu.endpointMove.localPosition(node as never, 'end') + const startWorld = obj.localToWorld(new THREE.Vector3(...startLocal)) + const endWorld = obj.localToWorld(new THREE.Vector3(...endLocal)) if (startEndpointGroupRef.current) { - startEndpointGroupRef.current.position.set( - startWorld.x, - startWorld.y + endpointYOffset, - startWorld.z, - ) + startEndpointGroupRef.current.position.copy(startWorld) } if (endEndpointGroupRef.current) { - endEndpointGroupRef.current.position.set( - endWorld.x, - endWorld.y + endpointYOffset, - endWorld.z, - ) + endEndpointGroupRef.current.position.copy(endWorld) } } + } else if (menuVisible) { + setMenuVisible(false) } }) @@ -195,6 +212,7 @@ export function FloatingActionMenu() { node.type === 'elevator' || node.type === 'wall' || node.type === 'fence' || + node.type === 'pipe' || node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || @@ -219,35 +237,37 @@ export function FloatingActionMenu() { e.stopPropagation() if (!node) return sfxEmitter.emit('sfx:item-pick') - if (node.type === 'wall') { - if (!canCurveSelectedWall) return - setCurvingWall(node) - } else if (node.type === 'fence') { - setCurvingFence(node) - } else { - return - } + if (!canCurveSelectedNode) return + createEditorApi().engageCurve(node) setSelection({ selectedIds: [] }) }, - [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection], + [canCurveSelectedNode, node, setSelection], ) const handleEndpointMove = useCallback( - (endpoint: 'start' | 'end', e: React.MouseEvent) => { + (endpoint: 'start' | 'end', e: React.MouseEvent | React.PointerEvent) => { + e.preventDefault() e.stopPropagation() - if (!node) return + if (!(node && actionMenu?.endpointMove)) return sfxEmitter.emit('sfx:item-pick') - if (node.type === 'wall') { - setMovingWallEndpoint({ wall: node, endpoint }) - } else if (node.type === 'fence') { - setMovingFenceEndpoint({ fence: node, endpoint }) - } else { - return - } + createEditorApi().engageEndpointMove(node, endpoint) setSelection({ selectedIds: [] }) }, - [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection], + [actionMenu?.endpointMove, node, setSelection], + ) + + const handleEndpointPointerDown = useCallback( + (endpoint: 'start' | 'end', e: React.PointerEvent) => { + if (e.button !== 0) return + handleEndpointMove(endpoint, e) + }, + [handleEndpointMove], ) + const handleEndpointClick = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + const handleDuplicate = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -263,6 +283,27 @@ export function FloatingActionMenu() { return } + if ( + node.type !== 'stair' && + 'children' in node && + Array.isArray(node.children) && + node.children.length > 0 + ) { + useScene.temporal.getState().pause() + try { + const { root } = duplicateNodeSubtree(node.id as AnyNodeId, { + markRootNew: true, + }) + setMovingNode(root as any) + setSelection({ selectedIds: [] }) + } catch (error) { + console.error('Failed to duplicate subtree', error) + } finally { + useScene.temporal.getState().resume() + } + return + } + useScene.temporal.getState().pause() let duplicateInfo = structuredClone(node) as any @@ -287,6 +328,10 @@ export function FloatingActionMenu() { duplicate = FenceNode.parse(duplicateInfo) duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1] + } else if (node.type === 'pipe') { + duplicate = PipeNode.parse(duplicateInfo) + duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] + duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1] } else if (node.type === 'roof-segment') { duplicateInfo.id = generateId('rseg') duplicate = RoofSegmentNode.parse(duplicateInfo) @@ -332,6 +377,8 @@ export function FloatingActionMenu() { useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) } else if (duplicate.type === 'fence') { useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + } else if (duplicate.type === 'pipe') { + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) } else if ( duplicate.type === 'roof-segment' || duplicate.type === 'stair' || @@ -372,17 +419,17 @@ export function FloatingActionMenu() { duplicate.type === 'column' || duplicate.type === 'wall' || duplicate.type === 'fence' || + duplicate.type === 'pipe' || duplicate.type === 'window' || duplicate.type === 'door' || duplicate.type === 'roof-segment' || duplicate.type === 'spawn' || duplicate.type === 'stair-segment' || - // Registry-driven kinds get picked up by MoveTool's generic - // fallback (MoveRegistryNodeTool) so the user can reposition. nodeRegistry.has(duplicate.type) ) { setMovingNode(duplicate as any) } else if (duplicate.type === 'stair') { + useScene.temporal.getState().resume() setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) } if (duplicate.type !== 'stair') { @@ -396,39 +443,18 @@ export function FloatingActionMenu() { const handleAddHole = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - if (!(node && selectedId && (node.type === 'slab' || node.type === 'ceiling'))) return - - const polygon = (node as SlabNode | CeilingNode).polygon - let cx = 0 - let cz = 0 - for (const [x, z] of polygon) { - cx += x - cz += z - } - cx /= polygon.length - cz /= polygon.length - - const holeSize = 0.5 - const newHole: Array<[number, number]> = [ - [cx - holeSize, cz - holeSize], - [cx + holeSize, cz - holeSize], - [cx + holeSize, cz + holeSize], - [cx - holeSize, cz + holeSize], - ] - const surfaceNode = node as SlabNode | CeilingNode - const currentHoles = surfaceNode.holes || [] - const currentMetadata = currentHoles.map( - (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const }, - ) - updateNode(selectedId as AnyNodeId, { - holes: [...currentHoles, newHole], - holeMetadata: [...currentMetadata, { source: 'manual' }], - }) - setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) + if (!(node && selectedId && addHoleAction)) return + const patch = addHoleAction(node as never) + if (!patch) return + const holeIndex = Array.isArray((node as { holes?: unknown }).holes) + ? (node as { holes: unknown[] }).holes.length + : 0 + updateNode(selectedId as AnyNodeId, patch as Partial) + setEditingHole({ nodeId: selectedId, holeIndex }) // Re-assert selection so the node stays selected setSelection({ selectedIds: [selectedId] }) }, - [node, selectedId, updateNode, setEditingHole, setSelection], + [node, selectedId, addHoleAction, updateNode, setEditingHole, setSelection], ) const handleDelete = useCallback( @@ -448,43 +474,51 @@ export function FloatingActionMenu() { if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || + movingNode || movingWallEndpoint || movingFenceEndpoint || - curvingFence + movingPipeEndpoint || + movingCableTrayEndpoint || + movingConveyorBeltEndpoint || + movingRoadEndpoint || + movingSteelBeamEndpoint || + activeAffordance || + curvingWall || + curvingFence || + curvingPipe || + curvingCableTray || + curvingRoad || + curvingSteelBeam ) return null return ( - + - {(node?.type === 'wall' || node?.type === 'fence') && ( + {actionMenu?.endpointMove && ( <> diff --git a/packages/editor/src/components/editor/floating-building-action-menu.tsx b/packages/editor/src/components/editor/floating-building-action-menu.tsx index bc9cd8216..1554df5cf 100644 --- a/packages/editor/src/components/editor/floating-building-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-building-action-menu.tsx @@ -1,13 +1,14 @@ 'use client' import { type BuildingNode, sceneRegistry, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import useViewer from '@pascal-app/viewer/store' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useRef } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { ACTION_MENU_DISTANCE_FACTOR, getActionMenuAnchor } from './action-menu-placement' import { NodeActionMenu } from './node-action-menu' export function FloatingBuildingActionMenu() { @@ -17,16 +18,41 @@ export function FloatingBuildingActionMenu() { const setSelection = useViewer((s) => s.setSelection) const groupRef = useRef(null) + const boxRef = useRef(new THREE.Box3()) + const anchorRef = useRef(new THREE.Vector3()) + const sizeRef = useRef(new THREE.Vector3()) + const lastPlacementRef = useRef<{ + id: string | null + matrixWorld: number[] + }>({ id: null, matrixWorld: [] }) useFrame(() => { if (!(buildingId && !levelId && groupRef.current)) return const obj = sceneRegistry.nodes.get(buildingId) if (obj) { - const box = new THREE.Box3().setFromObject(obj) + const lastPlacement = lastPlacementRef.current + obj.updateWorldMatrix(true, false) + const matrixElements = obj.matrixWorld.elements + const matrixChanged = + lastPlacement.matrixWorld.length !== matrixElements.length || + matrixElements.some( + (value: number, index: number) => value !== lastPlacement.matrixWorld[index], + ) + if (buildingId === lastPlacement.id && !matrixChanged) return + + const box = boxRef.current.setFromObject(obj) if (!box.isEmpty()) { - const center = box.getCenter(new THREE.Vector3()) - groupRef.current.position.set(center.x, 1.5, center.z) + const node = useScene.getState().nodes[buildingId] + if (node) { + groupRef.current.position.copy( + getActionMenuAnchor(node, box, anchorRef.current, sizeRef.current), + ) + } + } + lastPlacementRef.current = { + id: buildingId, + matrixWorld: Array.from(matrixElements), } } }) @@ -53,6 +79,7 @@ export function FloatingBuildingActionMenu() { (null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) + const [columnBuildPreviewPoint, setColumnBuildPreviewPoint] = useState(null) const [isPanning, setIsPanning] = useState(false) const [isDraggingPanel, setIsDraggingPanel] = useState(false) const [isMacPlatform, setIsMacPlatform] = useState(true) @@ -4572,6 +4580,7 @@ export function FloorplanPanel() { const isFenceBuildActive = phase === 'structure' && mode === 'build' && tool === 'fence' const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof' const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' + const isColumnBuildActive = phase === 'structure' && mode === 'build' && tool === 'column' const isStairMoveActive = movingNode?.type === 'stair' const isRoofMoveActive = movingNode?.type === 'roof' || movingNode?.type === 'roof-segment' const isSlabMoveActive = movingNode?.type === 'slab' @@ -4598,6 +4607,7 @@ export function FloorplanPanel() { isRoofBuildActive || isCeilingBuildActive || isStairBuildActive || + isColumnBuildActive || isStairMoveActive || isRoofMoveActive || isSlabMoveActive || @@ -4678,6 +4688,27 @@ export function FloorplanPanel() { : floorplanStairEntries, [floorplanPreviewStairEntry, floorplanStairEntries], ) + const floorplanPreviewColumnEntry = useMemo(() => { + if (!(isColumnBuildActive && columnBuildPreviewPoint)) { + return null + } + + const previewColumn = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ + columnBuildPreviewPoint[0], + 0, + columnBuildPreviewPoint[1], + ]) + const polygon = getColumnPlanFootprint(previewColumn) + if (polygon.length < 3) { + return null + } + + return { + column: previewColumn, + points: formatPolygonPoints(polygon), + polygon, + } + }, [columnBuildPreviewPoint, isColumnBuildActive]) const floorplanOpeningLocalY = useMemo(() => { if (movingNode?.type === 'door' || movingNode?.type === 'window') { return snapToHalf(movingNode.position[1]) @@ -4818,6 +4849,21 @@ export function FloorplanPanel() { } }) }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon]) + const siteEdgeHandles = useMemo(() => { + if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon && !siteVertexDragState)) { + return [] + } + + return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] ?? point + return { + nodeId: visibleSitePolygon.site.id, + edgeIndex, + start: toWallPlanPoint(point), + end: toWallPlanPoint(nextPoint), + } + }) + }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon]) const draftPolygon = useMemo(() => { if (!(levelId && draftStart && draftEnd && isWallLongEnough(draftStart, draftEnd))) { @@ -6047,6 +6093,25 @@ export function FloorplanPanel() { } }, [isStairBuildActive]) + useEffect(() => { + if (!isColumnBuildActive) { + setColumnBuildPreviewPoint(null) + return + } + + const handleGridMove = (event: GridEvent) => { + setColumnBuildPreviewPoint( + getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]]), + ) + } + + emitter.on('grid:move', handleGridMove) + + return () => { + emitter.off('grid:move', handleGridMove) + } + }, [isColumnBuildActive]) + useEffect(() => { if (!isItemPlacementPreviewActive) { return @@ -7788,6 +7853,81 @@ export function FloorplanPanel() { }, [displaySitePolygon], ) + const handleSiteEdgePointerDown = useCallback( + (siteId: SiteNode['id'], edgeIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredSiteHandleId(null) + + if (!(displaySitePolygon && displaySitePolygon.site.id === siteId)) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + const basePolygon = displaySitePolygon.polygon.map(toWallPlanPoint) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + + const insertedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const vertexHitRadius = FLOORPLAN_POLYGON_VERTEX_HIT_RADIUS_PX * floorplanUnitsPerPixel + const nearestVertex = basePolygon.reduce<{ + index: number + distance: number + point: WallPlanPoint + } | null>((nearest, point, index) => { + const distance = Math.hypot(insertedPoint[0] - point[0], insertedPoint[1] - point[1]) + if (nearest && nearest.distance <= distance) { + return nearest + } + + return { index, distance, point } + }, null) + + if (nearestVertex && nearestVertex.distance <= vertexHitRadius) { + setSiteBoundaryDraft({ + siteId, + polygon: basePolygon, + }) + setSiteVertexDragState({ + pointerId: event.pointerId, + siteId, + vertexIndex: nearestVertex.index, + }) + setCursorPoint(nearestVertex.point) + return + } + + const insertIndex = edgeIndex + 1 + const nextPolygon = [ + ...basePolygon.slice(0, insertIndex), + insertedPoint, + ...basePolygon.slice(insertIndex), + ] + + setSiteBoundaryDraft({ + siteId, + polygon: nextPolygon, + }) + setSiteVertexDragState({ + pointerId: event.pointerId, + siteId, + vertexIndex: insertIndex, + }) + setCursorPoint(insertedPoint) + }, + [displaySitePolygon, floorplanUnitsPerPixel, getPlanPointFromClientPoint], + ) const handlePointerLeave = useCallback(() => { if (!(panStateRef.current || wallEndpointDragRef.current || siteVertexDragState)) { @@ -8476,6 +8616,19 @@ export function FloorplanPanel() { selectedIdSet={selectedIdSet} stairEntries={renderedFloorplanStairEntries} /> + {floorplanPreviewColumnEntry && ( + + )} + handleSiteEdgePointerDown(nodeId as SiteNode['id'], edgeIndex, event) + } onHandleHoverChange={setHoveredSiteHandleId} onMidpointPointerDown={(nodeId, edgeIndex, event) => handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event) diff --git a/packages/editor/src/components/editor/grid.tsx b/packages/editor/src/components/editor/grid.tsx index f39ccf7e9..63f4ff9ee 100644 --- a/packages/editor/src/components/editor/grid.tsx +++ b/packages/editor/src/components/editor/grid.tsx @@ -1,14 +1,16 @@ 'use client' import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { GRID_LAYER } from '@pascal-app/viewer/layers' +import { getSceneTheme } from '@pascal-app/viewer/scene-themes' +import useViewer from '@pascal-app/viewer/store' import { useFrame } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { MathUtils, type Mesh, Vector2 } from 'three' +import { MathUtils, type Mesh, PlaneGeometry, Vector2 } from 'three' import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { useCeilingEvents } from '../../hooks/use-ceiling-events' import { useGridEvents } from '../../hooks/use-grid-events' -import { EDITOR_LAYER } from '../../lib/constants' export const Grid = ({ cellSize = 0.5, @@ -31,11 +33,11 @@ export const Grid = ({ fadeStrength?: number revealRadius?: number }) => { - const theme = useViewer((state) => state.theme) + const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') - // Use slightly lighter colors for dark mode grid to make it apparent - const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor - const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor + // Use slightly lighter colors for dark themes' grid to make it apparent + const effectiveCellColor = isDark ? '#555566' : cellColor + const effectiveSectionColor = isDark ? '#666677' : sectionColor const cursorPositionRef = useRef(new Vector2(0, 0)) @@ -118,11 +120,23 @@ export const Grid = ({ // Use custom raycasting for grid events (independent of mesh events) useGridEvents(gridY) - - // Update cursor position from grid:move events + // Same technique for ceiling-item placement: a math-plane raycast per ceiling, + // so commits don't depend on hitting the thin, single-sided `ceiling-grid` + // overlay mesh (which dropped clicks even with the green box showing). + useCeilingEvents() + + // Track the last world-space cursor hit. The reveal-fade shader reads + // `positionLocal.xy` (vertex position on the un-transformed plane), and + // the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z` + // relative to the mesh origin. The mesh origin itself is lerped each + // frame toward the active building's world XZ (see `useFrame` below), + // so the local-frame cursor must be recomputed every frame from the + // stored world cursor — otherwise the ring drifts whenever the grid is + // mid-lerp (e.g. just after a building rotation commits). + const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null) useEffect(() => { const onGridMove = (event: GridEvent) => { - cursorPositionRef.current.set(event.position[0], -event.position[2]) + lastWorldCursorRef.current = { x: event.position[0], z: event.position[2] } } emitter.on('grid:move', onGridMove) @@ -132,10 +146,13 @@ export const Grid = ({ }, []) useFrame((_, delta) => { - const currentLevelId = useViewer.getState().selection.levelId + const { levelId } = useViewer.getState().selection + // Grid stays anchored to world XZ (0, 0) — never chases the active + // building. The Y origin still lerps to the active level so the grid + // sits at floor height when a level is open. let targetY = 0 - if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId) + if (levelId) { + const levelMesh = sceneRegistry.nodes.get(levelId) if (levelMesh) { targetY = levelMesh.position.y } @@ -143,19 +160,38 @@ export const Grid = ({ const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta) gridRef.current.position.y = newY setGridY(newY) + + // Grid XZ is fixed at world origin, so the local-frame cursor uniform + // is just the world cursor (mirrored on Z to match the -π/2 X-rotation + // of the plane). + const world = lastWorldCursorRef.current + if (world) { + cursorPositionRef.current.set(world.x, -world.z) + } }) const showGrid = useViewer((state) => state.showGrid) + // Pass the geometry as a prop instead of a JSX child so the mesh + // is never reconciled with R3F's empty placeholder `BufferGeometry`. + // Combined with the grid's `MeshBasicNodeMaterial`, the child-attach + // path can submit a `Draw(0, 1, 0, 0)` on the first frame before + // `` attaches — which WebGPU flags as "Vertex buffer + // slot 0 ... was not set" (see `wall-move-side-handles.tsx`). + const geometry = useMemo( + () => new PlaneGeometry(fadeDistance * 2, fadeDistance * 2), + [fadeDistance], + ) + useEffect(() => () => geometry.dispose(), [geometry]) + return ( - - + /> ) } diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx new file mode 100644 index 000000000..caf71ffe2 --- /dev/null +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -0,0 +1,539 @@ +'use client' + +import { type Cursor, emitter } from '@pascal-app/core' +import type { ThreeEvent } from '@react-three/fiber' +import { type ReactNode, useEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + type BufferGeometry, + CircleGeometry, + Color, + CylinderGeometry, + DoubleSide, + ExtrudeGeometry, + type Group, + type Intersection, + Mesh, + type Raycaster, + Shape, + TorusGeometry, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../../lib/constants' +import useEditor from '../../../store/use-editor' + +// While a press-drag move is in flight (`placementDragMode`), the move tool +// owns the pointer and the handle rig rides the moving node — so a handle hit +// area would sit under the cursor and starve the tool's surface raycast +// (`wall:move` for openings, `grid:move` for free movers), freezing the drag. +// Make every handle hit area inert for the duration; the indicator mesh still +// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible. +function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void { + if (useEditor.getState().placementDragMode) return + Mesh.prototype.raycast.call(this, raycaster, intersects) +} + +export const ARROW_SCALE = 0.65 +export const ARROW_COLOR = '#8381ed' +export const ARROW_HOVER_COLOR = '#a5b4fc' +export const NO_RAYCAST = () => null +export const HIT_AREA_MARGIN = 0.035 + +const HIT_AREA_RENDER_ORDER = 1011 +const HIT_AREA_THICKNESS = 0.08 +const CHEVRON_MIN_X = -0.2 +const CHEVRON_MAX_X = 0.22 +const CHEVRON_HALF_WIDTH = 0.12 +const CHEVRON_NOTCH_X = -0.04 +const CHEVRON_SHAFT_HALF_WIDTH = 0.035 +const CHEVRON_DEPTH = 0.08 +const CHEVRON_BEVEL_THICKNESS = 0.035 +const CHEVRON_BEVEL_SIZE = 0.03 +const CHEVRON_BEVEL_SEGMENTS = 10 +const MOVE_CROSS_HALF_LENGTH = 0.36 +const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03 +const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12 +const MOVE_CROSS_HEAD_INSET = 0.2 +const MOVE_CROSS_DEPTH = 0.06 +const MOVE_CROSS_BEVEL_THICKNESS = 0.018 +const MOVE_CROSS_BEVEL_SIZE = 0.012 +const MOVE_CROSS_BEVEL_SEGMENTS = 6 +const ROTATE_HANDLE_RADIUS = 0.2 +const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3 +const ROTATE_RIBBON_HALF_WIDTH = 0.02 +const ROTATE_HEAD_HALF_WIDTH = 0.045 +const TRACKER_CUBE_SIZE = 0.16 +export const CORNER_HEX_RADIUS = 0.16 + +export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker' +export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross' + +export type HandleArrowPlacement = { + position: readonly [number, number, number] + rotation?: readonly [number, number, number] + baseScale: number +} + +type PointerHandler = (event: ThreeEvent) => void + +export type HandleArrowProps = { + shape: HandleArrowInputShape + placement: HandleArrowPlacement + hover: boolean + cursor: Cursor + onHoverChange: (hovered: boolean) => void + onPointerDown: PointerHandler + activeCursor?: Cursor + children?: ReactNode + hoverScale?: number + indicatorRotation?: readonly [number, number, number] + onPointerEnter?: PointerHandler + onPointerLeave?: PointerHandler +} + +function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape { + if (shape === 'arrow') return 'chevron' + if (shape === 'move-cross') return 'cross' + if (shape === 'chevron' && cursor === 'move') return 'cross' + return shape +} + +// Two-headed curved-arrow silhouette for whole-node rotation handles +// (today: the elevator's corner rotate gizmo). Symmetric arc centred on +// +X with sweeps to +/-halfSweep, arrowhead wings + tangentially-extended +// tips at each end. Drawn in 2D then extruded and rotated to lie in the +// XZ plane - same final-orientation contract as the chevron, so the +// outer rotation Y and inner-rotation chain in the renderer are reused +// unchanged. +export function createRotateArrowHandleGeometry() { + const R = 0.2 + const ribbonHalfWidth = 0.02 + const halfSweep = Math.PI / 3 + const headHalfWidth = 0.045 + const headOvershoot = 0.075 + const rIn = R - ribbonHalfWidth + const rOut = R + ribbonHalfWidth + const a1 = halfSweep + const a2 = -halfSweep + + const tip1: [number, number] = [ + R * Math.cos(a1) - headOvershoot * Math.sin(a1), + R * Math.sin(a1) + headOvershoot * Math.cos(a1), + ] + const tip2: [number, number] = [ + R * Math.cos(a2) + headOvershoot * Math.sin(a2), + R * Math.sin(a2) - headOvershoot * Math.cos(a2), + ] + const innerWing1: [number, number] = [ + (rIn - headHalfWidth) * Math.cos(a1), + (rIn - headHalfWidth) * Math.sin(a1), + ] + const outerWing1: [number, number] = [ + (rOut + headHalfWidth) * Math.cos(a1), + (rOut + headHalfWidth) * Math.sin(a1), + ] + const innerWing2: [number, number] = [ + (rIn - headHalfWidth) * Math.cos(a2), + (rIn - headHalfWidth) * Math.sin(a2), + ] + const outerWing2: [number, number] = [ + (rOut + headHalfWidth) * Math.cos(a2), + (rOut + headHalfWidth) * Math.sin(a2), + ] + const innerCorner1: [number, number] = [rIn * Math.cos(a1), rIn * Math.sin(a1)] + const outerCorner1: [number, number] = [rOut * Math.cos(a1), rOut * Math.sin(a1)] + const innerCorner2: [number, number] = [rIn * Math.cos(a2), rIn * Math.sin(a2)] + const outerCorner2: [number, number] = [rOut * Math.cos(a2), rOut * Math.sin(a2)] + + const shape = new Shape() + shape.moveTo(innerCorner1[0], innerCorner1[1]) + shape.lineTo(innerWing1[0], innerWing1[1]) + shape.lineTo(tip1[0], tip1[1]) + shape.lineTo(outerWing1[0], outerWing1[1]) + shape.lineTo(outerCorner1[0], outerCorner1[1]) + shape.absarc(0, 0, rOut, a1, a2, true) + shape.lineTo(outerWing2[0], outerWing2[1]) + shape.lineTo(tip2[0], tip2[1]) + shape.lineTo(innerWing2[0], innerWing2[1]) + shape.lineTo(innerCorner2[0], innerCorner2[1]) + shape.absarc(0, 0, rIn, a2, a1, false) + shape.closePath() + + const geometry = new ExtrudeGeometry(shape, { + depth: 0.06, + bevelEnabled: true, + bevelThickness: 0.018, + bevelSize: 0.012, + bevelOffset: 0, + bevelSegments: 6, + curveSegments: 24, + steps: 1, + }) + geometry.translate(0, 0, -0.03) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +// Reused chevron+shaft silhouette. The chevron points along +X by default; +// callers rotate it around Y for Z-axis handles and into a vertical frame for +// Y-axis handles. +export function createArrowHandleGeometry() { + const shape = new Shape() + shape.moveTo(CHEVRON_MAX_X, 0) + shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH) + shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_MIN_X, CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_MIN_X, -CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH) + shape.lineTo(CHEVRON_MAX_X, 0) + const geometry = new ExtrudeGeometry(shape, { + depth: CHEVRON_DEPTH, + bevelEnabled: true, + bevelThickness: CHEVRON_BEVEL_THICKNESS, + bevelSize: CHEVRON_BEVEL_SIZE, + bevelOffset: 0, + bevelSegments: CHEVRON_BEVEL_SEGMENTS, + curveSegments: 16, + steps: 1, + }) + geometry.translate(0, 0, -CHEVRON_DEPTH / 2) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +function createDoubleArrowShape(): Shape { + const shape = new Shape() + shape.moveTo(MOVE_CROSS_HALF_LENGTH, 0) + shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH) + shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HALF_LENGTH, 0) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH) + shape.closePath() + return shape +} + +export function createMoveCrossHandleGeometry() { + const shape = createDoubleArrowShape() + const extrudeOpts = { + depth: MOVE_CROSS_DEPTH, + bevelEnabled: true, + bevelThickness: MOVE_CROSS_BEVEL_THICKNESS, + bevelSize: MOVE_CROSS_BEVEL_SIZE, + bevelOffset: 0, + bevelSegments: MOVE_CROSS_BEVEL_SEGMENTS, + curveSegments: 8, + steps: 1, + } + const armX = new ExtrudeGeometry(shape, extrudeOpts) + armX.translate(0, 0, -MOVE_CROSS_DEPTH / 2) + armX.rotateX(-Math.PI / 2) + const armZ = armX.clone() + armZ.rotateY(Math.PI / 2) + const merged = mergeGeometries([armX, armZ], false) + if (!merged) { + armZ.dispose() + armX.computeVertexNormals() + armX.computeBoundingSphere() + return armX + } + armX.dispose() + armZ.dispose() + merged.computeVertexNormals() + merged.computeBoundingSphere() + return merged +} + +export function createArrowHitAreaGeometry() { + const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2 + const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2 + const geometry = new CylinderGeometry( + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + length, + 16, + ) + geometry.rotateZ(-Math.PI / 2) + geometry.translate(centerX, 0, 0) + geometry.computeBoundingSphere() + return geometry +} + +function createMoveCrossHitAreaGeometry() { + const geometry = new CylinderGeometry( + MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, + MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, + HIT_AREA_THICKNESS, + 32, + ) + geometry.computeBoundingSphere() + return geometry +} + +export function createRotateArrowHitAreaGeometry() { + const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS + const geometry = new TorusGeometry( + ROTATE_HANDLE_RADIUS, + ROTATE_RIBBON_HALF_WIDTH + ROTATE_HEAD_HALF_WIDTH + HIT_AREA_MARGIN, + 10, + 64, + halfSweep * 2, + ) + geometry.rotateZ(-halfSweep) + geometry.rotateX(-Math.PI / 2) + geometry.computeBoundingSphere() + return geometry +} + +function createTrackerHitAreaGeometry() { + const size = TRACKER_CUBE_SIZE + HIT_AREA_MARGIN * 2 + const geometry = new BoxGeometry(size, size, size) + geometry.computeBoundingSphere() + return geometry +} + +export function createEndpointHitAreaGeometry(radius: number) { + const geometry = new CylinderGeometry( + radius + HIT_AREA_MARGIN, + radius + HIT_AREA_MARGIN, + HIT_AREA_THICKNESS, + 24, + ) + geometry.rotateX(Math.PI / 2) + geometry.computeBoundingSphere() + return geometry +} + +function createHandleArrowGeometry(shape: HandleArrowShape) { + if (shape === 'chevron') return createArrowHandleGeometry() + if (shape === 'cross') return createMoveCrossHandleGeometry() + if (shape === 'curved-arrow') return createRotateArrowHandleGeometry() + if (shape === 'tracker') { + const geometry = new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE) + geometry.computeBoundingSphere() + return geometry + } + const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6) + geometry.computeBoundingSphere() + return geometry +} + +function createHandleArrowHitGeometry(shape: HandleArrowShape) { + if (shape === 'chevron') return createArrowHitAreaGeometry() + if (shape === 'cross') return createMoveCrossHitAreaGeometry() + if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry() + if (shape === 'tracker') return createTrackerHitAreaGeometry() + const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6) + geometry.computeBoundingSphere() + return geometry +} + +let sharedHitAreaMaterial: MeshBasicNodeMaterial | null = null +let sharedHitAreaMaterialRefs = 0 + +function createInvisibleHitAreaMaterial() { + return new MeshBasicNodeMaterial({ + color: new Color('#ffffff'), + colorWrite: false, + depthTest: false, + depthWrite: false, + opacity: 0, + side: DoubleSide, + transparent: true, + }) +} + +export function useInvisibleHitAreaMaterial(): MeshBasicNodeMaterial { + const materialRef = useRef(null) + if (!materialRef.current) { + sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial() + materialRef.current = sharedHitAreaMaterial + } + useEffect(() => { + sharedHitAreaMaterialRefs += 1 + return () => { + sharedHitAreaMaterialRefs -= 1 + if (sharedHitAreaMaterialRefs <= 0 && sharedHitAreaMaterial) { + sharedHitAreaMaterial.dispose() + sharedHitAreaMaterial = null + sharedHitAreaMaterialRefs = 0 + } + } + }, []) + return materialRef.current +} + +export function InvisibleHandleHitArea({ + geometry, + material, + onPointerDown, + onPointerEnter, + onPointerLeave, + scale, +}: { + geometry: BufferGeometry + material: MeshBasicNodeMaterial + onPointerDown: PointerHandler + onPointerEnter: PointerHandler + onPointerLeave: PointerHandler + scale: number +}) { + return ( + + ) +} + +export function useArrowMaterial(): MeshBasicNodeMaterial { + return useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + // `depthTest: false` keeps the chevron drawing on top of any + // geometry under it; `depthWrite: true` puts the chevron's depth + // into the scenePass buffer so the ink-edge shader's depth + // Laplacian fires on its silhouette from every angle. Without + // depthWrite, only the normal-discontinuity branch can detect + // the chevron, and that signal collapses when the arrow's faces + // happen to align with whatever sits behind them in screen space + // - which is why the lines used to drop out depending on the view. + depthTest: false, + depthWrite: true, + transparent: true, + opacity: 1, + }), + [], + ) +} + +function useHandleArrowMaterial(shape: HandleArrowShape): MeshBasicNodeMaterial { + return useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: shape === 'corner-picker' ? 0.95 : 1, + depthTest: false, + depthWrite: shape !== 'corner-picker', + }), + [shape], + ) +} + +function indicatorRenderOrder(shape: HandleArrowShape) { + return shape === 'tracker' || shape === 'corner-picker' ? 1003 : 1010 +} + +export function HandleArrow({ + shape, + placement, + hover, + cursor, + activeCursor, + children, + hoverScale = 1.12, + indicatorRotation, + onHoverChange, + onPointerDown, + onPointerEnter, + onPointerLeave, +}: HandleArrowProps) { + const visualShape = normalizeHandleArrowShape(shape, cursor) + const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape]) + const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape]) + const indicatorMaterial = useHandleArrowMaterial(visualShape) + const hitMaterial = useInvisibleHitAreaMaterial() + const rootRef = useRef(null) + const rotation: [number, number, number] = placement.rotation + ? [placement.rotation[0], placement.rotation[1], placement.rotation[2]] + : [0, 0, 0] + const localRotation: [number, number, number] = indicatorRotation + ? [indicatorRotation[0], indicatorRotation[1], indicatorRotation[2]] + : [0, 0, 0] + const scale = (hover ? hoverScale : 1) * placement.baseScale + const hitScale = visualShape === 'corner-picker' ? scale : placement.baseScale + + useEffect(() => { + indicatorMaterial.color.set(hover ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [indicatorMaterial, hover]) + useEffect(() => { + const hideForCapture = () => { + if (rootRef.current) rootRef.current.visible = false + } + const restoreAfterCapture = () => { + if (rootRef.current) rootRef.current.visible = true + } + emitter.on('thumbnail:before-capture', hideForCapture) + emitter.on('thumbnail:after-capture', restoreAfterCapture) + return () => { + emitter.off('thumbnail:before-capture', hideForCapture) + emitter.off('thumbnail:after-capture', restoreAfterCapture) + } + }, []) + useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => indicatorMaterial.dispose(), [indicatorMaterial]) + + const handleEnter: PointerHandler = (event) => { + event.stopPropagation() + onHoverChange(true) + if (!activeCursor || document.body.style.cursor !== activeCursor) { + document.body.style.cursor = cursor + } + onPointerEnter?.(event) + } + const handleLeave: PointerHandler = (event) => { + event.stopPropagation() + onHoverChange(false) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + onPointerLeave?.(event) + } + + return ( + + {children} + + + + + + ) +} diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts new file mode 100644 index 000000000..2dfb475c1 --- /dev/null +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -0,0 +1,225 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type Cursor, + createSceneApi, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import useViewer from '@pascal-app/viewer/store' +import { type ThreeEvent, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three' +import { sfxEmitter } from '../../../lib/sfx-bus' +import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' + +export type HandleDragControls = { + onStart: (index: number, snapshot: AnyNode) => void + onEnd: () => void +} + +type IntersectPlane = ( + clientX: number, + clientY: number, + plane: Plane, + target: Vector3, +) => Vector3 | null + +type GetPointerRay = (clientX: number, clientY: number, target: Ray) => Ray + +export type HandleDragStartContext = { + event: ThreeEvent + camera: Camera + getPointerRay: GetPointerRay + intersectPlane: IntersectPlane + initialNode: AnyNode + node: AnyNode + nodeId: AnyNodeId + rideObject: Object3D + sceneApi: ReturnType +} + +export type HandleDragMoveContext = { + event: PointerEvent + getPointerRay: GetPointerRay + intersectPlane: IntersectPlane +} + +type HandleDragSession = { + move: (context: HandleDragMoveContext) => Partial | null + markDirty?: boolean + onBegin?: () => void + onEnd?: () => void + overrideId?: AnyNodeId +} + +type UseHandleDragArgs = + | { + kind: 'drag' + cursor: Cursor + dragControls: HandleDragControls + handleIndex: number + node: AnyNode + onStart: (context: HandleDragStartContext) => HandleDragSession | null + rideObject: Object3D + setIsDragging: (dragging: boolean) => void + } + | { + kind: 'tap' + onTap: (event: ThreeEvent) => void + } + +export function swallowNextClick() { + const swallow = (clickEvent: Event) => { + clickEvent.stopPropagation() + clickEvent.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => { + window.removeEventListener('click', swallow, { capture: true }) + }, 300) +} + +function suppressInputDraggingUntilPointerRelease(pointerId: number) { + const previousInputDragging = useViewer.getState().inputDragging + useViewer.getState().setInputDragging(true) + + function restore(event?: PointerEvent) { + if (event && event.pointerId !== pointerId) return + useViewer.getState().setInputDragging(previousInputDragging) + window.removeEventListener('pointerup', restore) + window.removeEventListener('pointercancel', restore) + window.removeEventListener('blur', onBlur) + } + function onBlur() { + restore() + } + + window.addEventListener('pointerup', restore) + window.addEventListener('pointercancel', restore) + window.addEventListener('blur', onBlur) +} + +export function useHandleDrag(args: UseHandleDragArgs) { + const { camera, raycaster, gl } = useThree() + const dragCleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => () => dragCleanupRef.current?.(), []) + + return (event: ThreeEvent) => { + event.stopPropagation() + suppressBoxSelectForPointer(event) + + if (args.kind === 'tap') { + suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId) + swallowNextClick() + sfxEmitter.emit('sfx:item-pick') + document.body.style.cursor = '' + args.onTap(event) + return + } + + const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args + rideObject.updateMatrixWorld() + + const ndc = new Vector2() + const setPointerRay = (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + raycaster.setFromCamera(ndc, camera) + } + const getPointerRay: GetPointerRay = (clientX, clientY, target) => { + setPointerRay(clientX, clientY) + return target.copy(raycaster.ray) + } + const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { + setPointerRay(clientX, clientY) + return raycaster.ray.intersectPlane(plane, target) + } + + const nodeId = node.id as AnyNodeId + const sceneApi = createSceneApi(useScene) + const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode + const session = args.onStart({ + event, + camera, + getPointerRay, + intersectPlane, + initialNode, + node, + nodeId, + rideObject, + sceneApi, + }) + if (!session) return + + const overrideId = session.overrideId ?? nodeId + const markDirty = session.markDirty !== false + document.body.style.cursor = cursor + sfxEmitter.emit('sfx:item-pick') + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + setIsDragging(true) + dragControls.onStart(handleIndex, initialNode) + session.onBegin?.() + + let lastPatch: Partial | null = null + + const onMove = (moveEvent: PointerEvent) => { + const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane }) + if (!patch) return + lastPatch = patch + useLiveNodeOverrides.getState().set(overrideId, patch as Record) + if (markDirty) { + useScene.getState().markDirty(overrideId) + } + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useViewer.getState().setInputDragging(false) + setIsDragging(false) + session.onEnd?.() + dragControls.onEnd() + dragCleanupRef.current = null + } + + const clearOverride = () => { + useLiveNodeOverrides.getState().clear(overrideId) + if (markDirty) { + useScene.getState().markDirty(overrideId) + } + } + + const onUp = () => { + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + if (lastPatch) { + sceneApi.update(overrideId, lastPatch) + } + clearOverride() + cleanup() + } + + const onCancel = () => { + clearOverride() + cleanup() + } + + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index f03cfa96f..653efbcd6 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -2,12 +2,20 @@ import { Icon } from '@iconify/react' import { + type AnyNodeId, + emitter, initSpaceDetectionSync, initSpatialGridSync, spatialGridManager, useScene, } from '@pascal-app/core' -import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' +import { + clearViewerMaterialCaches, + type HoverStyles, + InteractiveSystem, + useViewer, + Viewer, +} from '@pascal-app/viewer' import { memo, type ReactNode, @@ -21,17 +29,22 @@ import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' -import { useKeyboard } from '../../hooks/use-keyboard' +import { deleteSelectedNodeIds, useKeyboard } from '../../hooks/use-keyboard' import { applySceneGraphToEditor, loadSceneFromLocalStorage, type SceneGraph, writePersistedSelection, } from '../../lib/scene' +import { computeSceneBoundsXZ, pickSceneCameraFocusBounds } from '../../lib/scene-bounds' import { initSFXBus } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system' +import { DynamicPreviewRuntime } from '../systems/live-data/dynamic-preview-runtime' +import { FixedLiveDataSource } from '../systems/live-data/fixed-live-data-source' +import { LiveDataBindingRuntime } from '../systems/live-data/live-data-binding-runtime' +import { LiveDataSourceConnector } from '../systems/live-data/live-data-source-connector' import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' @@ -44,6 +57,14 @@ import { EditorCommands } from '../ui/command-palette/editor-commands' import { FloatingLevelSelector } from '../ui/floating-level-selector' import { HelperManager } from '../ui/helpers/helper-manager' import { PanelManager } from '../ui/panels/panel-manager' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/primitives/dialog' import { ErrorBoundary } from '../ui/primitives/error-boundary' import { useSidebarStore } from '../ui/primitives/sidebar' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' @@ -53,6 +74,9 @@ import type { ExtraPanel } from '../ui/sidebar/icon-rail' import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel' import type { SidebarTab } from '../ui/sidebar/tab-bar' +import { usePluginPanels } from '../ui/sidebar/use-plugin-panels' +import { DataLensOverlay } from './canvas-lens/data-lens-overlay' +import { EquipmentLensOverlay } from './canvas-lens/equipment-lens-overlay' import { CustomCameraControls } from './custom-camera-controls' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' @@ -61,15 +85,16 @@ import { FloatingActionMenu } from './floating-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloorplanPanel } from './floorplan-panel' import { Grid } from './grid' +import { NodeArrowHandles } from './node-arrow-handles' import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' -import { WallMeasurementLabel } from './wall-measurement-label' import { WallMoveSideHandles } from './wall-move-side-handles' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' +const CAMERA_CONTROLS_HINT_ICON_COLOR = '#bfbfbf' const DELETE_CURSOR_BADGE_COLOR = '#ef4444' const DELETE_CURSOR_BADGE_OFFSET_X = 14 const DELETE_CURSOR_BADGE_OFFSET_Y = 14 @@ -79,9 +104,9 @@ const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const EDITOR_HOVER_STYLES: HoverStyles = { default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true }, - delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false }, - 'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true }, - 'paint-disabled': { + danger: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false }, + accent: { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true }, + blocked: { visibleColor: 0x94_a3_b8, hiddenColor: 0x47_55_69, strength: 4, @@ -89,6 +114,11 @@ const EDITOR_HOVER_STYLES: HoverStyles = { }, } +function SnapAwareGrid() { + const gridSnapStep = useEditor((s) => s.gridSnapStep) + return +} + /** * Wire up module-level singletons (spatial grid, space detection, SFX) for * an Editor mount. Returns a teardown function that detaches the scene-store @@ -106,12 +136,68 @@ function initializeEditorRuntime(): () => void { unsubscribeSpaceDetection?.() spatialGridManager.clear() + clearViewerMaterialCaches() const outliner = useViewer.getState().outliner outliner.selectedObjects.length = 0 outliner.hoveredObjects.length = 0 } } + +function DeleteSelectionConfirmDialog({ + selectedIds, + onCancel, + onConfirm, +}: { + selectedIds: readonly AnyNodeId[] + onCancel: () => void + onConfirm: () => void +}) { + const count = selectedIds.length + return ( + 1} onOpenChange={(open) => !open && onCancel()}> + + + 删除选中的物品? + + 已选中 {count} 个物品,删除后可通过撤销恢复。确定要删除吗? + + + + + + + + + ) +} + +function emitFitSceneForGraph(sceneGraph: SceneGraph | null | undefined) { + if (!sceneGraph) { + emitter.emit('camera-controls:fit-scene', {}) + return + } + const nodes = sceneGraph.nodes as Parameters[0] + const focus = pickSceneCameraFocusBounds(nodes) + const bounds = focus?.bounds ?? computeSceneBoundsXZ(nodes) + emitter.emit( + 'camera-controls:fit-scene', + bounds ? { bounds, reason: focus?.reason ?? 'scene-bounds' } : {}, + ) +} + export interface EditorProps { // Layout version — 'v1' (default) or 'v2' (navbar + two-column) layoutVersion?: 'v1' | 'v2' @@ -332,39 +418,40 @@ type CameraControlHint = { const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ { - action: 'Pan', + action: '平移', keys: [{ value: 'Space' }, { value: 'Left click' }], }, - { action: 'Rotate', keys: [{ value: 'Right click' }] }, - { action: 'Zoom', keys: [{ value: 'Scroll' }] }, + { action: '旋转', keys: [{ value: 'Right click' }] }, + { action: '缩放', keys: [{ value: 'Scroll' }] }, ] const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ - { action: 'Pan', keys: [{ value: 'Left click' }] }, - { action: 'Rotate', keys: [{ value: 'Right click' }] }, - { action: 'Zoom', keys: [{ value: 'Scroll' }] }, + { action: '平移', keys: [{ value: 'Left click' }] }, + { action: '旋转', keys: [{ value: 'Right click' }] }, + { action: '缩放', keys: [{ value: 'Scroll' }] }, ] const CAMERA_SHORTCUT_KEY_META: Record = { 'Left click': { icon: 'ph:mouse-left-click-fill', - label: 'Left click', + label: '鼠标左键', }, 'Middle click': { icon: 'qlementine-icons:mouse-middle-button-16', - label: 'Middle click', + label: '鼠标中键', }, 'Right click': { icon: 'ph:mouse-right-click-fill', - label: 'Right click', + label: '鼠标右键', }, Scroll: { icon: 'qlementine-icons:mouse-middle-button-16', - label: 'Scroll wheel', + label: '鼠标滚轮', }, Space: { icon: 'lucide:space', - label: 'Space', + label: '空格键', + text: '空格', }, } @@ -406,7 +493,13 @@ function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) { role="img" title={meta.label} > -