From c45808840c5694f4cff92def4f18fccb64590412 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Mon, 13 Jul 2026 04:03:48 +0530 Subject: [PATCH] nodes: add modular cabinets and wall trim controls (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 * fixed conflict * Add modular cabinet node * Add modular cabinet run and tall cabinet workflows * Add cabinet paint slots and material groups * Improve modular cabinet presets and wall snapping * Improve cabinet wall snapping * Fix cabinet wall snapping and countertop joins * Improve cabinet appliance compartments * Improve fridge visuals and smooth cabinet animations Fridge exterior is now brushed silver with brass accents and the interior is all white. Door/drawer animation no longer rebuilds the full cabinet geometry per frame — animated parts carry pose metadata that a new per-frame cabinet system applies directly to transforms. Co-Authored-By: Claude * Improve gas hob flames and cooktop knob controls Port the curved-flame look from reference gas-burner photos: each burner gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips) whose spines breathe and flicker per frame at ~30fps, plus a flat ignition glow and a faded heat halo. Fix the knob pointer notch to rotate with the knob instead of drifting sideways. Also includes cooktop compartment presets/panels and cabinet selection/move affordance work. Co-Authored-By: Claude * Fix cabinet UVs and registry actions * Fix registry move snap typecheck * Fix cabinet knob animation concurrency * Fix cabinet material disposal, undo flood, and 2D move parity - Flag shared cabinet appliance materials (and the viewer's cached material factories) as cached so geometry rebuilds no longer dispose materials still referenced by other nodes, forcing scene-wide shader recompiles. - Route the door/drawer open animation through useLiveNodeOverrides with a single final commit, so one play is one undo step instead of ~20. - Add a cabinet-module floorplanMoveTarget: 2D drags now convert through planToLocal + magneticSnap like the 3D path, instead of writing plan coords into the run-local position (teleporting modules on rotated runs). - Re-key sibling runs (cabinetAdjacencyRevision) when a run's neighbor-affecting inputs change, so countertop overhang joins re-trim when a neighbor moves/resizes/deletes. - Narrow the cabinet panel's scene subscriptions (useShallow module selector) so it stops re-rendering on every scene mutation. Co-Authored-By: Claude Fable 5 * Restructure cabinet schema and centralize run math/mutations Prepares the cabinet node for the queued specialty units (corner L-shape, sink base, appliance gap, open shelving): - Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode so new unit types extend an enum instead of overloading the stack, and split CabinetCompartment into a z.discriminatedUnion so invalid field combos (drawer with rack style, fridge with burner state) are unrepresentable. Shared box fields now come from one `cabinetBoxFields` object so run/module schemas can't drift. - Extract the straight-line run assumption (sort-by-x, edges, adjacency, spans, side-insert, reflow, frame transforms) into run-layout.ts — previously spread across definition/geometry/quick-actions/stack/ move-frame, so corner support would have meant five parallel edits. - Consolidate the run mutations (add module, wall cabinet/hood above, base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi. Panel and quick-actions had drifted copies: the panel's add-module skipped gap checks, revision-bump scope differed per surface. - Remove dead code: node-level doorStyle (with a migrateNodes entry — geometry only ever read per-compartment doorType), the unreachable slot-handle mesh block, the no-op handlePosition 'edge' enum value, wallLocalX, and duplicate totalCabinetHeight definitions. Co-Authored-By: Claude Fable 5 * Add cabinet snap tests and split the cabinet panel - New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold, Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset / collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71. - De-brittle geometry test lookups: coordinate-encoded mesh names are now matched by pattern, so dimension-default changes don't break them. - Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants into compartment-card.tsx, CabinetRunPanel + reflow wiring into run-panel.tsx, and the compartment type-transition tables into a pure resolveCompartmentTransition in stack-transitions.ts. Co-Authored-By: Claude Fable 5 * Split cabinet geometry.ts into per-appliance modules Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator dispatching into geometry/ — shared helpers + cached appliance materials (shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans + countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/ oven-microwave/pantry builders. No mesh names, userData stamps, materials, or math changed; the './geometry' import path is unchanged. Co-Authored-By: Claude Fable 5 * Add undermount sink compartment with CSG-cut countertops New 'sink' compartment type (single / double / 60-40 bowl layouts) following the cooktop pattern: zero stack height, countertop-plane geometry, panel card with a bowl-layout selector, and a Sink Base preset. Bowl openings are subtracted from both module- and run-owned countertop slabs via three-bvh-csg; the basin shells, drain plumbing, and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin lever) render beneath the cut. Sink modules skip the carcass top panel and the deck under the sink row so the basin hangs through. Also scope the viewer mock in roof-surface-placement-guides.test.ts to only stub useViewer — the bare module stub leaked into later suites and broke real CSG imports. Co-Authored-By: Claude Fable 5 * Update ifc-converter next-env reference to build-mode route types Regenerated by `next typegen` during check-types; points at .next/types instead of the dev-mode .next/dev/types path. Co-Authored-By: Claude Fable 5 * Draw cabinet floor plans with kitchen drafting symbols Replace the per-module box soup with NKBA/architectural-convention symbols: the run draws one heavier countertop outline per span (extended by the overhang), base modules draw their front edge plus compartment symbols — rounded bowl rects + faucet dot for sinks, burner/zone rings for cooktops (reusing the 3D layout tables so 2D and 3D always agree) — and appliance modules carry standard upright labels (DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules draw as dashed open outlines per the above-cut-plane convention. * Play cabinet open/close animation on the E interaction key Add an `e` slot to registry KeyboardActions and dispatch it in the keyboard hook ahead of the legacy door/window arms, so kinds opt into the E interaction on their NodeDefinition. Cabinets register it: E on a module eases its doors/drawers open or closed, E on a run swings every child module together, and hood-only modules fall through. The panel's rAF animator moves into cabinet/interaction.ts (live-override frames, single undo commit) so the Play button and E share one animation, and the shortcuts dialog now documents E. Co-Authored-By: Claude Fable 5 * Add kitchen island and bar counter support to cabinet runs Islands: run-level countertopBackOverhang (seating side) and withFinishedBack (decorative back panel), plus an I-key island placement mode in the cabinet tool that skips wall snap. Bar counters: optional barLedge {edge, height, depth} drawing a knee wall and raised slab along the back or either run end, superseding the seating overhang on its edge. withWaterfall drops slab-material panels to the floor on exposed run ends. 2D floorplan outlines, selection bounds, geometry keys, and run panel controls updated in parity; schemaVersion 3 -> 6. Co-Authored-By: Claude Fable 5 * Merge quick-action arrow and plus into one directional add glyph The add-left/add-right buttons showed two unrelated icons; a single plus-on-tail arrow reads as "add on this side". Co-Authored-By: Claude Fable 5 * Extend plugin discovery instead of replacing the host source Also track the ifc-converter next-env routes reference update. Co-Authored-By: Claude Fable 5 * Add L-corner runs to modular cabinets with run-scoped grouping An "L Left/Right" quick action spawns a perpendicular base leg, wall leg, and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the run stays the single group: selecting a base module selects only it, and the clicked module never becomes a container. Corner runs re-anchor when the source module resizes or moves (new MovableParentFrame.onCommit hook, mirrored in the 2D floorplan commit), run bounds fold in child leg runs, and deleting one corner member removes only that node while unlink patches keep the survivors' link metadata consistent. Co-Authored-By: Claude Fable 5 * Delete emptied cabinet run groups in the same delete gesture Removing a run's last child (module or derived corner leg) left an empty group node in the scene graph and persisted data. onDeleteCascade now receives the gesture's pending delete ids so multi-select deletes count siblings as gone, and both cabinet kinds cascade the orphaned parent run away in one undo step. Co-Authored-By: Claude Fable 5 * Remove filler front gap treatment * Apply architecture review fixes across cabinet, plugin-trees, and framework Move cabinet semantics out of packages/editor behind registry capabilities (def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons, catalog tool field), route cabinet selection through a core selection-proxy helper, and make PanelWorkspace an opaque host tag. Fix the cabinet placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable slots, plugin-trees ghost layers/stale level transforms/untyped find-sync event, and cross-file store-mock pollution plus orphaned dist tests that broke combined test runs. Co-Authored-By: Claude Fable 5 * Propagate front styling to L-corner runs even when re-layout bails Group style changes rode entirely on syncDerivedCornerRun, which bails silently when a later-drawn wall blocks the corner layout or a leg run gained modules outside the derived-run spec — leaving the L legs styled stale. Apply the style patch directly to every linked corner run before attempting the geometric re-layout. Co-Authored-By: Claude Opus 4.7 * Improve editor viewer integration * Refactor plugin panels and floorplan invalidation * UV-unwrap and material-assign roof-segment trim planes * Fix cabinet placement snap feedback * Add cabinet wall snap bypass * Fix cabinet continuous placement flow * Implement wall band material slots * Add corner addition preview functionality and related tests * Fix cabinet and wall band behavior * Fix architecture review blockers * Fix post-merge quality check * fix cursor review feedback * Fix cabinet placement type and quick action history * Improve cabinet placement feedback * Make wall bands configurable * Add wall trim profiles and default materials * Fix architecture review issues * Fix cabinet rotation and wall band visibility * Fix sink appliance paint persistence * Bake cabinet animations and preserve wall band material * Fix cabinet group duplication and module snapping * Fix cabinet duplication architecture * Fix cabinet floorplan test and make placement-type hint a live chip - floorplan.test.ts: pass the required liveTransforms map to cabinetFloorplanSiblingOverrides (the runtime callers always supply it) - ToolHint gains an optional generic `chip` contract (subscribe/value/ cycle/labels) so a kind can render a tool hint as a live mode chip — like the snapping/continuation chips — without the editor knowing the kind's store - cabinet: the I hint now shows the current value (Type: Cabinet / Type: Island) and is clickable to toggle Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Wassim SAMAD --- apps/editor/lib/bootstrap.ts | 14 +- apps/editor/package.json | 2 +- packages/core/src/events/bus.ts | 18 +- .../floor-placed-elevation.test.ts | 114 + .../spatial-grid/spatial-grid-manager.ts | 66 +- packages/core/src/index.ts | 5 + packages/core/src/lib/selection-proxy.ts | 16 + packages/core/src/registry/handles.ts | 14 +- packages/core/src/registry/index.ts | 17 +- packages/core/src/registry/registry.ts | 110 +- packages/core/src/registry/types.ts | 370 ++- packages/core/src/schema/index.ts | 28 +- packages/core/src/schema/material.ts | 1 + packages/core/src/schema/nodes/cabinet.ts | 149 + packages/core/src/schema/nodes/wall.test.ts | 256 ++ packages/core/src/schema/nodes/wall.ts | 296 +- packages/core/src/schema/types.ts | 3 + .../core/src/store/actions/node-actions.ts | 6 + packages/core/src/store/history-control.ts | 45 + packages/core/src/store/use-scene.ts | 27 + .../floorplan-registry-action-menu.tsx | 143 +- .../floorplan-registry-move-overlay.tsx | 139 +- .../floorplan-registry-layer.test.ts | 156 + .../renderers/floorplan-registry-layer.tsx | 91 +- .../editor/floating-action-menu.tsx | 155 +- .../src/components/editor/floorplan-panel.tsx | 2 - .../components/editor/group-rotate-handle.tsx | 9 +- .../editor/handles/use-handle-drag.ts | 7 +- .../editor/src/components/editor/index.tsx | 22 +- .../components/editor/node-arrow-handles.tsx | 74 +- .../components/editor/selection-manager.tsx | 82 +- .../use-floorplan-background-placement.ts | 13 +- .../ceiling-selection-affordance-system.tsx | 28 +- .../registry/move-registry-node-tool.tsx | 373 ++- .../tools/shared/drag-bounding-box.tsx | 5 +- .../components/tools/shared/placement-box.tsx | 184 +- .../src/components/tools/zone/zone-tool.tsx | 22 +- .../ui/helpers/contextual-helper-panel.tsx | 67 +- .../ui/helpers/registered-tool-helper.tsx | 12 +- .../editor/src/components/ui/icon-ref.tsx | 47 + .../ui/item-catalog/catalog-items.tsx | 40 +- .../ui/item-catalog/item-catalog.tsx | 8 +- .../src/components/ui/panels/node-display.ts | 6 +- .../src/components/ui/primitives/sidebar.tsx | 1 + .../src/components/ui/sidebar/app-sidebar.tsx | 8 +- .../keyboard-shortcuts-dialog.tsx | 9 +- .../panels/site-panel/registry-tree-node.tsx | 52 +- .../sidebar/panels/site-panel/tree-node.tsx | 12 +- .../panels/site-panel/tree-structure.ts | 36 + .../ui/sidebar/use-plugin-panels.tsx | 21 +- packages/editor/src/hooks/use-keyboard.ts | 17 +- packages/editor/src/index.tsx | 8 + packages/editor/src/lib/continuation.ts | 9 +- .../src/lib/direct-manipulation.test.ts | 77 + .../editor/src/lib/direct-manipulation.ts | 20 +- .../src/lib/fresh-planar-placement.test.ts | 243 +- .../editor/src/lib/fresh-planar-placement.ts | 69 +- packages/editor/src/lib/glb-export.test.ts | 50 +- packages/editor/src/lib/glb-export.ts | 38 +- packages/editor/src/lib/material-paint.ts | 11 +- packages/editor/src/lib/plugin-panels.ts | 70 + .../editor/src/lib/scene-clipboard.test.ts | 138 + packages/editor/src/lib/scene-clipboard.ts | 32 +- .../editor/src/lib/selection-routing.test.ts | 182 +- packages/editor/src/lib/selection-routing.ts | 36 +- packages/editor/src/lib/sfx/index.ts | 1 + .../editor/src/lib/sfx/movement-tick.test.ts | 65 + packages/editor/src/lib/sfx/movement-tick.ts | 18 + packages/editor/src/lib/surface-plan-snap.ts | 6 +- packages/editor/src/store/use-editor.tsx | 53 +- .../__tests__/continuous-placement.test.ts | 162 ++ .../__tests__/delete-corner-group.test.ts | 370 +++ .../src/cabinet/__tests__/drag-bounds.test.ts | 22 + .../__tests__/duplicate-subtree.test.ts | 78 + .../src/cabinet/__tests__/floorplan.test.ts | 510 ++++ .../src/cabinet/__tests__/front-style.test.ts | 178 ++ .../src/cabinet/__tests__/geometry.test.ts | 2590 +++++++++++++++++ .../src/cabinet/__tests__/move-frame.test.ts | 213 ++ .../__tests__/placement-collision.test.ts | 139 + .../cabinet/__tests__/quick-actions.test.ts | 454 +++ .../src/cabinet/__tests__/run-ops.test.ts | 1786 ++++++++++++ .../nodes/src/cabinet/__tests__/stack.test.ts | 491 ++++ .../cabinet/__tests__/tree-structure.test.ts | 253 ++ .../src/cabinet/__tests__/wall-snap.test.ts | 611 ++++ packages/nodes/src/cabinet/animation.ts | 78 + .../nodes/src/cabinet/compartment-card.tsx | 486 ++++ .../nodes/src/cabinet/continuous-placement.ts | 186 ++ packages/nodes/src/cabinet/cooktop-flame.ts | 145 + packages/nodes/src/cabinet/definition.ts | 1245 ++++++++ packages/nodes/src/cabinet/floorplan-move.ts | 249 ++ .../nodes/src/cabinet/floorplan-overrides.ts | 51 + packages/nodes/src/cabinet/floorplan.ts | 390 +++ packages/nodes/src/cabinet/geometry.ts | 514 ++++ .../nodes/src/cabinet/geometry/cooktop.ts | 568 ++++ .../nodes/src/cabinet/geometry/dishwasher.ts | 322 ++ packages/nodes/src/cabinet/geometry/fridge.ts | 1109 +++++++ packages/nodes/src/cabinet/geometry/fronts.ts | 851 ++++++ packages/nodes/src/cabinet/geometry/hood.ts | 175 ++ .../src/cabinet/geometry/oven-microwave.ts | 827 ++++++ packages/nodes/src/cabinet/geometry/pantry.ts | 226 ++ packages/nodes/src/cabinet/geometry/run.ts | 209 ++ packages/nodes/src/cabinet/geometry/shared.ts | 561 ++++ packages/nodes/src/cabinet/geometry/sink.ts | 453 +++ packages/nodes/src/cabinet/index.ts | 7 + packages/nodes/src/cabinet/interaction.ts | 139 + packages/nodes/src/cabinet/move-frame.ts | 329 +++ packages/nodes/src/cabinet/paint.ts | 17 + packages/nodes/src/cabinet/panel.tsx | 663 +++++ packages/nodes/src/cabinet/parametrics.ts | 51 + .../nodes/src/cabinet/placement-status.ts | 13 + packages/nodes/src/cabinet/placement-type.ts | 26 + packages/nodes/src/cabinet/presets.ts | 202 ++ .../nodes/src/cabinet/quick-action-icons.tsx | 91 + packages/nodes/src/cabinet/quick-actions.ts | 263 ++ packages/nodes/src/cabinet/run-layout.ts | 436 +++ packages/nodes/src/cabinet/run-ops.ts | 2089 +++++++++++++ packages/nodes/src/cabinet/run-panel.tsx | 555 ++++ packages/nodes/src/cabinet/scene-action.ts | 180 ++ packages/nodes/src/cabinet/schema.ts | 1 + packages/nodes/src/cabinet/slots.ts | 37 + .../nodes/src/cabinet/stack-transitions.ts | 163 ++ packages/nodes/src/cabinet/stack.ts | 545 ++++ packages/nodes/src/cabinet/system.tsx | 129 + packages/nodes/src/cabinet/tool.tsx | 1121 +++++++ packages/nodes/src/cabinet/tree-structure.ts | 189 ++ packages/nodes/src/cabinet/wall-snap.ts | 384 +++ .../nodes/src/ceiling/boundary-editor.tsx | 1 - .../src/ceiling/floorplan-affordances.ts | 4 +- packages/nodes/src/ceiling/tool.tsx | 2 - packages/nodes/src/column/tool.tsx | 12 +- packages/nodes/src/index.ts | 12 + packages/nodes/src/item/panel.tsx | 2 +- packages/nodes/src/shared/floor-placement.ts | 35 + .../roof-surface-placement-guides.test.ts | 38 +- packages/nodes/src/shared/slot-paint.ts | 3 + packages/nodes/src/shelf/tool.tsx | 12 +- packages/nodes/src/site/renderer.tsx | 8 +- packages/nodes/src/slab/boundary-editor.tsx | 1 - .../nodes/src/slab/floorplan-affordances.ts | 4 +- packages/nodes/src/slab/tool.tsx | 1 - packages/nodes/src/spawn/tool.tsx | 12 +- packages/nodes/src/wall/definition.ts | 10 +- packages/nodes/src/wall/paint.test.ts | 209 ++ packages/nodes/src/wall/paint.ts | 161 +- packages/nodes/src/wall/panel.tsx | 296 ++ packages/nodes/src/wall/renderer.tsx | 36 +- packages/nodes/src/wall/slots.ts | 76 +- packages/nodes/src/wall/treatments.tsx | 613 ++++ packages/plugin-trees/README.md | 23 +- packages/plugin-trees/src/find-sync.ts | 37 +- packages/plugin-trees/src/index.ts | 16 +- packages/plugin-trees/src/instanced.tsx | 39 +- packages/plugin-trees/src/preview.tsx | 4 + packages/plugin-trees/src/tool.tsx | 32 +- .../components/viewer/registered-systems.tsx | 15 +- .../components/viewer/selection-manager.tsx | 34 +- packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/materials.ts | 4 + packages/viewer/src/store/use-viewer.d.ts | 4 + packages/viewer/src/store/use-viewer.ts | 7 + .../systems/geometry/geometry-system.test.ts | 26 +- .../src/systems/geometry/geometry-system.tsx | 66 +- .../viewer/src/systems/roof/roof-system.tsx | 26 + .../viewer/src/systems/wall/wall-cutout.tsx | 45 +- .../viewer/src/systems/wall/wall-materials.ts | 159 +- .../viewer/src/systems/wall/wall-system.tsx | 183 +- wiki/architecture/plugin-authoring.md | 43 +- wiki/architecture/selection-managers.md | 2 +- wiki/architecture/tools.md | 8 + 169 files changed, 30272 insertions(+), 706 deletions(-) create mode 100644 packages/core/src/lib/selection-proxy.ts create mode 100644 packages/core/src/schema/nodes/cabinet.ts create mode 100644 packages/core/src/schema/nodes/wall.test.ts create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts create mode 100644 packages/editor/src/components/ui/icon-ref.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts create mode 100644 packages/editor/src/lib/plugin-panels.ts create mode 100644 packages/editor/src/lib/scene-clipboard.test.ts create mode 100644 packages/editor/src/lib/sfx/movement-tick.test.ts create mode 100644 packages/editor/src/lib/sfx/movement-tick.ts create mode 100644 packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/floorplan.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/front-style.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/geometry.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/move-frame.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/placement-collision.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/quick-actions.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/run-ops.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/stack.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/tree-structure.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/wall-snap.test.ts create mode 100644 packages/nodes/src/cabinet/animation.ts create mode 100644 packages/nodes/src/cabinet/compartment-card.tsx create mode 100644 packages/nodes/src/cabinet/continuous-placement.ts create mode 100644 packages/nodes/src/cabinet/cooktop-flame.ts create mode 100644 packages/nodes/src/cabinet/definition.ts create mode 100644 packages/nodes/src/cabinet/floorplan-move.ts create mode 100644 packages/nodes/src/cabinet/floorplan-overrides.ts create mode 100644 packages/nodes/src/cabinet/floorplan.ts create mode 100644 packages/nodes/src/cabinet/geometry.ts create mode 100644 packages/nodes/src/cabinet/geometry/cooktop.ts create mode 100644 packages/nodes/src/cabinet/geometry/dishwasher.ts create mode 100644 packages/nodes/src/cabinet/geometry/fridge.ts create mode 100644 packages/nodes/src/cabinet/geometry/fronts.ts create mode 100644 packages/nodes/src/cabinet/geometry/hood.ts create mode 100644 packages/nodes/src/cabinet/geometry/oven-microwave.ts create mode 100644 packages/nodes/src/cabinet/geometry/pantry.ts create mode 100644 packages/nodes/src/cabinet/geometry/run.ts create mode 100644 packages/nodes/src/cabinet/geometry/shared.ts create mode 100644 packages/nodes/src/cabinet/geometry/sink.ts create mode 100644 packages/nodes/src/cabinet/index.ts create mode 100644 packages/nodes/src/cabinet/interaction.ts create mode 100644 packages/nodes/src/cabinet/move-frame.ts create mode 100644 packages/nodes/src/cabinet/paint.ts create mode 100644 packages/nodes/src/cabinet/panel.tsx create mode 100644 packages/nodes/src/cabinet/parametrics.ts create mode 100644 packages/nodes/src/cabinet/placement-status.ts create mode 100644 packages/nodes/src/cabinet/placement-type.ts create mode 100644 packages/nodes/src/cabinet/presets.ts create mode 100644 packages/nodes/src/cabinet/quick-action-icons.tsx create mode 100644 packages/nodes/src/cabinet/quick-actions.ts create mode 100644 packages/nodes/src/cabinet/run-layout.ts create mode 100644 packages/nodes/src/cabinet/run-ops.ts create mode 100644 packages/nodes/src/cabinet/run-panel.tsx create mode 100644 packages/nodes/src/cabinet/scene-action.ts create mode 100644 packages/nodes/src/cabinet/schema.ts create mode 100644 packages/nodes/src/cabinet/slots.ts create mode 100644 packages/nodes/src/cabinet/stack-transitions.ts create mode 100644 packages/nodes/src/cabinet/stack.ts create mode 100644 packages/nodes/src/cabinet/system.tsx create mode 100644 packages/nodes/src/cabinet/tool.tsx create mode 100644 packages/nodes/src/cabinet/tree-structure.ts create mode 100644 packages/nodes/src/cabinet/wall-snap.ts create mode 100644 packages/nodes/src/wall/paint.test.ts create mode 100644 packages/nodes/src/wall/treatments.tsx diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 3238d804..a24f64cb 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -1,13 +1,14 @@ import { type AnyNodeDefinition, discoverPlugins, + extendPluginDiscovery, loadPlugin, nodeRegistry, registerNode, - setPluginDiscovery, } from '@pascal-app/core' +import { registerEditorHostPanel } from '@pascal-app/editor' import { builtinPlugin } from '@pascal-app/nodes' -import { treesPlugin } from '@pascal-app/plugin-trees' +import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees' // Idempotency guards: HMR can reload this module, but `registerNode` // throws on duplicate kinds. Flags live in the module closure so they @@ -79,10 +80,11 @@ export async function loadExternalPlugins(): Promise { } } -// Register the first-party example plugin (trees node + presets rail panel) -// through the same discovery hook a third-party pack would use. Must be set -// before `loadExternalPlugins()` reads it below. -setPluginDiscovery(async () => [treesPlugin]) +// Register the first-party example node plugin alongside any host-provided +// discovery source instead of replacing it. Its Nature rail panel is host UI, +// so it is registered separately from the core plugin manifest. +extendPluginDiscovery(async () => [treesPlugin]) +registerEditorHostPanel(treesHostPanel) loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/package.json b/apps/editor/package.json index 4f4b18b3..ab71645e 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "dev": "dotenv -e ../../.env.local -- next dev --port 3002", + "dev": "dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 42e6ab9e..c58f1dec 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -4,6 +4,8 @@ import type { Object3D } from 'three' import type { BoxVentNode, BuildingNode, + CabinetModuleNode, + CabinetNode, CeilingNode, ChimneyNode, ColumnNode, @@ -89,6 +91,8 @@ export type FenceEvent = NodeEvent export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent +export type CabinetEvent = NodeEvent +export type CabinetModuleEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent export type ShelfEvent = NodeEvent @@ -251,9 +255,20 @@ type RoomPresetEvents = { 'room-preset:create': RoomPresetCreateEvent } +type SelectionEvents = { + /** + * "Reveal this node" intent — the editor's node action menu emits it with the + * selected node; whoever owns the node's catalog/panel (host browser, a + * plugin's presets panel) listens and reveals it. + */ + 'selection:find-node': AnyNode +} + type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & + NodeEvents<'cabinet', CabinetEvent> & + NodeEvents<'cabinet-module', CabinetModuleEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & @@ -301,6 +316,7 @@ type EditorEvents = GridEvents & ThumbnailEvents & SnapshotEvents & AIChatEvents & - RoomPresetEvents + RoomPresetEvents & + SelectionEvents export const emitter = mitt() diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index 56300395..f0dfe76f 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { nodeRegistry, registerNode } from '../../registry' import type { AnyNodeDefinition } from '../../registry/types' import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation' import { spatialGridManager } from './spatial-grid-manager' @@ -86,6 +87,7 @@ describe('floor-placed elevation resolver', () => { beforeEach(() => { nodeRegistry._reset() spatialGridManager.clear() + useScene.setState({ nodes: {} }) }) test('returns 0 without a floorPlaced capability', () => { @@ -113,6 +115,118 @@ describe('floor-placed elevation resolver', () => { ).toBe(0) }) + test('canPlaceOnFloorFootprints accepts an L-shaped draft in the open corner gap', () => { + const baseAsset = (makeFloorNode() as { asset: Record }).asset + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: (node) => ({ + dimensions: (node as { asset: { dimensions: [number, number, number] } }).asset + .dimensions, + rotation: [0, (node as { rotation: [number, number, number] }).rotation[1] ?? 0, 0], + }), + collides: true, + }, + }), + ) + + const level = makeLevel() + const blocker = makeFloorNode({ + id: 'item_blocker', + position: [0.85, 0, 0.85], + asset: { + ...baseAsset, + id: 'asset_blocker', + name: 'Blocker', + src: 'asset:blocker', + dimensions: [0.3, 1, 0.3], + } as AnyNode extends { asset: infer T } ? T : never, + }) + useScene.setState({ nodes: nodesFor(level, blocker) }) + + const coarse = spatialGridManager.canPlaceOnFloor(LEVEL_ID, [0.5, 0, 0.5], [1, 1, 1], [0, 0, 0]) + const precise = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [ + { position: [0.2, 0, 0.5], dimensions: [0.4, 1, 1], rotation: [0, 0, 0] }, + { position: [0.8, 0, 0.2], dimensions: [0.4, 1, 0.4], rotation: [0, 0, 0] }, + ]) + + expect(coarse.valid).toBe(false) + expect(coarse.conflictIds).toEqual(['item_blocker']) + expect(precise.valid).toBe(true) + expect(precise.conflictIds).toEqual([]) + }) + + test('canPlaceOnFloorFootprints rejects overlapping draft footprints', () => { + useScene.setState({ nodes: nodesFor(makeLevel()) }) + + const result = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [ + { position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }, + { position: [0.25, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }, + ]) + + expect(result.valid).toBe(false) + expect(result.conflictIds).toEqual([]) + }) + + test('canPlaceOnFloorFootprints ignores descendants of an ignored composite node', () => { + registerNode( + makeDefinition('cabinet', { + floorPlaced: { + footprint: () => ({ + dimensions: [1, 1, 1], + rotation: [0, 0, 0], + }), + collides: true, + }, + }), + ) + registerNode( + makeDefinition('cabinet-module', { + floorPlaced: { + footprint: () => ({ + dimensions: [1, 1, 1], + rotation: [0, 0, 0], + }), + collides: true, + }, + }), + ) + + const level = makeLevel() + const run = { + id: 'cabinet_run', + type: 'cabinet', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: ['cabinet-module_child'], + position: [0, 0, 0], + rotation: 0, + } as unknown as AnyNode + const module = { + id: 'cabinet-module_child', + type: 'cabinet-module', + object: 'node', + parentId: run.id, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + } as unknown as AnyNode + useScene.setState({ nodes: nodesFor(level, run, module) }) + + const result = spatialGridManager.canPlaceOnFloorFootprints( + LEVEL_ID, + [{ position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }], + [run.id], + ) + + expect(result.valid).toBe(true) + expect(result.conflictIds).toEqual([]) + }) + test('returns 0 when applies returns false', () => { registerNode( makeDefinition('item', { 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 f8a186b8..e16087c1 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -163,6 +163,28 @@ function resolveNodeLevelId(node: AnyNode, nodes: Record): stri return 'default' } +function expandIgnoredNodeIds( + ignoreIds: readonly string[] | undefined, + nodes: Record, +): Set { + const ignored = new Set(ignoreIds ?? []) + const queue = [...ignored] + + while (queue.length > 0) { + const id = queue.pop()! + const node = nodes[id] + const children = (node as { children?: unknown } | undefined)?.children + if (!Array.isArray(children)) continue + for (const childId of children) { + if (typeof childId !== 'string' || ignored.has(childId)) continue + ignored.add(childId) + queue.push(childId) + } + } + + return ignored +} + /** * Test if two line segments (a1->a2) and (b1->b2) intersect. */ @@ -704,16 +726,43 @@ export class SpatialGridManager { dimensions: [number, number, number], rotation: [number, number, number], ignoreIds?: string[], + ) { + return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds) + } + + canPlaceOnFloorFootprints( + levelId: string, + footprints: readonly { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }[], + ignoreIds?: string[], ) { const nodes = useScene.getState().nodes - const ignoreSet = new Set(ignoreIds ?? []) - const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1]) + const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes) + const draftBounds = footprints.map((footprint) => + footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), + ) + for (let i = 0; i < draftBounds.length; i += 1) { + const a = draftBounds[i]! + for (let j = i + 1; j < draftBounds.length; j += 1) { + const b = draftBounds[j]! + if ( + intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) && + intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ) + ) { + return { valid: false, conflictIds: [] } + } + } + } // A floor placement conflicts with any other COLLIDING floor-resting node, // not just items — every kind whose `floorPlaced.collides` is set (item / - // shelf / column) contributes its footprint(s) as an obstacle. Each - // candidate's XZ extent is read from the same declarative footprint the - // elevation + sync paths use, so adding a colliding kind needs no change here. + // shelf / column / cabinet / stair) contributes its footprint(s) as an + // obstacle. Each candidate's XZ extent is read from the same declarative + // footprint the elevation + sync paths use, so adding a colliding kind + // needs no change here. const conflicts: string[] = [] for (const node of Object.values(nodes)) { if (ignoreSet.has(node.id)) continue @@ -733,8 +782,11 @@ export class SpatialGridManager { fpRotation, ) if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + draftBounds.some( + (draft) => + intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ), + ) ) { conflicts.push(node.id) break diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 823470cf..a0857b4c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,8 @@ export type { BoxVentEvent, BuildingEvent, + CabinetEvent, + CabinetModuleEvent, CameraControlEvent, CameraControlFitSceneEvent, CeilingEvent, @@ -72,6 +74,7 @@ export { polygonsOverlap, segmentsIntersect, } from './lib/polygon-relations' +export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { deriveSlotId, @@ -132,11 +135,13 @@ export type { export * from './registry' export * from './schema' export * from './services' +export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { getSceneHistoryPauseDepth, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, + runAsSingleSceneHistoryStep, } from './store/history-control' export { type ControlValue, diff --git a/packages/core/src/lib/selection-proxy.ts b/packages/core/src/lib/selection-proxy.ts new file mode 100644 index 00000000..2f92cccc --- /dev/null +++ b/packages/core/src/lib/selection-proxy.ts @@ -0,0 +1,16 @@ +import type { AnyNode, AnyNodeId } from '../schema/types' + +export function selectionProxyIdFromMetadata(metadata: unknown): AnyNodeId | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).nodeSelectionProxyId + return typeof value === 'string' ? (value as AnyNodeId) : null +} + +export function resolveSelectionProxyId( + node: AnyNode, + nodes: Readonly>, +): AnyNodeId { + const proxyId = selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + if (!proxyId || !nodes[proxyId]) return node.id as AnyNodeId + return proxyId +} diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 0b0bb5f6..73063d8d 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -100,9 +100,11 @@ export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing' export type HandleDecoration = { kind: 'ring' /** Node-local radius of the ring (XZ plane). */ - radius: (node: N) => number + radius: (node: N, sceneApi: SceneApi) => number /** Node-local Y of the ring. Defaults to 0. */ y?: (node: N) => number + /** Node-local center of the ring. Defaults to the node origin. */ + center?: (node: N, sceneApi: SceneApi) => readonly [number, number, number] } /** @@ -127,6 +129,16 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** Optional live-scene visibility gate for context-dependent arrows. */ + visible?: (node: N, sceneApi: SceneApi) => boolean + /** + * Optional committed-write hook. The generic handle renderer previews the + * selected node with the `apply` patch during drag; on release it normally + * writes that patch back to the same node. Composite nodes can override the + * final write here to fan the resize out to siblings / parents while keeping + * the handle UI generic. + */ + commit?: (node: N, patch: Partial, sceneApi: SceneApi) => void /** * 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 diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index d239acd8..830915fb 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -18,6 +18,7 @@ export type { export { bakePolicyOf, discoverPlugins, + extendPluginDiscovery, getHostRefFields, getSelectableKinds, hasRegistry3DMoveTool, @@ -32,7 +33,6 @@ export { loadPlugin, nodeRegistry, type PluginDiscovery, - panelRegistry, registerNode, resolveFacingIndicator, setPluginDiscovery, @@ -65,7 +65,11 @@ export type { CuttableConfig, DistributionRole, DragAction, + DuplicableConfig, + DuplicateSubtreeCloneArgs, + DuplicateSubtreeCloneResult, EditorCtx, + ExportAnimationContext, FloorPlacedConfig, FloorPlacedFootprint, FloorPlacedFootprintContext, @@ -88,31 +92,37 @@ export type { KeyboardAction, KeyboardActions, LazyComponent, + LiveTransformLike, McpOverrides, Modifiers, MovableConfig, + MovableParentFrame, NodeCategory, NodeDefinition, NodePort, + NodeQuickAction, + NodeQuickActionIcon, + NodeQuickActionProvider, + NodeQuickActionResult, NodeRegistry, PaintCapability, PaintEffectiveMaterialArgs, PaintPatchArgs, PaintPreviewArgs, PaintResolveArgs, - PanelWorkspace, ParamAction, ParametricDescriptor, ParamField, ParamGroup, + ParentFrameSnapMatch, Plugin, - PluginPanel, Presentation, Relations, RendererSource, RoofAccessoryConfig, RotatableConfig, ScalableConfig, + SceneActionCapability, SceneApi, SelectableConfig, SlotDeclaration, @@ -126,5 +136,6 @@ export type { SurfacesConfig, SystemContribution, ToolHint, + ToolHintChip, Vec2, } from './types' diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 093a9df3..f3f7335d 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -1,5 +1,5 @@ import type { ZodObject } from 'zod' -import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types' +import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types' const HOST_API_VERSION = 1 as const @@ -86,78 +86,6 @@ 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" @@ -317,20 +245,6 @@ 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}` }) - } - // 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}`) - } - } } /** @@ -346,7 +260,9 @@ export async function loadPlugin(plugin: Plugin): Promise { */ export type PluginDiscovery = () => Promise -let pluginDiscovery: PluginDiscovery = async () => [] +const defaultPluginDiscovery: PluginDiscovery = async () => [] + +let pluginDiscovery: PluginDiscovery = defaultPluginDiscovery /** * Replace the plugin discovery implementation. Call once at app startup @@ -359,9 +275,27 @@ let pluginDiscovery: PluginDiscovery = async () => [] * gate + duplicate-kind protection applies. */ export function setPluginDiscovery(fn: PluginDiscovery): void { + if (isDevMode() && pluginDiscovery !== defaultPluginDiscovery) { + console.warn( + '[registry] setPluginDiscovery replaced an existing discovery chain — plugins registered earlier (e.g. via extendPluginDiscovery) are dropped. Use extendPluginDiscovery to compose instead.', + ) + } pluginDiscovery = fn } +/** + * Extend the current plugin discovery instead of replacing it. Useful for app- + * bundled example or first-party plugins that should load alongside any host- + * provided discovery source, not clobber it. + */ +export function extendPluginDiscovery(fn: PluginDiscovery): void { + const previous = pluginDiscovery + pluginDiscovery = async () => { + const [base, extra] = await Promise.all([previous(), fn()]) + return [...base, ...extra] + } +} + /** * Run the active plugin discovery and return the discovered plugins. * Bootstrap code is expected to call this after `loadPlugin(builtinPlugin)` diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 4cd973b6..55fcc6f8 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,7 +1,7 @@ import type { ComponentType } from 'react' -import type { BufferGeometry, Object3D, Ray } from 'three' +import type { AnimationClip, BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' -import type { MaterialSchema } from '../schema/material' +import type { MaterialSchema, MaterialTarget } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import type { HandleList } from './handles' @@ -228,6 +228,30 @@ export type ToolHint = { * so the HUD reflects reality. Omit for always-shown hints. */ minDraftVertices?: number + /** + * Render this hint as a live mode chip — like the snapping / continuation + * chips — instead of a static key row: the HUD shows the current value's + * label and clicking the row (or pressing `key`) cycles it. The kind owns + * the state (typically its own ephemeral store); `label` above becomes the + * fallback when the current value has no entry in `chip.labels`. + */ + chip?: ToolHintChip +} + +export type ToolHintChip = { + /** Subscribe to live value changes (Zustand-store-like); returns unsubscribe. */ + subscribe: (onChange: () => void) => () => void + /** Current value token, resolved through `labels` / `icons` for display. */ + value: () => string + /** Advance to the next value — the chip's click action. The keyboard path is + * the tool's own handler for the hint's `key`; both must hit the same store. */ + cycle: () => void + /** value → chip row label, e.g. `{ cabinet: 'Type: Cabinet', island: 'Type: Island' }`. */ + labels: Record + /** value → iconify icon name. */ + icons?: Record + /** Hover tooltip, e.g. 'Placement type — click or press I to cycle'. */ + tooltip?: string } export type FloorplanGeometry = @@ -542,14 +566,13 @@ export type FloorplanGeometry = // history. // 3. Layer calls `apply` on every pointer-move with the current plan // point + modifier keys. -// 4. On pointer-up: layer reads the resulting scene state, reverts to -// the snapshot (still paused, untracked), resumes history, then -// re-applies the final state as a single tracked change (single- -// undo dance — same shape as Stage D 3D moves). +// 4. On pointer-up: layer either calls the session's atomic `commit()` for +// one tracked final write, or uses the legacy snapshot diff path for +// sessions that have not migrated yet. // 5. On pointer-cancel / unmount: revert + resume without committing. // -// `apply` is expected to call `scene.updateNodes` directly to drive -// previews — the layer doesn't keep a separate draft state. +// `apply` previews through live override/transform stores. It must not write +// committed scene state per pointer tick. export type FloorplanAffordancePoint = readonly [x: number, y: number] @@ -564,17 +587,11 @@ export type FloorplanAffordanceSession = { /** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */ affectedIds: AnyNodeId[] /** - * Run a single drag tick. Two patterns are supported: - * - **Scene-write preview**: implementation calls `scene.updateNodes` - * each tick; the dispatcher captures a pre-drag snapshot and runs - * a single-undo dance on commit (revert → resume → re-apply diff). - * Suitable for affordances whose commit is a pure diff of the - * affected fields. - * - **Live-override preview**: implementation publishes per-frame - * overrides to `useLiveNodeOverrides` (or another preview store); - * `useScene` stays untouched during the drag. The session must - * also expose `commit()` below, since there's no scene diff for - * the dispatcher to write back. + * Run a single drag tick. New implementations publish per-frame overrides to + * `useLiveNodeOverrides` / `useLiveTransforms` (or another preview store); + * `useScene` stays untouched during the drag. Legacy sessions that still + * write preview state into `useScene` are supported only by the dispatcher's + * snapshot-diff compatibility path. * * Snap logic, linked-node cascade, and angle locking live here. */ @@ -589,13 +606,12 @@ export type FloorplanAffordanceSession = { */ canCommit(): boolean /** - * Optional atomic-commit hook — mirror of the same field on - * `FloorplanMoveTargetSession`. When present, the dispatcher - * reverts to the pre-drag baseline (no-op if `apply()` never wrote - * to scene), resumes history, then calls `commit()` instead of - * re-applying a diff. The session owns the full final write - * (typically `applyNodeChanges` or `updateNodes`) plus clearing any - * live overrides it published in `apply()`. + * Optional atomic commit hook — mirror of the same field on + * `FloorplanMoveTargetSession`. New live-preview sessions should provide + * this so the dispatcher can revert to the pre-drag baseline, resume + * history, then call `commit()`. The session owns the full final write + * (typically `applyNodeChanges` or `updateNodes`) plus clearing any live + * overrides it published in `apply()`. */ commit?(): void } @@ -648,8 +664,10 @@ export type FloorplanMoveTargetSession = { /** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */ affectedIds: AnyNodeId[] /** - * Single move-preview tick. Implementations call `scene.updateNodes` - * directly to drive the live preview (no separate draft state). + * Single move-preview tick. Implementations publish per-frame overrides to + * `useLiveNodeOverrides` / `useLiveTransforms`; the scene store stays + * untouched during the drag. Provide `commit()` below so the final scene + * write happens once at the end. */ apply(args: { planPoint: FloorplanAffordancePoint @@ -695,36 +713,10 @@ export type FloorplanMoveTarget = (args: { // ─── 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[] } // ─── NodeDefinition ────────────────────────────────────────────────── @@ -765,6 +757,11 @@ export type SnapProfile = 'item' | 'structural' */ export type BakePolicy = 'static' | 'strip' | 'replace' +export type ExportAnimationContext = { + node: N + object: Object3D +} + export type NodeDefinition> = { kind: string schemaVersion: number @@ -863,6 +860,16 @@ export type NodeDefinition> = { * work (animations, named-mesh material poking). */ geometry?: (node: z.infer, ctx: GeometryContext) => Object3D + /** + * Optional GLB export animation hook for kind-owned moving parts. The + * exporter calls this against the cloned export subtree after material/mesh + * cleanup; implementations should leave `object` in its intended rest pose + * and return engine-agnostic Three.js clips that target objects inside that + * subtree. + */ + exportAnimation?: ( + ctx: ExportAnimationContext>, + ) => AnimationClip | AnimationClip[] | null | undefined /** * Optional cache key over the geometry-relevant inputs of `node`. When * set, `` skips the rebuild (dispose + re-create the @@ -943,8 +950,9 @@ export type NodeDefinition> = { * `def.floorplanAffordances?.[affordance].start({...})` on pointer-down, * receives a session, calls `apply(...)` on pointer-move and * `commit()` / `cancel()` on pointer-up / pointer-cancel. The session - * mutates scene state directly during `apply`; the dispatcher handles - * the snapshot + single-undo dance around it. + * previews through live override/transform stores during `apply`. Legacy + * sessions that still write preview state into `useScene` are handled by + * the dispatcher's snapshot + single-undo compatibility path. * * Mirrors the existing 3D `affordanceTools` map but for 2D SVG events, * and operates on plain JS data instead of mounting React. Kinds with @@ -966,12 +974,71 @@ export type NodeDefinition> = { * unset and rely on the generic overlay path. */ floorplanMoveTarget?: FloorplanMoveTarget> + /** + * Extra floating-menu actions contributed by this kind. The editor renders + * the returned descriptors generically; kind-specific mutation stays here + * and runs through `SceneApi`. + */ + quickActions?: NodeQuickActionProvider> + /** + * Sidebar-tree presentation hooks. Lets a kind reshape how the generic + * scene tree walks its subtree — hiding derived/managed nodes and + * flattening intermediate containers — without the tree hardcoding any + * kind. The tree consults these for every node whose kind declares them; + * kinds whose scene-graph shape matches their desired tree shape omit + * this entirely. + */ + tree?: { + /** + * Hide this node's row in the sidebar tree (e.g. derived/managed nodes + * whose contents surface elsewhere via `childIds`). + */ + hidden?: (node: AnyNode, nodes: Readonly>>) => boolean + /** + * Optional tree-row label override. When unset the host falls back to + * `node.name` / `def.presentation.label`. + */ + label?: (node: AnyNode, nodes: Readonly>>) => string + /** + * Override the child ids the sidebar tree renders under this node. + * When unset the tree falls back to the node's own `children`. + */ + childIds?: (node: AnyNode, nodes: Readonly>>) => AnyNodeId[] + } + /** + * Selection-proxy behavior overrides. A node opts into proxying by writing + * `metadata.nodeSelectionProxyId` (see `lib/selection-proxy.ts` for the + * metadata contract); grouped affordances (move / rotate) then key off the + * proxy target. `bypassDirectPick` lets a kind keep the proxy for those + * grouped affordances while still routing a direct canvas pick to the + * clicked node itself — e.g. corner-generated cabinet modules stay + * individually selectable even though they proxy to their run. + */ + selectionProxy?: { + /** Return true when a direct pick of `node` should select it instead of + * its resolved proxy target. */ + bypassDirectPick?: (node: AnyNode, proxyTarget: AnyNode) => boolean + } /** * Geometry reads sibling/parent/child nodes (e.g. wall miters, opening * dimensions); the floor-plan layer must rebuild it whenever a * sibling-affecting node is being dragged live. */ floorplanDependsOnSiblings?: boolean + /** + * Optional hook for kinds whose floor-plan cache invalidation reaches beyond + * the default framework relationships (wall junction neighbours, host wall + * opening cuts, gutter siblings under one roof). Called when a node of this + * kind has a live drag/override in flight; returns the extra entry ids that + * must rebuild this frame. + */ + floorplanAffectedIds?: (args: { + nodeId: AnyNodeId + node: AnyNode + nodes: Record + liveTransforms: Map + liveOverrides: Map> + }) => readonly AnyNodeId[] /** * Optional hook letting a kind project the `useLiveNodeOverrides` map * into a fresh `nodes` snapshot before its `def.floorplan` builder @@ -994,6 +1061,7 @@ export type NodeDefinition> = { floorplanSiblingOverrides?: (args: { nodeId: AnyNodeId nodes: Record + liveTransforms: Map liveOverrides: Map> }) => Record /** @@ -1114,6 +1182,8 @@ export type KeyboardActions = { r?: KeyboardAction /** T / Shift+T secondary action. */ t?: KeyboardAction + /** E interaction action — operate the node (doors, drawers, appliances). */ + e?: KeyboardAction /** * Set for kinds whose R/T rotation turns around a user-cyclable world * axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D @@ -1204,7 +1274,7 @@ export type AssetRef = { } export type SystemContribution = { - module: () => Promise<{ default: ComponentType }> + module: () => Promise<{ default: ComponentType<{ sceneApi: SceneApi }> }> priority?: number } @@ -1213,6 +1283,25 @@ export type McpOverrides = { semantic?: boolean } +export type DuplicateSubtreeCloneArgs = { + root: AnyNode + descendants: AnyNode[] + rootId: AnyNodeId + rootPatch: Partial + nodes: Readonly> +} + +export type DuplicateSubtreeCloneResult = { + root?: AnyNode + descendants?: AnyNode[] + parentId?: AnyNodeId | null +} + +export type DuplicableConfig = { + subtree?: boolean + prepareSubtreeClone?: (args: DuplicateSubtreeCloneArgs) => DuplicateSubtreeCloneResult +} + // ─── Capabilities ──────────────────────────────────────────────────── export type Capabilities = { @@ -1223,7 +1312,7 @@ export type Capabilities = { cuttable?: CuttableConfig snappable?: SnappableConfig surfaces?: SurfacesConfig - duplicable?: boolean + duplicable?: boolean | DuplicableConfig deletable?: boolean groupable?: boolean selectable?: SelectableConfig @@ -1246,13 +1335,16 @@ export type Capabilities = { * the box to wrap just the shaft they're moving. * * `size`: `[width, height, depth]` in the node's local frame. + * `center`: optional full local center. Use this when the footprint is + * offset from the node origin, such as a composite cabinet run after modules + * have been deleted or shifted. * `centerY`: optional Y center; defaults to `size[1] / 2` (box sits on * the ground plane). Override when the local origin isn't at the base. */ dragBounds?: ( node: AnyNode, nodes?: Readonly>, - ) => { size: [number, number, number]; centerY?: number } + ) => { size: [number, number, number]; center?: [number, number, number]; centerY?: number } roofAccessory?: RoofAccessoryConfig /** * Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed @@ -1261,6 +1353,15 @@ export type Capabilities = { */ ceilingCut?: CeilingCutCapability paint?: PaintCapability + /** + * In-scene click action dispatch (e.g. a cooktop knob toggling its burner). + * The editor's selection-manager walks the pointer hit's object chain + * through `resolveTarget`; when it returns non-null, `activate` runs and a + * `true` return consumes the click (no selection change). Keeps interactive + * sub-meshes registry-driven instead of `if (node.type === '')` arms + * in the editor. + */ + sceneAction?: SceneActionCapability /** * Declares the kind's paintable slots — the `{ slotId, label, default }` * contract shared by items (scanned from the GLB) and procedural kinds @@ -1376,6 +1477,11 @@ export type SlotDeclaration = { } export type PaintCapability = { + /** + * Material-picker target represented by this paint capability. Omit when + * the kind should not show up as a toolbar target from plain selection. + */ + materialTarget?: MaterialTarget /** * Opt this kind into the painter's `room` application scope: a paint click * spreads to every same-kind node bounding the clicked node's room (walls and @@ -1431,6 +1537,52 @@ export type PaintCapability = { } | null } +/** + * Per-kind in-scene click actions. A kind that builds interactive sub-meshes + * (a gas-hob knob, a switch) tags them via `userData` in its geometry builder, + * resolves the tag back out of the pointer hit in `resolveTarget`, and runs + * the state change in `activate`. The editor owns only the generic dispatch: + * walk the hit object's parent chain, and when `resolveTarget` returns + * non-null, call `activate`; a `true` return consumes the click. + * + * `activate` receives a `SceneApi` so the kind never imports `useScene` + * directly; transient animation frames may write through + * `useLiveNodeOverrides` + `markDirty` and commit once at the end. + */ +export type SceneActionCapability = { + /** Extract this kind's action target from one object in the hit chain. */ + resolveTarget: (object: { userData: Record }) => T | null + /** Run the action. Return `true` to consume the click (skip selection). */ + activate: (node: AnyNode, target: T, sceneApi: SceneApi) => boolean +} + +export type NodeQuickActionIcon = 'add-left' | 'add-right' | 'add' | 'convert' + +export type NodeQuickActionResult = { + selectedIds?: AnyNodeId[] +} + +export type NodeQuickAction = { + id: string + label: string + title?: string + /** + * Builtin glyph token (side-add arrows, convert) or an {@link IconRef} + * for kind-owned marks — quick actions with bespoke glyphs ship them + * from the kind's package instead of the menus hardcoding per-action + * SVG. + */ + icon?: NodeQuickActionIcon | IconRef + disabled?: boolean + history?: 'single' + run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined +} + +export type NodeQuickActionProvider = (args: { + node: N + nodes: Readonly>> +}) => NodeQuickAction[] + export type PaintResolveArgs = { node: AnyNode /** @@ -1568,9 +1720,95 @@ export type MovableConfig = { /** Snap radius in meters (XZ). Defaults to 0.5. */ radius?: number } + /** + * The node's `position` lives in a parent node's local frame (a cabinet + * module inside its run) rather than the level frame. The generic move + * tool converts the plan-frame cursor through these hooks, previews the + * child via `useLiveNodeOverrides` (dirtying the parent so its composite + * geometry re-flows), and skips the world-frame floor-collision box. + */ + parentFrame?: MovableParentFrame + /** + * Optional group-move snap for the generic multi-selection translate gizmo. + * Returns an adjusted candidate position for this node when the moving group + * should magnetically settle onto a nearby feature (for example, a cabinet + * run snapping flush to a wall while the whole selected kitchen moves as one). + */ + groupMoveSnap?: (args: GroupMoveSnapArgs) => [number, number, number] | null override?: (ctx: CapabilityCtx) => MovableConfig | null } +export type MovableParentFrame = { + /** The parent node owning the local frame; `null` → move in plan frame. */ + resolveParent: (node: AnyNode, nodes: Readonly>) => AnyNode | null + /** Parent's Y rotation, composed onto the child's preview rotation. */ + parentRotationY: (parent: AnyNode, nodes?: Readonly>) => number + localToPlan: ( + parent: AnyNode, + local: readonly [number, number, number], + nodes?: Readonly>, + ) => [number, number, number] + planToLocal: ( + parent: AnyNode, + planX: number, + localY: number, + planZ: number, + nodes?: Readonly>, + ) => [number, number, number] + /** + * Optional 2D live-transform projection. Used by the floor-plan layer for + * nodes whose live position is already in the parent-local frame and must not + * be treated as a level-frame / floor-placed position. + */ + floorplanLiveTransform?: (args: { node: AnyNode; live: LiveTransformLike }) => AnyNode + /** + * Optional magnetic snap in the parent's local frame (e.g. a module edge + * mating flush with a sibling module). Runs when magnetic snapping is + * active; returns the (possibly unchanged) local position. + */ + magneticSnap?: ( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + nodes: Readonly>, + ) => [number, number, number] + /** Optional snap-line matches for the parent-frame magnetic snap result. */ + magneticSnapMatches?: ( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + snappedLocal: readonly [number, number, number], + nodes: Readonly>, + ) => ParentFrameSnapMatch[] + /** + * Called after a move of the child commits, with the LIVE (post-commit) + * child and parent. Lets the kind run derived-state maintenance the + * generic tool can't know about (a cabinet run re-flowing its layout and + * re-anchoring linked corner runs to the moved module's new edge). + */ + onCommit?: (node: AnyNode, parent: AnyNode, sceneApi: SceneApi) => void +} + +export type ParentFrameSnapMatch = { + axis: 'x' | 'z' + candidateNodeId: AnyNodeId + from: { x: number; z: number } + to: { x: number; z: number } +} + +export type GroupMoveSnapArgs = { + node: AnyNode + candidatePosition: [number, number, number] + movingIds: readonly AnyNodeId[] + nodes: Readonly> + levelId: AnyNodeId | null +} + +export type LiveTransformLike = { + position: [number, number, number] + rotation: number +} + export type RotatableConfig = { axes: ReadonlyArray<'x' | 'y' | 'z'> snapAngles?: readonly number[] @@ -1744,6 +1982,20 @@ export type ParametricDescriptor = { node: N, nodes: Record, ) => Array<{ id: AnyNodeId; data: Partial }> + /** + * Companion deletes that should be folded into the same user-intent delete + * gesture — e.g. deleting the last module of a cabinet run should remove + * the now-empty run node too. Called against the live scene BEFORE + * deletion; returned ids are recursively expanded through the normal + * descendant cascade. `pendingDeleteIds` holds every id already part of + * the gesture so "would my parent become empty?" checks see sibling + * deletes from the same multi-select. + */ + onDeleteCascade?: ( + node: N, + nodes: Record, + pendingDeleteIds: ReadonlySet, + ) => AnyNodeId[] customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index b0f0d4cd..73eb57f4 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -33,6 +33,7 @@ export { } from './material' export { BoxVentNode } from './nodes/box-vent' export { BuildingNode } from './nodes/building' +export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' export { @@ -187,12 +188,37 @@ export { export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { TurbineVentNode } from './nodes/turbine-vent' -export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' +export type { + WallBandSurfaceSlotId, + WallFaceBand, + WallFaceBandConfig, + WallSurfaceMaterialSpec, + WallSurfaceSide, + WallSurfaceSlotId, + WallTrimConfig, +} from './nodes/wall' export { + buildEnabledWallFaceBandPatch, + buildWallFaceBandCountPatch, getEffectiveWallSurfaceMaterial, + getWallBandSlotId, + getWallFaceBandConfig, + getWallFaceBandForHeight, getWallSurfaceMaterialSignature, + getWallSurfaceSideFromBandSlot, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CHAIR_RAIL_SLOT_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_CROWN_SLOT_DEFAULT, + WALL_FACE_BAND_DEFAULT, + WALL_SKIRTING_DEFAULT, + WALL_SKIRTING_SLOT_DEFAULT, WALL_SLOT_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, + WALL_TRIM_DEFAULTS, WallNode, + WallTreatmentSide, + WallTrimProfile, } from './nodes/wall' export { WindowNode, WindowType } from './nodes/window' export { ZoneNode } from './nodes/zone' diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 0c8fcae5..59c85cd0 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -53,6 +53,7 @@ export const MaterialTarget = z.enum([ 'door', 'window', 'shelf', + 'cabinet', 'chimney', 'skylight', 'dormer', diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts new file mode 100644 index 00000000..df8a53a5 --- /dev/null +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +const compartmentBase = { + id: z.string(), + height: z.number().positive().max(2.5).optional(), +} + +const cooktopFields = { + cooktopBurnersOn: z.boolean().optional(), + cooktopActiveBurners: z.array(z.number().int().min(0).max(8)).optional(), + cooktopKnobProgress: z.array(z.number().min(0).max(1)).optional(), + cooktopShowGrate: z.boolean().optional(), +} + +export const CabinetFrontStyleSchema = z.enum(['slab', 'shaker', 'raised-arch']) + +// Discriminated on `type` so invalid field combinations (a drawer with a +// pantry rack style, a fridge with burner state) are unrepresentable. New +// compartment kinds add a variant here rather than widening a shared bag of +// optionals. +const CabinetCompartment = z.discriminatedUnion('type', [ + z.object({ + ...compartmentBase, + type: z.literal('shelf'), + shelfCount: z.number().int().min(0).max(8).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('drawer'), + drawerCount: z.number().int().min(1).max(6).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('door'), + doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), + shelfCount: z.number().int().min(0).max(8).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('sink'), + sinkLayout: z.enum(['single', 'double', 'double-offset']).optional(), + }), + z.object({ ...compartmentBase, type: z.literal('oven') }), + z.object({ ...compartmentBase, type: z.literal('microwave') }), + z.object({ ...compartmentBase, type: z.literal('dishwasher') }), + z.object({ + ...compartmentBase, + type: z.literal('cooktop-gas'), + ...cooktopFields, + cooktopLayout: z + .enum(['gas-2burner', 'gas-4burner', 'gas-5burner-wok', 'gas-6burner']) + .optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('cooktop-induction'), + ...cooktopFields, + cooktopLayout: z.enum(['induction-2zone', 'induction-4zone']).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('pull-out-pantry'), + shelfCount: z.number().int().min(0).max(8).optional(), + pantryRackStyle: z.enum(['wire', 'tray', 'glass']).optional(), + }), + z.object({ ...compartmentBase, type: z.literal('fridge-single') }), + z.object({ ...compartmentBase, type: z.literal('fridge-double') }), + z.object({ ...compartmentBase, type: z.literal('fridge-top-freezer') }), + z.object({ ...compartmentBase, type: z.literal('fridge-bottom-freezer') }), + z.object({ ...compartmentBase, type: z.literal('hood-pyramid') }), + z.object({ ...compartmentBase, type: z.literal('hood-curved-glass') }), +]) + +export type CabinetCompartmentSchema = z.infer + +// Box construction / hardware fields shared verbatim by the run and its +// modules. One source of truth so the two schemas can't drift. +const cabinetBoxFields = { + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + width: z.number().min(0.05).max(3).default(0.6), + depth: z.number().min(0.3).max(1.2).default(0.58), + carcassHeight: z.number().min(0.4).max(2.4).default(0.72), + operationState: z.number().min(0).max(1).default(0), + plinthHeight: z.number().min(0).max(0.3).default(0.1), + toeKickDepth: z.number().min(0).max(0.2).default(0.075), + boardThickness: z.number().min(0.01).max(0.08).default(0.018), + countertopThickness: z.number().min(0).max(0.08).default(0.02), + countertopOverhang: z.number().min(0).max(0.12).default(0.02), + // Extra slab reach off the back edge (island seating side) — up to a + // 45 cm knee-space overhang, unlike the small uniform front/side overhang. + countertopBackOverhang: z.number().min(0).max(0.45).default(0), + withFinishedBack: z.boolean().default(false), + frontThickness: z.number().min(0.01).max(0.05).default(0.018), + frontGap: z.number().min(0.001).max(0.02).default(0.003), + frontStyle: CabinetFrontStyleSchema.default('slab'), + handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), + handlePosition: z.enum(['auto', 'top', 'center']).default('auto'), + frontOverlay: z.enum(['full', 'inset']).default('full'), + withBottomPanel: z.boolean().default(true), + showPlinth: z.boolean().default(true), + withCountertop: z.boolean().default(true), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + slots: z.record(z.string(), z.string()).optional(), + stack: z.array(CabinetCompartment).optional(), +} + +export const CabinetNode = BaseNode.extend({ + id: objectId('cabinet'), + type: nodeType('cabinet'), + runTier: z.enum(['base', 'wall', 'tall']).default('base'), + children: z.array(objectId('cabinet-module')).default([]), + // Raised bar counter along one run edge: a knee wall topped by a slab at + // bar height. Run-level because it spans modules like the countertop. + barLedge: z + .object({ + edge: z.enum(['back', 'left', 'right']).default('back'), + height: z.number().min(0.9).max(1.3).default(1.06), + depth: z.number().min(0.15).max(0.5).default(0.35), + }) + .optional(), + // Countertop material dropping to the floor on exposed run ends. + withWaterfall: z.boolean().default(false), + ...cabinetBoxFields, +}).describe('Parametric modular cabinet run node') + +export const CabinetModuleNode = BaseNode.extend({ + id: objectId('cabinet-module'), + type: nodeType('cabinet-module'), + children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]), + cabinetType: z.enum(['base', 'tall']).default('base'), + // Discriminator for specialty units (corner L-shape, sink base, appliance + // gap, open shelving). 'standard' modules use the compartment stack as-is; + // new kinds extend this enum instead of overloading the stack. + moduleKind: z.enum(['standard', 'corner-filler']).default('standard'), + // Shared-boundary opening for inside-corner pockets: drops the side panel on + // that side and lets interior shelves / decks run through to the neighbour. + openSide: z.enum(['left', 'right']).optional(), + // Corner-pocket fillers carry a small internal shelf so the dead corner reads + // as reachable storage instead of an empty boxed void. + cornerShelf: z.boolean().optional(), + ...cabinetBoxFields, +}).describe('Parametric module inside a modular cabinet run') + +export type CabinetNode = z.infer +export type CabinetModuleNode = 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 00000000..abc2f5e8 --- /dev/null +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from 'bun:test' +import { + buildEnabledWallFaceBandPatch, + buildWallFaceBandCountPatch, + getWallFaceBandConfig, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CHAIR_RAIL_SLOT_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_CROWN_SLOT_DEFAULT, + WALL_FACE_BAND_DEFAULT, + WALL_FACE_BAND_SOLID_SLOT_DEFAULTS, + WALL_SKIRTING_DEFAULT, + WALL_SKIRTING_SLOT_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, + WallFaceBandConfig, + type WallNode, + WallTrimConfig, +} from './wall' + +describe('wall face bands', () => { + test('defaults to one band while preserving legacy enabled scenes as three bands', () => { + expect(WALL_FACE_BAND_DEFAULT.count).toBe(1) + expect(WALL_FACE_BAND_DEFAULT.lowerHeight).toBe(0.84) + expect(WALL_FACE_BAND_DEFAULT.middleHeight).toBe(0.61) + expect(WALL_FACE_BAND_DEFAULT.upperHeight).toBe(0.61) + + expect(WallFaceBandConfig.parse({})).toEqual({ + enabled: false, + count: 1, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }) + + expect(WallFaceBandConfig.parse({ enabled: true })).toEqual({ + enabled: true, + count: 3, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }) + + expect( + getWallFaceBandConfig({ + height: 2.5, + faceBands: { + enabled: true, + count: 3, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }, + }), + ).toMatchObject({ + count: 3, + lowerTop: 0.84, + middleTop: 1.45, + }) + }) + + test('four bands adds an upper split below the final top band', () => { + expect( + getWallFaceBandConfig({ + height: 2.5, + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.5, + middleHeight: 0.6, + upperHeight: 0.7, + }, + }), + ).toMatchObject({ + count: 4, + lowerTop: 0.5, + middleTop: 1.1, + upperTop: 1.8, + }) + }) + + test('enabling bands seeds visible solid-color band slots', () => { + const patch = buildEnabledWallFaceBandPatch({ + faceBands: { + enabled: false, + count: 1, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, + slots: { + interior: 'library:interior-finish', + exterior: 'scene:exterior-finish', + lowerInterior: 'library:stale-lower', + middleExterior: 'library:stale-middle', + }, + } as Pick) + + expect(patch.faceBands).toEqual({ + enabled: true, + count: 2, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }) + expect(patch.slots).toMatchObject({ + interior: 'library:interior-finish', + exterior: 'scene:exterior-finish', + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperInterior: 'library:interior-finish', + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperExterior: 'scene:exterior-finish', + }) + expect(patch.slots?.middleInterior).toBeUndefined() + expect(patch.slots?.middleExterior).toBeUndefined() + }) + + test('band count patch enables only the active slots', () => { + const patch = buildWallFaceBandCountPatch( + { + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, + slots: { + interior: 'library:interior-finish', + exterior: 'scene:exterior-finish', + topInterior: 'library:stale-top', + }, + } as Pick, + 3, + ) + + expect(patch.faceBands).toMatchObject({ enabled: true, count: 3 }) + expect(patch.slots).toMatchObject({ + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: 'library:stale-top', + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: 'scene:exterior-finish', + }) + expect(patch.slots?.topInterior).toBeUndefined() + expect(patch.slots?.topExterior).toBeUndefined() + }) + + test('enabling bands replaces stale inactive band slots with solid-color defaults', () => { + const patch = buildEnabledWallFaceBandPatch({ + slots: { + lowerInterior: 'library:stale-lower', + middleInterior: 'library:stale-middle', + upperExterior: 'library:stale-upper', + }, + } as Pick) + + expect(patch.slots).toEqual({ + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperInterior: WALL_SURFACE_SLOT_DEFAULTS.interior, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperExterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, + }) + expect(patch.slots?.middleInterior).toBeUndefined() + expect(patch.slots?.middleExterior).toBeUndefined() + }) + + test('increasing band count paints newly active bands with the solid palette', () => { + const patch = buildWallFaceBandCountPatch( + { + faceBands: { + enabled: true, + count: 2, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, + slots: { + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + }, + } as Pick, + 3, + ) + + expect(patch.slots).toMatchObject({ + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + }) + }) + + test('increasing to four bands keeps the previous top material on the new top band', () => { + const patch = buildWallFaceBandCountPatch( + { + faceBands: { + enabled: true, + count: 3, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, + slots: { + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: 'scene:painted-top-interior', + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: 'library:painted-top-exterior', + }, + } as Pick, + 4, + ) + + expect(patch.slots).toMatchObject({ + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + topInterior: 'scene:painted-top-interior', + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + topExterior: 'library:painted-top-exterior', + }) + }) +}) + +describe('wall trim profiles', () => { + test('uses curated defaults while preserving legacy profile values', () => { + expect(WALL_SKIRTING_DEFAULT.profile).toBe('flat') + expect(WALL_CROWN_DEFAULT.profile).toBe('flat') + expect(WALL_CHAIR_RAIL_DEFAULT.profile).toBe('flat') + + expect(WallTrimConfig.parse({ profile: 'flat' }).profile).toBe('flat') + expect(WallTrimConfig.parse({ profile: 'crown-layered' }).profile).toBe('crown-layered') + expect(WallTrimConfig.parse({ profile: 'triangle' }).profile).toBe('triangle') + }) + + test('declares separate default materials for each trim family', () => { + expect(WALL_SKIRTING_SLOT_DEFAULT).toBe('library:preset-softwhite') + expect(WALL_CROWN_SLOT_DEFAULT).toBe('library:preset-white') + expect(WALL_CHAIR_RAIL_SLOT_DEFAULT).toBe('library:preset-cream') + + expect(WALL_SURFACE_SLOT_DEFAULTS.skirtingInterior).toBe(WALL_SKIRTING_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.skirtingExterior).toBe(WALL_SKIRTING_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.crownInterior).toBe(WALL_CROWN_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.crownExterior).toBe(WALL_CROWN_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailInterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) + }) +}) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index a93d22bd..4bfaa063 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -6,6 +6,127 @@ import { DoorNode } from './door' import { ItemNode } from './item' import { WindowNode } from './window' +export const WallTreatmentSide = z.enum(['interior', 'exterior', 'both']) +export type WallTreatmentSide = z.infer + +export const WallTrimProfile = z.enum([ + 'flat', + 'bevel', + 'triangle', + 'cove', + 'bullnose', + 'base-modern', + 'base-colonial', + 'base-shoe', + 'base-ogee', + 'crown-cove', + 'crown-ogee', + 'crown-craftsman', + 'crown-layered', + 'rail-rounded', + 'rail-ogee', + 'rail-picture', + 'rail-stepped', +]) +export type WallTrimProfile = z.infer + +export const WallTrimConfig = z.object({ + enabled: z.boolean().default(false), + sides: WallTreatmentSide.default('both'), + height: z.number().default(0.1), + proud: z.number().default(0.015), + profile: WallTrimProfile.default('flat'), + offsetY: z.number().optional(), +}) +export type WallTrimConfig = z.infer + +export const WALL_SKIRTING_DEFAULT: WallTrimConfig = { + enabled: false, + sides: 'both', + height: 0.12, + proud: 0.02, + profile: 'flat', +} + +export const WALL_CROWN_DEFAULT: WallTrimConfig = { + enabled: false, + sides: 'both', + height: 0.12, + proud: 0.055, + profile: 'flat', +} + +export const WALL_CHAIR_RAIL_DEFAULT: WallTrimConfig = { + enabled: false, + sides: 'both', + height: 0.055, + proud: 0.026, + profile: 'flat', + offsetY: 0.9, +} + +export const WALL_TRIM_DEFAULTS = { + skirting: WALL_SKIRTING_DEFAULT, + crown: WALL_CROWN_DEFAULT, + chairRail: WALL_CHAIR_RAIL_DEFAULT, +} as const + +const WallFaceBandConfigShape = z.object({ + enabled: z.boolean().default(false), + count: z.number().int().min(1).max(4).default(1), + lowerHeight: z.number().default(0.84), + middleHeight: z.number().default(0.61), + upperHeight: z.number().default(0.61), +}) + +export const WallFaceBandConfig = z.preprocess((value) => { + if (value && typeof value === 'object' && !Array.isArray(value) && !('count' in value)) { + const enabled = (value as { enabled?: unknown }).enabled === true + return { ...value, count: enabled ? 3 : 1 } + } + return value +}, WallFaceBandConfigShape) +export type WallFaceBandConfig = z.infer + +export const WALL_FACE_BAND_DEFAULT: WallFaceBandConfig = { + enabled: false, + count: 1, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, +} + +export const WALL_SKIRTING_SLOT_DEFAULT = 'library:preset-softwhite' +export const WALL_CROWN_SLOT_DEFAULT = 'library:preset-white' +export const WALL_CHAIR_RAIL_SLOT_DEFAULT = 'library:preset-cream' +export const WALL_FACE_BAND_SOLID_SLOT_DEFAULTS = { + lower: 'library:preset-white', + middle: 'library:preset-lightgrey', + upper: 'library:preset-greige', + top: 'library:preset-softwhite', +} as const satisfies Record + +export const WALL_SURFACE_SLOT_DEFAULTS = { + interior: 'library:concrete-drywall', + exterior: 'library:concrete-drywall', + lowerInterior: 'library:concrete-drywall', + middleInterior: 'library:concrete-drywall', + upperInterior: 'library:concrete-drywall', + topInterior: 'library:concrete-drywall', + lowerExterior: 'library:concrete-drywall', + middleExterior: 'library:concrete-drywall', + upperExterior: 'library:concrete-drywall', + topExterior: 'library:concrete-drywall', + skirtingInterior: WALL_SKIRTING_SLOT_DEFAULT, + skirtingExterior: WALL_SKIRTING_SLOT_DEFAULT, + crownInterior: WALL_CROWN_SLOT_DEFAULT, + crownExterior: WALL_CROWN_SLOT_DEFAULT, + chairRailInterior: WALL_CHAIR_RAIL_SLOT_DEFAULT, + chairRailExterior: WALL_CHAIR_RAIL_SLOT_DEFAULT, +} as const + +export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS + export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), @@ -30,6 +151,10 @@ export const WallNode = BaseNode.extend({ thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), + faceBands: WallFaceBandConfig.optional(), + skirting: WallTrimConfig.optional(), + crown: WallTrimConfig.optional(), + chairRail: WallTrimConfig.optional(), // e.g., start/end points for path start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), @@ -52,6 +177,16 @@ export const WallNode = BaseNode.extend({ export type WallNode = z.infer export type WallSurfaceSide = 'interior' | 'exterior' +export type WallFaceBand = 'lower' | 'middle' | 'upper' | 'top' +export type WallBandSurfaceSlotId = + | 'lowerInterior' + | 'middleInterior' + | 'upperInterior' + | 'topInterior' + | 'lowerExterior' + | 'middleExterior' + | 'upperExterior' + | 'topExterior' // Declared default appearance for an unpainted wall face in colored mode — // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the @@ -59,8 +194,165 @@ export type WallSurfaceSide = 'interior' | 'exterior' // May be a `#rrggbb` colour or a `library:` ref. Textures-off still // collapses to the themed wall role (the escape hatch). export const WALL_SLOT_DEFAULT: Record = { - interior: 'library:concrete-drywall', - exterior: 'library:concrete-drywall', + interior: WALL_SURFACE_SLOT_DEFAULTS.interior, + exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, +} + +export function getWallFaceBandConfig(wall: Pick) { + const wallHeight = wall.height ?? 2.5 + const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } + const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1 + const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0 + const middleHeight = + count >= 3 ? Math.max(0, Math.min(wallHeight - lowerHeight, raw.middleHeight)) : 0 + const upperHeight = + count >= 4 ? Math.max(0, Math.min(wallHeight - lowerHeight - middleHeight, raw.upperHeight)) : 0 + + return { + enabled: raw.enabled && count > 1, + count, + lowerHeight, + middleHeight, + upperHeight, + lowerTop: lowerHeight, + middleTop: lowerHeight + middleHeight, + upperTop: lowerHeight + middleHeight + upperHeight, + } +} + +export function getWallFaceBandForHeight( + wall: Pick, + y: number, +): WallFaceBand { + const bands = getWallFaceBandConfig(wall) + if (!bands.enabled) return 'upper' + if (y < bands.lowerTop) return 'lower' + if (y < bands.middleTop) return 'middle' + if (bands.count >= 4 && y < bands.upperTop) return 'upper' + if (bands.count >= 4) return 'top' + return 'upper' +} + +export function getWallBandSlotId( + side: WallSurfaceSide, + band: WallFaceBand, +): WallBandSurfaceSlotId { + const suffix = side === 'interior' ? 'Interior' : 'Exterior' + return `${band}${suffix}` as WallBandSurfaceSlotId +} + +const WALL_FACE_BAND_SLOTS_BY_SIDE = { + interior: ['lowerInterior', 'middleInterior', 'upperInterior', 'topInterior'], + exterior: ['lowerExterior', 'middleExterior', 'upperExterior', 'topExterior'], +} as const satisfies Record + +function getWallFaceBandSlotsForCount( + side: WallSurfaceSide, + count: number, +): readonly WallBandSurfaceSlotId[] { + if (count <= 1) return [] + if (side === 'interior') { + if (count === 2) return ['lowerInterior', 'upperInterior'] + if (count === 3) return ['lowerInterior', 'middleInterior', 'upperInterior'] + return ['lowerInterior', 'middleInterior', 'upperInterior', 'topInterior'] + } + + if (count === 2) return ['lowerExterior', 'upperExterior'] + if (count === 3) return ['lowerExterior', 'middleExterior', 'upperExterior'] + return ['lowerExterior', 'middleExterior', 'upperExterior', 'topExterior'] +} + +function getWallFaceBandDefaultSlot(slotId: WallBandSurfaceSlotId): string { + if (slotId.startsWith('lower')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower + if (slotId.startsWith('middle')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle + if (slotId.startsWith('top')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.top + return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper +} + +function getWallFaceTopBandSlot( + side: WallSurfaceSide, + count: number, +): WallBandSurfaceSlotId | null { + const slots = getWallFaceBandSlotsForCount(side, count) + return slots[slots.length - 1] ?? null +} + +export function buildWallFaceBandCountPatch( + wall: Pick, + count: number, +): Pick { + const slots = { ...(wall.slots ?? {}) } + const nextCount = Math.max(1, Math.min(4, Math.round(count))) + const previousCount = wall.faceBands?.enabled + ? Math.max(1, Math.min(4, Math.round(wall.faceBands.count ?? 3))) + : 1 + + for (const side of ['interior', 'exterior'] as const) { + const nextSlots = getWallFaceBandSlotsForCount(side, nextCount) + const activeSlots = new Set(nextSlots) + const previouslyActiveSlots = new Set(getWallFaceBandSlotsForCount(side, previousCount)) + const previousTopSlot = getWallFaceTopBandSlot(side, previousCount) + const nextTopSlot = getWallFaceTopBandSlot(side, nextCount) + const topMaterial = + (previousTopSlot ? slots[previousTopSlot] : undefined) ?? + slots[side] ?? + WALL_SURFACE_SLOT_DEFAULTS[side] + for (const slotId of WALL_FACE_BAND_SLOTS_BY_SIDE[side]) { + if (activeSlots.has(slotId)) { + if (slotId === nextTopSlot) { + slots[slotId] = topMaterial + continue + } + const wasActive = previouslyActiveSlots.has(slotId) + const wasPreviousTop = slotId === previousTopSlot + if (!wasActive || wasPreviousTop || !slots[slotId]) { + slots[slotId] = getWallFaceBandDefaultSlot(slotId) + } + } else { + delete slots[slotId] + } + } + } + + return { + faceBands: { + ...WALL_FACE_BAND_DEFAULT, + ...(wall.faceBands ?? {}), + enabled: nextCount > 1, + count: nextCount, + lowerHeight: wall.faceBands?.lowerHeight ?? WALL_FACE_BAND_DEFAULT.lowerHeight, + middleHeight: wall.faceBands?.middleHeight ?? WALL_FACE_BAND_DEFAULT.middleHeight, + upperHeight: wall.faceBands?.upperHeight ?? WALL_FACE_BAND_DEFAULT.upperHeight, + }, + slots, + } +} + +export function buildEnabledWallFaceBandPatch( + wall: Pick, +): Pick { + return buildWallFaceBandCountPatch(wall, 2) +} + +export function getWallSurfaceSideFromBandSlot(slotId: string): WallSurfaceSide | null { + if (slotId === 'interior' || slotId === 'exterior') return slotId + if ( + slotId === 'lowerInterior' || + slotId === 'middleInterior' || + slotId === 'upperInterior' || + slotId === 'topInterior' + ) { + return 'interior' + } + if ( + slotId === 'lowerExterior' || + slotId === 'middleExterior' || + slotId === 'upperExterior' || + slotId === 'topExterior' + ) { + return 'exterior' + } + return null } export type WallSurfaceMaterialSpec = { diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d404367b..69547d5f 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,6 +1,7 @@ import z from 'zod' import { BoxVentNode } from './nodes/box-vent' import { BuildingNode } from './nodes/building' +import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' @@ -49,6 +50,8 @@ export const AnyNode = z.discriminatedUnion('type', [ ColumnNode, WallNode, FenceNode, + CabinetNode, + CabinetModuleNode, ItemNode, ZoneNode, SlabNode, diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index bc7b524a..3b0d09b5 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1072,6 +1072,12 @@ export const deleteNodesAction = ( if (allIds.has(id)) return allIds.add(id) const node = nextNodes[id] + const cascadeDeletes = node + ? nodeRegistry.get(node.type)?.parametrics?.onDeleteCascade?.(node, nextNodes, allIds) + : null + if (cascadeDeletes) { + for (const companionId of cascadeDeletes) collect(companionId) + } if (node && 'children' in node) { for (const cid of node.children as AnyNodeId[]) collect(cid) } diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index 51e191b6..7e631741 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -9,6 +9,15 @@ type TemporalStoreLike = { } } +type TemporalHistoryStoreLike = { + temporal: { + getState(): { + pastStates: TPastState[] + } + setState(state: { pastStates: TPastState[] }): void + } +} + export function pauseSceneHistory(sceneStore: TemporalStoreLike): void { if (sceneHistoryPauseDepth === 0) { sceneStore.temporal.getState().pause() @@ -34,3 +43,39 @@ export function getSceneHistoryPauseDepth(): number { export function resetSceneHistoryPauseDepth(): void { sceneHistoryPauseDepth = 0 } + +function retainedPastStateCount(before: TPastState[], after: TPastState[]): number { + for (let start = 0; start < before.length; start += 1) { + const retained = before.length - start + if (retained > after.length) continue + let matches = true + for (let index = 0; index < retained; index += 1) { + if (before[start + index] !== after[index]) { + matches = false + break + } + } + if (matches) return retained + } + return 0 +} + +export function runAsSingleSceneHistoryStep( + sceneStore: TemporalHistoryStoreLike, + run: () => TResult, +): TResult { + const beforePastStates = sceneStore.temporal.getState().pastStates + const result = run() + const afterPastStates = sceneStore.temporal.getState().pastStates + const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates) + const addedCount = afterPastStates.length - retainedCount + if (addedCount > 1) { + const firstAddedState = afterPastStates[retainedCount] + if (firstAddedState !== undefined) { + sceneStore.temporal.setState({ + pastStates: [...afterPastStates.slice(0, retainedCount), firstAddedState], + }) + } + } + return result +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 643065ed..c401238f 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -675,6 +675,33 @@ function migrateNodes(nodes: Record): { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials) } + // Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the + // per-compartment `doorType`) and was removed from both cabinet schemas; + // `handlePosition: 'edge'` behaved identically to 'auto' and was dropped + // from the enum; the compartment stack became a discriminated union that + // rejects a `cooktopLayout` mismatched to its gas/induction type (the old + // loose schema ignored it). + if (node.type === 'cabinet' || node.type === 'cabinet-module') { + const { doorStyle: _doorStyle, ...rest } = node + const next: Record = rest + if (next.handlePosition === 'edge') next.handlePosition = 'auto' + if (Array.isArray(next.stack)) { + next.stack = next.stack.map((compartment: any) => { + if (!compartment || typeof compartment !== 'object') return compartment + const layout = compartment.cooktopLayout + if (typeof layout !== 'string') return compartment + if (compartment.type === 'cooktop-gas' && !layout.startsWith('gas-')) { + return { ...compartment, cooktopLayout: 'gas-4burner' } + } + if (compartment.type === 'cooktop-induction' && !layout.startsWith('induction-')) { + return { ...compartment, cooktopLayout: 'induction-4zone' } + } + return compartment + }) + } + patchedNodes[id] = next + } + if (node.type === 'slab' || node.type === 'ceiling') { patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) } 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 c209c87d..fe6d0915 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 @@ -4,8 +4,11 @@ import { type AnyNode, type AnyNodeId, type CeilingNode, + createSceneApi, getWallMidpointHandlePoint, + type NodeQuickAction, nodeRegistry, + runAsSingleSceneHistoryStep, type SlabNode, useLiveNodeOverrides, useScene, @@ -14,10 +17,90 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' +import { + createFreshPlacementSubtree, + duplicatesAsFreshSubtree, +} from '../../lib/fresh-planar-placement' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' +import { IconRefGlyph } from '../ui/icon-ref' + +function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +// Builtin string tokens map to the generic glyphs above; kind-owned marks +// arrive as IconRef objects and render through the shared IconRefGlyph. +// Mirrors the 3D floating-action-menu (2D ↔ 3D parity). +function QuickActionIcon({ action }: { action: NodeQuickAction }) { + const icon = action.icon + if (!icon) return null + if (typeof icon === 'object') return + switch (icon) { + case 'add-left': + return + case 'add-right': + return + default: + return null + } +} + +function collectQuickActionNodes( + nodes: Record, + selectedId: string | null, +): Record | null { + if (!selectedId) return null + const selected = nodes[selectedId as AnyNodeId] + if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null + + const collected: Record = { [selected.id as AnyNodeId]: selected } + const add = (id: string | null | undefined) => { + if (!id) return + const node = nodes[id as AnyNodeId] + if (node) collected[node.id as AnyNodeId] = node + } + const addChildren = (node: AnyNode | undefined) => { + for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { + add(childId) + } + } + + add(selected.parentId ?? null) + addChildren(selected) + const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined + addChildren(parent) + + return collected +} /** * Floating Move / Duplicate / Delete buttons that appear above the @@ -71,6 +154,9 @@ export function FloorplanRegistryActionMenu() { const isRegistryKind = !!def const isVisible = isRegistryKind && !movingNode && isFloorplanHovered const isWall = selectedKind === 'wall' + const quickActionNodes = useScene( + useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)), + ) useEffect(() => { if (!(isVisible && selectedId)) { @@ -130,6 +216,14 @@ export function FloorplanRegistryActionMenu() { const node = useScene.getState().nodes[selectedId] if (!node) return null + const quickActions = + quickActionNodes && nodeRegistry.get(node.type)?.quickActions + ? (nodeRegistry.get(node.type)?.quickActions?.({ + node: node as never, + nodes: quickActionNodes, + }) ?? []) + : [] + // Move button is enabled when any of: // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence) // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item) @@ -203,6 +297,18 @@ export function FloorplanRegistryActionMenu() { if (!node.parentId) return sfxEmitter.emit('sfx:item-pick') useScene.temporal.getState().pause() + if (duplicatesAsFreshSubtree(node as AnyNode)) { + const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) + const draft = draftId ? useScene.getState().nodes[draftId] : null + if (draft) { + setMovingNode(draft as never) + setMovingNodeOrigin('2d') + useScene.temporal.getState().resume() + return + } + useScene.temporal.getState().resume() + return + } const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId } delete (cloned as { id?: AnyNodeId }).id const prevMeta = @@ -227,9 +333,20 @@ export function FloorplanRegistryActionMenu() { useViewer.getState().setSelection({ selectedIds: [] }) } + const handleQuickAction = (action: NodeQuickAction) => { + if (action.disabled) return + const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) + const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() + if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds }) + if (result?.selectedIds) { + const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) + sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') + } + } + return createPortal(
event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} /> + {quickActions.length > 0 ? ( +
event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + > + {quickActions.map((action) => ( + + ))} +
+ ) : null}
, document.body, ) 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 5d4890f9..071a25a0 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 @@ -20,6 +20,7 @@ import { useEffect } from 'react' import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' +import { movementSfxStepKey } from '../../lib/sfx/movement-tick' import { sfxEmitter } from '../../lib/sfx-bus' import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' import useAlignmentGuides from '../../store/use-alignment-guides' @@ -119,6 +120,17 @@ export function FloorplanRegistryMoveOverlay() { pauseSceneHistory(useScene) let historyPaused = true + const clearLivePreviews = () => { + const liveTransforms = useLiveTransforms.getState() + const liveOverrides = useLiveNodeOverrides.getState() + const scene = useScene.getState() + for (const id of session.affectedIds) { + liveTransforms.clear(id) + liveOverrides.clear(id) + scene.markDirty(id) + } + } + // The registry action menu's Move button portals to `document.body`, // so the trigger click's pointer-up happens OUTSIDE the floor-plan // scene and never reaches `onPointerUp` here. That means: the very @@ -170,7 +182,11 @@ export function FloorplanRegistryMoveOverlay() { const moved = movedId ? useScene.getState().nodes[movedId] : undefined const pos = (moved as { position?: [number, number, number] } | undefined)?.position if (pos) { - const key = `${pos[0]},${pos[2]}` + const key = movementSfxStepKey({ + coords: [pos[0], pos[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) if (key !== lastSnapKey) { lastSnapKey = key sfxEmitter.emit('sfx:grid-snap') @@ -354,12 +370,7 @@ export function FloorplanRegistryMoveOverlay() { resumeSceneHistory(useScene) historyPaused = false } - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() useAlignmentGuides.getState().clear() setMovingNode(null) return @@ -376,12 +387,7 @@ export function FloorplanRegistryMoveOverlay() { // `useLiveNodeOverrides`. Either way, leaving them in place // after Esc would freeze the 2D / 3D view at the cancelled // position. - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() // Restore selection cleared by the action menu's Move click. useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) }) setMovingNode(null) @@ -429,12 +435,7 @@ export function FloorplanRegistryMoveOverlay() { // `useLiveTransforms`; wall sessions write to // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D // tool's cleanup isn't there to clear them for us. - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() // Sessions that publish Figma-style alignment guides during `apply` // (item / shelf / column) leave them in the store; this cleanup runs // after every terminal path (commit + Esc both unmount via @@ -452,6 +453,11 @@ export function FloorplanRegistryMoveOverlay() { // ── Path 2 — generic free-floating translate ──────────────────── const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null if (!entry) return + const sceneNodes = useScene.getState().nodes as Record + const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes) + const relatedEntries = Array.from(relatedEntryIds) + .map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null) + .filter((value): value is SVGGElement => value != null) // Polyline kinds (duct / pipe / lineset) carry a `path`, not a // `position` — translating a `position` here would write a field their @@ -490,12 +496,12 @@ export function FloorplanRegistryMoveOverlay() { // so its untransformed bbox IS the world-space footprint. Cache the // moving entry's local bbox once (relative to originalPosition) and // derive anchors at any proposed (sx, sz) by translating it. - const movingLocalBBox = entry.getBBox() + const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries) const candidateAnchors: AlignmentAnchor[] = [] const allEntries = scene.querySelectorAll('[data-node-id]') for (const el of Array.from(allEntries)) { const otherId = el.getAttribute('data-node-id') - if (!otherId || otherId === movingNode.id) continue + if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue const b = (el as SVGGraphicsElement).getBBox() // Skip only fully-degenerate (point) entries. A thin run (duct / pipe / // lineset drawn as a line) has one zero dimension but is still a valid @@ -595,9 +601,33 @@ export function FloorplanRegistryMoveOverlay() { useAlignmentGuides.getState().clear() } + // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the + // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an + // alignment guide, so it runs in every snapping mode except Off. + const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap + if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) { + const snappedPosition = groupMoveSnap({ + node: movingNode, + candidatePosition: [finalX, originalPosition[1], finalZ], + movingIds: [movingNode.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (movingNode.parentId as AnyNodeId | undefined) ?? + null, + }) + if (snappedPosition) { + finalX = snappedPosition[0] + finalZ = snappedPosition[2] + useAlignmentGuides.getState().clear() + } + } + const dx = finalX - originalPosition[0] const dz = finalZ - originalPosition[2] - entry.setAttribute('transform', `translate(${dx} ${dz})`) + for (const relatedEntry of relatedEntries) { + relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`) + } boxEl.setAttribute('transform', `translate(${dx} ${dz})`) lastSnapped = [finalX, finalZ] } @@ -633,7 +663,9 @@ export function FloorplanRegistryMoveOverlay() { : { path: nextPath }) as Partial, ) useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] }) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) swallowNextClick() @@ -660,7 +692,9 @@ export function FloorplanRegistryMoveOverlay() { ) } useViewer.getState().setSelection({ selectedIds: [selectedId] }) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) swallowNextClick() @@ -677,7 +711,9 @@ export function FloorplanRegistryMoveOverlay() { useScene.getState().deleteNode(movingNode.id as AnyNodeId) if (wasTracking) temporal.resume() } - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) } @@ -690,10 +726,14 @@ export function FloorplanRegistryMoveOverlay() { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } // Always un-hide on teardown so a committed copy shows and a // never-revealed entry doesn't leak a hidden style onto a reused node. - entry.style.visibility = '' + for (const relatedEntry of relatedEntries) { + relatedEntry.style.visibility = '' + } boxEl.remove() useAlignmentGuides.getState().clear() } @@ -745,6 +785,51 @@ function deepEqual(a: unknown, b: unknown): boolean { return false } +function collectFloorplanMoveEntryIds( + rootId: AnyNodeId, + nodes: Record, +): Set { + const root = nodes[rootId] + if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') { + return new Set([rootId]) + } + + const ids = new Set() + const queue: AnyNodeId[] = [rootId] + while (queue.length > 0) { + const id = queue.pop()! + if (ids.has(id)) continue + const node = nodes[id] + if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue + ids.add(id) + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet' || child?.type === 'cabinet-module') { + queue.push(childId as AnyNodeId) + } + } + } + return ids +} + +function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect { + const first = entries[0]?.getBBox() + if (!first) return new DOMRect(0, 0, 0, 0) + + let minX = first.x + let minY = first.y + let maxX = first.x + first.width + let maxY = first.y + first.height + for (const entry of entries.slice(1)) { + const box = entry.getBBox() + minX = Math.min(minX, box.x) + minY = Math.min(minY, box.y) + maxX = Math.max(maxX, box.x + box.width) + maxY = Math.max(maxY, box.y + box.height) + } + return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY)) +} + function swallowNextClick() { const swallowClick = (e: MouseEvent) => { e.stopPropagation() diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts new file mode 100644 index 00000000..a1ea589a --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core' +import { z } from 'zod' +import { computeAffectedSiblingIds } from './floorplan-registry-layer' + +function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') { + return { + id, + type: 'cabinet', + object: 'node', + parentId, + visible: true, + metadata: {}, + children, + position: [0, 0, 0], + rotation: 0, + width: 1.2, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + showPlinth: true, + withCountertop: true, + countertopThickness: 0.02, + } as AnyNode +} + +function cabinetModule(id: string, parentId: string, children: string[] = []) { + return { + id, + type: 'cabinet-module', + object: 'node', + parentId, + visible: true, + metadata: {}, + children, + position: [0, 0.1, 0], + rotation: 0, + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + showPlinth: true, + countertopThickness: 0.02, + } as AnyNode +} + +function isCabinetNode(node: AnyNode | undefined): boolean { + return node?.type === 'cabinet' || node?.type === 'cabinet-module' +} + +function childIdsOf(node: AnyNode | undefined): AnyNodeId[] { + return Array.isArray((node as { children?: unknown } | undefined)?.children) + ? ((node as { children: AnyNodeId[] }).children ?? []) + : [] +} + +function cabinetAffectedIds({ + node, + nodes, + liveOverrides, +}: { + node: AnyNode + nodes: Record + liveOverrides: Map> +}): readonly AnyNodeId[] { + const affected = new Set() + const visited = new Set() + const queue: AnyNodeId[] = [node.id as AnyNodeId] + + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const current = nodes[id] + if (!isCabinetNode(current)) continue + affected.add(id) + + const parentIds = [ + current?.parentId as AnyNodeId | undefined, + (liveOverrides.get(id) as { parentId?: AnyNodeId } | undefined)?.parentId, + ] + for (const parentId of parentIds) { + const parent = parentId ? nodes[parentId] : undefined + if (parentId && isCabinetNode(parent)) queue.push(parentId) + } + for (const childId of childIdsOf(current)) { + if (isCabinetNode(nodes[childId])) queue.push(childId) + } + } + + return Array.from(affected) +} + +function registerCabinetFloorplanDefinition(kind: 'cabinet' | 'cabinet-module') { + registerNode({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + floorplanAffectedIds: cabinetAffectedIds, + } as unknown as AnyNodeDefinition) +} + +describe('computeAffectedSiblingIds', () => { + beforeEach(() => { + nodeRegistry._reset() + registerCabinetFloorplanDefinition('cabinet') + registerCabinetFloorplanDefinition('cabinet-module') + }) + + test('propagates cabinet live overrides through the cabinet family', () => { + const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner']) + const module = cabinetModule('cabinet-module_main', run.id) + const cornerModule = cabinetModule('cabinet-module_corner', run.id, ['cabinet_child-run']) + const childRun = cabinetRun('cabinet_child-run', ['cabinet-module_child'], cornerModule.id) + const childModule = cabinetModule('cabinet-module_child', childRun.id) + const nodes = { + [run.id]: run, + [module.id]: module, + [cornerModule.id]: cornerModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } as Record + + const affected = computeAffectedSiblingIds( + [run.id as AnyNodeId], + nodes, + new Map([[run.id, { position: [2, 0, 3] }]]), + ) + + expect(affected).toEqual( + new Set([run.id, module.id, cornerModule.id, childRun.id, childModule.id] as AnyNodeId[]), + ) + }) + + test('propagates a live-moving cabinet module back to its owning run', () => { + const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_child']) + const module = cabinetModule('cabinet-module_main', run.id) + const sibling = cabinetModule('cabinet-module_child', run.id) + const nodes = { + [run.id]: run, + [module.id]: module, + [sibling.id]: sibling, + } as Record + + const affected = computeAffectedSiblingIds( + [module.id as AnyNodeId], + nodes, + new Map([[module.id, { position: [1.2, 0.1, 0.3] }]]), + ) + + expect(affected).toEqual(new Set([module.id, run.id, sibling.id] as AnyNodeId[])) + }) +}) 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 b899ea24..9a438a86 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 @@ -16,6 +16,7 @@ import { nodeRegistry, pauseSceneHistory, resolveBuildingForLevel, + resolveSelectionProxyId, resumeSceneHistory, useInteractive, useLiveNodeOverrides, @@ -35,6 +36,7 @@ import { import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help' import { canDirectRotateNode, + resolveDirectManipulationNode, resolveDirectRotationDragDelta, resolveDirectRotationPatch, snapDirectRotationDelta, @@ -409,13 +411,25 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { for (const [id] of liveTransforms) { const node = sceneNodes[id as AnyNodeId] - if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + const def = node ? nodeRegistry.get(node.type) : null + if ( + node && + (def?.floorplanDependsOnSiblings || + def?.floorplanSiblingOverrides || + def?.floorplanAffectedIds) + ) { liveFlaggedIds.push(id as AnyNodeId) } } for (const [id] of liveOverrides) { const node = sceneNodes[id as AnyNodeId] - if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + const def = node ? nodeRegistry.get(node.type) : null + if ( + node && + (def?.floorplanDependsOnSiblings || + def?.floorplanSiblingOverrides || + def?.floorplanAffectedIds) + ) { liveFlaggedIds.push(id as AnyNodeId) } } @@ -557,7 +571,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { (id: AnyNodeId, event: ReactPointerEvent): boolean => { if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false - const node = useScene.getState().nodes[id] + const sceneNodes = useScene.getState().nodes + const selectedNode = sceneNodes[id] + const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null if (!node || !canDirectRotateNode(node)) return false // Sole selection only — same stand-down as the direct move above. const selectedIds = useViewer.getState().selection.selectedIds @@ -757,7 +773,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const def = nodeRegistry.get(node.type) if (!def?.floorplan) return const dependsOnSiblingInputs = !!( - def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides + def.floorplanDependsOnSiblings || + def.floorplanSiblingOverrides || + def.floorplanAffectedIds ) const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs } if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides @@ -1003,8 +1021,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { return } - // Capture the final state BEFORE the revert so we know what to - // re-apply post-resume. + // Legacy compatibility for sessions that still wrote preview state into + // `useScene` during `apply()`: capture the final state BEFORE the revert + // so we know what to re-apply post-resume. New sessions should provide a + // `commit()` hook and preview through live overrides/transforms instead. const sceneNodes = useScene.getState().nodes const finalUpdates: Array<{ id: AnyNodeId; data: Record }> = [] for (const snap of drag.snapshots) { @@ -1023,7 +1043,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } if (commitValid && finalUpdates.length > 0) { - // Single-undo dance (mirrors the 3D move-endpoint-tool): + // Legacy single-undo dance (mirrors the old 3D move-endpoint-tool): // 1. Revert to baseline while history is still paused (untracked). // 2. Resume history. // 3. Re-apply the final state — recorded as one tracked change. @@ -1354,11 +1374,26 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives // the highlight halo in 3D as well as registry floor-plan hover strokes. const handlePointerEnter = useCallback(() => { - onHoveredIdChange(nodeId) + const node = useScene.getState().nodes[nodeId] + onHoveredIdChange( + node + ? resolveSelectionProxyId( + node, + useScene.getState().nodes as Record, + ) + : nodeId, + ) }, [nodeId, onHoveredIdChange]) const handlePointerLeave = useCallback(() => { - if (useViewer.getState().hoveredId === nodeId) onHoveredIdChange(null) + const node = useScene.getState().nodes[nodeId] + const targetId = node + ? resolveSelectionProxyId( + node, + useScene.getState().nodes as Record, + ) + : nodeId + if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null) }, [nodeId, onHoveredIdChange]) const handleHandlePointerDown = useCallback( @@ -1516,7 +1551,11 @@ function buildFloorplanEntryGeometry({ return null } - const dependsOnSiblingInputs = !!(def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides) + const dependsOnSiblingInputs = !!( + def.floorplanDependsOnSiblings || + def.floorplanSiblingOverrides || + def.floorplanAffectedIds + ) const deps: NodeDeps = { node, live, @@ -1538,6 +1577,11 @@ function buildFloorplanEntryGeometry({ const applyLiveTransform = (sourceNode: AnyNode): AnyNode => { if (!live) return sourceNode const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position) + const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable + ?.parentFrame?.floorplanLiveTransform + if (parentFrameProjection) { + return parentFrameProjection({ node: sourceNode, live }) + } if (sourceNode.type === 'door' || sourceNode.type === 'window') { const r = (sourceNode as { rotation?: unknown }).rotation return { @@ -1571,7 +1615,12 @@ function buildFloorplanEntryGeometry({ } const contextNodes = def.floorplanSiblingOverrides - ? def.floorplanSiblingOverrides({ nodeId, nodes, liveOverrides }) + ? def.floorplanSiblingOverrides({ + nodeId, + nodes, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) : nodes const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode @@ -1645,7 +1694,12 @@ export function getFloorplanLevelData( const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook const contextNodes = def.floorplanSiblingOverrides - ? def.floorplanSiblingOverrides({ nodeId: sampleId, nodes, liveOverrides }) + ? def.floorplanSiblingOverrides({ + nodeId: sampleId, + nodes, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) : nodes const siblings: AnyNode[] = [] for (const id of ids) { @@ -2766,7 +2820,7 @@ function endpointKey(x: number, y: number): string { // - a gutter join depends on sibling gutters under the same roof. // Everything else stays cached, so dragging one wall/opening rebuilds a handful // of geometries rather than every wall + opening on the level. -function computeAffectedSiblingIds( +export function computeAffectedSiblingIds( liveFlaggedIds: readonly AnyNodeId[], nodes: Record, liveOverrides: Map>, @@ -2798,6 +2852,17 @@ function computeAffectedSiblingIds( const node = nodes[id] if (!node) continue affected.add(id) + const def = nodeRegistry.get(node.type) + const extraAffectedIds = def?.floorplanAffectedIds?.({ + nodeId: id, + node, + nodes: nodes as Record, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) + if (extraAffectedIds) { + for (const extraId of extraAffectedIds) affected.add(extraId) + } if (node.type === 'wall') { const w = node as unknown as { start: [number, number] diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index b0ab3795..f5b0fb08 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type CeilingNode, ColumnNode, + createSceneApi, DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, @@ -20,8 +21,10 @@ import { isRegistryMovable, isRegistrySelectable, isSplineFence, + type NodeQuickAction, nodeRegistry, RoofSegmentNode, + runAsSingleSceneHistoryStep, type SlabNode, SpawnNode, StairNode, @@ -38,6 +41,11 @@ import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' +import { useShallow } from 'zustand/react/shallow' +import { + createFreshPlacementSubtree, + duplicatesAsFreshSubtree, +} from '../../lib/fresh-planar-placement' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' import { duplicateRoofSubtree } from '../../lib/roof-duplication' @@ -49,6 +57,7 @@ import useInteractionScope, { useEndpointReshape, useIsCurveReshape, } from '../../store/use-interaction-scope' +import { IconRefGlyph } from '../ui/icon-ref' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' @@ -147,6 +156,79 @@ function getAttributeVersion( : 0 } +function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +// Builtin string tokens map to the generic glyphs above; kind-owned marks +// arrive as IconRef objects and render through the shared IconRefGlyph. +function QuickActionIcon({ action }: { action: NodeQuickAction }) { + const icon = action.icon + if (!icon) return null + if (typeof icon === 'object') return + switch (icon) { + case 'add-left': + return + case 'add-right': + return + default: + return null + } +} + +function collectQuickActionNodes( + nodes: Record, + selectedId: string | null, +): Record | null { + if (!selectedId) return null + const selected = nodes[selectedId as AnyNodeId] + if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null + + const collected: Record = { [selected.id as AnyNodeId]: selected } + const add = (id: string | null | undefined) => { + if (!id) return + const node = nodes[id as AnyNodeId] + if (node) collected[node.id as AnyNodeId] = node + } + const addChildren = (node: AnyNode | undefined) => { + for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { + add(childId) + } + } + + add(selected.parentId ?? null) + addChildren(selected) + const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined + addChildren(parent) + + return collected +} + // Pooled scratch for the per-frame anchor recompute (see useFrame below) so a // dragged node doesn't allocate a fresh Box3 + Vector3 every frame. const _anchorBox = new THREE.Box3() @@ -261,11 +343,22 @@ export function FloatingActionMenu() { }) // Only show for single selection of specific types - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null // 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 quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId))) + const quickActions = useMemo( + () => + node && quickActionNodes + ? (nodeRegistry.get(node.type)?.quickActions?.({ + node: node as never, + nodes: quickActionNodes, + }) ?? []) + : [], + [node, quickActionNodes], + ) // 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. @@ -442,6 +535,18 @@ export function FloatingActionMenu() { useScene.temporal.getState().pause() + if (duplicatesAsFreshSubtree(node as AnyNode)) { + const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) + const draft = draftId ? useScene.getState().nodes[draftId] : null + if (draft) { + setMovingNode(draft as any) + setSelection({ selectedIds: [] }) + return + } + useScene.temporal.getState().resume() + return + } + let duplicateInfo = structuredClone(node) as any delete duplicateInfo.id duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true } @@ -664,11 +769,27 @@ export function FloatingActionMenu() { const handleFind = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - if (node) emitter.emit('selection:find-node' as never, node as never) + if (node) emitter.emit('selection:find-node', node) }, [node], ) + const handleQuickAction = useCallback( + (action: NodeQuickAction) => (e: React.MouseEvent) => { + e.stopPropagation() + if (!node || action.disabled) return + const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) + const result = + action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() + if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds }) + if (result?.selectedIds) { + const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) + sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') + } + }, + [node, setSelection], + ) + if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || endpointReshape || @@ -688,7 +809,11 @@ export function FloatingActionMenu() { }} zIndexRange={[25, 0]} > -
+
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> + {quickActions.length > 0 ? ( +
e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + > + {quickActions.map((action) => ( + + ))} +
+ ) : null} {/* Height-drag dimension pill. Absolutely positioned just above the menu (away from the height arrow below it) so it rides the same scale transform + anchor, never overlaps the menu, and diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 26532f64..9ff3f66f 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9385,7 +9385,6 @@ export function FloorplanPanel({ showOpeningGhost, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, levelId, publishFloorplanNavigationPose, @@ -9677,7 +9676,6 @@ export function FloorplanPanel({ isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 5ef130f7..eb77f030 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -369,7 +369,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: {/* Fat invisible grab target (torus wrapping the arrow). A plain default-layer mesh with the standard raycast — the shared `InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never - received pointer events in this portalled context. */} + received pointer events in this portalled context: R3F's + `createPortal` gives the portal root its own fresh `Raycaster` + (default mask = layer 0 only), so the EDITOR_LAYER enable applied + to the root raycaster in `custom-camera-controls` never reaches + it. Harmless render-wise — the material is colorWrite:false / + depthWrite:false, so nothing leaks into thumbnails or the ink + pass. A proper fix would enable EDITOR_LAYER on the portal + raycaster from inside the portal. */} Partial | null + commit?: (patch: Partial) => void markDirty?: boolean onBegin?: () => void onEnd?: () => void @@ -206,7 +207,11 @@ export function useHandleDrag(args: UseHandleDragArgs) { swallowNextClick() sfxEmitter.emit('sfx:item-place') if (lastPatch) { - sceneApi.update(overrideId, lastPatch) + if (session.commit) { + session.commit(lastPatch) + } else { + sceneApi.update(overrideId, lastPatch) + } } clearOverride() cleanup() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5202fe56..b571b972 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -55,7 +55,7 @@ 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 { useHostPanels } from '../ui/sidebar/use-plugin-panels' import { CustomCameraControls } from './custom-camera-controls' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' @@ -1130,17 +1130,22 @@ export default function Editor({ const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) - // Plugin-contributed rail panels (registry-only). Called unconditionally so + // Host-registered rail panels. Called unconditionally so // hook order is stable across the v1 / v2 layout branches below; the v1 // AppSidebar path merges its own copy internally, the v2 path merges these // into its tab bar. - const pluginRailPanels = usePluginPanels() + const hostRailPanels = useHostPanels() useEffect(() => { const teardown = initializeEditorRuntime() return teardown }, []) + useEffect(() => { + void useEditor.persist.rehydrate() + void useSidebarStore.persist.rehydrate() + }, []) + useEffect(() => { useViewer.getState().setProjectId(projectId ?? null) @@ -1311,13 +1316,12 @@ export default function Editor({ // ── V2 layout ── if (layoutVersion === 'v2') { - // Plugin panels join the host's `sidebarTabs` as first-class tabs. Host - // tabs keep precedence (already in the map first); a plugin panel id can't - // collide with a host tab because it's namespaced by plugin id. + // Registered host panels join the host's `sidebarTabs` as first-class tabs. + // Explicit tabs keep precedence because they are already in the map first. const tabMap = new Map( sidebarTabs?.map((t) => [t.id, t]) ?? [], ) - for (const p of pluginRailPanels) { + for (const p of hostRailPanels) { if (!tabMap.has(p.id)) { tabMap.set(p.id, { id: p.id, label: p.label, icon: p.icon, component: p.component }) } @@ -1346,9 +1350,9 @@ export default function Editor({ mobileIcon, icon, })) ?? []), - // Plugin panels appear after the host's tabs in the rail. The icon + // Host panels appear after the explicit tabs in the rail. The icon // doubles as the mobile icon; a half-height sheet is a sensible default. - ...pluginRailPanels.map((p) => ({ + ...hostRailPanels.map((p) => ({ id: p.id, label: p.label, mobileDefaultSnap: 0.5, diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index c5cc5466..bb4f2590 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -44,6 +44,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' +import { resolveDirectManipulationNode } from '../../lib/direct-manipulation' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' @@ -137,6 +138,20 @@ export { swallowNextClick } from './handles/use-handle-drag' // its end). Upward trackers — wall / chimney height — stop at the cube. const TRACKER_THROUGH = 0.12 +type SceneApiForHandles = ReturnType +type CenteredGuideDecoration = { + center?: (node: AnyNode, sceneApi: SceneApiForHandles) => readonly [number, number, number] + radius: (node: AnyNode, sceneApi: SceneApiForHandles) => number +} + +function guideDecorationCenter(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) { + return (decoration as CenteredGuideDecoration | undefined)?.center?.(node, sceneApi) +} + +function guideDecorationRadius(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) { + return (decoration as CenteredGuideDecoration).radius(node, sceneApi) +} + // Mirrors the formatter used by wall / fence measurement labels so all // in-world dimension chips read consistently. function formatDimension(value: number, unit: 'metric' | 'imperial'): string { @@ -195,9 +210,11 @@ export function NodeArrowHandles() { const isCurveReshape = useIsCurveReshape() const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId - const rawNode = useScene((state) => - selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null, - ) + const rawNode = useScene((state) => { + if (!selectedId) return null + const selectedNode = state.nodes[selectedId as AnyNodeId] + return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null + }) // Merge any live drag override so the arrows themselves (positions, // ring decorations) track the in-flight drag instead of freezing at @@ -211,6 +228,7 @@ export function NodeArrowHandles() { [rawNode, liveOverride], ) const def = node ? nodeRegistry.get(node.type) : null + const descriptorSceneApi = useMemo(() => createSceneApi(useScene), []) const descriptors = useMemo(() => { if (!(node && def?.handles)) return null const all = @@ -221,8 +239,13 @@ export function NodeArrowHandles() { // the selected node body (see selection-manager). Drop both flavours — the // `translate` ground cross (column/roof/shelf/spawn) and the `tap-action` // `move-cross` (item/door/window/elevator/stair) — keep rotate/resize. - return all.filter((d) => d.kind !== 'translate' && !('shape' in d && d.shape === 'move-cross')) - }, [node, def]) + return all.filter( + (d) => + d.kind !== 'translate' && + !('shape' in d && d.shape === 'move-cross') && + (d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false), + ) + }, [node, def, descriptorSceneApi]) const shouldRender = Boolean(node && descriptors?.length) && @@ -714,6 +737,10 @@ function LinearArrow({ return { overrideId, + commit: + descriptor.kind === 'linear-resize' && descriptor.commit + ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi) + : undefined, onBegin: () => { // Always claim the handle-drag scope so the HUD knows a resize is the // active interaction (keeps the idle select hints off-screen). The @@ -818,7 +845,8 @@ function LinearArrow({ <> {showDecoration && decoration ? ( ) : null} @@ -846,7 +874,8 @@ function LinearArrow({ <> {showDecoration && decoration ? ( ) : null} @@ -870,7 +899,15 @@ function LinearArrow({ // e.g. the curved-stair width arrow traces the outer rim, the inner-radius // arrow traces the central pillar. Floats at node-local `y`, lies in the // XZ plane. -export function GuideRing({ radius, y }: { radius: number; y: number }) { +export function GuideRing({ + center, + radius, + y, +}: { + center?: readonly [number, number, number] + radius: number + y: number +}) { const safeRadius = Math.max(radius, 0.01) const ringGeometry = useMemo(() => { const inner = Math.max(safeRadius - 0.015, 0.001) @@ -897,7 +934,7 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) { frustumCulled={false} geometry={ringGeometry} material={ringMaterial} - position={[0, y, 0]} + position={center ? [center[0], y, center[2]] : [0, y, 0]} renderOrder={1009} rotation={[-Math.PI / 2, 0, 0]} /> @@ -1006,11 +1043,13 @@ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) { // from the pivot; the fill is pulled inside it so it reads as the handle // swinging around rather than overlapping the icon. function RotationWedge({ + center, delta, handleAngle, orbitRadius, y, }: { + center?: readonly [number, number, number] delta: number handleAngle: number orbitRadius: number @@ -1050,7 +1089,7 @@ function RotationWedge({ ] return ( - + {showDecoration && decoration ? ( ) : null} @@ -1249,9 +1290,16 @@ function ArcArrow({ ring on any surface — flat ground or a pitched roof. */} {rotationDelta !== null ? ( ) : null} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index df25db81..b7278c90 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -59,6 +59,7 @@ import { } from '../../lib/paint-scope' import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { + resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, type SelectionModifierKeys, @@ -218,6 +219,26 @@ function getEventObject(event: NodeEvent): Object3D { return eventWithObject.object ?? event.nativeEvent.object } +/** + * Registry-driven in-scene click actions (`capabilities.sceneAction`): walk + * the pointer hit's object chain, ask the clicked kind to resolve an action + * target from each object's userData, and run it. Returns `true` when the + * kind consumed the click (no selection change should happen). + */ +function dispatchSceneAction(node: AnyNode, object: Object3D | null): boolean { + const sceneAction = nodeRegistry.get(node.type)?.capabilities?.sceneAction + if (!sceneAction) return false + let current: Object3D | null = object + while (current) { + const target = sceneAction.resolveTarget(current) + if (target !== null) { + return sceneAction.activate(node, target, createSceneApi(useScene)) + } + current = current.parent + } + return false +} + function getIntersectionMaterialIndex( object: Object3D, faceIndex: number | undefined, @@ -1196,7 +1217,12 @@ export const SelectionManager = () => { if (!isCommandModifier(pointer)) return - const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node + const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node + const node = resolveCanvasSelectionNode({ + node: eventNode, + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) if (!canDirectMoveNode(node)) return // Sole selection only: per-node direct manipulation stands down for a // multi-selection (the group sessions own plain drags there, and Cmd is @@ -1483,7 +1509,20 @@ export const SelectionManager = () => { const activeScope = useInteractionScope.getState().scope if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return - const node = resolveSelectModeNodeTarget(event) + if (dispatchSceneAction(event.node, getEventObject(event))) { + event.stopPropagation() + clickHandledRef.current = true + setTimeout(() => { + clickHandledRef.current = false + }, 50) + return + } + + const node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) // A ceiling is selectable only through its corner handles, never via // the `ceiling-grid` body mesh. When the grid is revealed (ceiling @@ -1542,7 +1581,11 @@ export const SelectionManager = () => { nodeToSelect = parentNode } } - + nodeToSelect = resolveCanvasSelectionNode({ + node: nodeToSelect, + nodes: useScene.getState().nodes, + selectedIds: selectedIdsBeforeRouting, + }) // Clicking any node (e.g. the slab surface outside a hole) exits slab // hole-edit mode. The hole handles + hit mesh stopPropagation, so a // click reaching here means the user clicked outside the hole. @@ -1713,7 +1756,11 @@ export const SelectionManager = () => { // surface move tools keep tracking — but the select-hover outline must // stay put, so don't repaint under the cursor mid-drag. if (useViewer.getState().inputDragging) return - const node = resolveSelectModeNodeTarget(event) + const node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) const currentPhase = useEditor.getState().phase // Ignore site/building if we are already inside a building @@ -1741,14 +1788,22 @@ export const SelectionManager = () => { const onLeave = (event: NodeEvent) => { if (useViewer.getState().inputDragging) return - const nodeId = resolveSelectModeNodeTarget(event)?.id + const nodeId = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + })?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) } } const onDoubleClick = (event: NodeEvent) => { - let node = resolveSelectModeNodeTarget(event) + let node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) const currentPhase = useEditor.getState().phase @@ -2009,6 +2064,7 @@ const SelectionMaterialSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const geometryRevision = useViewer((s) => s.geometryRevision) const activeHighlightKindsRef = useRef(new Map()) const highlightedMaterialsRef = useRef( new Map< @@ -2085,6 +2141,7 @@ const SelectionMaterialSync = () => { }, []) useEffect(() => { + void geometryRevision const nextHighlightKinds = new Map() for (const id of new Set([...selectedIds, ...previewSelectedIds])) { @@ -2097,7 +2154,14 @@ const SelectionMaterialSync = () => { activeHighlightKindsRef.current = nextHighlightKinds syncSelectionMaterials() - }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials]) + }, [ + geometryRevision, + hoverHighlightMode, + hoveredId, + previewSelectedIds, + selectedIds, + syncSelectionMaterials, + ]) useEffect(() => { return useScene.subscribe((state, prevState) => { @@ -2152,10 +2216,12 @@ const EditorOutlinerSync = () => { const selection = useViewer((s) => s.selection) const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) + const geometryRevision = useViewer((s) => s.geometryRevision) const outliner = useViewer((s) => s.outliner) const nodes = useScene((s) => s.nodes) useEffect(() => { + void geometryRevision let idsToHighlight: string[] = [] // 1. Determine what should be highlighted based on Phase @@ -2208,7 +2274,7 @@ const EditorOutlinerSync = () => { if (obj?.parent) outliner.hoveredObjects.push(obj) } } - }, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) + }, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) return null } diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 2ee3925d..24b4e079 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -52,7 +52,6 @@ type UseFloorplanBackgroundPlacementArgs = { isOpeningPlacementActive: boolean isPolygonBuildActive: boolean isRoofBuildActive: boolean - isSlabBuildActive: boolean isWallBuildActive: boolean isZoneBuildActive: boolean levelId: string | null @@ -111,7 +110,6 @@ export function useFloorplanBackgroundPlacement({ isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, @@ -161,7 +159,7 @@ export function useFloorplanBackgroundPlacement({ // Align the committed vertex the same way the move-preview did, so the // placed point matches what the user saw — mode-driven (the chip): // `grid` quantizes, `angles` locks 15° rays, `lines` snaps onto walls / - // alignment, `off` is free. Alt forces (skips alignment). + // alignment, `off` is free. Alt remains force/free at commit time. const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive() const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, @@ -172,7 +170,6 @@ export function useFloorplanBackgroundPlacement({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point @@ -183,10 +180,10 @@ export function useFloorplanBackgroundPlacement({ if (isRoofBuildActive) { // Footprint placement (polygon context: grid / lines / off, no angle), - // mode-driven to match the chip. Alt forces (skips alignment). + // mode-driven to match the chip. Alt is force/free at commit time; + // alignment display/pull follows the active magnetic mode. const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { applySnap: isMagneticSnapActive(), - bypass: event.altKey, }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -281,7 +278,6 @@ export function useFloorplanBackgroundPlacement({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point @@ -361,7 +357,7 @@ export function useFloorplanBackgroundPlacement({ // local floor-plan draft handler (column / spawn / shelf / etc.). // The tool's `grid:click` subscriber owns the placement. if (isFloorplanGridInteractionActive) { - const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + const snappedPoint = getSnappedFloorplanPoint(planPoint) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) return true @@ -392,7 +388,6 @@ export function useFloorplanBackgroundPlacement({ isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 80eab103..2c089f8a 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -5,6 +5,7 @@ import { emitter, resolveLevelId, sceneRegistry, + snapPointToGrid, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -18,12 +19,11 @@ import { resolveCeilingPlanPointSnap, } from '../../../lib/ceiling-plan-snap' import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' +import useEditor, { isGridSnapActive } from '../../../store/use-editor' import useInteractionScope, { useIsCurveReshape, useMovingNode, } from '../../../store/use-interaction-scope' -import { snapToHalf } from '../../tools/item/placement-math' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' const BRACKET_THICKNESS = 0.04 @@ -300,23 +300,29 @@ const CeilingSelectionAffordance = ({ initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]), initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]), ] - const gridNextPosition: [number, number] = event.shiftKey - ? rawNextPosition - : [ - initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), - initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), - ] + const rawDelta: [number, number] = [ + planePosition[0] - drag.startPlanePosition[0], + planePosition[1] - drag.startPlanePosition[1], + ] + const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const snappedDelta = snapPointToGrid(rawDelta, gridStep) + const gridNextPosition: [number, number] = [ + initialCorner[0] + snappedDelta[0], + initialCorner[1] + snappedDelta[1], + ] const nextPosition = resolveCeilingPlanPointSnap({ rawPoint: rawNextPosition, fallbackPoint: gridNextPosition, levelId, excludeId: drag.ceilingId, - altKey: event.altKey, - shiftKey: event.shiftKey, }).point + const snapEpsilon = 1e-6 + const alignmentSnapped = + Math.abs(nextPosition[0] - gridNextPosition[0]) > snapEpsilon || + Math.abs(nextPosition[1] - gridNextPosition[1]) > snapEpsilon if ( - !event.shiftKey && + (gridStep > 0 || alignmentSnapped) && drag.previousSnappedPosition && (nextPosition[0] !== drag.previousSnappedPosition[0] || nextPosition[1] !== drag.previousSnappedPosition[1]) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index e1f404a8..0d0360f9 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -3,16 +3,22 @@ import '../../../three-types' import { + type AlignmentGuide, type AnyNode, type AnyNodeId, analyzePortConnectivity, + bboxCornerAnchors, collectAlignmentAnchors, + createSceneApi, type EventSuffix, emitter, + footprintAABBFrom, type GridEvent, + getFloorPlacedFootprints, movingFootprintAnchors, type NodeEvent, nodeRegistry, + type ParentFrameSnapMatch, type PortConnectivity, resolveAlignment, resolveConnectivityUpdates, @@ -29,6 +35,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' +import { movementSfxStepKey } from '../../../lib/sfx/movement-tick' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' @@ -36,6 +43,7 @@ import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { getActiveSnappingMode, isAlignmentGuideActive, + isGridSnapActive, isMagneticSnapActive, } from '../../../store/use-editor' @@ -60,6 +68,61 @@ const ROTATION_STEP = Math.PI / 4 /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */ const PORT_SNAP_RADIUS_M = 0.5 +const VALID_COLOR = 0x22_c5_5e +const INVALID_COLOR = 0xef_44_44 + +type DragBoundsOverride = { + size: [number, number, number] + center?: [number, number, number] + centerY?: number +} + +function offsetPlanPositionByLocalCenter( + position: [number, number, number], + center: [number, number, number], + rotationY: number, +): [number, number, number] { + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + return [ + position[0] + center[0] * cos + center[2] * sin, + position[1] + center[1], + position[2] - center[0] * sin + center[2] * cos, + ] +} + +/** + * Alignment anchors for the moving node. When the kind declares + * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet + * run whose modules extend past the node origin), the anchors come from that + * declared box instead of the origin-centred footprint. + */ +function movingDragBoundsAnchors( + node: AnyNode, + bounds: DragBoundsOverride | null, + x: number, + z: number, + rotationY: number, +) { + if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY) + const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY) + const aabb = footprintAABBFrom(center, bounds.size, rotationY) + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) +} + +function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide { + return { + axis: match.axis, + coord: match.axis === 'x' ? match.from.x : match.from.z, + from: match.from, + to: match.to, + anchor: match.from, + movingAnchorKind: 'corner', + candidateAnchorKind: 'corner', + candidateNodeId: match.candidateNodeId, + distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z), + } +} /** * Magnetic port snap for a dragged node: if one of the node's own ports @@ -168,6 +231,15 @@ const CLICK_TRIGGER_KINDS = [ ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + // Kinds whose `position` lives in a host parent's local frame declare + // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the + // plan-frame cursor through the capability's hooks and previews via + // `useLiveNodeOverrides` so the parent's composite geometry re-flows. + const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null + const frameParent = useMemo( + () => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null, + [parentFrame, node], + ) const originalPosition: [number, number, number] = useMemo( () => 'position' in node && Array.isArray((node as { position?: unknown }).position) @@ -193,7 +265,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { return 0 }, [node]) const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) /** * The latest snapped cursor position from `grid:move`. We commit at * THIS position regardless of which event variant fires the click — @@ -233,17 +305,73 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // refuse an invalid drop unless Alt forces it. The gate + footprint both come // from the kind's declarative `floorPlaced` capability, so opting a new kind // in is just `collides: true` — no change here. - const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true - const boxDimensions = useMemo( + // Parent-frame kinds skip the world-frame floor-collision box — their + // position isn't in the level frame the spatial grid indexes. + const collides = + !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true + // Snapshot the scene once at drag-start — bounds depend on `node` (locked + // for the lifetime of this tool) and any sibling state the kind reads. If a + // future kind needs live sibling state mid-drag, switch to a subscribed + // selector; for v1 (elevator shaft, cabinet run) start-time is correct and + // avoids subscribing the whole `nodes` map. + const dragBounds = useMemo( + (): DragBoundsOverride | null => + (nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as + | DragBoundsOverride + | undefined) ?? null, + [node], + ) + // Collision extents: the declared drag bounds (composite kinds — a cabinet + // run spans its modules) win over the single-node footprint. + const resolvedFootprint = useMemo( () => - collides - ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? - null) - : null, - [collides, node], + dragBounds?.size ?? + nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? + null, + [dragBounds, node], + ) + const boxDimensions = useMemo( + () => (collides ? resolvedFootprint : null), + [collides, resolvedFootprint], ) const [valid, setValid] = useState(true) - const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) + const previewRotationY = useCallback( + (rotationY = rotationRef.current) => + parentFrame && frameParent + ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY + : rotationY, + [parentFrame, frameParent], + ) + const visualPositionFor = useCallback( + (position: [number, number, number], rotationY = rotationRef.current) => { + if (parentFrame && frameParent) { + return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes) + } + return getFloorStackPreviewPosition({ + node, + position, + rotation: (() => { + const r = (node as { rotation?: unknown }).rotation + return Array.isArray(r) + ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0] + : rotationY + })(), + }) + }, + [parentFrame, frameParent, node], + ) + const canonicalPositionFromPlan = useCallback( + (planX: number, localY: number, planZ: number): [number, number, number] => + parentFrame && frameParent + ? parentFrame.planToLocal(frameParent, planX, localY, planZ, useScene.getState().nodes) + : [planX, localY, planZ], + [parentFrame, frameParent], + ) + const originalPlanPosition = useMemo( + () => visualPositionFor(originalPosition, originalRotationY), + [originalPosition, originalRotationY, visualPositionFor], + ) + const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY)) const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = useFreshPlacementVisibility({ node }) // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the @@ -255,6 +383,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // a register collar drops onto a duct run end. Reads `def.ports` through // the core registry, so it stays layer-clean (no @pascal-app/nodes import). const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null + // Kind-owned magnetic snap for the generic 3D move path. Cabinets use this + // to settle a dragged run flush against a wall without forking the move tool. + const groupMoveSnapConfig = + nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null // Mirrors of `valid` / Alt for the event handlers inside the effect, which // can't read React state without stale closures. const validRef = useRef(true) @@ -277,7 +409,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // fresh clone after a drop, or the user picks a different catalog tile — // and `useState` only honours its initial value, so without this the box // would keep the previous clone's rotation/position until the next R/T. - setCursorRotationY(originalRotationY) + setCursorRotationY(previewRotationY(originalRotationY)) lastCursorRef.current = originalPosition let committed = false const isNew = isFreshPlacement @@ -288,15 +420,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0] : y - const getVisualPosition = ( - position: [number, number, number], - rotationY = rotationRef.current, - ): [number, number, number] => { - return getFloorStackPreviewPosition({ - node, - position, - rotation: toCommitRotation(rotationY), - }) + const getVisualPosition = visualPositionFor + const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => { + const object = sceneRegistry.nodes.get(node.id) + if (!object) return + object.position.set(...position) + object.rotation.y = rotationY } const markMovedNodeDirty = () => { if (useScene.getState().nodes[node.id]) { @@ -343,6 +472,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } + const syncParentFramePreview = (position: [number, number, number]) => { + if (!frameParent) return + useLiveNodeOverrides.getState().set(node.id, { + position, + rotation: rotationRef.current, + }) + useScene.getState().markDirty(frameParent.id as AnyNodeId) + } + + const clearParentFramePreview = () => { + if (!frameParent) return + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(frameParent.id as AnyNodeId) + } + setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) // Re-run the floor-collision check at the live cursor + rotation and push @@ -362,14 +506,42 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setValid(true) return } - const [x, y, z] = lastCursorRef.current - const { valid: placeable } = spatialGridManager.canPlaceOnFloor( - levelId, - [x, y, z], - boxDimensions, - [0, rotationRef.current, 0], - [node.id], - ) + const livePosition = lastCursorRef.current + const liveRotation = previewRotationY(rotationRef.current) + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + const effectiveNode = { + ...(node as Record), + position: livePosition, + rotation: Array.isArray((node as { rotation?: unknown }).rotation) + ? [ + ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0, + rotationRef.current, + ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0, + ] + : rotationRef.current, + } as AnyNode + const footprints = floorPlaced + ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes }) + : [] + const resolvedFootprints: Array<{ + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }> = footprints.map((footprint) => ({ + position: footprint.position ?? livePosition, + dimensions: footprint.dimensions, + rotation: footprint.rotation, + })) + const { valid: placeable } = + resolvedFootprints.length > 0 + ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id]) + : spatialGridManager.canPlaceOnFloor( + levelId, + getVisualPosition(livePosition), + boxDimensions, + [0, liveRotation, 0], + [node.id], + ) validRef.current = placeable setValid(placeable) } @@ -425,7 +597,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const resolved = resolvePlanarCursorPosition({ cursor: [rawX, rawZ], - original: [originalPosition[0], originalPosition[2]], + original: [originalPlanPosition[0], originalPlanPosition[2]], anchor: dragAnchorRef.current, mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. @@ -444,7 +616,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const magnetic = isMagneticSnapActive() if (isAlignmentGuideActive() && alignmentCandidates.length > 0) { const result = resolveAlignment({ - moving: movingFootprintAnchors(node, x, z, rotationRef.current), + moving: movingDragBoundsAnchors( + node, + dragBounds, + x, + z, + previewRotationY(rotationRef.current), + ), candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) @@ -457,6 +635,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useAlignmentGuides.getState().clear() } + // Kind-owned attachment snap (cabinet → wall): an attach behavior like + // door/window wall placement, not an alignment guide — active in every + // snapping mode except Off. + if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) { + const snappedPosition = groupMoveSnapConfig({ + node, + candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z), + movingIds: [node.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null, + }) + if (snappedPosition) { + const snappedPlanPosition = getVisualPosition(snappedPosition) + x = snappedPlanPosition[0] + z = snappedPlanPosition[2] + useAlignmentGuides.getState().clear() + } + } + // Magnetic port snap (duct terminals): mate a collar onto a nearby // duct run end. Takes precedence over grid / alignment snap; Alt // bypasses. Only kinds that opted in via `movable.portSnap`. @@ -474,7 +671,38 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } - const position: [number, number, number] = [x, originalPosition[1], z] + let position = canonicalPositionFromPlan(x, originalPosition[1], z) + if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) { + const preSnapPosition = position + const snappedPosition = parentFrame.magneticSnap( + node, + frameParent, + position, + useScene.getState().nodes, + ) + if ( + snappedPosition[0] !== position[0] || + snappedPosition[1] !== position[1] || + snappedPosition[2] !== position[2] + ) { + position = snappedPosition + const snappedPlanPosition = getVisualPosition(position) + x = snappedPlanPosition[0] + z = snappedPlanPosition[2] + } + if (isAlignmentGuideActive() && parentFrame.magneticSnapMatches) { + const guides = parentFrame + .magneticSnapMatches( + node, + frameParent, + preSnapPosition, + snappedPosition, + useScene.getState().nodes, + ) + .map(alignmentGuideFromParentFrameMatch) + if (guides.length > 0) useAlignmentGuides.getState().set(guides) + } + } const visualPosition = getVisualPosition(position) hasMovedRef.current = true setCursorPosition(visualPosition) @@ -482,7 +710,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { recomputeValidity() // Pure imperative: move the mesh via its registered Object3D ref. - sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition) + applyMeshPose(position) // Publish to `useLiveTransforms` so the 2D floor plan can mirror // the drag in real-time (the floor-plan layer subscribes to this // store and overrides the node's rendered position when an entry @@ -497,14 +725,20 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position, rotation: rotationRef.current, }) + syncParentFramePreview(position) markMovedNodeDirty() // Carry connected ductwork along (preview only — committed on drop). previewConnectivity(position, rotationRef.current) + const nextSnapKey = movementSfxStepKey({ + coords: [x, z], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== x || prev[1] !== z) { + if (prev !== nextSnapKey) { sfxEmitter.emit('sfx:grid-snap') - previousSnapRef.current = [x, z] + previousSnapRef.current = nextSnapKey } } @@ -544,7 +778,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const position: [number, number, number] = [...lastCursorRef.current] const rotation = toCommitRotation(rotationRef.current) - const visualPosition = getVisualPosition(position) let committedId = node.id as AnyNodeId if (useScene.getState().nodes[node.id]) { @@ -578,6 +811,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useScene .getState() .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates]) + // Kind-owned derived-state maintenance after a parent-frame move + // (cabinet run re-flow + linked corner-run re-anchor). Runs in the + // resumed window so its writes are undoable alongside the move. + if (parentFrame?.onCommit && frameParent) { + const liveNode = useScene.getState().nodes[node.id as AnyNodeId] + const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId] + if (liveNode && liveParent) { + parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene)) + } + } useScene.temporal.getState().pause() committed = true } @@ -606,11 +849,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Connected ductwork is now committed to the store — drop its live // overrides so the renderers read the canonical path/position. clearConnectivityOverrides() - const mesh = sceneRegistry.nodes.get(node.id) - if (mesh) { - mesh.position.set(...visualPosition) - mesh.rotation.y = rotationRef.current - } + clearParentFramePreview() + applyMeshPose(position) useAlignmentGuides.getState().clear() if (isNew && committed) { @@ -653,19 +893,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { e.preventDefault() sfxEmitter.emit('sfx:item-rotate') rotationRef.current += delta - setCursorRotationY(rotationRef.current) + setCursorRotationY(previewRotationY(rotationRef.current)) const position = lastCursorRef.current const visualPosition = getVisualPosition(position) setCursorPosition(visualPosition) - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...visualPosition) - m.rotation.y = rotationRef.current - } + applyMeshPose(position) useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, }) + syncParentFramePreview(position) markMovedNodeDirty() // Rotating the fitting swings its collars — connected ducts follow. previewConnectivity(position, rotationRef.current) @@ -712,14 +949,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() + clearParentFramePreview() if (isNew) { useScene.getState().deleteNode(node.id as AnyNodeId) } else { - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...getVisualPosition(originalPosition, originalRotationY)) - m.rotation.y = originalRotationY - } + applyMeshPose(originalPosition, originalRotationY) markMovedNodeDirty() } useAlignmentGuides.getState().clear() @@ -750,38 +984,33 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (!(committed || isNew || finalisedBy2D)) { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() - sceneRegistry.nodes - .get(node.id) - ?.position.set(...getVisualPosition(originalPosition, originalRotationY)) + clearParentFramePreview() + applyMeshPose(originalPosition, originalRotationY) markMovedNodeDirty() } useScene.temporal.getState().resume() } }, [ boxDimensions, + dragBounds, + canonicalPositionFromPlan, + parentFrame, + frameParent, cursorAttached, portSnapConfig, + groupMoveSnapConfig, exitMoveMode, isFreshPlacement, node, originalPosition, + originalPlanPosition, originalRotationY, + previewRotationY, revealFreshPlacement, useAbsoluteCursorPlacement, + visualPositionFor, ]) - // Snapshot the scene once at drag-start — bounds depend on `node` (locked - // for the lifetime of this tool) and any sibling state the kind reads. If a - // future kind needs live sibling state mid-drag, switch to a subscribed - // selector; for v1 (elevator shaft height from level set) start-time is - // correct and avoids subscribing the whole `nodes` map. - const dragBounds = useMemo( - () => - nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ?? - null, - [node], - ) - // Forward-facing triangle for the footprint-box branch (item / shelf / column // — anything that renders ``). Published to the editor-side // overlay; the `` branch (e.g. stair, which has no centred @@ -794,14 +1023,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position: cursorPosition, rotationY: cursorRotationY, depth: boxDimensions[2], + center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined, reversed: facing.reversed, }) - }, [previewVisible, facing, boxDimensions, cursorPosition, cursorRotationY]) + }, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY]) useEffect(() => () => useFacingPose.getState().clear(), []) if (!previewVisible) return null - if (boxDimensions) { + if (boxDimensions && !dragBounds?.center) { return ( - + +
+ {label} +
+ + ) +} /** * Green/red placement footprint shown while a node follows the cursor — the @@ -27,12 +143,15 @@ const INVALID_COLOR = 0xef_44_44 // red-500 */ export function PlacementBox({ dimensions, + measurements, position, rotationY = 0, valid, }: { /** Footprint extent `[width, height, depth]` (unrotated). */ dimensions: [number, number, number] + /** Optional dimension guide labels matching the GLB item placement cursor. */ + measurements?: PlacementBoxMeasurements /** World-plan position of the footprint centre (floor level). */ position: [number, number, number] /** Y-rotation in radians, applied to the whole box. */ @@ -61,6 +180,14 @@ export function PlacementBox({ geometry.translate(0, 0.01, 0) return geometry }, [width, depth]) + const measurementGuideGeometries = useMemo(() => { + const points = getMeasurementGuidePoints(width, height, depth) + return { + depth: createLineGeometry(points.depth), + height: createLineGeometry(points.height), + width: createLineGeometry(points.width), + } + }, [width, height, depth]) const edgeMaterial = useMemo( () => new LineBasicNodeMaterial({ linewidth: 3, depthTest: false, depthWrite: false }), @@ -77,6 +204,16 @@ export function PlacementBox({ material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) return material }, []) + const measurementMaterial = useMemo( + () => + new LineBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + linewidth: 2, + depthTest: false, + depthWrite: false, + }), + [], + ) useEffect(() => { const color = valid ? VALID_COLOR : INVALID_COLOR @@ -90,6 +227,14 @@ export function PlacementBox({ }, [edgeGeometry], ) + useEffect( + () => () => { + measurementGuideGeometries.depth.dispose() + measurementGuideGeometries.height.dispose() + measurementGuideGeometries.width.dispose() + }, + [measurementGuideGeometries], + ) useEffect( () => () => { basePlaneGeometry.dispose() @@ -100,10 +245,46 @@ export function PlacementBox({ () => () => { edgeMaterial.dispose() basePlaneMaterial.dispose() + measurementMaterial.dispose() }, - [edgeMaterial, basePlaneMaterial], + [edgeMaterial, basePlaneMaterial, measurementMaterial], ) + const measurementContent = measurements ? ( + <> + + + + + + + + ) : null + return ( + {measurementContent} { lastPoint: [number, number] | undefined, gridPoint: [number, number], rawPoint: [number, number], - altKey: boolean, ): [number, number] => { const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 const orthoPoint: [number, number] = @@ -110,7 +109,6 @@ export const ZoneTool: React.FC = () => { rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, - altKey, }).point } @@ -189,12 +187,10 @@ export const ZoneTool: React.FC = () => { levelYRef.current = event.localPosition[1] const lastPoint = pointsRef.current[pointsRef.current.length - 1] - const displayPoint = snapDraftPoint( - lastPoint, - cursorPosition, - [event.localPosition[0], event.localPosition[2]], - event.nativeEvent?.altKey === true, - ) + const displayPoint = snapDraftPoint(lastPoint, cursorPosition, [ + event.localPosition[0], + event.localPosition[2], + ]) snappedCursorPosition = displayPoint // Play snap sound when the snapped position changes during drawing — only @@ -219,12 +215,10 @@ export const ZoneTool: React.FC = () => { if (!currentLevelId) return const lastPoint = pointsRef.current[pointsRef.current.length - 1] - const clickPoint = snapDraftPoint( - lastPoint, - gridPointOf(event), - [event.localPosition[0], event.localPosition[2]], - event.nativeEvent?.altKey === true, - ) + const clickPoint = snapDraftPoint(lastPoint, gridPointOf(event), [ + event.localPosition[0], + event.localPosition[2], + ]) // Check if clicking on the first point to close the shape const firstPoint = pointsRef.current[0] diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index 1d0b2c0e..2167a302 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -1,5 +1,6 @@ import { Icon } from '@iconify/react' -import { Fragment } from 'react' +import type { ToolHint } from '@pascal-app/core' +import { Fragment, useSyncExternalStore } from 'react' import { CONTINUATION_PROFILES, type ContinuationContext, @@ -43,7 +44,13 @@ const KEY_CELL_CLASS = 'flex items-center gap-1' // Keys pressed together join with "+"; an entry that is itself an array is a // group of alternatives and joins with "/" — so [['Cmd/Ctrl', 'Shift'], // 'Left click'] reads "⌘ / ⇧ + click". -function ShortcutSequence({ keys }: { keys: Array }) { +function ShortcutSequence({ + active = false, + keys, +}: { + active?: boolean + keys: Array +}) { return (
{keys.map((entry, index) => ( @@ -59,11 +66,17 @@ function ShortcutSequence({ keys }: { keys: Array }) { {altIndex > 0 ? ( / ) : null} - + )) ) : ( - + )} ))} @@ -193,6 +206,26 @@ function SnappingChips({ context }: { context: SnapContext }) { ) } +// A kind-owned live mode chip declared on a `ToolHint` (`hint.chip`) — the +// registry counterpart of the snapping / continuation chips above: shows the +// current value's label, and clicking the row (or the hint's key, handled by +// the tool itself) cycles it. +function ToolHintChipRow({ hint }: { hint: ToolHint & { chip: NonNullable } }) { + const { chip } = hint + const value = useSyncExternalStore(chip.subscribe, chip.value, chip.value) + const label = chip.labels[value] ?? hint.label + return ( + + ) +} + function ContinuationChip({ context }: { context: ContinuationContext }) { const mode = useEditor((s) => s.getContinuation(context)) const cycleContinuation = useEditor((s) => s.cycleContinuation) @@ -327,18 +360,28 @@ function PaintScopeChip() { export function ContextualHelperPanel({ hints, + chipHints = [], snapContext = null, showPaintScope = false, continuationContext = null, }: { hints: ContextualShortcutHint[] + // Kind-owned live mode chips (`ToolHint.chip`), rendered alongside the + // snapping / continuation chips. + chipHints?: ToolHint[] // The active snapping context drives the snapping chips (which mode set). Null // → no snapping chips for this interaction. snapContext?: SnapContext | null showPaintScope?: boolean continuationContext?: ContinuationContext | null }) { - if (hints.length === 0 && !snapContext && !showPaintScope && !continuationContext) + if ( + hints.length === 0 && + chipHints.length === 0 && + !snapContext && + !showPaintScope && + !continuationContext + ) return null return ( @@ -348,18 +391,26 @@ export function ContextualHelperPanel({ {continuationContext && continuationContext !== 'fence' ? ( ) : null} + {chipHints.map((hint) => + hint.chip ? ( + }} + key={`${hint.key}:${hint.label}`} + /> + ) : null, + )} {showPaintScope ? : null} {hints.map((hint) => (
- +
{hint.label} diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 54443de3..cce7c0f2 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -27,18 +27,22 @@ export function RegisteredToolHelper({ // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. const draftVertexCount = useEditor((s) => s.draftVertexCount) - // The snapping chip (when a context is active) already shows Shift = cycle, so - // drop the redundant 'Cycle snapping mode' tool hint to avoid a double pill; - // also hide draft-gated hints until the draft is far enough along. + // Some hints are replaced by live contextual chips, so keep the generic + // registry renderer from duplicating stale/static versions. const visible = hints.filter( (hint) => !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), ) if (visible.length === 0 && !snapContext && !continuationContext) return null + // Hints carrying a live-state `chip` render as mode chips next to the + // snapping / continuation rows; the rest stay static key rows. + const chipHints = visible.filter((hint) => hint.chip) + const staticHints = visible.filter((hint) => !hint.chip) return ( { + chipHints={chipHints} + hints={staticHints.map((hint) => { // Shift is a per-kind bypass for opening / zone / duct placement ("Free // place", "Free angle", …) — those flip to a bypassed state while held. const isBypassHint = hint.key === 'Shift' diff --git a/packages/editor/src/components/ui/icon-ref.tsx b/packages/editor/src/components/ui/icon-ref.tsx new file mode 100644 index 00000000..9b3a42b2 --- /dev/null +++ b/packages/editor/src/components/ui/icon-ref.tsx @@ -0,0 +1,47 @@ +'use client' + +import { Icon } from '@iconify/react' +import type { IconRef } from '@pascal-app/core' +import { type ComponentType, lazy, Suspense } from 'react' + +// `React.lazy` must be called once per loader so the resolved component keeps +// a stable identity across renders (otherwise every parent re-render remounts +// the icon). Cache by the loader function — same pattern as the plugin-panel +// component cache. +const lazyIconCache = new WeakMap<() => Promise<{ default: ComponentType }>, ComponentType>() + +function resolveLazyIcon(module: () => Promise<{ default: ComponentType }>): ComponentType { + const cached = lazyIconCache.get(module) + if (cached) return cached + const Lazy = lazy(module) + lazyIconCache.set(module, Lazy) + return Lazy +} + +/** + * Generic renderer for a registry {@link IconRef} — url / iconify / inline-svg + * marks are sized by `size` (px); `component`-kind icons size themselves. + * Shared by the quick-action menus; the icon rail and inspector keep their + * own copies with bespoke wrappers for now. + */ +export function IconRefGlyph({ icon, size = 16 }: { icon: IconRef; size?: number }) { + if (icon.kind === 'url') { + return + } + if (icon.kind === 'iconify') { + return + } + if (icon.kind === 'svg') { + return ( + + + + ) + } + const LazyIcon = resolveLazyIcon(icon.module) + return ( + + + + ) +} diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index d0a42e71..5328e6f7 100644 --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -1,6 +1,14 @@ import type { AssetInput } from '@pascal-app/core' -export const CATALOG_ITEMS: AssetInput[] = [ +/** + * A catalog tile: the asset plus optional editor placement metadata. + * `tool` names the placement tool the tile arms (defaults to the generic + * `'item'` drop tool) — kinds drawn by their own registry tool (e.g. the + * modular cabinet) point at that tool id instead. + */ +export type CatalogItem = AssetInput & { tool?: string } + +export const CATALOG_ITEMS: CatalogItem[] = [ { id: 'cactus', category: 'furniture', @@ -437,6 +445,36 @@ export const CATALOG_ITEMS: AssetInput[] = [ scale: [1, 1, 1], surface: { height: 0.48 }, }, + { + id: 'cabinet', + tool: 'cabinet', + category: 'furniture', + name: 'Modular Cabinet', + tags: [ + 'cabinet', + 'cupboard', + 'storage', + 'kitchen', + 'pantry', + 'wood', + 'timber', + 'modern', + 'contemporary', + 'minimalist', + 'organization', + 'furniture', + ], + thumbnail: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/thumbnail.png', + src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/model.glb', + floorPlanUrl: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/floor-plan.png', + dimensions: [1.65, 1.09, 0.77], + offset: [0, 0.0004, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 1.09 }, + }, { id: 'sofa', category: 'furniture', diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index f37f9cac..b111e624 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -7,7 +7,7 @@ import { triggerSFX } from './../../../lib/sfx-bus' import { cn } from './../../../lib/utils' import useEditor, { type CatalogCategory } from './../../../store/use-editor' import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge' -import { CATALOG_ITEMS } from './catalog-items' +import { CATALOG_ITEMS, type CatalogItem } from './catalog-items' export function ItemCatalog({ category, @@ -36,9 +36,9 @@ export function ItemCatalog({ const setMode = useEditor((state) => state.setMode) const setTool = useEditor((state) => state.setTool) - const sourceItems = itemsOverride ?? CATALOG_ITEMS + const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS // Server-provided results bypass all local filtering; otherwise filter by category/search/tags - const filteredItems = + const filteredItems: CatalogItem[] = overrideItems ?? (() => { const categoryItems = search @@ -80,7 +80,7 @@ export function ItemCatalog({ // the selected node. useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) setSelectedItem(item) - setTool('item') + setTool(item.tool ?? 'item') setMode('build') }} onMouseEnter={() => triggerSFX('sfx:menu-hover')} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 8afded77..65a57e6b 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -6,7 +6,7 @@ export type NodeDisplay = { } const TYPE_DEFAULTS: Record = { - item: { icon: '/icons/furniture.webp', label: 'Item' }, + item: { icon: '/icons/item.webp', label: 'Item' }, wall: { icon: '/icons/wall.webp', label: 'Wall' }, door: { icon: '/icons/door.webp', label: 'Door' }, window: { icon: '/icons/window.webp', label: 'Window' }, @@ -17,8 +17,8 @@ const TYPE_DEFAULTS: Record = { fence: { icon: '/icons/fence.webp', label: 'Fence' }, roof: { icon: '/icons/roof.webp', label: 'Roof' }, 'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' }, - stair: { icon: '/icons/stair.webp', label: 'Stair' }, - 'stair-segment': { icon: '/icons/stair.webp', label: 'Stair segment' }, + stair: { icon: '/icons/stairs.webp', label: 'Stair' }, + 'stair-segment': { icon: '/icons/stairs.webp', label: 'Stair segment' }, scan: { icon: '/icons/mesh.webp', label: '3D Scan' }, guide: { icon: '/icons/floorplan.webp', label: 'Guide image' }, } diff --git a/packages/editor/src/components/ui/primitives/sidebar.tsx b/packages/editor/src/components/ui/primitives/sidebar.tsx index 6ec2ea1b..0231fd2a 100644 --- a/packages/editor/src/components/ui/primitives/sidebar.tsx +++ b/packages/editor/src/components/ui/primitives/sidebar.tsx @@ -63,6 +63,7 @@ export const useSidebarStore = create()( { name: 'sidebar-preferences', partialize: (state) => ({ width: state.width, isCollapsed: state.isCollapsed }), + skipHydration: true, }, ), ) diff --git a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx index 99f756e0..be4fa28e 100644 --- a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx +++ b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx @@ -16,7 +16,7 @@ import useEditor from './../../../store/use-editor' import { type ExtraPanel, IconRail } from './icon-rail' import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel' import { SitePanel, type SitePanelProps } from './panels/site-panel' -import { usePluginPanels } from './use-plugin-panels' +import { useHostPanels } from './use-plugin-panels' interface AppSidebarProps { appMenuButton?: ReactNode @@ -35,9 +35,9 @@ export function AppSidebar({ extraPanels: hostExtraPanels, commandPaletteEmptyAction, }: AppSidebarProps) { - // Host-provided panels merged with plugin-contributed panels from the - // registry — the icon rail and content area treat both identically. - const extraPanels = usePluginPanels(hostExtraPanels) + // Host-provided panels merged with registered host panels — the icon rail and + // content area treat both identically. + const extraPanels = useHostPanels(hostExtraPanels) const activePanel = useEditor((s) => s.activeSidebarPanel) const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) const hasActivePanel = diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index 7e516dce..9c8dac81 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -134,7 +134,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { title: 'Item Placement', shortcuts: [ - { keys: ['R', 'T'], action: 'Rotate item; with a door selected, R toggles open/closed and T closes' }, + { + keys: ['R', 'T'], + action: 'Rotate item; with a door selected, R toggles open/closed and T closes', + }, + { + keys: ['E'], + action: 'Operate the selected node — doors, windows, and cabinet doors/drawers animate open/closed', + }, { keys: ['Shift'], action: 'Temporarily bypass placement validation constraints', diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx index 6e99e4da..438e20b2 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx @@ -1,16 +1,19 @@ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge' import { InlineRenameInput } from './inline-rename-input' import { focusTreeNode, handleTreeSelection, routeTreeSelectionToNode, + TreeNode, TreeNodeWrapper, } from './tree-node' import { TreeNodeActions } from './tree-node-actions' +import { resolveTreeChildIds, treeContainsDescendant } from './tree-structure' interface RegistryTreeNodeProps { nodeId: AnyNodeId @@ -19,11 +22,11 @@ interface RegistryTreeNodeProps { } /** - * Generic, leaf tree-node row driven entirely by the kind's - * `def.presentation` (icon + label). Replaces the per-kind boilerplate + * Generic, registry-driven tree-node row powered by `def.presentation` and + * `def.tree`. Replaces the per-kind boilerplate * components that differed only in their default name and icon — today the - * roof vents (box / ridge / turbine / cupola / eyebrow). Register a kind in - * `treeNodeByType` against this component instead of authoring another copy. + * roof vents plus cabinet rows. Register a kind in `treeNodeByType` against + * this component instead of authoring another copy. */ export const RegistryTreeNode = memo(function RegistryTreeNode({ nodeId, @@ -31,18 +34,37 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ isLast, }: RegistryTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) + const [expanded, setExpanded] = useState(true) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) const node = useScene((s) => s.nodes[nodeId]) + const children = useScene(useShallow((s) => resolveTreeChildIds(nodeId, s.nodes))) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined + const tree = node ? nodeRegistry.get(node.type)?.tree : undefined const icon = presentation?.icon const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp' const snapTarget = resolveNodeSnapTarget(node) - const defaultName = node?.name || presentation?.label || 'Node' + const defaultName = + node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node' + const hasChildren = children.length > 0 + + useEffect(() => { + return useViewer.subscribe((state) => { + const { selectedIds } = state.selection + if (selectedIds.length === 0) return + const nodes = useScene.getState().nodes + for (const id of selectedIds) { + if (treeContainsDescendant(nodeId, id as AnyNodeId, nodes)) { + setExpanded(true) + return + } + } + }) + }, [nodeId]) const handleClick = useCallback( (e: React.MouseEvent) => { @@ -62,8 +84,8 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ } depth={depth} - expanded={false} - hasChildren={false} + expanded={expanded} + hasChildren={hasChildren} icon={ snapTarget ? ( @@ -103,7 +125,17 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ onDoubleClick={() => focusTreeNode(nodeId)} onMouseEnter={() => setHoveredId(nodeId)} onMouseLeave={() => setHoveredId(null)} - onToggle={() => {}} - /> + onToggle={() => setExpanded((prev) => !prev)} + > + {hasChildren && + children.map((childId, index) => ( + + ))} + ) }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 7c46a116..6f2e832a 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { ChevronRight } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' @@ -126,6 +126,8 @@ const treeNodeByType: Record< isLast?: boolean nodeId: AnyNodeId }>, + cabinet: RegistryTreeNode, + 'cabinet-module': RegistryTreeNode, 'box-vent': RegistryTreeNode, ceiling: CeilingTreeNode, chimney: ChimneyTreeNode, @@ -171,7 +173,15 @@ const treeNodeByType: Record< } export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { + // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean + // selector so unrelated scene updates don't re-render every row. + const shouldHide = useScene((state) => { + const node = state.nodes[nodeId] + if (!node) return false + return nodeRegistry.get(node.type)?.tree?.hidden?.(node, state.nodes) ?? false + }) const nodeType = useScene((state) => state.nodes[nodeId]?.type) + if (shouldHide) return null if (!nodeType) return null const Component = treeNodeByType[nodeType] if (!Component) return null diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts new file mode 100644 index 00000000..6cc4ac2c --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts @@ -0,0 +1,36 @@ +import { type AnyNode, type AnyNodeId, nodeRegistry } from '@pascal-app/core' + +/** + * Child ids the sidebar tree renders under a node: the kind's + * `def.tree.childIds` override when declared, otherwise the node's own + * `children`. Kind-agnostic — kinds that reshape their subtree (hidden + * derived nodes, flattened containers) do so via the registry hook. + */ +export function resolveTreeChildIds( + nodeId: AnyNodeId, + nodes: Readonly>>, +): AnyNodeId[] { + const node = nodes[nodeId] + if (!node) return [] + const childIds = nodeRegistry.get(node.type)?.tree?.childIds + if (childIds) return childIds(node, nodes) + const children = (node as { children?: unknown }).children + return Array.isArray(children) ? (children as AnyNodeId[]) : [] +} + +/** + * Whether `targetId` appears anywhere under `nodeId` in the *rendered* + * sidebar tree (i.e. walking registry-overridden child ids, not the raw + * scene graph). Drives auto-expansion when a descendant gets selected. + */ +export function treeContainsDescendant( + nodeId: AnyNodeId, + targetId: AnyNodeId, + nodes: Readonly>>, +): boolean { + for (const childId of resolveTreeChildIds(nodeId, nodes)) { + if (childId === targetId) return true + if (treeContainsDescendant(childId, targetId, nodes)) return true + } + return false +} diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx index f009b72d..68ba5e4a 100644 --- a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -1,9 +1,10 @@ 'use client' import { Icon } from '@iconify/react' -import { type IconRef, panelRegistry, type PluginPanel } from '@pascal-app/core' +import { type IconRef } from '@pascal-app/core' import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react' import useEditor from '../../../store/use-editor' +import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels' import { ErrorBoundary } from '../primitives/error-boundary' import type { ExtraPanel } from './icon-rail' @@ -46,9 +47,9 @@ function PluginPanelCrashed({ label }: { label: string }) { // `React.lazy` must be called once per loader so the resolved component keeps a // stable identity across renders (otherwise switching panels remounts the tree // every time the sidebar re-renders). Cache the wrapped component by its loader. -const wrappedPanelCache = new WeakMap() +const wrappedPanelCache = new WeakMap() -function resolvePanelComponent(panel: PluginPanel): ComponentType { +function resolvePanelComponent(panel: EditorHostPanel): ComponentType { const cached = wrappedPanelCache.get(panel.component) if (cached) return cached const Lazy = lazy(panel.component) @@ -65,21 +66,21 @@ function resolvePanelComponent(panel: PluginPanel): ComponentType { } /** - * Merge plugin-contributed panels (from the observable {@link panelRegistry}) + * Merge host-registered panels (from the observable {@link editorHostPanelRegistry}) * with the host's `extraPanels`, returning the combined list the icon rail and * panel-content area render. Host panels keep their leading order and win on id * collisions. Subscribes to the registry so a panel that registers after the - * first render (plugin discovery is async) makes the rail re-render. + * first render makes the rail re-render. * * Panels are filtered by the current workspace: a panel surfaces only in the - * workspaces it declares (`PluginPanel.workspaces`, default `['edit']`), so an + * workspaces it declares (`EditorHostPanel.workspaces`, default `['edit']`), so an * authoring panel like Nature doesn't ride into the studio rail. */ -export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { +export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { const registered = useSyncExternalStore( - panelRegistry.subscribe, - panelRegistry.getSnapshot, - panelRegistry.getSnapshot, + editorHostPanelRegistry.subscribe, + editorHostPanelRegistry.getSnapshot, + editorHostPanelRegistry.getSnapshot, ) const workspaceMode = useEditor((s) => s.workspaceMode) const hostIds = new Set(hostPanels?.map((p) => p.id)) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index d34b58af..0ab01d85 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -19,6 +19,7 @@ import { rotateGroupPatches, } from '../components/editor/group-transform-shared' import { steppedRotation } from '../components/tools/item/placement-math' +import { resolveDirectManipulationNode } from '../lib/direct-manipulation' import { toggleDoorOpenState } from '../lib/door-interaction' import { guideEmitter } from '../lib/guide-events' import { runRedo, runUndo } from '../lib/history' @@ -413,7 +414,9 @@ export const useKeyboard = ({ } const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { - const node = useScene.getState().nodes[selectedNodeIds[0]!] + const sceneNodes = useScene.getState().nodes + const selectedNode = sceneNodes[selectedNodeIds[0]!] + const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null if (node?.type === 'door') { e.preventDefault() useScene.getState().updateNode(node.id, { @@ -486,7 +489,9 @@ export const useKeyboard = ({ } const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { - const node = useScene.getState().nodes[selectedNodeIds[0]!] + const sceneNodes = useScene.getState().nodes + const selectedNode = sceneNodes[selectedNodeIds[0]!] + const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null if (node?.type === 'door') { // Door's open/close moved to E; T is a no-op for doors so // it doesn't free-rotate a wall-bound node by π/4. @@ -525,7 +530,13 @@ export const useKeyboard = ({ const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node?.type === 'door' && node.openingKind !== 'opening') { + const registryE = node && nodeRegistry.get(node.type)?.keyboardActions?.e + if (node && registryE?.appliesTo(node)) { + // Registry-driven E interaction. Same shape as the R/T arms. + e.preventDefault() + registryE.run(node) + sfxEmitter.emit('sfx:item-rotate') + } else if (node?.type === 'door' && node.openingKind !== 'opening') { e.preventDefault() toggleDoorOpenState(node.id) sfxEmitter.emit('sfx:item-rotate') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 6c513c75..358b92f9 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -112,6 +112,7 @@ export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' +export { PlacementBox } from './components/tools/shared/placement-box' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, @@ -318,6 +319,12 @@ export { type PlanarPoint, resolvePlanarCursorPosition, } from './lib/planar-cursor-placement' +export { + type EditorHostPanel, + type EditorHostPanelWorkspace, + editorHostPanelRegistry, + registerEditorHostPanel, +} from './lib/plugin-panels' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' // Roof wall-face hit resolution + overlap guard — shared by the // kind-owned door / window tools in `@pascal-app/nodes` and the item @@ -325,6 +332,7 @@ export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-dup export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from './lib/roof-wall-hit' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' +export { movementSfxStepKey } from './lib/sfx/movement-tick' export { triggerSFX } from './lib/sfx-bus' export { clearSlabSnapFeedback, diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts index e4899bc6..d8535c88 100644 --- a/packages/editor/src/lib/continuation.ts +++ b/packages/editor/src/lib/continuation.ts @@ -1,4 +1,4 @@ -export type ContinuationContext = 'wall' | 'fence' | 'point' +export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet' export type ContinuationMode = string export const CONTINUATION_PROFILES: Record< @@ -36,6 +36,12 @@ export const CONTINUATION_PROFILES: Record< labels: { once: 'Place once', repeat: 'Place multiple' }, icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' }, }, + cabinet: { + options: ['single', 'continuous'], + default: 'single', + labels: { single: 'Single cabinet', continuous: 'Continuous run' }, + icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' }, + }, } const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column']) @@ -53,5 +59,6 @@ export function nextContinuation( export function continuationContextOf(kind: string): ContinuationContext | null { if (kind === 'wall') return 'wall' if (kind === 'fence') return 'fence' + if (kind === 'cabinet') return 'cabinet' return POINT_KINDS.has(kind) ? 'point' : null } diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 188cb84b..0b336418 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -9,6 +9,7 @@ import { import { z } from 'zod' import { canDirectMoveNode, + resolveDirectManipulationNode, resolveDirectRotationDragDelta, snapDirectRotationDelta, } from './direct-manipulation' @@ -113,3 +114,79 @@ describe('canDirectMoveNode', () => { expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) }) }) + +describe('resolveDirectManipulationNode', () => { + test('routes proxied members to their assembly for direct transforms', () => { + const group = { + id: 'direct_manipulation_group', + type: 'direct-manipulation-group-test', + } as unknown as AnyNode + const member = { + id: 'direct_manipulation_member', + type: 'direct-manipulation-member-test', + metadata: { nodeSelectionProxyId: group.id }, + } as unknown as AnyNode + + expect( + resolveDirectManipulationNode(member, { + [group.id]: group, + [member.id]: member, + }), + ).toBe(group) + }) + + test('falls back to the selected node when the proxy target is missing', () => { + const member = { + id: 'direct_manipulation_orphan_member', + type: 'direct-manipulation-member-test', + metadata: { nodeSelectionProxyId: 'missing_group' }, + } as unknown as AnyNode + + expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member) + }) + + test('routes parent-frame children to their rotatable parent', () => { + const parentKind = 'direct-manipulation-parent-frame-parent-test' + const childKind = 'direct-manipulation-parent-frame-child-test' + registerTestDefinition(parentKind, { + capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } }, + }) + registerTestDefinition(childKind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + parentRotationY: () => 0, + localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [ + local[0], + local[1], + local[2], + ], + planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [ + planX, + localY, + planZ, + ], + }, + }, + }, + }) + + const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode + const child = { + id: 'direct_manipulation_child', + type: childKind, + parentId: parent.id, + } as unknown as AnyNode + + expect( + resolveDirectManipulationNode(child, { + [parent.id]: parent, + [child.id]: child, + }), + ).toBe(parent) + }) +}) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index 3f5139a2..f3518961 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -5,7 +5,9 @@ import { DEFAULT_ANGLE_STEP, type HandleDescriptor, hasRegistry3DMoveTool, + isMovable, nodeRegistry, + resolveSelectionProxyId, type SceneApi, useScene, } from '@pascal-app/core' @@ -53,7 +55,23 @@ export function canDirectMoveNode(node: AnyNode): boolean { // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that // mounts in 3D — distinct from `isRegistryMovable`, which also accepts // floorplan-only movers (zone) for the 2D plan. - return hasRegistry3DMoveTool(node.type) + if (!hasRegistry3DMoveTool(node.type)) return false + // Bespoke movers (`affordanceTools.move`) own their constraints and often + // deliberately omit `capabilities.movable` — only gate registry-movable + // kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet + // run locked behind its selection proxy) can opt out of direct move. + if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true + return isMovable(node) +} + +export function resolveDirectManipulationNode( + node: AnyNode, + nodes: Readonly>, +): AnyNode { + const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node + const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame + const parent = parentFrame?.resolveParent(target, nodes as Readonly>) + return parent && canDirectRotateNode(parent) ? parent : target } export function snapDirectRotationDelta(delta: number, free: boolean): number { diff --git a/packages/editor/src/lib/fresh-planar-placement.test.ts b/packages/editor/src/lib/fresh-planar-placement.test.ts index 7c67c4be..f4dd30d8 100644 --- a/packages/editor/src/lib/fresh-planar-placement.test.ts +++ b/packages/editor/src/lib/fresh-planar-placement.test.ts @@ -1,7 +1,17 @@ import { beforeEach, describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId } from '@pascal-app/core' -import { useScene } from '@pascal-app/core' -import { commitFreshPlacementSubtree } from './fresh-planar-placement' +import { + type AnyNode, + type AnyNodeDefinition, + type AnyNodeId, + CabinetModuleNode, + CabinetNode, + findLevelAncestorId, + nodeRegistry, + registerNode, + useScene, +} from '@pascal-app/core' +import { z } from 'zod' +import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement' type RafFn = (cb: (time: number) => void) => number ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( @@ -14,6 +24,61 @@ type RafFn = (cb: (time: number) => void) => number const LEVEL_ID = 'level_test' as AnyNodeId const SHELF_ID = 'shelf_draft' as AnyNodeId +const CABINET_RUN_ID = 'cabinet_original-run' as AnyNodeId +const CABINET_LEFT_ID = 'cabinet-module_original-left' as AnyNodeId +const CABINET_RIGHT_ID = 'cabinet-module_original-right' as AnyNodeId +const CABINET_CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId +const CABINET_CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId + +function registerCabinetClonePrepTestKind() { + if (nodeRegistry.has('cabinet')) return + + registerNode({ + kind: 'cabinet', + schemaVersion: 1, + schema: z.any() as never, + category: 'furnish', + defaults: () => ({}), + capabilities: { + duplicable: { + subtree: true, + prepareSubtreeClone: ({ root, descendants, rootId, nodes }) => { + const parent = root.parentId ? nodes[root.parentId as AnyNodeId] : null + if (root.type !== 'cabinet' || parent?.type !== 'cabinet') { + return { root, descendants } + } + const parentPosition = (parent as { position: [number, number, number] }).position + const parentRotation = (parent as { rotation: number }).rotation + const childPosition = (root as { position: [number, number, number] }).position + const childRotation = (root as { rotation: number }).rotation + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const { + cabinetCornerDerivedRun: _derived, + nodeSelectionProxyId: _proxy, + ...metadata + } = (root.metadata ?? {}) as Record + return { + root: { + ...root, + parentId: findLevelAncestorId(rootId, nodes), + position: [ + parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin, + parentPosition[1] + childPosition[1], + parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos, + ], + rotation: parentRotation + childRotation, + metadata, + } as AnyNode, + descendants, + parentId: findLevelAncestorId(rootId, nodes), + } + }, + }, + }, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition) +} function level(children: AnyNodeId[]): AnyNode { return { @@ -53,6 +118,42 @@ function shelf(): AnyNode { } as AnyNode } +function seedCabinetRun() { + const run = CabinetNode.parse({ + id: CABINET_RUN_ID, + parentId: LEVEL_ID, + position: [0, 0, 0], + rotation: 0, + children: [CABINET_LEFT_ID, CABINET_RIGHT_ID], + showPlinth: true, + withCountertop: true, + }) + const left = CabinetModuleNode.parse({ + id: CABINET_LEFT_ID, + parentId: CABINET_RUN_ID, + position: [-0.45, 0.1, 0], + width: 0.9, + }) + const right = CabinetModuleNode.parse({ + id: CABINET_RIGHT_ID, + parentId: CABINET_RUN_ID, + position: [0.45, 0.1, 0], + width: 0.9, + }) + + useScene.setState({ + nodes: { + [LEVEL_ID]: level([CABINET_RUN_ID]), + [CABINET_RUN_ID]: run as AnyNode, + [CABINET_LEFT_ID]: left as AnyNode, + [CABINET_RIGHT_ID]: right as AnyNode, + }, + rootNodeIds: [LEVEL_ID], + collections: {}, + dirtyNodes: new Set(), + } as never) +} + describe('commitFreshPlacementSubtree', () => { beforeEach(() => { useScene.setState({ @@ -99,4 +200,140 @@ describe('commitFreshPlacementSubtree', () => { expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined() expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([]) }) + + test('commits a duplicated cabinet draft without deleting the original modules', () => { + seedCabinetRun() + useScene.temporal.getState().clear() + useScene.temporal.getState().pause() + + const draftId = createFreshPlacementSubtree(CABINET_RUN_ID) + expect(draftId).toBeTruthy() + expect(draftId).not.toBe(CABINET_RUN_ID) + + const draft = useScene.getState().nodes[draftId as AnyNodeId] as + | (AnyNode & { children: AnyNodeId[]; metadata?: Record }) + | undefined + expect(draft?.metadata?.isNew).toBe(true) + expect(draft?.children).toHaveLength(2) + expect(draft?.children).not.toContain(CABINET_LEFT_ID) + expect(draft?.children).not.toContain(CABINET_RIGHT_ID) + + const finalId = commitFreshPlacementSubtree( + draftId as AnyNodeId, + { + position: [2, 0, 3], + visible: true, + } as Partial, + ) + expect(finalId).toBeTruthy() + + const nodes = useScene.getState().nodes + expect(nodes[CABINET_RUN_ID]).toBeDefined() + expect(nodes[CABINET_LEFT_ID]).toBeDefined() + expect(nodes[CABINET_RIGHT_ID]).toBeDefined() + + const finalRun = nodes[finalId as AnyNodeId] as + | (AnyNode & { children: AnyNodeId[]; metadata?: Record }) + | undefined + expect(finalRun?.children).toHaveLength(2) + expect(finalRun?.metadata?.isNew).toBeUndefined() + for (const childId of finalRun?.children ?? []) { + const child = nodes[childId] + expect(child?.type).toBe('cabinet-module') + expect(child?.parentId).toBe(finalId) + } + }) + + test('flattens duplicated nested cabinet runs into level coordinates before dragging', () => { + registerCabinetClonePrepTestKind() + const parentRun = { + id: CABINET_RUN_ID, + type: 'cabinet', + object: 'node', + visible: true, + metadata: {}, + parentId: LEVEL_ID, + position: [4, 0, 5], + rotation: Math.PI / 2, + children: [CABINET_LEFT_ID, CABINET_CHILD_RUN_ID], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, + withWaterfall: false, + frontThickness: 0.018, + frontGap: 0.003, + frontStyle: 'slab', + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + withBottomPanel: true, + showPlinth: true, + withCountertop: true, + operationState: 0, + runTier: 'base', + } as AnyNode + const parentModule = CabinetModuleNode.parse({ + id: CABINET_LEFT_ID, + parentId: CABINET_RUN_ID, + position: [0.5, 0.1, 0], + width: 1, + }) + const childRun = CabinetNode.parse({ + id: CABINET_CHILD_RUN_ID, + parentId: CABINET_RUN_ID, + position: [1, 0, 0.25], + rotation: Math.PI / 2, + children: [CABINET_CHILD_MODULE_ID], + metadata: { + cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: CABINET_RUN_ID }, + nodeSelectionProxyId: CABINET_RUN_ID, + }, + }) + const childModule = CabinetModuleNode.parse({ + id: CABINET_CHILD_MODULE_ID, + parentId: CABINET_CHILD_RUN_ID, + position: [0, 0.1, 0], + width: 0.9, + }) + useScene.setState({ + nodes: { + [LEVEL_ID]: level([CABINET_RUN_ID]), + [CABINET_RUN_ID]: parentRun as AnyNode, + [CABINET_LEFT_ID]: parentModule as AnyNode, + [CABINET_CHILD_RUN_ID]: childRun as AnyNode, + [CABINET_CHILD_MODULE_ID]: childModule as AnyNode, + }, + rootNodeIds: [LEVEL_ID], + collections: {}, + dirtyNodes: new Set(), + } as never) + + const draftId = createFreshPlacementSubtree(CABINET_CHILD_RUN_ID) + const draft = useScene.getState().nodes[draftId as AnyNodeId] as + | (AnyNode & { + children: AnyNodeId[] + metadata?: Record + position: [number, number, number] + rotation: number + }) + | undefined + + expect(draft).toBeDefined() + expect(draft?.parentId).toBe(LEVEL_ID) + expect(draft?.position[0]).toBeCloseTo(4.25) + expect(draft?.position[2]).toBeCloseTo(4) + expect(draft?.rotation).toBeCloseTo(Math.PI) + expect(draft?.metadata?.isNew).toBe(true) + expect(draft?.metadata?.cabinetCornerDerivedRun).toBeUndefined() + expect(draft?.metadata?.nodeSelectionProxyId).toBeUndefined() + expect(draft?.children).toHaveLength(1) + expect(draft?.children).not.toContain(CABINET_CHILD_MODULE_ID) + }) }) diff --git a/packages/editor/src/lib/fresh-planar-placement.ts b/packages/editor/src/lib/fresh-planar-placement.ts index 6bbf29a9..fee6e0b6 100644 --- a/packages/editor/src/lib/fresh-planar-placement.ts +++ b/packages/editor/src/lib/fresh-planar-placement.ts @@ -3,9 +3,11 @@ import { type AnyNodeId, cloneNodesInto, collectSubtree, + type DuplicableConfig, + nodeRegistry, useScene, } from '@pascal-app/core' -import { stripPlacementMetadataFlags } from './placement-metadata' +import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata' function cleanPlacementMetadata(node: N): N { return { @@ -19,6 +21,71 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined { return parentId ?? undefined } +function duplicableConfigFor(node: AnyNode): DuplicableConfig | null { + const duplicable = nodeRegistry.get(node.type)?.capabilities?.duplicable + return duplicable && typeof duplicable === 'object' ? duplicable : null +} + +export function duplicatesAsFreshSubtree(node: AnyNode): boolean { + const children = (node as { children?: unknown }).children + return ( + duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0 + ) +} + +/** + * Creates a fresh draft copy of a live subtree, with every child reference + * rewired before move mode starts. + */ +export function createFreshPlacementSubtree( + rootId: AnyNodeId, + rootPatch: Partial = {}, +): AnyNodeId | null { + const scene = useScene.getState() + const subtree = collectSubtree(scene.nodes, rootId) + if (!subtree) return null + + const baseRoot = { + ...subtree.root, + ...rootPatch, + } as AnyNode + const prepared = duplicableConfigFor(subtree.root)?.prepareSubtreeClone?.({ + root: baseRoot, + descendants: subtree.descendants, + rootId, + rootPatch, + nodes: scene.nodes, + }) + const preparedRoot = prepared?.root ?? baseRoot + const root = { + ...preparedRoot, + metadata: { + ...getPlacementMetadataRecord(stripPlacementMetadataFlags(preparedRoot.metadata)), + isNew: true, + }, + } as AnyNode + const descendants = (prepared?.descendants ?? subtree.descendants).map((node: AnyNode) => ({ + ...node, + metadata: stripPlacementMetadataFlags(node.metadata), + })) as AnyNode[] + const parentId = + prepared && Object.hasOwn(prepared, 'parentId') + ? (prepared.parentId ?? undefined) + : parentIdOf(root) + const cloned = cloneNodesInto([root, ...descendants], { + rootId, + parentId, + }) + + useScene + .getState() + .createNodes( + cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })), + ) + + return cloned.rootId +} + /** * Finalises a fresh catalog/duplicate draft as a single undoable creation. * diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 11b4df7e..6677ae7f 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { type AnyNode, DoorNode, sceneRegistry } from '@pascal-app/core' +import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core' import { buildDoorPreviewMesh } from '@pascal-app/viewer' import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' @@ -266,6 +266,54 @@ describe('prepareSceneForExport', () => { expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0) }) + test('bakes registry-owned open clips and stamps the node openable', () => { + const root = new THREE.Group() + const nodeGroup = new THREE.Group() + const movingPart = new THREE.Group() + movingPart.add(meshWithNodeMaterial(nodeMaterial())) + nodeGroup.add(movingPart) + root.add(nodeGroup) + + const kind = `test-openable-${crypto.randomUUID()}` + const nodeId = 'registry_openable' + registerNode({ + kind, + schemaVersion: 1, + category: 'fixtures', + defaults: () => ({}), + capabilities: {}, + exportAnimation: ({ node, object }: { node: AnyNode; object: THREE.Object3D }) => { + const target = object.children[0]! + const clip = new THREE.AnimationClip(`${node.id}: open`, 1, [ + new THREE.VectorKeyframeTrack(`${target.uuid}.position`, [0, 1], [0, 0, 0, 0, 0, 1]), + ]) + clip.userData = { loop: false } + return clip + }, + } as never) + sceneRegistry.nodes.set(nodeId, nodeGroup) + + const { scene, animations } = prepareSceneForExport(root, { + [nodeId]: { + object: 'node', + id: nodeId, + type: kind, + name: 'Custom openable', + } as unknown as AnyNode, + }) + + expect(animations).toHaveLength(1) + expect(animations[0]!.name).toBe('registry_openable: open') + const exported = scene.getObjectByProperty('name', nodeId) + expect(exported?.userData).toMatchObject({ + pascalId: nodeId, + kind, + label: 'Custom openable', + openable: true, + clips: ['registry_openable: open'], + }) + }) + test('bakes a sliding door into a sampled position clip', () => { // Operation doors build their moving parts in a named group posed by // `poseDoorMovingParts`; the exporter samples it into keyframes. The active diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index fb16a1ae..694eaa4d 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -7,6 +7,7 @@ import { isOperationDoorType, itemClipRegistry, type LevelNode, + nodeRegistry, sceneRegistry, type WindowNode, type ZoneNode, @@ -101,9 +102,9 @@ export async function exportSceneToGlb( * `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set * `isNodeMaterial` instead, so without this every surface exports as a blank * default material. - * - Bakes each openable door/window's open motion into a glTF animation clip - * via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors, - * `poseWindowMovingParts` for windows). + * - Bakes open motions into glTF animation clips via kind-owned registry + * hooks, plus the legacy door/window build-once + pose-at-t primitives + * (`pascalSwingLeaf` for doors, `poseWindowMovingParts` for windows). * - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover * survive the bake with no in-memory registry, and strips all other userData * so editor/runtime ephemera never leak into glTF extras. @@ -461,23 +462,35 @@ function bakeAnimationClips( if (!node || !target) continue const clip = - node.type === 'door' + bakeRegistryAnimationClips(node, target) ?? + (node.type === 'door' ? bakeDoorClip(id, node, target) : node.type === 'window' ? bakeWindowClip(id, node as WindowNode, target) : node.type === 'item' ? bakeItemClip(id, target) - : null + : null) if (clip) { - clips.push(clip) - clipNamesByNode.set(id, [clip.name]) + const nodeClips = Array.isArray(clip) ? clip : [clip] + clips.push(...nodeClips) + clipNamesByNode.set( + id, + nodeClips.map((c) => c.name), + ) } } return { clips, clipNamesByNode } } +function bakeRegistryAnimationClips( + node: AnyNode, + object: THREE.Object3D, +): THREE.AnimationClip | THREE.AnimationClip[] | null | undefined { + return nodeRegistry.get(node.type)?.exportAnimation?.({ node, object }) +} + /** * Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked * subtree. The source clip targets the item GLB's nodes by name (`lamp_018`); @@ -771,6 +784,9 @@ function nodeDisplayLabel(node: AnyNode): string { return 'Door' case 'window': return 'Window' + case 'cabinet': + case 'cabinet-module': + return 'Cabinet' case 'slab': return 'Slab' case 'ceiling': @@ -820,10 +836,10 @@ function stampIdentity( extras.label = getLevelDisplayName(node as LevelNode) target.visible = true } - // Only doors/windows that actually baked an open clip are openable. A cased - // opening (no leaf) or a fixed window (no operable sash) produces no clip, so - // it stays unflagged — the file never claims a part opens when nothing moves. - if (node.type === 'door' || node.type === 'window') { + // Only nodes that actually baked an open clip are openable. A cased opening + // (no leaf), fixed window, or static cabinet produces no clip, so it stays + // unflagged — the file never claims a part opens when nothing moves. + if (clipNamesByNode.get(id)?.some((name) => name.endsWith(': open'))) { const clipNames = clipNamesByNode.get(id) if (clipNames?.length) { extras.openable = true diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 08313bba..7c0af3e7 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -39,6 +39,7 @@ export type PaintableMaterialTarget = | 'slab' | 'ceiling' | 'shelf' + | 'cabinet' | 'chimney' | 'dormer' | 'box-vent' @@ -273,7 +274,7 @@ export function resolveActivePaintMaterialFromSelection(params: { nodes, }) if (surface) { - const sourceTarget = selectedNode.type as PaintableMaterialTarget + const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget return hasActivePaintMaterial({ material: surface.material, materialPreset: surface.materialPreset, @@ -351,6 +352,10 @@ export function resolveActivePaintMaterialFromSelection(params: { // Wall / chimney / dormer flow through the registry-driven path // at the top of this function. + // Slot-backed kinds resolve via the registry-driven `getEffectiveMaterial` + // path at the top of this function, including legacy inline-material + // fallbacks when their capability exposes one. + if ( (selectedNode.type === 'fence' || selectedNode.type === 'column' || @@ -386,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: { const selectedNode = nodes[selectedId] if (!selectedNode) return null + const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint + ?.materialTarget + if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget + if (selectedNode.type === 'wall') { return 'wall' } diff --git a/packages/editor/src/lib/plugin-panels.ts b/packages/editor/src/lib/plugin-panels.ts new file mode 100644 index 00000000..16ca3bd9 --- /dev/null +++ b/packages/editor/src/lib/plugin-panels.ts @@ -0,0 +1,70 @@ +import type { IconRef, LazyComponent } from '@pascal-app/core' + +export type EditorHostPanelWorkspace = string & {} + +export type EditorHostPanel = { + id: string + label: string + icon: IconRef + component: LazyComponent + workspaces?: readonly EditorHostPanelWorkspace[] +} + +function isDevMode(): boolean { + try { + const meta = import.meta as { env?: { DEV?: boolean } } + if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV + } catch { + // import.meta unavailable in some CJS contexts — fall through. + } + if (typeof process !== 'undefined' && process.env?.NODE_ENV) { + return process.env.NODE_ENV !== 'production' + } + return false +} + +class EditorHostPanelRegistryImpl { + private readonly panels = new Map() + private readonly listeners = new Set<() => void>() + private cached: EditorHostPanel[] = [] + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => { + this.listeners.delete(onChange) + } + } + + getSnapshot = (): EditorHostPanel[] => this.cached + + reset(): void { + this.panels.clear() + this.emit() + } + + registerPanel(panel: EditorHostPanel): void { + if (typeof panel.id !== 'string' || panel.id.length === 0) { + throw new Error('[editor:host-panels] panel id must be a non-empty string') + } + if (this.panels.has(panel.id)) { + if (isDevMode()) { + console.warn(`[editor:host-panels] re-registering panel "${panel.id}" (HMR)`) + } else { + throw new Error(`[editor:host-panels] duplicate panel id: "${panel.id}" already registered`) + } + } + this.panels.set(panel.id, panel) + this.emit() + } + + private emit(): void { + this.cached = Array.from(this.panels.values()) + for (const listener of this.listeners) listener() + } +} + +export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl() + +export function registerEditorHostPanel(panel: EditorHostPanel): void { + editorHostPanelRegistry.registerPanel(panel) +} diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts new file mode 100644 index 00000000..dac7bc93 --- /dev/null +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + CabinetModuleNode, + type CabinetModuleNode as CabinetModuleNodeType, + CabinetNode, + type CabinetNode as CabinetNodeType, + type LevelNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { + copySelectedNodesToEditorClipboard, + getEditorClipboardSnapshot, + pasteEditorClipboardToLevel, +} from './scene-clipboard' + +const sourceLevelId = 'level_clipboard-source' as LevelNode['id'] +const targetLevelId = 'level_clipboard-target' as LevelNode['id'] +const runId = 'cabinet_clipboard-run' as CabinetNodeType['id'] +const leftModuleId = 'cabinet-module_clipboard-left' as CabinetModuleNodeType['id'] +const rightModuleId = 'cabinet-module_clipboard-right' as CabinetModuleNodeType['id'] + +function makeLevel(id: AnyNodeId, children: AnyNodeId[] = []): AnyNode { + return { + id, + type: 'level', + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + parentId: null, + level: 0, + children, + } as unknown as AnyNode +} + +function seedCabinetRun() { + const sourceLevel = makeLevel(sourceLevelId, [runId]) + const targetLevel = makeLevel(targetLevelId) + const run = CabinetNode.parse({ + id: runId, + parentId: sourceLevelId, + position: [1, 0, 2], + rotation: 0, + children: [leftModuleId, rightModuleId], + withCountertop: true, + showPlinth: true, + }) + const leftModule = CabinetModuleNode.parse({ + id: leftModuleId, + parentId: runId, + position: [-0.45, 0.1, 0], + width: 0.9, + showPlinth: false, + withCountertop: false, + }) + const rightModule = CabinetModuleNode.parse({ + id: rightModuleId, + parentId: runId, + position: [0.45, 0.1, 0], + width: 0.9, + showPlinth: false, + withCountertop: false, + }) + + useScene.setState({ + nodes: { + [sourceLevel.id]: sourceLevel, + [targetLevel.id]: targetLevel, + [run.id]: run as AnyNode, + [leftModule.id]: leftModule as AnyNode, + [rightModule.id]: rightModule as AnyNode, + }, + rootNodeIds: [sourceLevel.id, targetLevel.id], + } as never) + useViewer.getState().setSelection({ + levelId: sourceLevelId, + selectedIds: [], + }) +} + +function isPastedCabinetRun(node: AnyNode): node is CabinetNodeType { + return node.type === 'cabinet' && node.id !== runId +} + +function pastedCabinetRun() { + return Object.values(useScene.getState().nodes).find(isPastedCabinetRun) +} + +describe('scene clipboard', () => { + beforeEach(() => { + seedCabinetRun() + useScene.temporal.getState().clear() + }) + + test('copies a selected cabinet run as one subtree instead of independent modules', () => { + const copied = copySelectedNodesToEditorClipboard([runId, leftModuleId, rightModuleId]) + + expect(copied).toBe(true) + expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId]) + + const result = pasteEditorClipboardToLevel(targetLevelId) + expect(result?.pastedIds).toHaveLength(1) + + const pastedRun = pastedCabinetRun() + expect(pastedRun).toBeDefined() + expect(pastedRun?.parentId).toBe(targetLevelId) + expect(pastedRun?.children).toHaveLength(2) + + for (const childId of pastedRun?.children ?? []) { + const child = useScene.getState().nodes[childId as AnyNodeId] + expect(child?.type).toBe('cabinet-module') + expect(child?.parentId).toBe(pastedRun?.id) + } + + const sourceRun = useScene.getState().nodes[runId] + expect(sourceRun?.type).toBe('cabinet') + expect((sourceRun as CabinetNodeType | undefined)?.children).toEqual([ + leftModuleId, + rightModuleId, + ] satisfies CabinetModuleNodeType['id'][]) + }) + + test('promotes a complete module selection to the cabinet run before copying', () => { + const copied = copySelectedNodesToEditorClipboard([leftModuleId, rightModuleId]) + + expect(copied).toBe(true) + expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId]) + + const result = pasteEditorClipboardToLevel(targetLevelId) + expect(result?.pastedIds).toHaveLength(1) + expect(pastedCabinetRun()?.children).toHaveLength(2) + }) +}) diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index f27c1f1f..e4ee8204 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -30,6 +30,8 @@ const COPYABLE_ROOT_TYPES = new Set([ 'stair', 'spawn', 'zone', + 'cabinet', + 'cabinet-module', ]) let clipboardPayload: ClipboardPayload | null = null @@ -99,6 +101,30 @@ function isLevelChildRoot(nodes: Record, node: AnyNode) { return nodes[parentId]?.type === 'level' } +function getPromotedCabinetRunId( + nodes: Record, + node: AnyNode, + selectedIds: Set, +) { + if (node.type !== 'cabinet-module') return null + + const parentId = node.parentId as AnyNodeId | null + const parent = parentId ? nodes[parentId] : null + if (parent?.type !== 'cabinet') return null + if (!parentId) return null + if (selectedIds.has(parentId)) return parentId + + const siblingIds = Array.isArray(parent.children) ? (parent.children as AnyNodeId[]) : [] + const hasOnlySelectedModules = + siblingIds.length > 0 && + siblingIds.every((childId) => { + const child = nodes[childId] + return child?.type === 'cabinet-module' && selectedIds.has(childId) + }) + + return hasOnlySelectedModules ? parentId : null +} + function getPasteTargetLevel(targetLevelId?: AnyNodeId) { const scene = useScene.getState() const resolvedLevelId = @@ -179,7 +205,11 @@ function remapNodeReferences( function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null { const scene = useScene.getState() const selectedIdSet = new Set(ids) - const rootIds = ids.filter((id) => { + const promotedIds = ids.map((id) => { + const node = scene.nodes[id] + return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : id + }) + const rootIds = Array.from(new Set(promotedIds)).filter((id) => { const node = scene.nodes[id] return ( node && diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index 2e7f65d8..e034681c 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -1,12 +1,28 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode } from '@pascal-app/core' +import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core' +import { z } from 'zod' import { + resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, selectionModifiersFromEvent, shouldPreserveSelectedRoofHostTarget, } from './selection-routing' +function registerTestDefinition(kind: string, overrides: Record = {}) { + if (nodeRegistry.has(kind)) return + registerNode({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'furnish', + defaults: () => ({ type: kind }) as never, + capabilities: {}, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + ...overrides, + } as never) +} + describe('resolveSelectedIdsForNodeClick', () => { test('preserves the pre-routing selection when a phase switch clears current ids', () => { expect( @@ -79,6 +95,170 @@ describe('resolveNodeSelectionTarget', () => { }) }) +describe('resolveCanvasSelectionNode', () => { + // Mirrors the cabinet-module setup: modules proxy to their run for grouped + // move / rotate, but declare `selectionProxy.bypassDirectPick` so a direct + // body click selects the clicked module. + const groupKind = 'selection-routing-proxy-group-test' + const memberKind = 'selection-routing-proxy-member-test' + + function registerBypassKinds() { + registerTestDefinition(groupKind) + registerTestDefinition(memberKind, { + selectionProxy: { + bypassDirectPick: (node: AnyNode, proxyTarget: AnyNode) => + (node.type as string) === memberKind && (proxyTarget.type as string) === groupKind, + }, + }) + } + + test('keeps proxied members individually selectable when the kind declares bypassDirectPick', () => { + registerBypassKinds() + const run = { id: 'group_run', type: groupKind, metadata: {} } as unknown as AnyNode + const module = { + id: 'group_member', + type: memberKind, + parentId: run.id, + metadata: { nodeSelectionProxyId: run.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: module, + nodes: { + [run.id]: run, + [module.id]: module, + }, + selectedIds: [], + }), + ).toBe(module) + }) + + test('keeps nested proxied members leaf-selectable by default', () => { + registerBypassKinds() + const rootRun = { id: 'group_root_run', type: groupKind, metadata: {} } as unknown as AnyNode + const legRun = { + id: 'group_leg_run', + type: groupKind, + parentId: rootRun.id, + metadata: { nodeSelectionProxyId: rootRun.id }, + } as unknown as AnyNode + const nestedCornerBase = { + id: 'group_nested_member', + type: memberKind, + parentId: legRun.id, + metadata: { nodeSelectionProxyId: legRun.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: nestedCornerBase, + nodes: { + [rootRun.id]: rootRun, + [legRun.id]: legRun, + [nestedCornerBase.id]: nestedCornerBase, + }, + selectedIds: [], + }), + ).toBe(nestedCornerBase) + }) + + test('follows the proxy when the kind declares no bypass', () => { + const kind = 'selection-routing-proxy-no-bypass-test' + registerTestDefinition(kind) + const group = { id: 'plain_group', type: kind, metadata: {} } as unknown as AnyNode + const member = { + id: 'plain_member', + type: kind, + parentId: group.id, + metadata: { nodeSelectionProxyId: group.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: member, + nodes: { + [group.id]: group, + [member.id]: member, + }, + selectedIds: [], + }), + ).toBe(group) + }) + + test('keeps parent-frame children routed to their parent when that parent is solely selected', () => { + const kind = 'selection-routing-parent-frame-test' + registerTestDefinition(kind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + }, + }, + }, + }) + + const parent = { id: 'parent_1', type: groupKind, metadata: {} } as unknown as AnyNode + const child = { + id: 'child_1', + type: kind, + parentId: parent.id, + metadata: {}, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: child, + nodes: { + [parent.id]: parent, + [child.id]: child, + }, + selectedIds: [parent.id], + }), + ).toBe(parent) + }) + + test('prefers an explicit selection proxy before parent-frame routing', () => { + const kind = 'selection-routing-proxy-before-parent-frame-test' + registerTestDefinition(kind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + }, + }, + }, + }) + + const root = { id: 'root_1', type: groupKind, metadata: {} } as unknown as AnyNode + const proxyGroup = { id: 'proxy_1', type: groupKind, metadata: {} } as unknown as AnyNode + const child = { + id: 'child_proxy_1', + type: kind, + parentId: root.id, + metadata: { nodeSelectionProxyId: proxyGroup.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: child, + nodes: { + [root.id]: root, + [proxyGroup.id]: proxyGroup, + [child.id]: child, + }, + selectedIds: [root.id], + }), + ).toBe(proxyGroup) + }) +}) + describe('shouldPreserveSelectedRoofHostTarget', () => { test('keeps the roof host target while that roof is the sole armed selection', () => { const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index 207f7266..f7b87736 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -1,4 +1,9 @@ -import { type AnyNode, type ItemNode, nodeRegistry } from '@pascal-app/core' +import { + type AnyNode, + type ItemNode, + nodeRegistry, + resolveSelectionProxyId, +} from '@pascal-app/core' export type SelectionModifierKeys = { meta: boolean @@ -11,6 +16,35 @@ export type NodeSelectionTarget = { structureLayer?: 'zones' | 'elements' } +function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean { + if (node.id === target.id) return false + // Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind + // keeps a proxy for grouped affordances but wants a direct body click to + // select the clicked node itself. + return nodeRegistry.get(node.type)?.selectionProxy?.bypassDirectPick?.(node, target) ?? false +} + +export function resolveCanvasSelectionNode({ + node, + nodes, + selectedIds, +}: { + node: AnyNode + nodes: Readonly> + selectedIds: readonly string[] +}): AnyNode { + const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node + let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget + const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame + if (parentFrame) { + const parent = parentFrame.resolveParent(target, nodes as Readonly>) + if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) { + target = parent + } + } + return target +} + export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean { return keys.meta || keys.ctrl || keys.shift } diff --git a/packages/editor/src/lib/sfx/index.ts b/packages/editor/src/lib/sfx/index.ts index 2574c463..68f444e8 100644 --- a/packages/editor/src/lib/sfx/index.ts +++ b/packages/editor/src/lib/sfx/index.ts @@ -1,2 +1,3 @@ export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus' export { playSFX, SFX, type SFXName, updateSFXVolumes } from '../sfx-player' +export { movementSfxStepKey } from './movement-tick' diff --git a/packages/editor/src/lib/sfx/movement-tick.test.ts b/packages/editor/src/lib/sfx/movement-tick.test.ts new file mode 100644 index 00000000..e0e5ef3e --- /dev/null +++ b/packages/editor/src/lib/sfx/movement-tick.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { movementSfxStepKey } from './movement-tick' + +describe('movementSfxStepKey', () => { + test('keeps tiny free-move changes in the same bucket', () => { + expect( + movementSfxStepKey({ + coords: [1.01, 2.04], + gridSnapActive: false, + gridStep: 0.5, + }), + ).toBe( + movementSfxStepKey({ + coords: [1.04, 2.01], + gridSnapActive: false, + gridStep: 0.5, + }), + ) + }) + + test('changes bucket after crossing the free-move cadence', () => { + expect( + movementSfxStepKey({ + coords: [1.01, 2.04], + gridSnapActive: false, + gridStep: 0.5, + }), + ).not.toBe( + movementSfxStepKey({ + coords: [1.16, 2.04], + gridSnapActive: false, + gridStep: 0.5, + }), + ) + }) + + test('uses the live grid step when grid snapping is active', () => { + expect( + movementSfxStepKey({ + coords: [0.24, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ).toBe( + movementSfxStepKey({ + coords: [0.21, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ) + expect( + movementSfxStepKey({ + coords: [0.24, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ).not.toBe( + movementSfxStepKey({ + coords: [0.76, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ) + }) +}) diff --git a/packages/editor/src/lib/sfx/movement-tick.ts b/packages/editor/src/lib/sfx/movement-tick.ts new file mode 100644 index 00000000..5484c513 --- /dev/null +++ b/packages/editor/src/lib/sfx/movement-tick.ts @@ -0,0 +1,18 @@ +const DEFAULT_FREE_MOVEMENT_SFX_STEP_M = 0.1 + +type MovementSfxStepKeyArgs = { + coords: readonly number[] + gridSnapActive: boolean + gridStep: number + freeStep?: number +} + +export function movementSfxStepKey({ + coords, + gridSnapActive, + gridStep, + freeStep = DEFAULT_FREE_MOVEMENT_SFX_STEP_M, +}: MovementSfxStepKeyArgs): string { + const step = gridSnapActive && gridStep > 0 ? gridStep : freeStep + return coords.map((coord) => Math.round(coord / step)).join(',') +} diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index 505ba08a..d9e560b1 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -43,8 +43,6 @@ export type SurfacePlanSnapInput = { walls?: readonly WallNode[] candidates?: readonly AlignmentAnchor[] threshold?: number - altKey?: boolean - shiftKey?: boolean magnetic?: boolean align?: boolean highlightWalls?: boolean @@ -206,9 +204,9 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac // Alignment "lines" are DISPLAYED in every mode (grid / lines / angles / // off); the magnetic pull onto an axis is applied only when magnetic - // ('lines'). Shift / Alt / `align: false` fully bypass (no guides). + // ('lines'). `align: false` fully bypasses (no guides). const basePoint = fallbackPoint ?? wallSnap.point - if (input.align === false || input.altKey) { + if (input.align === false) { useAlignmentGuides.getState().clear() return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 9891a6f9..f2521a2b 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -5,6 +5,8 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type CabinetModuleNode, + type CabinetNode, type CeilingNode, type ChimneyMaterialRole, type ChimneyNode, @@ -69,6 +71,31 @@ const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const MIN_FLOORPLAN_PANE_RATIO = 0.15 const MAX_FLOORPLAN_PANE_RATIO = 0.85 +function resolveMovingNodeTarget( + node: + | ItemNode + | WindowNode + | DoorNode + | ElevatorNode + | CeilingNode + | ChimneyNode + | ColumnNode + | DormerNode + | SlabNode + | WallNode + | FenceNode + | RoofNode + | RoofSegmentNode + | SpawnNode + | StairNode + | StairSegmentNode + | BuildingNode + | CabinetNode + | CabinetModuleNode, +) { + return node +} + export type ViewMode = '3d' | '2d' | 'split' export type SplitOrientation = 'horizontal' | 'vertical' export type WorkspaceMode = 'edit' | 'studio' @@ -147,7 +174,7 @@ export type StructureTool = | 'pipe-trap' // Furnish mode tools (items and decoration) -export type FurnishTool = 'item' +export type FurnishTool = 'item' | 'cabinet' // Site mode tools export type SiteTool = 'property-line' @@ -273,6 +300,8 @@ type EditorState = { | StairNode | StairSegmentNode | BuildingNode + | CabinetNode + | CabinetModuleNode | null, ) => void /** @@ -492,6 +521,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = wall: CONTINUATION_PROFILES.wall.default, fence: CONTINUATION_PROFILES.fence.default, point: CONTINUATION_PROFILES.point.default, + cabinet: CONTINUATION_PROFILES.cabinet.default, }, showReferenceFloor: false, referenceFloorOffset: 1, @@ -632,6 +662,9 @@ function normalizeContinuationByContext( point: migrateContinuationMode(state?.continuationByContext?.point, 'point') ?? CONTINUATION_PROFILES.point.default, + cabinet: + migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ?? + CONTINUATION_PROFILES.cabinet.default, } } @@ -908,18 +941,25 @@ const useEditor = create()( set({ placementDragMode: false }) return } - const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew) + const targetNode = resolveMovingNodeTarget(node) + const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew) if (isNew) { scope.begin({ kind: 'placing', - node, - nodeId: node.id, - nodeType: node.type, + node: targetNode, + nodeId: targetNode.id, + nodeType: targetNode.type, view: '3d', pressDrag: get().placementDragMode, }) } else { - scope.begin({ kind: 'moving', node, nodeId: node.id, nodeType: node.type, view: '3d' }) + scope.begin({ + kind: 'moving', + node: targetNode, + nodeId: targetNode.id, + nodeType: targetNode.type, + view: '3d', + }) } set({ movingNodeOrigin: null }) }, @@ -1247,6 +1287,7 @@ const useEditor = create()( referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, }), + skipHydration: true, }, ), ) diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts new file mode 100644 index 00000000..9b342638 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test' +import { + cabinetStretchEndLocalX, + cabinetStretchExitSide, + chooseCabinetContinuousAnchor, + createCabinetContinuousContinuation, + fillCabinetContinuousSpan, + isCabinetContinuousFollowUpClick, + planCabinetContinuousStretch, + resolveCabinetContinuousValidity, + type StretchAnchor, +} from '../continuous-placement' + +const ANCHOR: StretchAnchor = { + position: [0, 0, 0], + yaw: 0, + snappedToWall: false, +} + +describe('cabinet continuous placement', () => { + test('fills a stretch with full modules plus a partial end module when needed', () => { + const widths = fillCabinetContinuousSpan(1.35) + expect(widths).toHaveLength(3) + expect(widths[0]).toBeCloseTo(0.6) + expect(widths[1]).toBeCloseTo(0.6) + expect(widths[2]).toBeCloseTo(0.15) + }) + + test('drops a tiny remainder below the minimum end-module width', () => { + expect(fillCabinetContinuousSpan(1.27)).toEqual([0.6, 0.6]) + }) + + test('plans module offsets to the right of the anchored cabinet', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [1.35, 0, 0], + }) + + expect(stretch.modules).toHaveLength(3) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[0]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeCloseTo(0.6) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[2]?.x).toBeCloseTo(1.125) + expect(stretch.modules[2]?.width).toBeCloseTo(0.45) + expect(stretch.length).toBeCloseTo(1.65) + expect(stretch.centerLocalX).toBeCloseTo(0.525) + expect(stretch.direction).toBe(1) + expect(cabinetStretchExitSide(stretch)).toBe('right') + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.35) + }) + + test('mirrors module offsets when the stretch grows left of the anchor', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [-1.35, 0, 0], + }) + + expect(stretch.modules).toHaveLength(3) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[0]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeCloseTo(-0.6) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[2]?.x).toBeCloseTo(-1.125) + expect(stretch.modules[2]?.width).toBeCloseTo(0.45) + expect(stretch.centerLocalX).toBeCloseTo(-0.525) + expect(stretch.direction).toBe(-1) + expect(cabinetStretchExitSide(stretch)).toBe('left') + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.35) + }) + + test('forced-direction anchors keep orthogonal follow-on legs growing outward', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1 }, + previewWidth: 0.6, + rawPlanPosition: [-1.0, 0, 0], + }) + + expect(stretch.direction).toBe(1) + expect(stretch.length).toBeCloseTo(0.6) + }) + + test('leading-width anchors reserve the corner filler before adding cabinet modules', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 }, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + + expect(stretch.modules[0]?.width).toBeCloseTo(0.58) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2) + }) + + test('leading-width anchors always preview the first connected cabinet after the corner filler', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 }, + previewWidth: 0.6, + rawPlanPosition: [0.05, 0, 0], + }) + + expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.6]) + expect(stretch.length).toBeCloseTo(1.18) + }) + + test('prefers continuing straight when the cursor moves forward from the committed end', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + const continuation = createCabinetContinuousContinuation({ + anchor: ANCHOR, + previewDepth: 0.58, + previewWidth: 0.6, + stretch, + }) + + expect(chooseCabinetContinuousAnchor(continuation, [1.9, 0, 0.1])).toEqual( + continuation.straightAnchor, + ) + }) + + test('prefers the L turn when the cursor moves more laterally than forward', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + const continuation = createCabinetContinuousContinuation({ + anchor: ANCHOR, + previewDepth: 0.58, + previewWidth: 0.6, + stretch, + }) + + expect(chooseCabinetContinuousAnchor(continuation, [1.35, 0, -0.9])).toEqual( + continuation.turnAnchor, + ) + }) + + test('treats Alt force-place as valid while keeping normal collisions blocked', () => { + const blocked = { conflictIds: ['cabinet_a', 'cabinet_b'], valid: false } + + expect(resolveCabinetContinuousValidity(blocked, false)).toEqual(blocked) + expect(resolveCabinetContinuousValidity(blocked, true)).toEqual({ + conflictIds: [], + valid: true, + }) + }) + + test('treats the second click in a double-click as a follow-up click to ignore', () => { + expect(isCabinetContinuousFollowUpClick(2)).toBe(true) + }) + + test('treats a normal click as a segment commit click', () => { + expect(isCabinetContinuousFollowUpClick(1)).toBe(false) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts new file mode 100644 index 00000000..5b920f33 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts @@ -0,0 +1,370 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, +} from '@pascal-app/core' +import { loadPlugin, nodeRegistry } from '../../../../core/src/registry' +import { createSceneApi } from '../../../../core/src/registry/scene-api' +import { runAsSingleSceneHistoryStep } from '../../../../core/src/store/history-control' +import useScene from '../../../../core/src/store/use-scene' +import { builtinPlugin } from '../../index' +import { cabinetQuickActions } from '../quick-actions' +import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +function cornerMetadata(node: AnyNode | undefined): Record { + const metadata = (node as { metadata?: unknown } | undefined)?.metadata + return metadata && typeof metadata === 'object' ? (metadata as Record) : {} +} + +function seedCornerScene(prefix: string, options: { withWallTop?: boolean } = {}) { + const levelId = `level_${prefix}` as AnyNodeId + const runId = `cabinet_${prefix}-run` as AnyNodeId + const moduleId = `cabinet-module_${prefix}-module` as AnyNodeId + const wallTopId = `cabinet-module_${prefix}-wall-top` as AnyNodeId + + const level = { + id: levelId, + type: 'level', + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + level: 0, + parentId: null, + children: [runId], + } as unknown as AnyNode + const run = CabinetNode.parse({ + id: runId, + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [moduleId], + }) + const module = CabinetModuleNode.parse({ + id: moduleId, + parentId: runId, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + ...(options.withWallTop ? { children: [wallTopId] } : {}), + stack: [{ id: `door-${prefix}`, type: 'door', shelfCount: 2 }], + }) + const nodes: Record = { + [level.id]: level, + [run.id]: run as AnyNode, + [module.id]: module as AnyNode, + } + if (options.withWallTop) { + const wallTop = CabinetModuleNode.parse({ + id: wallTopId, + parentId: moduleId, + position: [0, wallBottomHeightForTallAlignment() - module.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + stack: [{ id: `door-${prefix}-wall-top`, type: 'door', shelfCount: 1 }], + }) + nodes[wallTop.id] = wallTop as AnyNode + } + + useScene.setState({ nodes, rootNodeIds: [level.id] } as never) + return { levelId, runId, moduleId, wallTopId, run, module } +} + +function linkedRunIdsOf(moduleId: AnyNodeId): AnyNodeId[] { + const link = cornerMetadata(useScene.getState().nodes[moduleId]).cabinetCornerSourceLink as + | { linkedRunIds?: AnyNodeId[] } + | undefined + return link?.linkedRunIds ?? [] +} + +describe('cabinet corner member deletion', () => { + beforeEach(async () => { + if (!nodeRegistry.get('cabinet') || !nodeRegistry.get('cabinet-module')) { + await loadPlugin(builtinPlugin) + } + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) + useScene.temporal.getState().clear() + }) + + test('deleting one derived leg run removes only that run and unlinks it from the source module', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-one-leg') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const legIds = linkedRunIdsOf(moduleId) + expect(legIds.length).toBeGreaterThan(1) + const deletedLegId = legIds.find((id) => { + const node = useScene.getState().nodes[id] + return node?.type === 'cabinet' && node.parentId !== runId + })! + const survivingLegIds = legIds.filter((id) => id !== deletedLegId) + + useScene.getState().deleteNode(deletedLegId!) + + const nodes = useScene.getState().nodes + expect(nodes[deletedLegId!]).toBeUndefined() + // Everything else survives: source run, source module, the other legs. + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + for (const survivorId of survivingLegIds) { + expect(nodes[survivorId]).toBeDefined() + } + // The source module's link no longer references the deleted leg. + expect(linkedRunIdsOf(moduleId)).toEqual(survivingLegIds) + }) + + test('undo and redo treat an L-corner quick action as one scene step', () => { + const { runId, moduleId, module } = seedCornerScene('undo-redo-corner') + const beforeIds = new Set(Object.keys(useScene.getState().nodes)) + const action = cabinetQuickActions({ + node: module, + nodes: useScene.getState().nodes, + }).find((entry) => entry.id === 'cabinet:add-corner-right') + + expect(action).toBeDefined() + expect(action?.history).toBe('single') + + const beforePastCount = useScene.temporal.getState().pastStates.length + const result = runAsSingleSceneHistoryStep(useScene, () => + action!.run({ + node: module as AnyNode, + sceneApi: createSceneApi(useScene), + }), + ) + + expect(result?.selectedIds?.[0]).toBeTruthy() + expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1) + + const afterIds = new Set(Object.keys(useScene.getState().nodes)) + const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[] + expect(createdIds.length).toBeGreaterThan(5) + expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1) + + useScene.temporal.getState().undo() + + const undoneNodes = useScene.getState().nodes + expect(undoneNodes[runId]).toBeDefined() + expect(undoneNodes[moduleId]).toBeDefined() + for (const id of createdIds) { + expect(undoneNodes[id]).toBeUndefined() + } + expect(linkedRunIdsOf(moduleId)).toEqual([]) + + useScene.temporal.getState().redo() + + const redoneNodes = useScene.getState().nodes + for (const id of createdIds) { + expect(redoneNodes[id]).toBeDefined() + } + expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1) + }) + + test('undo treats a side-add quick action as one scene step', () => { + const { runId, moduleId, module } = seedCornerScene('undo-side-add') + const beforeIds = new Set(Object.keys(useScene.getState().nodes)) + const action = cabinetQuickActions({ + node: module, + nodes: useScene.getState().nodes, + }).find((entry) => entry.id === 'cabinet:add-right') + + expect(action).toBeDefined() + expect(action?.history).toBe('single') + + const beforePastCount = useScene.temporal.getState().pastStates.length + const result = runAsSingleSceneHistoryStep(useScene, () => + action!.run({ + node: module as AnyNode, + sceneApi: createSceneApi(useScene), + }), + ) + + expect(result?.selectedIds?.[0]).toBeTruthy() + expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1) + + const afterIds = new Set(Object.keys(useScene.getState().nodes)) + const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[] + expect(createdIds).toHaveLength(1) + + useScene.temporal.getState().undo() + + const undoneNodes = useScene.getState().nodes + expect(undoneNodes[runId]).toBeDefined() + expect(undoneNodes[moduleId]).toBeDefined() + expect(undoneNodes[createdIds[0]!]).toBeUndefined() + }) + + test('deleting a module inside a leg run removes only that module', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-leg-module') + const sceneApi = createSceneApi(useScene) + const derivedModuleId = addCornerRun({ module, run, sceneApi, side: 'right' }) + expect(derivedModuleId).toBeTruthy() + + const legRunId = ( + useScene.getState().nodes[derivedModuleId! as AnyNodeId] as CabinetModuleNodeType + ).parentId as AnyNodeId + + useScene.getState().deleteNode(derivedModuleId! as AnyNodeId) + + const nodes = useScene.getState().nodes + expect(nodes[derivedModuleId! as AnyNodeId]).toBeUndefined() + expect(nodes[legRunId]).toBeDefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + expect(linkedRunIdsOf(moduleId)).toContain(legRunId) + }) + + test('deleting the source wall-top removes only the wall-top', () => { + const { runId, moduleId, wallTopId, run, module } = seedCornerScene('delete-wall-top', { + withWallTop: true, + }) + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + + useScene.getState().deleteNode(wallTopId) + + const nodes = useScene.getState().nodes + expect(nodes[wallTopId]).toBeUndefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + for (const legId of legIds) { + expect(nodes[legId]).toBeDefined() + } + expect(linkedRunIdsOf(moduleId)).toEqual(legIds) + }) + + test('deleting the source module keeps the legs as plain unlinked runs', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-source-module') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + expect(legIds.length).toBeGreaterThan(0) + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + // The run still hosts the derived leg runs, so it must survive. + expect(nodes[runId]).toBeDefined() + for (const legId of legIds) { + expect(nodes[legId]).toBeDefined() + expect('cabinetCornerDerivedRun' in cornerMetadata(nodes[legId])).toBe(false) + } + }) + + test('deleting every leg drops the source link entirely', () => { + const { moduleId, run, module } = seedCornerScene('delete-all-legs') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + + for (const legId of legIds) { + useScene.getState().deleteNode(legId) + } + + const metadata = cornerMetadata(useScene.getState().nodes[moduleId]) + expect('cabinetCornerSourceLink' in metadata).toBe(false) + }) + + test('deleting the only module of a run deletes the emptied run group', () => { + const { levelId, runId, moduleId } = seedCornerScene('empty-run-single') + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[runId]).toBeUndefined() + const level = nodes[levelId] as { children?: AnyNodeId[] } + expect(level.children ?? []).not.toContain(runId) + }) + + test('deleting one of several modules keeps the run', () => { + const { levelId, runId, moduleId } = seedCornerScene('keep-run-sibling') + const secondModuleId = 'cabinet-module_keep-run-sibling-2' as AnyNodeId + const second = CabinetModuleNode.parse({ + id: secondModuleId, + parentId: runId, + position: [0.9, 0.1, 0], + width: 0.9, + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [secondModuleId]: second as AnyNode, + [runId]: { + ...state.nodes[runId], + children: [moduleId, secondModuleId], + } as AnyNode, + }, + })) + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[secondModuleId]).toBeDefined() + expect(nodes[levelId]).toBeDefined() + }) + + test('multi-select deleting every module of a run deletes the emptied run group', () => { + const { levelId, runId, moduleId } = seedCornerScene('empty-run-multiselect') + const secondModuleId = 'cabinet-module_empty-run-multiselect-2' as AnyNodeId + const second = CabinetModuleNode.parse({ + id: secondModuleId, + parentId: runId, + position: [0.9, 0.1, 0], + width: 0.9, + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [secondModuleId]: second as AnyNode, + [runId]: { + ...state.nodes[runId], + children: [moduleId, secondModuleId], + } as AnyNode, + }, + })) + + useScene.getState().deleteNodes([moduleId, secondModuleId]) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[secondModuleId]).toBeUndefined() + expect(nodes[runId]).toBeUndefined() + const level = nodes[levelId] as { children?: AnyNodeId[] } + expect(level.children ?? []).not.toContain(runId) + }) + + test('deleting the whole source run removes the run subtree including the legs', () => { + const { levelId, runId, moduleId, run, module } = seedCornerScene('delete-source-run') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + useScene.getState().deleteNode(runId) + + const remaining = Object.values(useScene.getState().nodes).filter( + (node) => node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(remaining).toEqual([]) + expect(useScene.getState().nodes[levelId]).toBeDefined() + expect(useScene.getState().nodes[moduleId]).toBeUndefined() + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts new file mode 100644 index 00000000..42b49af8 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { cabinetModuleDefinition } from '../definition' +import { CabinetModuleNode } from '../schema' + +describe('cabinet module drag bounds', () => { + test('uses schema dimensions instead of measured render geometry', () => { + const module = CabinetModuleNode.parse({ + width: 0.82, + depth: 0.64, + carcassHeight: 0.74, + plinthHeight: 0.11, + countertopThickness: 0.03, + showPlinth: true, + withCountertop: true, + }) + + const bounds = cabinetModuleDefinition.capabilities.dragBounds?.(module, {}) + + expect(bounds?.size).toEqual([0.82, 0.88, 0.64]) + expect(bounds?.center).toEqual([0, 0.44, 0]) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts b/packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts new file mode 100644 index 00000000..7fa6ac12 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, DuplicableConfig } from '@pascal-app/core' +import { cabinetDefinition } from '../definition' +import { CabinetModuleNode, CabinetNode } from '../schema' + +const LEVEL_ID = 'level_test' as AnyNodeId +const ROOT_RUN_ID = 'cabinet_root-run' as AnyNodeId +const CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId +const CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId + +describe('cabinet duplicate subtree preparation', () => { + test('flattens nested runs into level coordinates and strips derived metadata', () => { + const rootRun = { + id: ROOT_RUN_ID, + type: 'cabinet', + object: 'node', + visible: true, + metadata: {}, + parentId: LEVEL_ID, + position: [4, 0, 5], + rotation: Math.PI / 2, + children: [CHILD_RUN_ID], + } as AnyNode + const childRun = CabinetNode.parse({ + id: CHILD_RUN_ID, + parentId: ROOT_RUN_ID, + position: [1, 0, 0.25], + rotation: Math.PI / 2, + children: [CHILD_MODULE_ID], + metadata: { + cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: ROOT_RUN_ID }, + nodeSelectionProxyId: ROOT_RUN_ID, + }, + }) + const childModule = CabinetModuleNode.parse({ + id: CHILD_MODULE_ID, + parentId: CHILD_RUN_ID, + position: [0, 0.1, 0], + }) + const level = { + id: LEVEL_ID, + type: 'level', + parentId: null, + children: [ROOT_RUN_ID], + } as AnyNode + const nodes = { + [LEVEL_ID]: level, + [ROOT_RUN_ID]: rootRun, + [CHILD_RUN_ID]: childRun as AnyNode, + [CHILD_MODULE_ID]: childModule as AnyNode, + } + const duplicable = cabinetDefinition.capabilities.duplicable as DuplicableConfig + + const prepared = duplicable.prepareSubtreeClone?.({ + root: childRun as AnyNode, + descendants: [childModule as AnyNode], + rootId: CHILD_RUN_ID, + rootPatch: {}, + nodes, + }) + + expect(prepared?.parentId).toBe(LEVEL_ID) + expect(prepared?.root?.parentId).toBe(LEVEL_ID) + expect( + (prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[0], + ).toBeCloseTo(4.25) + expect( + (prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[2], + ).toBeCloseTo(4) + expect((prepared?.root as { rotation?: number } | undefined)?.rotation).toBeCloseTo(Math.PI) + expect( + (prepared?.root?.metadata as Record | undefined)?.cabinetCornerDerivedRun, + ).toBeUndefined() + expect( + (prepared?.root?.metadata as Record | undefined)?.nodeSelectionProxyId, + ).toBeUndefined() + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts new file mode 100644 index 00000000..22e04667 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts @@ -0,0 +1,510 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core' +import { cabinetDefinition } from '../definition' +import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from '../floorplan' +import { cabinetFloorplanSiblingOverrides } from '../floorplan-overrides' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function makeContext(overrides: Partial = {}): GeometryContext { + return { + children: [], + parent: null, + resolve: () => undefined as never, + siblings: [], + ...overrides, + } +} + +function flatten(geometry: FloorplanGeometry | null): FloorplanGeometry[] { + if (!geometry) return [] + if (geometry.kind !== 'group') return [geometry] + return [geometry, ...geometry.children.flatMap((child) => flatten(child))] +} + +function primitives(geometry: FloorplanGeometry | null, kind: string): FloorplanGeometry[] { + return flatten(geometry).filter((g) => g.kind === kind) +} + +function composeWorldPose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +) { + return { + position: [ + parentPosition[0] + + childPosition[0] * Math.cos(parentRotation) + + childPosition[2] * Math.sin(parentRotation), + parentPosition[1] + childPosition[1], + parentPosition[2] - + childPosition[0] * Math.sin(parentRotation) + + childPosition[2] * Math.cos(parentRotation), + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +describe('buildCabinetFloorplan', () => { + // A childless run still draws its node-level footprint — the 2D placement + // ghost publishes a moduleless preview run and needs a visible outline. + test('empty cabinet runs fall back to the run node footprint', () => { + const run = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + id: 'cabinet_empty-floorplan-run', + children: [], + }) + + const rects = primitives(buildCabinetFloorplan(run, makeContext()), 'rect') + expect(rects.length).toBeGreaterThanOrEqual(1) + const body = rects[0]! as Extract + // Countertop outline: node footprint extended by the overhang on the + // front / left / right edges (defaults: countertop on, overhang 0.02). + expect(body.width).toBeCloseTo(run.width + 2 * run.countertopOverhang) + expect(body.height).toBeCloseTo(run.depth + run.countertopOverhang) + }) + + test('run draws one countertop rect per span, extended by the overhang', () => { + const run = CabinetNode.parse({ + id: 'cabinet_run-spans', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_a', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_b', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + ] + + const geometry = buildCabinetFloorplan(run, makeContext({ children: modules as AnyNode[] })) + const rects = primitives(geometry, 'rect') as Array<{ x: number; width: number }> + + expect(rects).toHaveLength(1) + expect(rects[0]!.x).toBeCloseTo(-0.62) + expect(rects[0]!.width).toBeCloseTo(1.24) + }) + + // 2D ↔ 3D parity: the plan outline must trim its side overhang exactly + // where the 3D slab does (adjacent collinear runs, L-corner mating edges, + // finished backs) — otherwise abutting runs draw overlapping countertops + // in plan that don't exist in 3D. + test('adjacent collinear sibling run suppresses that side overhang in plan', () => { + const run = CabinetNode.parse({ + id: 'cabinet_adjacent-a', + position: [0, 0, 0], + countertopOverhang: 0.02, + children: ['cabinet-module_adjacent-a'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_adjacent-a', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + // Sibling run flush against this run's right edge (0.3 + 0.3). + const sibling = CabinetNode.parse({ + id: 'cabinet_adjacent-b', + position: [0.6, 0, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan( + run, + makeContext({ children: [module] as AnyNode[], siblings: [sibling] as AnyNode[] }), + ) + const body = primitives(geometry, 'rect')[0] as Extract + // Left edge keeps the overhang; right edge is flush against the sibling. + expect(body.x).toBeCloseTo(-0.32) + expect(body.width).toBeCloseTo(0.62) + }) + + test('derived L-corner base leg draws flush on its mating edge', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-leg', + countertopOverhang: 0.02, + children: ['cabinet-module_corner-leg'], + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: 'cabinet-module_src', + sourceRunId: 'cabinet_src', + }, + }, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-leg', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] })) + const body = primitives(geometry, 'rect')[0] as Extract + // side 'right' mates on the leg's first-span left edge → flush left. + expect(body.x).toBeCloseTo(-0.3) + expect(body.width).toBeCloseTo(0.62) + }) + + test('finished back extends the plan footprint by the board thickness', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-back', + withFinishedBack: true, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + boardThickness: 0.018, + children: ['cabinet-module_finished-back'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_finished-back', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] })) + const body = primitives(geometry, 'rect')[0] as Extract + expect(body.y).toBeCloseTo(-0.29 - 0.018) + expect(body.height).toBeCloseTo(0.58 + 0.018 + 0.02) + }) + + test('nested L-leg runs compose their source module transform in floorplan space', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-nested', + position: [1.2, 0, 2.4], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-nested'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-nested', + parentId: sourceRun.id, + position: [0.45, 0.1, -0.12], + rotation: Math.PI / 4, + width: 0.9, + depth: 0.58, + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-nested', + parentId: sourceModule.id, + position: [0.3, 0, -0.2], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-nested'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-nested', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan( + childRun, + makeContext({ + parent: sourceModule as AnyNode, + children: [childModule] as AnyNode[], + resolve: ((id: string) => + id === sourceRun.id ? sourceRun : undefined) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + expect(transformed.kind).toBe('group') + + const expectedX = + sourceRun.position[0] + + sourceModule.position[0] * Math.cos(sourceRun.rotation) + + sourceModule.position[2] * Math.sin(sourceRun.rotation) + + childRun.position[0] * Math.cos(sourceRun.rotation + sourceModule.rotation) + + childRun.position[2] * Math.sin(sourceRun.rotation + sourceModule.rotation) + const expectedZ = + sourceRun.position[2] - + sourceModule.position[0] * Math.sin(sourceRun.rotation) + + sourceModule.position[2] * Math.cos(sourceRun.rotation) - + childRun.position[0] * Math.sin(sourceRun.rotation + sourceModule.rotation) + + childRun.position[2] * Math.cos(sourceRun.rotation + sourceModule.rotation) + + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedX) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedZ) + expect(transformed.transform?.rotate).toBeCloseTo( + -(sourceRun.rotation + sourceModule.rotation + childRun.rotation), + ) + }) + + test('deeply nested corner runs compose the full cabinet ancestry chain in floorplan space', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-deep', + position: [2.4, 0, 1.3], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-deep'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-deep', + parentId: sourceRun.id, + position: [0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-deep', + parentId: sourceModule.id, + position: [0.58, 0, 0.29], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-deep'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-deep', + parentId: childRun.id, + position: [0.74, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const grandchildRun = CabinetNode.parse({ + id: 'cabinet_grandchild-run-floorplan-deep', + parentId: childModule.id, + position: [0.58, 0, 0.29], + rotation: -Math.PI / 2, + children: ['cabinet-module_grandchild-run-floorplan-deep'], + }) + const grandchildModule = CabinetModuleNode.parse({ + id: 'cabinet-module_grandchild-run-floorplan-deep', + parentId: grandchildRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceModule.id]: sourceModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } + + const geometry = buildCabinetFloorplan( + grandchildRun, + makeContext({ + parent: childModule as AnyNode, + children: [grandchildModule] as AnyNode[], + resolve: ((id: string) => nodes[id as keyof typeof nodes]) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + const sourceModuleWorld = composeWorldPose( + sourceRun.position, + sourceRun.rotation, + sourceModule.position, + sourceModule.rotation, + ) + const childRunWorld = composeWorldPose( + sourceModuleWorld.position, + sourceModuleWorld.rotation, + childRun.position, + childRun.rotation, + ) + const childModuleWorld = composeWorldPose( + childRunWorld.position, + childRunWorld.rotation, + childModule.position, + childModule.rotation, + ) + const expectedWorld = composeWorldPose( + childModuleWorld.position, + childModuleWorld.rotation, + grandchildRun.position, + grandchildRun.rotation, + ) + + expect(transformed.kind).toBe('group') + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0]) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2]) + expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation) + }) + + test('live parent overrides move nested cabinet symbols before commit', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-live', + position: [1.2, 0, 2.4], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-live'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-live', + parentId: sourceRun.id, + position: [0.45, 0.1, -0.12], + rotation: Math.PI / 4, + width: 0.9, + depth: 0.58, + children: ['cabinet_child-run-floorplan-live'], + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-live', + parentId: sourceModule.id, + position: [0.3, 0, -0.2], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-live'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-live', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceModule.id]: sourceModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } as Record + const contextNodes = cabinetFloorplanSiblingOverrides({ + nodeId: childRun.id, + nodes, + liveTransforms: new Map(), + liveOverrides: new Map([[sourceRun.id, { position: [3.2, 0, 4.4] }]]), + }) + + const geometry = buildCabinetFloorplan( + contextNodes[childRun.id] as typeof childRun, + makeContext({ + parent: contextNodes[sourceModule.id] as AnyNode, + children: [contextNodes[childModule.id] as AnyNode], + resolve: ((id: string) => contextNodes[id]) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + const sourceModuleWorld = composeWorldPose( + [3.2, 0, 4.4], + sourceRun.rotation, + sourceModule.position, + sourceModule.rotation, + ) + const expectedWorld = composeWorldPose( + sourceModuleWorld.position, + sourceModuleWorld.rotation, + childRun.position, + childRun.rotation, + ) + + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0]) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2]) + expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation) + }) +}) + +describe('buildCabinetModuleFloorplan', () => { + const run = CabinetNode.parse({ id: 'cabinet_symbol-run' }) + + function moduleFloorplan(module: ReturnType) { + return buildCabinetModuleFloorplan(module, makeContext({ parent: run as AnyNode })) + } + + test('sink module draws rounded bowl rects and a faucet circle', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + width: 0.8, + depth: 0.58, + stack: [ + { id: 'd', type: 'door', doorType: 'double' }, + { id: 's', type: 'sink', sinkLayout: 'double' }, + ], + }) + + const geometry = moduleFloorplan(module) + const roundedRects = (primitives(geometry, 'rect') as Array<{ rx?: number }>).filter( + (rect) => (rect.rx ?? 0) > 0, + ) + const faucet = (primitives(geometry, 'circle') as Array<{ r: number; cy: number }>)[0] + expect(roundedRects).toHaveLength(2) + expect(primitives(geometry, 'circle')).toHaveLength(1) + expect(faucet?.r).toBeCloseTo(0.02) + expect(faucet?.cy).toBeCloseTo(-0.26) + }) + + test('gas cooktop module draws two rings per burner', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + width: 0.75, + depth: 0.58, + stack: [ + { id: 'd', type: 'drawer', drawerCount: 2 }, + { id: 'c', type: 'cooktop-gas', cooktopLayout: 'gas-4burner' }, + ], + }) + + expect(primitives(moduleFloorplan(module), 'circle')).toHaveLength(8) + }) + + test('appliance modules carry standard plan labels', () => { + const cases: Array<{ stack: unknown[]; label: string }> = [ + { stack: [{ id: 'x', type: 'dishwasher', height: 0.72 }], label: 'DW' }, + { stack: [{ id: 'x', type: 'fridge-single', height: 1.78 }], label: 'REF' }, + { + stack: [ + { id: 'x', type: 'oven', height: 0.595 }, + { id: 'y', type: 'microwave', height: 0.39 }, + ], + label: 'OV/MW', + }, + ] + for (const { stack, label } of cases) { + const module = CabinetModuleNode.parse({ parentId: run.id, stack }) + const texts = primitives(moduleFloorplan(module), 'text') as Array<{ text: string }> + expect(texts.map((t) => t.text)).toContain(label) + } + }) + + test('nested wall cabinet draws a dashed open outline', () => { + const baseModule = CabinetModuleNode.parse({ parentId: run.id, position: [0, 0.1, 0] }) + const wallModule = CabinetModuleNode.parse({ + parentId: baseModule.id, + position: [0, 1.4, -0.13], + depth: 0.32, + }) + + const geometry = buildCabinetModuleFloorplan( + wallModule, + makeContext({ + parent: baseModule as AnyNode, + resolve: ((id: string) => (id === run.id ? run : undefined)) as GeometryContext['resolve'], + }), + ) + const rects = primitives(geometry, 'rect') as Array<{ + strokeDasharray?: string + fill?: string + }> + expect(rects).toHaveLength(1) + expect(rects[0]!.strokeDasharray).toBeTruthy() + expect(rects[0]!.fill).toBe('none') + }) + + test('plain door module draws no appliance symbols or labels', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + stack: [{ id: 'd', type: 'door', doorType: 'double', shelfCount: 1 }], + }) + + const geometry = moduleFloorplan(module) + expect(primitives(geometry, 'circle')).toHaveLength(0) + expect(primitives(geometry, 'text')).toHaveLength(0) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/front-style.test.ts b/packages/nodes/src/cabinet/__tests__/front-style.test.ts new file mode 100644 index 00000000..f41cb428 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/front-style.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from 'bun:test' +import type { BufferAttribute, Mesh } from 'three' +import { buildCabinetGeometry } from '../geometry' +import { CabinetModuleNode } from '../schema' + +function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name && pattern.test(item.name)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found matching: ${pattern}`) +} + +function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name?.startsWith(prefix)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found with prefix: ${prefix}`) +} + +function frontProfileStats(mesh: Mesh, recessTolerance = 0.001) { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) frameMaxZ = Math.max(frameMaxZ, position.getZ(i)) + + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const z = position.getZ(i) + if (z < frameMaxZ - recessTolerance) panelMaxZ = Math.max(panelMaxZ, z) + } + return { frameMaxZ, panelMaxZ } +} + +function archOutlineStats(mesh: Mesh, sideThreshold: number, centerThreshold: number) { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + if (Math.abs(x) <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (Math.abs(x) >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return { centerMaxY, sideMaxY } +} + +function archShoulderStats(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + if (!box) throw new Error('Expected geometry bounding box') + + const halfWidth = (box.max.x - box.min.x) / 2 + const centerThreshold = halfWidth * 0.18 + const shoulderMin = halfWidth * 0.45 + const shoulderMax = halfWidth * 0.72 + const sideThreshold = halfWidth * 0.9 + + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let shoulderMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = Math.abs(position.getX(i)) + const y = position.getY(i) + if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (x >= shoulderMin && x <= shoulderMax) shoulderMaxY = Math.max(shoulderMaxY, y) + if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return { centerMaxY, shoulderMaxY, sideMaxY } +} + +function archCrownRise(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + if (!box) throw new Error('Expected geometry bounding box') + + const halfWidth = (box.max.x - box.min.x) / 2 + const centerThreshold = halfWidth * 0.18 + const sideThreshold = halfWidth * 0.88 + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = Math.abs(position.getX(i)) + const y = position.getY(i) + if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return centerMaxY - sideMaxY +} + +describe('cabinet raised-arch front style', () => { + test('door fronts get an arched recessed panel instead of a rectangular shaker recess', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const { frameMaxZ, panelMaxZ } = frontProfileStats(leftDoor) + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('drawer fronts carry the same raised-arch profile at smaller proportions', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const { frameMaxZ, panelMaxZ } = frontProfileStats(drawerFront) + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('glass doors use an arched glass opening instead of a rectangular pane', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + const paneStats = archOutlineStats(glassPane, 0.08, 0.035) + + expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.004) + }) + + test('glass door arches keep broad shoulders instead of pinching into spikes', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + const paneStats = archShoulderStats(glassPane) + + expect(paneStats.shoulderMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.008) + expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.shoulderMaxY + 0.002) + }) + + test('glass arches keep a consistent crown for the same width across short and tall doors', () => { + const tallNode = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + carcassHeight: 0.9, + stack: [{ id: 'glass-door-tall', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const shortNode = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + carcassHeight: 0.45, + stack: [{ id: 'glass-door-short', type: 'door', doorType: 'glass', shelfCount: 2 }], + }) + + const tallGroup = buildCabinetGeometry(tallNode, undefined, 'rendered', false) + const shortGroup = buildCabinetGeometry(shortNode, undefined, 'rendered', false) + const tallGlass = findMeshByNamePattern(tallGroup, /^cabinet-door-left-[\d.]+-glass$/) + const shortGlass = findMeshByNamePattern(shortGroup, /^cabinet-door-left-[\d.]+-glass$/) + + expect(archCrownRise(tallGlass)).toBeCloseTo(archCrownRise(shortGlass), 2) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts new file mode 100644 index 00000000..c137fa1d --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -0,0 +1,2590 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, GeometryContext, LinearResizeHandle } from '@pascal-app/core' +import type { BufferAttribute, Mesh, Object3D } from 'three' +import { Box3 } from 'three' +import { bakeCabinetAnimationClip } from '../animation' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { buildCabinetGeometry } from '../geometry' +import { runLocalToPlan } from '../run-layout' +import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' +import { cabinetSlots } from '../slots' +import { + backAnchoredModuleZ, + COOKTOP_DEFAULT_HEIGHT, + COOKTOP_STANDARD_WIDTH, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + HOOD_CANOPY_DEPTH, + HOOD_CURVED_TOTAL_HEIGHT, + HOOD_DUCT_SIZE, + HOOD_PYRAMID_CANOPY_HEIGHT, + MICROWAVE_STANDARD_HEIGHT, + PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + PULL_OUT_PANTRY_STANDARD_WIDTH, + SINK_STANDARD_WIDTH, + TALL_CABINET_CARCASS_HEIGHT, +} from '../stack' + +function findMeshByName(root: { children: unknown[] }, name: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name === name) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found: ${name}`) +} + +function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name?.startsWith(prefix)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found with prefix: ${prefix}`) +} + +/** + * Coordinate-encoded names (`cabinet-drawer-front--`) shift when + * dimension defaults change; match on the stable prefix/suffix instead. + */ +function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name && pattern.test(item.name)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found matching: ${pattern}`) +} + +function hasVertex( + mesh: Mesh, + predicate: (point: { x: number; y: number; z: number }) => boolean, +): boolean { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + for (let i = 0; i < position.count; i += 1) { + if ( + predicate({ + x: position.getX(i), + y: position.getY(i), + z: position.getZ(i), + }) + ) { + return true + } + } + return false +} + +function findMeshesBySlot(root: Object3D, slotId: string): Mesh[] { + const meshes: Mesh[] = [] + root.traverse((object) => { + const mesh = object as Mesh + if (mesh.isMesh && mesh.userData.slotId === slotId) meshes.push(mesh) + }) + return meshes +} + +function worldBounds(object: Object3D): Box3 { + let root = object + while (root.parent) root = root.parent + root.updateMatrixWorld(true) + return new Box3().setFromObject(object) +} + +function geometryContext({ + children, + resolvables = [], + siblings = [], +}: { + children: AnyNode[] + resolvables?: AnyNode[] + siblings?: AnyNode[] +}): GeometryContext { + const nodes = new Map([...children, ...resolvables, ...siblings].map((node) => [node.id, node])) + return { + children, + parent: null, + resolve: (id) => nodes.get(id) as never, + siblings, + } +} + +function sceneApiFixture(seed: AnyNode[]) { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node: AnyNode, parentId?: AnyNodeId | null) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent = resolveCabinetWorldTransform(parent, nodes) + return { + position: runLocalToPlan( + { + position: worldParent.position, + rotation: worldParent.rotation, + }, + node.position, + ), + rotation: worldParent.rotation + node.rotation, + } + } + + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +function countertopBounds(group: Object3D) { + return findMeshesBySlot(group, 'countertop') + .map((mesh) => { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + expect(box).toBeDefined() + return { + minX: mesh.position.x + box!.min.x, + maxX: mesh.position.x + box!.max.x, + minZ: mesh.position.z + box!.min.z, + maxZ: mesh.position.z + box!.max.z, + } + }) + .sort((a, b) => a.minX - b.minX) +} + +function boxTopUvSpan(mesh: Mesh) { + const uv = mesh.geometry.getAttribute('uv') as BufferAttribute + const faceStart = 8 + const values = Array.from({ length: 4 }, (_, index) => ({ + u: uv.getX(faceStart + index), + v: uv.getY(faceStart + index), + })) + return { + u: Math.max(...values.map((value) => value.u)) - Math.min(...values.map((value) => value.u)), + v: Math.max(...values.map((value) => value.v)) - Math.min(...values.map((value) => value.v)), + } +} + +function boxFrontUvSpan(mesh: Mesh) { + const uv = mesh.geometry.getAttribute('uv') as BufferAttribute + const faceStart = 16 + const values = Array.from({ length: 4 }, (_, index) => ({ + u: uv.getX(faceStart + index), + v: uv.getY(faceStart + index), + })) + return { + u: Math.max(...values.map((value) => value.u)) - Math.min(...values.map((value) => value.u)), + v: Math.max(...values.map((value) => value.v)) - Math.min(...values.map((value) => value.v)), + } +} + +function shakerFrameSize(width: number, height: number) { + return Math.min(0.085, Math.max(0.045, Math.min(width, height) * (height >= 0.22 ? 0.16 : 0.2))) +} + +function raisedArchFrameSize(width: number, height: number) { + return Math.min(0.09, Math.max(0.048, Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21))) +} + +describe('buildCabinetGeometry — cutout handles', () => { + test('door cutouts sit vertically on the handle edge instead of the top edge', () => { + const node = CabinetModuleNode.parse({ + handleStyle: 'cutout', + width: 0.6, + frontGap: 0.003, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const maxX = box!.max.x + const halfHeight = box!.max.y + const hasSideBite = hasVertex( + leftDoor, + ({ x, y }) => Math.abs(x - (maxX - 0.014)) < 0.002 && Math.abs(y) < 0.008, + ) + const hasTopBite = hasVertex( + leftDoor, + ({ x, y }) => Math.abs(x) < 0.01 && Math.abs(y - (halfHeight - 0.014)) < 0.002, + ) + + expect(hasSideBite).toBe(true) + expect(hasTopBite).toBe(false) + }) +}) + +describe('buildCabinetGeometry — shaker fronts', () => { + test('door fronts add a recessed center panel while keeping the outer frame proud', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const position = leftDoor.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + const z = position.getZ(i) + if (Math.abs(x) < 0.07 && Math.abs(y) < 0.16) panelMaxZ = Math.max(panelMaxZ, z) + else frameMaxZ = Math.max(frameMaxZ, z) + } + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('door bar handles sit on the shaker side frame instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const handle = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('door knob handles sit on the shaker side frame too', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + handleStyle: 'knob', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const knob = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(knob.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('drawer fronts support the same recessed shaker profile', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const position = drawerFront.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + const z = position.getZ(i) + if (Math.abs(x) < 0.08 && Math.abs(y) < 0.03) panelMaxZ = Math.max(panelMaxZ, z) + else frameMaxZ = Math.max(frameMaxZ, z) + } + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('drawer handles sit on the shaker top rail instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.y).toBeCloseTo(box!.max.y - frame / 2, 3) + }) +}) + +describe('buildCabinetGeometry — raised arch fronts', () => { + test('door bar handles sit on the raised-arch side frame instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const handle = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = raisedArchFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('drawer auto handles sit on the raised-arch top rail instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const frame = raisedArchFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.y).toBeCloseTo(box!.max.y - frame / 2, 3) + }) + + test('drawer centered handles stay vertically centered when requested', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + handlePosition: 'center', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + expect(handle.position.y).toBeCloseTo(0, 3) + }) +}) + +describe('buildCabinetGeometry — inset internals', () => { + test('inset drawer boxes stay set back behind the front plane', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + frontOverlay: 'inset', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const drawerSide = findMeshByNamePrefix(group, 'cabinet-drawer-side-left-') + + const frontBounds = worldBounds(drawerFront) + const sideBounds = worldBounds(drawerSide) + + expect(sideBounds.max.z).toBeLessThan(frontBounds.min.z - 0.005) + }) +}) + +describe('buildCabinetGeometry — glass doors', () => { + test('glass door panes use the glass slot and transparent material', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPanes = findMeshesBySlot(group, 'glass') + + expect(glassPanes.length).toBe(2) + for (const pane of glassPanes) { + const material = Array.isArray(pane.material) ? pane.material[0]! : pane.material + expect(pane.name.endsWith('-glass')).toBe(true) + expect(material.transparent).toBe(true) + expect(typeof material.opacity).toBe('number') + expect(material.opacity).toBeLessThan(1) + } + }) + + test('rectangular glass panes stay inside the front frame instead of protruding past it', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const frame = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-frame-top$/) + const glass = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + + const frameBounds = worldBounds(frame) + const glassBounds = worldBounds(glass) + + expect(glassBounds.max.z).toBeLessThanOrEqual(frameBounds.max.z) + expect(glassBounds.min.z).toBeGreaterThanOrEqual(frameBounds.min.z) + }) + + test('raised-arch glass panes stay inside the front frame instead of protruding past it', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + frontStyle: 'raised-arch', + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const frame = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-frame$/) + const glass = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + + const frameBounds = worldBounds(frame) + const glassBounds = worldBounds(glass) + + expect(glassBounds.max.z).toBeLessThanOrEqual(frameBounds.max.z) + expect(glassBounds.min.z).toBeGreaterThanOrEqual(frameBounds.min.z) + }) +}) + +describe('buildCabinetGeometry — appliance compartments', () => { + function findObjectByName(root: Object3D, name: string): Object3D { + let found: Object3D | null = null + root.traverse((object) => { + if (object.name === name) found = object + }) + if (!found) throw new Error(`Object not found: ${name}`) + return found + } + + function hasObjectByName(root: Object3D, name: string): boolean { + let found = false + root.traverse((object) => { + if (object.name === name) found = true + }) + return found + } + + test('bakes open clips for cabinet storage and appliance moving parts', () => { + const cases = [ + { + name: 'door', + expectedTrack: '.quaternion', + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }, + { + name: 'drawer', + expectedTrack: '.position', + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }, + { + name: 'pull-out-pantry', + expectedTrack: '.position', + stack: [{ id: 'pantry', type: 'pull-out-pantry', height: 1.8, shelfCount: 5 }], + }, + { + name: 'oven', + expectedTrack: '.quaternion', + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }, + { + name: 'microwave', + expectedTrack: '.quaternion', + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }, + { + name: 'dishwasher', + expectedTrack: '.quaternion', + stack: [{ id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }], + }, + { + name: 'fridge-single', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }], + }, + { + name: 'fridge-double', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-double', height: FRIDGE_COLUMN_HEIGHT }], + }, + { + name: 'fridge-top-freezer', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }, + { + name: 'fridge-bottom-freezer', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }, + ] as const + + for (const entry of cases) { + const node = CabinetModuleNode.parse({ + id: `cabinet-module_anim_${entry.name.replaceAll('-', '_')}`, + width: entry.name.includes('fridge') ? FRIDGE_COLUMN_WIDTH : 0.8, + carcassHeight: entry.name.includes('fridge') ? TALL_CABINET_CARCASS_HEIGHT : 0.9, + stack: entry.stack, + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const clip = bakeCabinetAnimationClip(node, group) + + expect(clip?.name).toBe(`${node.id}: open`) + expect(clip?.duration).toBe(1) + expect(clip?.userData).toEqual({ loop: false }) + expect(clip!.tracks.length).toBeGreaterThan(0) + expect(clip!.tracks.some((track) => track.name.endsWith(entry.expectedTrack))).toBe(true) + for (const track of clip!.tracks) { + const targetUuid = track.name.slice(0, track.name.lastIndexOf('.')) + expect(group.getObjectByProperty('uuid', targetUuid)).toBeDefined() + } + } + }) + + test('does not bake a cabinet clip when no compartment part moves', () => { + const node = CabinetModuleNode.parse({ + id: 'cabinet-module_anim_static_sink', + stack: [{ id: 'sink', type: 'sink' }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(bakeCabinetAnimationClip(node, group)).toBeNull() + }) + + test('oven compartment emits fascia, cavity, racks, and glass door with appliance slots', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-oven-0-fascia')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-knob-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-knob-0-indicator')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-mode-button-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-status-light-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-cavity-back')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-cavity-lip-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-convection-fan-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-top-heating-element')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-rack-0-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-rack-1-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-window-gasket-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-door-lower-rail')).toBeDefined() + + const glass = findMeshByName(group, 'cabinet-oven-0-door-glass') + expect(glass.userData.slotId).toBe('glass') + const applianceMeshes = findMeshesBySlot(group, 'appliance') + const interiorMeshes = findMeshesBySlot(group, 'applianceInterior') + expect(applianceMeshes.length).toBeGreaterThan(0) + expect(interiorMeshes.length).toBeGreaterThan(0) + }) + + test('oven controls fit inside the black panel without display or light overlap', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = worldBounds(findMeshByName(group, 'cabinet-oven-0-control-panel')) + const display = worldBounds(findMeshByName(group, 'cabinet-oven-0-display')) + const controls = [ + 'cabinet-oven-0-mode-button-0', + 'cabinet-oven-0-mode-button-1', + 'cabinet-oven-0-mode-button-2', + 'cabinet-oven-0-status-light-0', + 'cabinet-oven-0-status-light-1', + 'cabinet-oven-0-status-light-2', + 'cabinet-oven-0-vent-0', + 'cabinet-oven-0-vent-5', + ].map((name) => worldBounds(findMeshByName(group, name))) + + for (const control of controls) { + expect(control.min.x).toBeGreaterThanOrEqual(panel.min.x - 0.001) + expect(control.max.x).toBeLessThanOrEqual(panel.max.x + 0.001) + expect(control.min.y).toBeGreaterThanOrEqual(panel.min.y - 0.001) + expect(control.max.y).toBeLessThanOrEqual(panel.max.y + 0.001) + } + + for (const light of controls.slice(3, 6)) { + expect(light.intersectsBox(display)).toBe(false) + } + }) + + test('oven door keeps a thinner border around a larger glass window', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const door = worldBounds(findObjectByName(group, 'cabinet-oven-0-door')) + const glass = worldBounds(findMeshByName(group, 'cabinet-oven-0-door-glass')) + const glassWidthRatio = (glass.max.x - glass.min.x) / (door.max.x - door.min.x) + const glassHeightRatio = (glass.max.y - glass.min.y) / (door.max.y - door.min.y) + + expect(glassWidthRatio).toBeGreaterThan(0.84) + expect(glassHeightRatio).toBeGreaterThan(0.8) + }) + + test('appliance interior default avoids a near-black void when opened', () => { + expect(cabinetSlots().find((slot) => slot.slotId === 'applianceInterior')?.default).toBe( + 'library:preset-charcoal', + ) + }) + + test('microwave compartment emits keypad, vents, mesh window, and turntable details', () => { + const node = CabinetModuleNode.parse({ + width: 0.61, + carcassHeight: 0.72, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-microwave-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-button-0-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-quick-button-30s')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-start-button')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-cancel-button')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-top-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-window-dot-0-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-turntable')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-roller-ring')).toBeDefined() + }) + + test('microwave keypad stays compact inside the control panel', () => { + const node = CabinetModuleNode.parse({ + width: 0.61, + carcassHeight: 0.72, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = worldBounds(findMeshByName(group, 'cabinet-microwave-0-control-panel')) + const controls = [ + 'cabinet-microwave-0-display', + 'cabinet-microwave-0-quick-button-30s', + 'cabinet-microwave-0-button-0-0', + 'cabinet-microwave-0-button-3-2', + 'cabinet-microwave-0-cancel-button', + 'cabinet-microwave-0-start-button', + ].map((name) => worldBounds(findMeshByName(group, name))) + + for (const control of controls) { + expect(control.min.x).toBeGreaterThanOrEqual(panel.min.x - 0.001) + expect(control.max.x).toBeLessThanOrEqual(panel.max.x + 0.001) + expect(control.min.y).toBeGreaterThanOrEqual(panel.min.y - 0.001) + expect(control.max.y).toBeLessThanOrEqual(panel.max.y + 0.001) + } + + const cancel = worldBounds(findMeshByName(group, 'cabinet-microwave-0-cancel-button')) + const start = worldBounds(findMeshByName(group, 'cabinet-microwave-0-start-button')) + const panelHeight = panel.max.y - panel.min.y + expect(cancel.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) + expect(start.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) + + const ventSlats = ['cabinet-microwave-0-top-vent-4', 'cabinet-microwave-0-bottom-vent-0'].map( + (name) => worldBounds(findMeshByName(group, name)), + ) + for (const vent of ventSlats) { + expect(vent.intersectsBox(panel)).toBe(false) + } + }) + + test('oven door drops down with operationState, microwave door swings sideways', () => { + const oven = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + operationState: 1, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const ovenGroup = buildCabinetGeometry(oven, undefined, 'rendered', false) + const ovenHinge = findObjectByName(ovenGroup, 'cabinet-oven-0-door-hinge') + expect(ovenHinge.rotation.x).toBeCloseTo((88 * Math.PI) / 180) + expect(ovenHinge.rotation.y).toBeCloseTo(0) + + const microwave = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + operationState: 1, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const microwaveGroup = buildCabinetGeometry(microwave, undefined, 'rendered', false) + const microwaveHinge = findObjectByName(microwaveGroup, 'cabinet-microwave-0-door-hinge') + expect(microwaveHinge.rotation.y).toBeCloseTo(-Math.PI / 2) + expect(microwaveHinge.rotation.x).toBeCloseTo(0) + }) + + test('dishwasher compartment emits tub racks, controls, toe vent, and drop-down door', () => { + const node = CabinetModuleNode.parse({ + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + operationState: 1, + stack: [{ id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-dishwasher-0-tub-back')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-upper-rack-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-lower-rack-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-spray-arm')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-cycle-button-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-outer-trim-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-outer-trim-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-pocket-handle-lip')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-brushed-front-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-front-highlight')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-front-groove-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-brushed-line-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-badge')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-detergent-cup')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-toe-vent-slat-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-door-panel').userData.slotId).toBe( + 'appliance', + ) + + const hinge = findObjectByName(group, 'cabinet-dishwasher-0-door-hinge') + expect(hinge.rotation.x).toBeCloseTo((88 * Math.PI) / 180) + expect(hinge.rotation.y).toBeCloseTo(0) + + const door = findObjectByName(group, 'cabinet-dishwasher-0-door') + expect(findObjectByName(group, 'cabinet-dishwasher-0-toe-vent').parent).toBe(door) + expect(findMeshByName(group, 'cabinet-dishwasher-0-detergent-cup').position.z).toBeLessThan(0) + }) + + test('gas cooktop emits trim, five stepped burners, continuous grate, and knobs', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-surface').userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-frame-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-frame-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-4-base')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-3-cap').userData.slotId).toBe( + 'hardware', + ) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-row-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-column-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4-hit').userData.cabinetCooktopKnob, + ).toEqual({ + type: 'gas', + compartmentIndex: 1, + burnerIndex: 4, + }) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4-notch')).toBeDefined() + }) + + test('gas cooktop can hide the top grate and show individual burner flames', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { + id: 'cooktop', + type: 'cooktop-gas', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopActiveBurners: [0], + cooktopKnobProgress: [1, 0, 0, 0, 0], + cooktopShowGrate: false, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(hasObjectByName(group, 'cabinet-cooktop-gas-1-continuous-grate-front')).toBe(false) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-core')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-0')).toBeDefined() + expect(hasObjectByName(group, 'cabinet-cooktop-gas-1-burner-1-flame-ring')).toBe(false) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-0').rotation.y).toBeLessThan(0) + }) + + test('induction cooktop emits ceramic surface, heating zones, and touch controls', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-induction', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-surface').userData.slotId).toBe( + 'appliance', + ) + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-ring-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-fill')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-ring-2')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-3-ring-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-touch-control-bar')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-touch-dot-4')).toBeDefined() + }) + + test('induction cooktop can show active zone glow', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { + id: 'cooktop', + type: 'cooktop-induction', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopBurnersOn: true, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const fill = findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-fill') + const dot = findMeshByName(group, 'cabinet-cooktop-induction-1-touch-dot-0') + + expect(fill.material).toBe(dot.material) + }) + + test('cooktop seats into the countertop instead of floating above it', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + withCountertop: true, + countertopThickness: 0.02, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const surface = worldBounds(findMeshByName(group, 'cabinet-cooktop-gas-1-surface')) + const countertopTop = node.plinthHeight + node.carcassHeight + node.countertopThickness + + expect(surface.min.y).toBeLessThan(countertopTop) + expect(surface.max.y).toBeGreaterThan(countertopTop) + }) + + test('cooktop does not reserve a blank front row below the countertop', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const topDrawer = worldBounds(findMeshByNamePattern(group, /^cabinet-drawer-front-[\d.]+-1$/)) + const topBoardY = node.plinthHeight + node.carcassHeight + + expect(topDrawer.max.y).toBeGreaterThan(topBoardY - 0.04) + expect(hasObjectByName(group, 'cabinet-back-1')).toBe(false) + }) + + test('pull-out pantry emits a narrow sliding rack with basket shelves', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + operationState: 1, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const slide = findObjectByName(group, 'cabinet-pull-out-pantry-0-slide') + expect(slide.userData.cabinetPose.type).toBe('translate') + expect(slide.userData.cabinetPose.axis).toBe('z') + expect(slide.userData.cabinetPose.distance).toBeGreaterThan(0) + expect(slide.position.z).toBeCloseTo(slide.userData.cabinetPose.distance) + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-handle')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-left-front-upright')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-basket-0-front-rail')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-basket-4-divider-2')).toBeDefined() + }) + + test('pull-out pantry supports tray and glass rack styles', () => { + const tray = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: 3, + pantryRackStyle: 'tray', + }, + ], + }), + undefined, + 'rendered', + false, + ) + const glass = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: 3, + pantryRackStyle: 'glass', + }, + ], + }), + undefined, + 'rendered', + false, + ) + + expect( + findMeshByName(tray, 'cabinet-pull-out-pantry-0-basket-0-tray-front-panel'), + ).toBeDefined() + expect( + findMeshByName(glass, 'cabinet-pull-out-pantry-0-basket-0-glass-front-panel').userData.slotId, + ).toBe('glass') + }) + + test('single refrigerator emits steel door, shelves, bins, vents, and an opening hinge', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel') + expect(panel.userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-appliance-toe-grille')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-badge')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-water-dispenser'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-blue-drip-tray'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-fresh-shelf-1')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-fresh-shelf-1-front-lip'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-left-liner-rib-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-rear-diffuser-panel'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-rear-diffuser-channel-0'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0-handle'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0-humidity-slider'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-deli-drawer')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-control-strip')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-retainer'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-cover'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-retainer').position.z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-base').position.z, + ) + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-cover').position.z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-box').position.z, + ) + const topCap = worldBounds(findMeshByName(group, 'cabinet-fridge-single-0-appliance-top-cap')) + const cavityTop = worldBounds(findMeshByName(group, 'cabinet-fridge-single-0-cavity-top')) + const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top')) + const leftShell = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-left'), + ) + const rightShell = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-right'), + ) + const leftCarcass = worldBounds(findMeshByName(group, 'cabinet-side-left')) + const rightCarcass = worldBounds(findMeshByName(group, 'cabinet-side-right')) + expect(topCap.max.y).toBeLessThan(cabinetTop.min.y - 0.01) + expect(leftShell.min.x).toBeGreaterThan(leftCarcass.max.x + 0.01) + expect(rightShell.max.x).toBeLessThan(rightCarcass.min.x - 0.01) + expect(leftShell.max.z).toBeLessThan(leftCarcass.max.z - 0.01) + expect(rightShell.max.z).toBeLessThan(rightCarcass.max.z - 0.01) + expect(leftShell.intersectsBox(topCap)).toBe(false) + expect(rightShell.intersectsBox(topCap)).toBe(false) + expect(cavityTop.max.y).toBeLessThan(topCap.min.y - 0.001) + const hinge = findObjectByName(group, 'cabinet-fridge-single-0-door-single-hinge') + expect(hinge.rotation.y).toBeGreaterThan(1.9) + }) + + test('fridge cabinet fills tall-carcass remainder with a drawer front above the fridge', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + showPlinth: false, + stack: fridgeCabinetStack('fridge-single'), + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const fridgePanel = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel'), + ) + const drawerFront = worldBounds(findMeshByNamePrefix(group, 'cabinet-drawer-front-')) + const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top')) + + expect(cabinetTop.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + expect(fridgePanel.max.y).toBeLessThan(drawerFront.min.y) + expect(drawerFront.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + }) + + test('double refrigerator opens opposing side-by-side leaves', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_WIDE_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-double', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const left = findObjectByName(group, 'cabinet-fridge-double-0-door-left-hinge') + const right = findObjectByName(group, 'cabinet-fridge-double-0-door-right-hinge') + expect(findMeshByName(group, 'cabinet-fridge-double-0-left-ice-maker-box')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-left-freezer-wire-basket-bar-1'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-double-0-right-control-strip')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-wire-1'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-dairy-box')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-top-rail').position + .z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-base-rail').position + .z, + ) + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin-retainer').position + .z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin-base').position.z, + ) + expect(left.rotation.y).toBeLessThan(-1.9) + expect(right.rotation.y).toBeGreaterThan(1.9) + }) + + test('top and bottom freezer refrigerators create separate upper and lower doors', () => { + const topFreezer = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }) + const bottomFreezer = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }) + + expect( + findMeshByName( + buildCabinetGeometry(topFreezer, undefined, 'rendered', false), + 'cabinet-fridge-top-freezer-0-door-freezer-panel', + ), + ).toBeDefined() + const bottomGroup = buildCabinetGeometry(bottomFreezer, undefined, 'rendered', false) + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-door-freezer-panel'), + ).toBeDefined() + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-freezer-freezer-basket'), + ).toBeDefined() + expect( + findMeshByName( + bottomGroup, + 'cabinet-fridge-bottom-freezer-0-freezer-freezer-wire-basket-bar-1', + ), + ).toBeDefined() + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-horizontal-divider'), + ).toBeDefined() + }) + + test('top and bottom freezer refrigerator doors use the same hinge direction', () => { + const topFreezer = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }), + undefined, + 'rendered', + false, + ) + const bottomFreezer = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }), + undefined, + 'rendered', + false, + ) + + expect( + findObjectByName(topFreezer, 'cabinet-fridge-top-freezer-0-door-freezer-hinge').rotation.y, + ).toBeGreaterThan(0) + expect( + findObjectByName(topFreezer, 'cabinet-fridge-top-freezer-0-door-fresh-hinge').rotation.y, + ).toBeGreaterThan(0) + expect( + findObjectByName(bottomFreezer, 'cabinet-fridge-bottom-freezer-0-door-freezer-hinge').rotation + .y, + ).toBeGreaterThan(0) + expect( + findObjectByName(bottomFreezer, 'cabinet-fridge-bottom-freezer-0-door-fresh-hinge').rotation + .y, + ).toBeGreaterThan(0) + }) +}) + +describe('buildCabinetGeometry — run countertops', () => { + test('empty cabinet runs render no fallback cabinet mesh', () => { + const run = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + id: 'cabinet_empty-run', + children: [], + }) + + const group = buildCabinetGeometry(run, geometryContext({ children: [] }), 'rendered', false) + + expect(group.children).toHaveLength(0) + }) + + test('run plinth follows shifted module depth extents instead of growing backward', () => { + const run = CabinetNode.parse({ + id: 'cabinet_mixed-depth-run', + showPlinth: true, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + }) + const standardDepth = 0.58 + const fridgeZ = backAnchoredModuleZ(0, standardDepth, FRIDGE_STANDARD_DEPTH) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_tall', + parentId: run.id, + cabinetType: 'tall', + position: [-0.3, 0.1, 0], + width: 0.6, + depth: standardDepth, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_fridge', + parentId: run.id, + cabinetType: 'tall', + position: [0.38, 0.1, fridgeZ], + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + }), + ] + + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const plinth = worldBounds(findMeshByName(group, 'cabinet-run-plinth')) + + expect(plinth.min.z).toBeCloseTo(-standardDepth / 2) + expect(plinth.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth) + }) + + test('run countertop follows shifted module depth extents instead of staying centered', () => { + const run = CabinetNode.parse({ + id: 'cabinet_shifted-depth-countertop', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const standardDepth = 0.58 + const nextDepth = 0.78 + const shiftedZ = backAnchoredModuleZ(0, standardDepth, nextDepth) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, shiftedZ], + width: 0.6, + depth: nextDepth, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + const [countertop] = countertopBounds(group) + + expect(countertop).toBeDefined() + expect(countertop!.minZ).toBeCloseTo(-standardDepth / 2) + expect(countertop!.maxZ).toBeCloseTo(shiftedZ + nextDepth / 2 + run.countertopOverhang) + }) + + test('island back overhang extends the slab backward and adds a finished back panel', () => { + const run = CabinetNode.parse({ + id: 'cabinet_island-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + withFinishedBack: true, + boardThickness: 0.018, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_island-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minZ).toBeCloseTo(-0.58 / 2 - run.countertopBackOverhang) + expect(countertop!.maxZ).toBeCloseTo(0.58 / 2 + run.countertopOverhang) + + const backPanel = worldBounds(findMeshByName(group, 'cabinet-run-back-panel')) + expect(backPanel.max.z).toBeCloseTo(-0.58 / 2) + expect(backPanel.min.z).toBeCloseTo(-0.58 / 2 - run.boardThickness) + expect(backPanel.min.y).toBeCloseTo(0) + expect(backPanel.max.y).toBeCloseTo(0.1 + 0.72) + }) + + test('bar ledge adds a knee wall and raised slab behind the run', () => { + const run = CabinetNode.parse({ + id: 'cabinet_bar-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + barLedge: { height: 1.06, depth: 0.35 }, + boardThickness: 0.018, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_bar-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + // The bar supersedes the seating overhang: the base slab stays at the + // carcass back edge. + const slabs = countertopBounds(group) + expect(slabs).toHaveLength(2) + const baseSlab = slabs.find( + (slab) => Math.abs(slab.maxZ - (0.58 / 2 + run.countertopOverhang)) < 1e-6, + ) + expect(baseSlab).toBeDefined() + expect(baseSlab!.minZ).toBeCloseTo(-0.58 / 2) + + const support = worldBounds(findMeshByName(group, 'cabinet-run-bar-support')) + expect(support.max.z).toBeCloseTo(-0.58 / 2) + expect(support.max.y).toBeCloseTo(1.06 - run.countertopThickness) + + const barSlab = worldBounds(findMeshByName(group, 'cabinet-run-bar-slab')) + expect(barSlab.max.y).toBeCloseTo(1.06) + expect(barSlab.max.z).toBeCloseTo(-0.58 / 2) + expect(barSlab.min.z).toBeCloseTo(-0.58 / 2 - 0.35) + }) + + test('right-edge bar ledge hangs off the run end and keeps the seating overhang', () => { + const run = CabinetNode.parse({ + id: 'cabinet_side-bar-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + barLedge: { edge: 'right', height: 1.06, depth: 0.35 }, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_side-bar-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + // Side bar leaves the back seating overhang intact. + const slabs = countertopBounds(group) + const baseSlab = slabs.find((slab) => Math.abs(slab.minX - (-0.3 - 0.02)) < 1e-6) + expect(baseSlab).toBeDefined() + expect(baseSlab!.minZ).toBeCloseTo(-0.58 / 2 - 0.3) + // No side overhang on the bar edge — slab ends at the carcass. + expect(baseSlab!.maxX).toBeCloseTo(0.3) + + const support = worldBounds(findMeshByName(group, 'cabinet-run-bar-support')) + expect(support.min.x).toBeCloseTo(0.3) + expect(support.max.y).toBeCloseTo(1.06 - run.countertopThickness) + + const barSlab = worldBounds(findMeshByName(group, 'cabinet-run-bar-slab')) + expect(barSlab.min.x).toBeCloseTo(0.3) + expect(barSlab.max.x).toBeCloseTo(0.3 + 0.35) + expect(barSlab.max.y).toBeCloseTo(1.06) + }) + + test('waterfall ends drop slab panels to the floor on exposed run ends', () => { + const run = CabinetNode.parse({ + id: 'cabinet_waterfall-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + withWaterfall: true, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_waterfall-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + const left = worldBounds(findMeshByName(group, 'cabinet-run-waterfall-left')) + const right = worldBounds(findMeshByName(group, 'cabinet-run-waterfall-right')) + // Outer faces flush with the slab overhang edges, floor to slab underside. + expect(left.min.x).toBeCloseTo(-0.3 - run.countertopOverhang) + expect(right.max.x).toBeCloseTo(0.3 + run.countertopOverhang) + expect(left.min.y).toBeCloseTo(0) + expect(left.max.y).toBeCloseTo(0.1 + 0.72) + }) + + test('countertop UVs stay world-scaled when cabinet dimensions change', () => { + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_uv-countertop', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.04, + width: 1.4, + depth: 0.72, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry(module, undefined, 'rendered', true) + const countertop = findMeshByName(group, 'cabinet-countertop') + const span = boxTopUvSpan(countertop) + + expect(span.u).toBeCloseTo(module.width + module.countertopOverhang * 2) + expect(span.v).toBeCloseTo(module.depth + module.countertopOverhang) + }) + + test('drawer front UVs stay world-scaled when cabinet dimensions change', () => { + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_uv-drawers', + width: 1.25, + depth: 0.62, + carcassHeight: 0.84, + frontGap: 0.006, + stack: [{ id: 'drawers', type: 'drawer', drawerCount: 3 }], + }) + + const group = buildCabinetGeometry(module, undefined, 'rendered', true) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const span = boxFrontUvSpan(drawerFront) + const expectedWidth = module.width - 3 * module.frontGap + const usableHeight = module.carcassHeight - 2 * module.frontGap + const expectedHeight = (usableHeight - 2 * module.frontGap) / 3 + + expect(span.u).toBeCloseTo(expectedWidth) + expect(span.v).toBeCloseTo(expectedHeight) + }) + + test('countertop overhang does not enter an adjacent tall module span', () => { + const run = CabinetNode.parse({ + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_base-left', + parentId: run.id, + cabinetType: 'base', + position: [-0.3, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_tall-middle', + parentId: run.id, + cabinetType: 'tall', + position: [0.3, 0.1, 0], + width: 0.6, + carcassHeight: 2.07, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_base-right', + parentId: run.id, + cabinetType: 'base', + position: [0.9, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }), + ] + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(2) + expect(countertops[0]!.maxX).toBeCloseTo(0) + expect(countertops[1]!.minX).toBeCloseTo(0.6) + }) + + test('corner-derived base leg keeps the inner corner countertop edge flush on right turns', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-base-leg-right', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: 'cabinet-module_source', + sourceRunId: 'cabinet_source-run', + }, + }, + }) + const filler = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-right', + parentId: run.id, + moduleKind: 'corner-filler', + openSide: 'right', + cornerShelf: true, + position: [0, 0.1, 0], + width: 0.58, + depth: 0.58, + carcassHeight: 0.72, + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-right', + parentId: run.id, + position: [0.59, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [filler, base] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.29) + expect(countertop!.maxX).toBeCloseTo(0.89 + run.countertopOverhang) + }) + + test('corner-derived base leg keeps the inner corner countertop edge flush on left turns', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-base-leg-left', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'left', + sourceModuleId: 'cabinet-module_source', + sourceRunId: 'cabinet_source-run', + }, + }, + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-left', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const filler = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-left', + parentId: run.id, + moduleKind: 'corner-filler', + openSide: 'left', + cornerShelf: true, + position: [0.59, 0.1, 0], + width: 0.58, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [base, filler] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.3 - run.countertopOverhang) + expect(countertop!.maxX).toBeCloseTo(0.88) + }) + + test('corner-filler top pulls back slightly from its open side to avoid coplanar wall-top overlap', () => { + const rightOpen = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-top-open-right', + moduleKind: 'corner-filler', + openSide: 'right', + position: [0, 0.1, 0], + width: 0.58, + depth: 0.32, + carcassHeight: 0.72, + boardThickness: 0.018, + }) + const leftOpen = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-top-open-left', + moduleKind: 'corner-filler', + openSide: 'left', + position: [0, 0.1, 0], + width: 0.58, + depth: 0.32, + carcassHeight: 0.72, + boardThickness: 0.018, + }) + + const rightGroup = buildCabinetGeometry(rightOpen, undefined, 'rendered', false) + const leftGroup = buildCabinetGeometry(leftOpen, undefined, 'rendered', false) + + const rightTop = worldBounds(findMeshByName(rightGroup, 'cabinet-corner-filler-top')) + const leftTop = worldBounds(findMeshByName(leftGroup, 'cabinet-corner-filler-top')) + + expect(rightTop.max.x).toBeLessThan(0.58 / 2) + expect(leftTop.min.x).toBeGreaterThan(-0.58 / 2) + }) + + test('generated wall bridge and corner wall fillers do not keep coplanar internal side panels', () => { + const levelId = 'level_corner-wall-filler-side-gap' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-filler-side-gap', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-wall-filler-side-gap', + 'cabinet-module_center-wall-filler-side-gap', + 'cabinet-module_right-wall-filler-side-gap', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-wall-filler-side-gap', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-filler-side-gap', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-wall-filler-side-gap'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-wall-filler-side-gap', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-wall-filler-side-gap', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const bridge = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + const corner = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + + expect(bridge).toBeTruthy() + expect(corner).toBeTruthy() + + const bridgePose = resolveCabinetWorldTransform(bridge!, nodes) + const cornerPose = resolveCabinetWorldTransform(corner!, nodes) + + const bridgeGroup = buildCabinetGeometry( + bridge!, + { + children: [], + parent: nodes[bridge!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + bridgeGroup.position.set(...bridgePose.position) + bridgeGroup.rotation.y = bridgePose.rotation + bridgeGroup.updateMatrixWorld(true) + + const cornerGroup = buildCabinetGeometry( + corner!, + { + children: [], + parent: nodes[corner!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + cornerGroup.position.set(...cornerPose.position) + cornerGroup.rotation.y = cornerPose.rotation + cornerGroup.updateMatrixWorld(true) + + const bridgeSide = worldBounds(findMeshByName(bridgeGroup, 'cabinet-corner-filler-side-right')) + const cornerSide = worldBounds(findMeshByName(cornerGroup, 'cabinet-corner-filler-side-left')) + + expect(bridgeSide.max.x).toBeLessThan(cornerSide.min.x) + }) + + test('generated wall bridge and corner wall filler fronts pull back from the shared corner', () => { + const levelId = 'level_corner-wall-filler-front-gap' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-filler-front-gap', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-wall-filler-front-gap', + 'cabinet-module_center-wall-filler-front-gap', + 'cabinet-module_right-wall-filler-front-gap', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-wall-filler-front-gap', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-filler-front-gap', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-wall-filler-front-gap'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-wall-filler-front-gap', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-wall-filler-front-gap', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const bridge = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + const corner = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + + expect(bridge).toBeTruthy() + expect(corner).toBeTruthy() + + const bridgePose = resolveCabinetWorldTransform(bridge!, nodes) + const cornerPose = resolveCabinetWorldTransform(corner!, nodes) + + const bridgeGroup = buildCabinetGeometry( + bridge!, + { + children: [], + parent: nodes[bridge!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + bridgeGroup.position.set(...bridgePose.position) + bridgeGroup.rotation.y = bridgePose.rotation + bridgeGroup.updateMatrixWorld(true) + + const cornerGroup = buildCabinetGeometry( + corner!, + { + children: [], + parent: nodes[corner!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + cornerGroup.position.set(...cornerPose.position) + cornerGroup.rotation.y = cornerPose.rotation + cornerGroup.updateMatrixWorld(true) + + const bridgeFront = worldBounds(findMeshByName(bridgeGroup, 'cabinet-corner-filler-front')) + const cornerFront = worldBounds(findMeshByName(cornerGroup, 'cabinet-corner-filler-front')) + + expect(bridgeFront.max.x).toBeLessThan(cornerFront.min.x) + expect(bridgeFront.max.y).toBeLessThanOrEqual(cornerFront.max.y) + }) + + test('source run trims its right countertop overhang when a right L-leg is attached', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-right-leg', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + children: ['cabinet-module_source-left', 'cabinet-module_source-right'] as AnyNodeId[], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_source-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_source-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const rightLeg = CabinetNode.parse({ + id: 'cabinet_child-right-leg', + parentId: run.id, + runTier: 'base', + position: [0.6, 0, 0], + rotation: -Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: right.id, + sourceRunId: run.id, + }, + }, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [left, right, rightLeg] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.6 - run.countertopOverhang) + expect(countertop!.maxX).toBeCloseTo(0.6) + }) + + test('source run trims its left countertop overhang when a left L-leg is attached', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-left-leg', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + children: ['cabinet-module_source-left', 'cabinet-module_source-right'] as AnyNodeId[], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_source-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_source-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const leftLeg = CabinetNode.parse({ + id: 'cabinet_child-left-leg', + parentId: run.id, + runTier: 'base', + position: [-0.6, 0, 0], + rotation: Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'left', + sourceModuleId: left.id, + sourceRunId: run.id, + }, + }, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [leftLeg, left, right] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.6) + expect(countertop!.maxX).toBeCloseTo(0.6 + run.countertopOverhang) + }) + + test('countertop overhang does not enter an adjacent sibling tall cabinet', () => { + const run = CabinetNode.parse({ + id: 'cabinet_base-run', + position: [0, 0, 0], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const tallRun = CabinetNode.parse({ + id: 'cabinet_tall-run', + position: [-0.6, 0, 0], + children: ['cabinet-module_tall' as AnyNodeId], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const tallModule = CabinetModuleNode.parse({ + id: 'cabinet-module_tall', + parentId: tallRun.id, + cabinetType: 'tall', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 2.07, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ + children: [baseModule], + resolvables: [tallModule], + siblings: [tallRun], + }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(1) + expect(countertops[0]!.minX).toBeCloseTo(-0.3) + expect(countertops[0]!.maxX).toBeCloseTo(0.32) + }) + + test('countertop overhang is trimmed between adjacent sibling base cabinets', () => { + const run = CabinetNode.parse({ + id: 'cabinet_left-run', + position: [0, 0, 0], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_left', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const siblingRun = CabinetNode.parse({ + id: 'cabinet_right-run', + position: [0.6, 0, 0], + children: ['cabinet-module_right' as AnyNodeId], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const siblingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_right', + parentId: siblingRun.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ + children: [baseModule], + resolvables: [siblingModule], + siblings: [siblingRun], + }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(1) + expect(countertops[0]!.minX).toBeCloseTo(-0.32) + expect(countertops[0]!.maxX).toBeCloseTo(0.3) + }) +}) + +describe('cabinet handles', () => { + function localPointToWorld( + node: { position: [number, number, number]; rotation?: number }, + point: readonly [number, number, number], + ) { + const rotation = node.rotation ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [ + node.position[0] + point[0] * cos + point[2] * sin, + node.position[1] + point[1], + node.position[2] - point[0] * sin + point[2] * cos, + ] as const + } + + function linearHandles() { + const node = CabinetModuleNode.parse({ + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(node) + : (cabinetModuleDefinition.handles ?? []) + return { + node, + handles: handles.filter( + (handle): handle is LinearResizeHandle => handle.kind === 'linear-resize', + ), + } + } + + test('width arrows resize from the chosen side instead of around center', () => { + const { node, handles } = linearHandles() + const leftHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'max') + const rightHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min') + + expect(leftHandle).toBeDefined() + expect(rightHandle).toBeDefined() + expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1) + expect(rightHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(0.1) + }) + + test('depth arrow keeps the back aligned and grows toward the front', () => { + const { node, handles } = linearHandles() + const depthHandle = handles.find((handle) => handle.axis === 'z') + + expect(depthHandle).toBeDefined() + expect(depthHandle!.anchor).toBe('min') + expect(depthHandle!.apply(node, 0.78, null as never).position?.[2]).toBeCloseTo(0.1) + }) + + test('run rotation keeps the cabinet bounding-box center fixed', () => { + const run = CabinetNode.parse({ + id: 'cabinet_offset-run', + position: [4, 0, 3], + rotation: Math.PI / 8, + children: ['cabinet-module_left' as AnyNodeId, 'cabinet-module_right' as AnyNodeId], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_left', + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.4, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_right', + parentId: run.id, + position: [0.7, 0.1, 0.1], + width: 0.6, + depth: 0.78, + }), + ] + const nodes = Object.fromEntries( + [run, ...modules].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + nodes: () => nodes, + } + const rotateHandle = ( + typeof cabinetDefinition.handles === 'function' + ? cabinetDefinition.handles(run) + : (cabinetDefinition.handles ?? []) + ).find((handle) => handle.kind === 'arc-resize' && handle.shape === 'rotate') + + expect(rotateHandle).toBeDefined() + if (!(rotateHandle && rotateHandle.kind === 'arc-resize')) return + + const center = rotateHandle.rotationCenter!(run, sceneApi as never) + const before = localPointToWorld(run, center) + const patch = rotateHandle.apply(run, Math.PI / 4, sceneApi as never) + const after = localPointToWorld({ ...run, ...patch }, center) + + expect(after[0]).toBeCloseTo(before[0]) + expect(after[1]).toBeCloseTo(before[1]) + expect(after[2]).toBeCloseTo(before[2]) + }) +}) + +describe('buildCabinetGeometry — range hood compartments', () => { + function hoodModule(type: 'hood-pyramid' | 'hood-curved-glass', hoodHeight: number) { + return CabinetModuleNode.parse({ + id: 'cabinet-module_hood', + cabinetType: 'base', + position: [0, 1.45, 0], + width: 0.6, + depth: 0.32, + carcassHeight: Math.max(0.4, hoodHeight), + showPlinth: false, + withCountertop: false, + stack: [{ id: 'hood', type, height: hoodHeight }], + }) + } + + test('pyramid hood emits canopy, rim, and duct in the appliance slot with no carcass boxes', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const canopy = findMeshByName(group, 'cabinet-hood-pyramid-0-canopy') + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + expect(canopy.userData.slotId).toBe('appliance') + expect(duct.userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-hood-pyramid-0-rim')).toBeDefined() + + expect(findMeshesBySlot(group, 'carcass')).toHaveLength(0) + expect(() => findMeshByName(group, 'cabinet-side-left')).toThrow() + expect(() => findMeshByName(group, 'cabinet-top')).toThrow() + }) + + test('pyramid canopy protrudes past the wall cabinet depth', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const canopy = findMeshByName(group, 'cabinet-hood-pyramid-0-canopy') + const bounds = worldBounds(canopy) + expect(bounds.max.z).toBeGreaterThan(node.depth / 2) + expect(bounds.max.z).toBeCloseTo(-node.depth / 2 + HOOD_CANOPY_DEPTH) + }) + + test('duct is centered, sized to the duct cross-section, and reaches the fallback ceiling', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + const bounds = worldBounds(duct) + expect(bounds.max.x - bounds.min.x).toBeCloseTo(HOOD_DUCT_SIZE) + expect((bounds.max.x + bounds.min.x) / 2).toBeCloseTo(0) + // module base sits at y=1.45 world, so local duct top = 2.5 - 1.45 + expect(bounds.max.y).toBeCloseTo(2.5 - 1.45) + }) + + test('duct reaches the tallest wall height when the parent chain resolves to a level with walls', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: 'cabinet_run' as AnyNodeId, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const run = CabinetNode.parse({ + id: 'cabinet_run', + parentId: 'level_0' as AnyNodeId, + position: [0, 0, 0], + children: ['cabinet-module_base' as AnyNodeId], + }) + const level = { + id: 'level_0', + type: 'level', + parentId: null, + children: ['wall_a', 'cabinet_run'], + } as unknown as AnyNode + const wall = { + id: 'wall_a', + type: 'wall', + parentId: 'level_0', + height: 3.0, + } as unknown as AnyNode + const nodes = new Map([ + [baseModule.id, baseModule as AnyNode], + [run.id, run as AnyNode], + ['level_0', level], + ['wall_a', wall], + ]) + const ctx: GeometryContext = { + children: [], + parent: baseModule as AnyNode, + resolve: (id) => nodes.get(id) as never, + siblings: [], + } + const group = buildCabinetGeometry(node, ctx, 'rendered', false) + + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + const bounds = worldBounds(duct) + // world base = 1.45 (hood) + 0.1 (base module) + 0 (run) = 1.55; ceiling 3.0 + expect(bounds.max.y).toBeCloseTo(3.0 - 1.55) + }) + + test('curved glass hood emits a stainless body and a glass visor', () => { + const node = hoodModule('hood-curved-glass', HOOD_CURVED_TOTAL_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const body = findMeshByName(group, 'cabinet-hood-curved-glass-0-body') + expect(body.userData.slotId).toBe('appliance') + const visor = findMeshByName(group, 'cabinet-hood-curved-glass-0-glass-visor') + expect(visor.userData.slotId).toBe('glass') + expect(findMeshByName(group, 'cabinet-hood-curved-glass-0-duct')).toBeDefined() + expect(findMeshesBySlot(group, 'carcass')).toHaveLength(0) + + const visorBounds = worldBounds(visor) + expect(visorBounds.max.x - visorBounds.min.x).toBeCloseTo(node.width) + expect(visorBounds.max.y).toBeCloseTo(HOOD_CURVED_TOTAL_HEIGHT) + }) +}) + +describe('buildCabinetGeometry — sink compartments', () => { + const sinkStack = [ + { id: 'door', type: 'door' as const, doorType: 'double' as const }, + { id: 'sink', type: 'sink' as const, sinkLayout: 'single' as const }, + ] + + function sinkModule(overrides: Record = {}) { + return CabinetModuleNode.parse({ + width: SINK_STANDARD_WIDTH, + depth: 0.58, + carcassHeight: 0.72, + withCountertop: true, + countertopThickness: 0.02, + stack: sinkStack, + ...overrides, + }) + } + + test('sink module emits basin walls, drain, and faucet', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-bottom')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-drain')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-base')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-gooseneck')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-cap')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin')).toBeDefined() + }) + + test('sink appliance paint persists onto faucet and basin after rebuild', () => { + const ctx: GeometryContext = { + ...geometryContext({ children: [] }), + materials: { + mat_sink: { + id: 'mat_sink', + name: 'Painted sink', + material: { + properties: { + color: '#ff3366', + roughness: 0.4, + metalness: 0.1, + }, + }, + }, + } as GeometryContext['materials'], + } + const group = buildCabinetGeometry( + sinkModule({ slots: { appliance: 'scene:mat_sink' } }), + ctx, + 'rendered', + true, + ) + const paintedMeshes = [ + findMeshByName(group, 'cabinet-sink-1-0-basin-bottom'), + findMeshByName(group, 'cabinet-sink-1-0-drain'), + findMeshByName(group, 'cabinet-sink-1-faucet-base'), + findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel'), + ] + + for (const mesh of paintedMeshes) { + const material = Array.isArray(mesh.material) ? mesh.material[0]! : mesh.material + expect(material.color.getHexString()).toBe('ff3366') + } + }) + + test('sink module keeps a top false front in front of the basin', () => { + const node = sinkModule() + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const falseFront = findMeshByName(group, 'cabinet-sink-false-front-1') + const basinFront = findMeshByName(group, 'cabinet-sink-1-0-basin-front') + + const frontBounds = worldBounds(falseFront) + const basinBounds = worldBounds(basinFront) + const topY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + + expect(falseFront.userData.slotId).toBe('front') + expect(frontBounds.max.y).toBeCloseTo(topY, 2) + expect(frontBounds.min.y).toBeLessThanOrEqual(basinBounds.min.y + 0.01) + expect(frontBounds.max.z).toBeGreaterThan(basinBounds.max.z) + }) + + test('faucet handle uses a horizontal mixer barrel with an upright pin lever', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + const barrel = findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel') + const pin = findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin') + const cap = findMeshByName(group, 'cabinet-sink-1-faucet-handle-cap') + + const barrelBounds = worldBounds(barrel) + const pinBounds = worldBounds(pin) + const capBounds = worldBounds(cap) + + expect(barrel.rotation.z).toBeCloseTo(Math.PI / 2) + expect(barrelBounds.max.x - barrelBounds.min.x).toBeGreaterThan( + barrelBounds.max.y - barrelBounds.min.y, + ) + expect(pinBounds.max.y - pinBounds.min.y).toBeGreaterThan(pinBounds.max.x - pinBounds.min.x) + expect(pinBounds.min.y).toBeGreaterThan(barrelBounds.min.y) + expect(capBounds.min.x).toBeGreaterThan(barrelBounds.max.x - 0.012) + }) + + test('double layout emits two basins, single emits one', () => { + const single = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + expect(() => findMeshByName(single, 'cabinet-sink-1-1-basin-bottom')).toThrow() + + const double = buildCabinetGeometry( + sinkModule({ + stack: [sinkStack[0], { id: 'sink', type: 'sink', sinkLayout: 'double' }], + }), + undefined, + 'rendered', + false, + ) + expect(findMeshByName(double, 'cabinet-sink-1-0-basin-bottom')).toBeDefined() + expect(findMeshByName(double, 'cabinet-sink-1-1-basin-bottom')).toBeDefined() + }) + + test('sink module skips the carcass top panel and the deck under the sink row', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + const names: string[] = [] + group.traverse((object) => names.push(object.name)) + expect(names).not.toContain('cabinet-top') + expect(names).not.toContain('cabinet-deck-0') + }) + + test('module countertop is CSG-cut with a bowl opening', () => { + const node = sinkModule() + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const countertop = findMeshByName(group, 'cabinet-countertop') + + // A plain box has 24 vertices; the cut slab has the bowl ring baked in. + const position = countertop.geometry.getAttribute('position') as BufferAttribute + expect(position.count).toBeGreaterThan(24) + + // No countertop surface remains across the bowl center. + const topY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + const hasSurfaceAtBowlCenter = hasVertex( + countertop, + (point) => Math.abs(point.x) < 0.05 && Math.abs(point.z) < 0.05 && point.y > topY - 0.001, + ) + expect(hasSurfaceAtBowlCenter).toBe(false) + }) + + test('run countertop is cut above a sink module', () => { + const run = CabinetNode.parse({ + id: 'cabinet_sink-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_plain', + parentId: run.id, + cabinetType: 'base', + position: [-0.6, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_sink', + parentId: run.id, + cabinetType: 'base', + position: [0.1, 0.1, 0], + width: SINK_STANDARD_WIDTH, + depth: 0.58, + carcassHeight: 0.72, + stack: sinkStack, + }), + ] + + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const countertop = findMeshByName(group, 'cabinet-run-countertop') + const position = countertop.geometry.getAttribute('position') as BufferAttribute + expect(position.count).toBeGreaterThan(24) + + const topY = 0.1 + 0.72 + 0.02 + // Open above the sink module center, intact above the plain module. + expect( + hasVertex( + countertop, + (point) => + Math.abs(point.x - 0.1) < 0.05 && Math.abs(point.z) < 0.05 && point.y > topY - 0.001, + ), + ).toBe(false) + const bounds = worldBounds(countertop) + expect(bounds.min.x).toBeLessThan(-0.85) + expect(bounds.max.x).toBeGreaterThan(0.45) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts new file mode 100644 index 00000000..36907ce6 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { cabinetModuleParentFrame } from '../move-frame' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function runFixture(modules: CabinetModuleNode[]): { + run: CabinetNode + nodes: Record +} { + const run = CabinetNode.parse({ + id: 'cabinet_magnet-run', + children: modules.map((module) => module.id), + }) + const nodes = Object.fromEntries( + [run, ...modules].map((node) => [node.id, node as AnyNode]), + ) as Record + return { run, nodes } +} + +function module( + id: string, + position: [number, number, number], + overrides: { width?: number; depth?: number } = {}, +): CabinetModuleNode { + return CabinetModuleNode.parse({ + id, + parentId: 'cabinet_magnet-run' as AnyNodeId, + position, + width: overrides.width ?? 0.6, + depth: overrides.depth ?? 0.58, + }) +} + +const magneticSnap = cabinetModuleParentFrame.magneticSnap! +const magneticSnapMatches = cabinetModuleParentFrame.magneticSnapMatches! + +describe('cabinetModuleParentFrame.magneticSnap', () => { + test('pulls a module flush against a sibling edge within the 8 cm threshold', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + // Sibling right edge at 0.3; moving left edge at 0.35 → 5 cm gap. + const snapped = magneticSnap(moving, run, [0.65, 0.1, 0], nodes) + + expect(snapped[0]).toBeCloseTo(0.6) + expect(snapped[0] - moving.width / 2).toBeCloseTo(sibling.position[0] + sibling.width / 2) + expect(snapped[1]).toBeCloseTo(0.1) + expect(snapped[2]).toBeCloseTo(0) + }) + + test('does not snap when the nearest sibling edge is beyond the threshold', () => { + const moving = module('cabinet-module_moving', [0.75, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + // Sibling right edge at 0.3; moving left edge at 0.45 → 15 cm gap. + const snapped = magneticSnap(moving, run, [0.75, 0.1, 0], nodes) + + expect(snapped).toEqual([0.75, 0.1, 0]) + }) + + test('center-aligns depth against a deeper sibling when width bands overlap', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0.03]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0], { depth: 0.78 }) + const { run, nodes } = runFixture([moving, sibling]) + + const snapped = magneticSnap(moving, run, [0.65, 0.1, 0.03], nodes) + + // Z center (delta 3 cm) beats front-face alignment (delta 7 cm). + expect(snapped[2]).toBeCloseTo(sibling.position[2]) + // X edge-mating still applies in the same pass. + expect(snapped[0]).toBeCloseTo(0.6) + }) + + test('returns the input position unchanged when the run has no siblings', () => { + const moving = module('cabinet-module_moving', [0.42, 0.1, 0.07]) + const { run, nodes } = runFixture([moving]) + + const snapped = magneticSnap(moving, run, [0.42, 0.1, 0.07], nodes) + + expect(snapped).toEqual([0.42, 0.1, 0.07]) + }) + + test('snaps to the nearest of two candidate sibling edges', () => { + const moving = module('cabinet-module_moving', [0.675, 0.1, 0]) + const left = module('cabinet-module_left', [0, 0.1, 0]) // right edge 0.3 + const right = module('cabinet-module_right', [1.34, 0.1, 0]) // left edge 1.04 + const { run, nodes } = runFixture([moving, left, right]) + + // Moving edges at [0.375, 0.975]: 7.5 cm from left sibling, 6.5 cm from + // right sibling — the right edge must win. + const snapped = magneticSnap(moving, run, [0.675, 0.1, 0], nodes) + + expect(snapped[0]).toBeCloseTo(0.74) + expect(snapped[0] + moving.width / 2).toBeCloseTo(right.position[0] - right.width / 2) + }) +}) + +describe('cabinetModuleParentFrame nested transforms', () => { + test('projects module positions through nested cabinet ancestors', () => { + const rootRun = CabinetNode.parse({ + id: 'cabinet_root-run', + position: [4, 0, 3], + rotation: Math.PI / 2, + children: ['cabinet-module_parent'], + }) + const parentModule = CabinetModuleNode.parse({ + id: 'cabinet-module_parent', + parentId: rootRun.id, + position: [1.2, 0.1, -0.4], + rotation: Math.PI / 4, + children: ['cabinet_nested-run'], + }) + const nestedRun = CabinetNode.parse({ + id: 'cabinet_nested-run', + parentId: parentModule.id, + position: [0.5, 0, 0.25], + rotation: -Math.PI / 6, + children: ['cabinet-module_moving'], + }) + const moving = CabinetModuleNode.parse({ + id: 'cabinet-module_moving', + parentId: nestedRun.id, + position: [0.35, 0.1, 0.2], + }) + const nodes = Object.fromEntries( + [rootRun, parentModule, nestedRun, moving].map((node) => [node.id, node as AnyNode]), + ) as Record + + const plan = cabinetModuleParentFrame.localToPlan(nestedRun, moving.position, nodes) + const local = cabinetModuleParentFrame.planToLocal( + nestedRun, + plan[0], + moving.position[1], + plan[2], + nodes, + ) + + expect(cabinetModuleParentFrame.parentRotationY(nestedRun, nodes)).toBeCloseTo( + Math.PI / 2 + Math.PI / 4 - Math.PI / 6, + ) + expect(plan).not.toEqual(moving.position) + expect(local[0]).toBeCloseTo(moving.position[0]) + expect(local[1]).toBeCloseTo(moving.position[1]) + expect(local[2]).toBeCloseTo(moving.position[2]) + }) +}) + +describe('cabinetModuleParentFrame.magneticSnapMatches', () => { + test('emits side and depth matches for a module snapped to a sibling', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + const snapped = magneticSnap(moving, run, moving.position, nodes) + + const matches = magneticSnapMatches(moving, run, moving.position, snapped, nodes) + + expect(matches.map((match) => match.axis).sort()).toEqual(['x', 'z']) + const sideMatch = matches.find((match) => match.axis === 'x') + expect(sideMatch?.from.x).toBeCloseTo(0.3) + expect(sideMatch?.to.x).toBeCloseTo(0.3) + const depthMatch = matches.find((match) => match.axis === 'z') + expect(depthMatch?.from.z).toBeCloseTo(0) + expect(depthMatch?.to.z).toBeCloseTo(0) + }) + + test('projects match endpoints through nested cabinet ancestors', () => { + const rootRun = CabinetNode.parse({ + id: 'cabinet_root-run', + position: [4, 0, 3], + rotation: Math.PI / 2, + children: ['cabinet-module_parent'], + }) + const parentModule = CabinetModuleNode.parse({ + id: 'cabinet-module_parent', + parentId: rootRun.id, + position: [1.2, 0.1, -0.4], + rotation: Math.PI / 4, + children: ['cabinet_nested-run'], + }) + const nestedRun = CabinetNode.parse({ + id: 'cabinet_nested-run', + parentId: parentModule.id, + position: [0.5, 0, 0.25], + rotation: -Math.PI / 6, + children: ['cabinet-module_moving', 'cabinet-module_sibling'], + }) + const moving = CabinetModuleNode.parse({ + id: 'cabinet-module_moving', + parentId: nestedRun.id, + position: [0.65, 0.1, 0], + }) + const sibling = CabinetModuleNode.parse({ + id: 'cabinet-module_sibling', + parentId: nestedRun.id, + position: [0, 0.1, 0], + }) + const nodes = Object.fromEntries( + [rootRun, parentModule, nestedRun, moving, sibling].map((node) => [node.id, node as AnyNode]), + ) as Record + const snapped = magneticSnap(moving, nestedRun, moving.position, nodes) + + const matches = magneticSnapMatches(moving, nestedRun, moving.position, snapped, nodes) + const sideMatch = matches.find((match) => match.axis === 'x') + const localMatchStart = cabinetModuleParentFrame.localToPlan(nestedRun, [0.3, 0, -0.29], nodes) + + expect(sideMatch).toBeDefined() + expect(sideMatch?.from.x).toBeCloseTo(localMatchStart[0]) + expect(sideMatch?.from.z).toBeCloseTo(localMatchStart[2]) + expect(sideMatch?.from.x).not.toBeCloseTo(0.3) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts b/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts new file mode 100644 index 00000000..897ae424 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry, registerNode, spatialGridManager, useScene } from '@pascal-app/core' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { CabinetModuleNode, CabinetNode } from '../schema' + +const LEVEL_ID = 'level_cabinet-placement-collision' + +function levelNode() { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['cabinet_existing'], + level: 0, + } +} + +describe('cabinet placement collision', () => { + beforeEach(() => { + nodeRegistry._reset() + registerNode(cabinetDefinition) + registerNode(cabinetModuleDefinition) + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('ignores child module local footprints when the parent run is elsewhere', () => { + const level = levelNode() + const run = CabinetNode.parse({ + id: 'cabinet_existing', + parentId: LEVEL_ID, + position: [0, 0, 2], + rotation: 0, + children: ['cabinet-module_existing'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_existing', + parentId: run.id, + position: [0, 0.1, 0], + width: 1.2, + depth: 0.58, + }) + useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } }) + + const result = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [0, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + + expect(result).toEqual({ valid: true, conflictIds: [] }) + }) + + test('still blocks against the parent run world footprint', () => { + const level = levelNode() + const run = CabinetNode.parse({ + id: 'cabinet_existing', + parentId: LEVEL_ID, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_existing'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_existing', + parentId: run.id, + position: [0, 0.1, 0], + width: 1.2, + depth: 0.58, + }) + useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } }) + + const result = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [0, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + + expect(result.valid).toBe(false) + expect(result.conflictIds).toEqual(['cabinet_existing']) + }) + + test('does not treat nested cabinet run local footprints as world blockers', () => { + const level = levelNode() + const rootRun = { + ...CabinetNode.parse({ + id: 'cabinet_existing', + parentId: LEVEL_ID, + position: [10, 0, 0], + rotation: 0, + children: [], + }), + children: ['cabinet_child-run'], + } + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run', + parentId: rootRun.id, + position: [5, 0, 0], + rotation: 0, + children: ['cabinet-module_nested'], + }) + const nestedModule = CabinetModuleNode.parse({ + id: 'cabinet-module_nested', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 1.2, + depth: 0.58, + }) + useScene.setState({ + nodes: { + [level.id]: level, + [rootRun.id]: rootRun, + [childRun.id]: childRun, + [nestedModule.id]: nestedModule, + }, + }) + + const falseBlockAtChildLocal = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [5, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + const blockAtChildWorld = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [15, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + + expect(falseBlockAtChildLocal).toEqual({ valid: true, conflictIds: [] }) + expect(blockAtChildWorld.valid).toBe(false) + expect(blockAtChildWorld.conflictIds).toEqual(['cabinet_existing']) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts new file mode 100644 index 00000000..8d7c3d6c --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core' +import { cabinetQuickActions } from '../quick-actions' +import { addCabinetModuleSide, addCornerRun } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('cabinet quick actions', () => { + test('offers and runs an L-corner action from run selection using the end module', () => { + const levelId = 'level_quick_actions_corner' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-corner', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-quick-actions-corner', + 'cabinet-module_right-quick-actions-corner', + ], + }) + const leftModule = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-corner', + parentId: run.id, + position: [-0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-left-quick-actions-corner', type: 'door', shelfCount: 2 }], + }) + const rightModule = CabinetModuleNode.parse({ + id: 'cabinet-module_right-quick-actions-corner', + parentId: run.id, + position: [0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-right-quick-actions-corner', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-corner-blocker', + parentId: levelId, + start: [-1, 0.95], + end: [3, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + leftModule as AnyNode, + rightModule as AnyNode, + blockingWall as AnyNode, + ]) + + const actions = cabinetQuickActions({ + node: run, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(cornerAction).toBeTruthy() + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + expect(sceneApi.get(rightModule.id)?.width).toBeLessThan(0.9) + }) + + test('offers L Right with Right on the outer end of an extended right-corner leg', () => { + const levelId = 'level_quick_actions_extended-leg-right-action' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-extended-leg-right-action', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-extended-leg-right-action'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-extended-leg-right-action', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-source-quick-actions-extended-leg-right-action', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })! + const firstSelectedModule = sceneApi.get(firstSelectedId)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + const extendedId = addCabinetModuleSide({ + anchorModule: firstSelectedModule, + run: firstDerivedRun, + sceneApi, + side: 'right', + })! + const extendedModule = sceneApi.get(extendedId)! + + const actions = cabinetQuickActions({ + node: extendedModule, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + + expect(actions.some((action) => action.id === 'cabinet:add-right')).toBe(true) + expect(oppositeCornerAction?.disabled).toBe(true) + expect(cornerAction?.label).toBe('L Right') + expect(cornerAction?.disabled).toBeFalsy() + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + }) + + test('offers L Left with Left on the outer end of an extended left-corner leg', () => { + const levelId = 'level_quick_actions_extended-leg-left-action' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-extended-leg-left-action', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-extended-leg-left-action'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-extended-leg-left-action', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-source-quick-actions-extended-leg-left-action', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'left' })! + const firstSelectedModule = sceneApi.get(firstSelectedId)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + const standardModule = firstDerivedRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((node) => node?.type === 'cabinet-module' && node.moduleKind === 'standard')! + const extendedId = addCabinetModuleSide({ + anchorModule: standardModule, + run: firstDerivedRun, + sceneApi, + side: 'left', + })! + const extendedModule = sceneApi.get(extendedId)! + + const actions = cabinetQuickActions({ + node: extendedModule, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(actions.some((action) => action.id === 'cabinet:add-left')).toBe(true) + expect(oppositeCornerAction?.disabled).toBe(true) + expect(cornerAction?.label).toBe('L Left') + expect(cornerAction?.disabled).toBeFalsy() + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + }) + + test('runs L Left from the floating action and trims against a left wall', () => { + const levelId = 'level_quick_actions_left-wall-corner' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-left-wall-corner', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-left-wall-corner'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-left-wall-corner', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-quick-actions-left-wall-corner', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-left-corner-blocker', + parentId: levelId, + start: [-0.82, -1], + end: [-0.82, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) + + const actions = cabinetQuickActions({ + node: source, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + + expect(cornerAction?.label).toBe('L Left') + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + expect(sceneApi.get(source.id)?.width).toBeCloseTo(0.59) + }) + + test('disables blocked side and corner actions instead of hiding them', () => { + const levelId = 'level_quick_actions_disabled-blocked-side' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-disabled-blocked-side', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-quick-actions-disabled-blocked-side', + 'cabinet-module_right-quick-actions-disabled-blocked-side', + ], + }) + const leftModule = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-disabled-blocked-side', + parentId: run.id, + position: [-0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-left-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 }], + }) + const rightModule = CabinetModuleNode.parse({ + id: 'cabinet-module_right-quick-actions-disabled-blocked-side', + parentId: run.id, + position: [0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-right-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + leftModule as AnyNode, + rightModule as AnyNode, + ]) + + const actions = cabinetQuickActions({ + node: leftModule, + nodes: sceneApi.nodes(), + }) + const moduleCountBefore = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ).length + const rightAction = actions.find((action) => action.id === 'cabinet:add-right') + const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(rightAction?.disabled).toBe(true) + expect(cornerRightAction?.disabled).toBe(true) + expect(rightAction?.run({ sceneApi })).toBeUndefined() + expect(cornerRightAction?.run({ sceneApi })).toBeUndefined() + expect( + Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ), + ).toHaveLength(moduleCountBefore) + }) + + test('disables wall-blocked side add while keeping shrinkable L action enabled', () => { + const levelId = 'level_quick_actions_disabled-wall-side' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-disabled-wall-side', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-disabled-wall-side'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-disabled-wall-side', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-quick-actions-disabled-wall-side', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-disabled-wall-side', + parentId: levelId, + start: [0.6, -1], + end: [0.6, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) + + const actions = cabinetQuickActions({ + node: source, + nodes: sceneApi.nodes(), + }) + const rightAction = actions.find((action) => action.id === 'cabinet:add-right') + const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(rightAction?.disabled).toBe(true) + expect(cornerRightAction?.disabled).toBeFalsy() + }) + + test('disables L action when the corner preview has no usable width', () => { + const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-disabled-corner-wall', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-disabled-corner-wall'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-disabled-corner-wall', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-source-quick-actions-disabled-corner-wall', type: 'door', shelfCount: 2 }, + ], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-disabled-corner-wall', + parentId: levelId, + start: [-1, 0.65], + end: [2, 0.65], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) + + const actions = cabinetQuickActions({ + node: source, + nodes: sceneApi.nodes(), + }) + const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(cornerRightAction?.disabled).toBe(true) + expect(cornerRightAction?.run({ sceneApi })).toBeUndefined() + }) + + test('runs L Left with an existing right corner and walls on both sides', () => { + const levelId = 'level_quick-actions-two-wall-left-corner' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-two-wall-left-corner', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-quick-actions-two-wall-left-corner', + 'cabinet-module_mid-quick-actions-two-wall-left-corner', + 'cabinet-module_right-quick-actions-two-wall-left-corner', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-two-wall-left-corner', + parentId: run.id, + position: [-0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-left-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }], + }) + const middle = CabinetModuleNode.parse({ + id: 'cabinet-module_mid-quick-actions-two-wall-left-corner', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-quick-actions-two-wall-left-corner', + parentId: run.id, + position: [0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-right-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + middle as AnyNode, + right as AnyNode, + ]) + expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy() + sceneApi.upsert( + WallNode.parse({ + id: 'wall_quick-actions-two-wall-left-side', + parentId: levelId, + start: [-1.3, -1], + end: [-1.3, 1], + thickness: 0.2, + }) as AnyNode, + ) + sceneApi.upsert( + WallNode.parse({ + id: 'wall_quick-actions-two-wall-right-side', + parentId: levelId, + start: [1.3, -1], + end: [1.3, 1], + thickness: 0.2, + }) as AnyNode, + ) + + const actions = cabinetQuickActions({ + node: sceneApi.get(left.id)!, + nodes: sceneApi.nodes(), + }) + const cornerLeftAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + + expect(cornerLeftAction?.disabled).toBeFalsy() + const result = cornerLeftAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + expect(sceneApi.get(left.id)?.width).toBeCloseTo(0.17) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts new file mode 100644 index 00000000..e9453c61 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -0,0 +1,1786 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, type SceneApi, WallNode, ZoneNode } from '@pascal-app/core' +import { runLocalToPlan } from '../run-layout' +import { + addCabinetModuleSide, + addCornerRun, + previewCornerAdditionLayout, + syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, + wallBottomHeightForTallAlignment, +} from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent = resolveCabinetWorldTransform(parent, nodes) + return { + position: runLocalToPlan( + { + position: worldParent.position, + rotation: worldParent.rotation, + }, + node.position, + ), + rotation: worldParent.rotation + node.rotation, + } + } + + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +describe('addCabinetModuleSide', () => { + test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { + const levelId = 'level_add-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-wall-clearance'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-blocking' as AnyNodeId, + parentId: levelId, + start: [1.1, -1], + end: [1.1, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode, blockingWall as AnyNode]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeTruthy() + const added = sceneApi.get(id!) + expect(added?.width).toBeCloseTo(0.55) + expect(added?.position[0]).toBeCloseTo(0.725) + expect(sceneApi.get(anchor.id)?.width).toBeCloseTo(0.9) + }) + + test('does not add a corner-end base cabinet when remaining wall clearance falls below minimum width', () => { + const levelId = 'level_add-side-wall-too-tight' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-wall-too-tight', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-wall-too-tight'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-wall-too-tight', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-too-tight' as AnyNodeId, + parentId: levelId, + start: [0.8, -1], + end: [0.8, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode, blockingWall as AnyNode]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeNull() + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + expect(modulesOut).toHaveLength(1) + }) + + test('shrinks a newly added side cabinet when the run is nested under a level child', () => { + const levelId = 'level_add-side-zone-parent' as AnyNodeId + const zone = ZoneNode.parse({ + id: 'zone_add-side-zone-parent' as AnyNodeId, + parentId: levelId, + name: 'Kitchen', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-zone-parent', + parentId: zone.id, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-zone-parent'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-zone-parent', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-zone-parent' as AnyNodeId, + parentId: levelId, + start: [1.1, -1], + end: [1.1, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + zone as AnyNode, + run as AnyNode, + anchor as AnyNode, + blockingWall as AnyNode, + ]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeTruthy() + const added = sceneApi.get(id!) + expect(added?.width).toBeCloseTo(0.55) + expect(added?.position[0]).toBeCloseTo(0.725) + }) +}) + +describe('addCornerRun', () => { + test('creates generated corner pieces under the actual base-cabinet and corner-filler parents', () => { + const levelId = 'level_corner-test' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + withCountertop: true, + countertopThickness: 0.04, + countertopOverhang: 0.03, + countertopBackOverhang: 0.12, + withFinishedBack: true, + children: ['cabinet-module_source-corner'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0, + showPlinth: false, + withCountertop: false, + stack: [{ id: 'door-source', type: 'door', shelfCount: 3 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const allNodes = Object.values(sceneApi.nodes()) + const runs = allNodes.filter((node): node is CabinetNode => node.type === 'cabinet') + const modulesOut = allNodes.filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + + expect(runs).toHaveLength(4) + expect(runs.filter((node) => node.runTier === 'wall')).toHaveLength(2) + expect(modulesOut).toHaveLength(7) + + const fillers = modulesOut.filter((node) => node.moduleKind === 'corner-filler') + expect(fillers).toHaveLength(3) + expect(fillers.filter((node) => node.cornerShelf)).toHaveLength(3) + expect(fillers.every((node) => node.stack?.[0]?.shelfCount === 3)).toBe(true) + const bridgeFiller = fillers.find((node) => node.name === 'Wall Bridge Filler') + expect(bridgeFiller?.openSide).toBe('left') + expect(bridgeFiller?.cornerShelf).toBe(true) + expect(bridgeFiller?.stack?.[0]?.type).toBe('door') + expect(bridgeFiller?.stack?.[0]?.shelfCount).toBe(3) + expect(modulesOut.find((node) => node.name === 'Wall Corner Cabinet')).toBeUndefined() + + const sourceModuleAfter = sceneApi.get(module.id)! + const sourceModuleChildren = (sourceModuleAfter.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter(Boolean) + expect(sourceModuleChildren.filter((child) => child!.type === 'cabinet-module')).toHaveLength(1) + expect( + sourceModuleChildren.find( + (child) => child!.type === 'cabinet-module' && child!.name === 'Wall Cabinet', + ), + ).toBeTruthy() + const sourceWallTop = sourceModuleChildren.find( + (child): child is CabinetModuleNode => + child!.type === 'cabinet-module' && child!.name === 'Wall Cabinet', + ) + expect(sourceWallTop?.openSide).toBe('right') + + const legCabinet = modulesOut.find((node) => node.id === selectedId) + expect(legCabinet?.openSide).toBe('left') + expect(legCabinet?.parentId).toBeTruthy() + expect(legCabinet?.width).toBeCloseTo(module.width) + expect(legCabinet?.stack?.[0]?.type).toBe('door') + expect(legCabinet?.stack?.[0]?.shelfCount).toBe(3) + const wallLegCabinet = modulesOut.find( + (node) => node.name === 'Wall Cabinet' && node.parentId === legCabinet?.id, + ) + expect(wallLegCabinet?.stack?.[0]?.type).toBe('door') + expect(wallLegCabinet?.stack?.[0]?.shelfCount).toBe(3) + expect(wallLegCabinet?.openSide).toBe('left') + + const cornerFiller = modulesOut.find((node) => node.name === 'Corner Filler') + expect(cornerFiller).toBeTruthy() + const cornerFillerChildRuns = runs.filter((node) => node.parentId === cornerFiller?.id) + expect(cornerFillerChildRuns.map((node) => node.name).sort()).toEqual([ + 'Corner Wall Bridge', + 'Corner Wall Run', + ]) + const cornerFillerGrandchildren = cornerFillerChildRuns.flatMap((childRun) => + (childRun.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter(Boolean) + .map((child) => child!.name), + ) + expect(cornerFillerGrandchildren.sort()).toEqual(['Corner Wall Filler', 'Wall Bridge Filler']) + + const derivedRuns = runs.filter((node) => node.id !== run.id) + const baseLeg = derivedRuns.find((node) => node.runTier === 'base') + expect(baseLeg?.withCountertop).toBe(true) + expect(baseLeg?.countertopThickness).toBeCloseTo(run.countertopThickness) + expect(baseLeg?.countertopOverhang).toBeCloseTo(run.countertopOverhang) + expect(baseLeg?.countertopBackOverhang).toBeCloseTo(run.countertopBackOverhang) + expect(baseLeg?.withFinishedBack).toBe(true) + + const sourceAfter = sceneApi.get(run.id)! + const metadata = sourceAfter.metadata as Record + expect(typeof metadata.cabinetLayoutRevision).toBe('number') + }) + + test('inherits the connected cabinet shelf count for every generated corner module', () => { + const levelId = 'level_corner-shelf-inheritance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-shelf-inheritance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-shelf-inheritance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-shelf-inheritance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0, + showPlinth: false, + withCountertop: false, + stack: [{ id: 'door-source-shelf-inheritance', type: 'door', shelfCount: 5 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const generatedModules = modulesOut.filter((node) => node.parentId !== run.id) + + expect(generatedModules).toHaveLength(6) + expect(generatedModules.every((node) => node.stack?.[0]?.type === 'door')).toBe(true) + expect(generatedModules.every((node) => node.stack?.[0]?.shelfCount === 5)).toBe(true) + }) + + test('keeps linked L runs aligned when the source cabinet width changes later', () => { + const levelId = 'level_corner-linked-width' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-width', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + withCountertop: true, + children: ['cabinet-module_source-corner-linked-width'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-width', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-linked-width', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + sceneApi.update(module.id as AnyNodeId, { width: 0.45 } as Partial) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const linkedBase = modulesOut.find( + (node) => node.id !== module.id && node.name === 'Base Cabinet', + ) + expect(linkedBase?.width).toBeCloseTo(0.45) + expect( + modulesOut.find((node) => node.name === 'Wall Cabinet' && node.parentId === linkedBase?.id) + ?.width, + ).toBeCloseTo(0.45) + }) + + test('re-anchors linked L runs when the source module moves along its run', () => { + const levelId = 'level_corner-linked-move' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-move', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-linked-move'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-move', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-linked-move', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const baseLegBefore = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + const legWorldBefore = resolveCabinetWorldTransform( + baseLegBefore, + sceneApi.nodes() as Record, + ) + + const delta = 0.5 + sceneApi.update( + module.id as AnyNodeId, + { + position: [module.position[0] + delta, module.position[1], module.position[2]], + } as Partial, + ) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const baseLegAfter = sceneApi.get(baseLegBefore.id)! + const legWorldAfter = resolveCabinetWorldTransform( + baseLegAfter, + sceneApi.nodes() as Record, + ) + expect(legWorldAfter.position[0] - legWorldBefore.position[0]).toBeCloseTo(delta) + expect(legWorldAfter.position[2]).toBeCloseTo(legWorldBefore.position[2]) + expect(legWorldAfter.rotation).toBeCloseTo(legWorldBefore.rotation) + }) + + test('propagates front styling changes into linked corner runs and modules', () => { + const levelId = 'level_corner-linked-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: ['cabinet-module_source-corner-linked-front-style'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + stack: [{ id: 'door-source-linked-front-style', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + sceneApi.update( + run.id as AnyNodeId, + { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + } as Partial, + ) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const nodes = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + const linkedNodes = nodes.filter( + (node) => node.id !== run.id && node.id !== module.id && node.parentId !== module.id, + ) + + expect(linkedNodes.length).toBeGreaterThan(0) + expect(linkedNodes.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(linkedNodes.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(linkedNodes.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(linkedNodes.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling changes when a derived corner run is the selected run', () => { + const levelId = 'level_corner-derived-run-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-derived-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: ['cabinet-module_source-corner-derived-front-style'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-derived-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + stack: [{ id: 'door-source-derived-front-style', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const derivedRun = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + + const changed = syncCornerStyleGroupFromRun({ + run: derivedRun, + patch: { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(allCabinets.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(allCabinets.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling changes to corner groups on both sides of the source run', () => { + const levelId = 'level_corner-both-sides-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-both-sides-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: [ + 'cabinet-module_left-both-sides-front-style', + 'cabinet-module_center-both-sides-front-style', + 'cabinet-module_right-both-sides-front-style', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-both-sides-front-style', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-both-sides-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-both-sides-front-style', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(allCabinets.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(allCabinets.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling into linked runs even when the corner re-layout bails', () => { + const levelId = 'level_corner-style-layout-bail' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-style-layout-bail', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: ['cabinet-module_source-style-layout-bail'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-style-layout-bail', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + stack: [{ id: 'door-style-layout-bail', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + // A wall drawn AFTER the corner exists blocks computeCornerRunLayout + // (connected width falls below minimum), so syncDerivedCornerRun bails. + const blockingWall = WallNode.parse({ + id: 'wall_style-layout-bail' as AnyNodeId, + parentId: levelId, + start: [0, 0.5], + end: [3, 0.5], + thickness: 0.2, + }) + sceneApi.upsert(blockingWall as AnyNode) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(changed).toBe(true) + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + }) + + test('propagates front styling into a leg run that gained extra modules', () => { + const levelId = 'level_corner-style-extended-leg' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-style-extended-leg', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: ['cabinet-module_source-style-extended-leg'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-style-extended-leg', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + stack: [{ id: 'door-style-extended-leg', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + // Extending the leg gives it modules that don't match the derived-run + // spec names, which makes syncDerivedCornerRun's re-layout bail. + const legRun = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + addCabinetModuleSide({ anchorModule: null, run: legRun, sceneApi, side: 'right' }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(changed).toBe(true) + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + }) + + test('anchors the right bridge filler to the live source wall cabinet edge', () => { + const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-right', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-right', + 'cabinet-module_center-bridge-anchor-right', + 'cabinet-module_right-bridge-anchor-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-right', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-right', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-right'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-right', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-right', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && + node.name === 'Wall Cabinet' && + node.parentId === right.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] - bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] + sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('anchors the left bridge filler to the live source wall cabinet edge', () => { + const levelId = 'level_corner-bridge-anchor-left' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-left', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-left', + 'cabinet-module_center-bridge-anchor-left', + 'cabinet-module_right-bridge-anchor-left', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-left', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-left', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-left'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-left', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-left', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Cabinet' && node.parentId === left.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] + bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] - sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('keeps the left bridge filler anchored after resyncing the source module', () => { + const levelId = 'level_corner-bridge-anchor-left-resync' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-left-resync', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-left-resync', + 'cabinet-module_center-bridge-anchor-left-resync', + 'cabinet-module_right-bridge-anchor-left-resync', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-left-resync', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-left-resync', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-left-resync'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-left-resync', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-left-resync', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + syncCornerRunsFromSourceModule({ + module: sceneApi.get(left.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Cabinet' && node.parentId === left.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] + bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] - sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('keeps the right bridge filler anchored after syncing a front-style change', () => { + const levelId = 'level_corner-bridge-anchor-right-style-sync' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-right-style-sync', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: [ + 'cabinet-module_left-bridge-anchor-right-style-sync', + 'cabinet-module_center-bridge-anchor-right-style-sync', + 'cabinet-module_right-bridge-anchor-right-style-sync', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-right-style-sync', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-right-style-sync', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + children: ['cabinet-module_center-wall-bridge-anchor-right-style-sync'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-right-style-sync', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-right-style-sync', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { + frontStyle: 'raised-arch', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && + node.name === 'Wall Cabinet' && + node.parentId === right.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] - bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] + sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('adds only the uncovered bridge piece when a wall-top already occupies the corner', () => { + const levelId = 'level_corner-wall-existing' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-existing-wall', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-existing-wall'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-existing-wall', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const existingWallRun = CabinetNode.parse({ + id: 'cabinet_existing-wall-run', + parentId: levelId, + position: [0, wallBottomHeightForTallAlignment(), -0.13], + rotation: 0, + runTier: 'wall', + depth: 0.32, + carcassHeight: 0.72, + children: ['cabinet-module_existing-wall-module'], + }) + const existingWallModule = CabinetModuleNode.parse({ + id: 'cabinet-module_existing-wall-module', + parentId: existingWallRun.id, + position: [0, 0, 0], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + module as AnyNode, + existingWallRun as AnyNode, + existingWallModule as AnyNode, + ]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const runs = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => node.type === 'cabinet', + ) + expect(runs.filter((node) => node.runTier === 'wall')).toHaveLength(3) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler') + expect(bridgeFillers).toHaveLength(1) + expect(bridgeFillers[0]?.width).toBeCloseTo(0.26) + + const linkedBase = modulesOut.find( + (node) => node.id !== module.id && node.name === 'Base Cabinet', + ) + expect( + modulesOut.find((node) => node.name === 'Wall Cabinet' && node.parentId === linkedBase?.id), + ).toBeTruthy() + }) + + test('creates nested second-corner runs in the correct world position', () => { + const levelId = 'level_corner-double-nested' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-double-nested', + parentId: levelId, + position: [1.6, 0, 2.1], + rotation: Math.PI / 2, + withCountertop: true, + children: ['cabinet-module_source-corner-double-nested'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-double-nested', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-double-nested', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const firstSelectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const firstSelectedModule = sceneApi.get(firstSelectedId!)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + + const secondSelectedId = addCornerRun({ + module: firstSelectedModule, + run: firstDerivedRun, + sceneApi, + side: 'right', + }) + + expect(secondSelectedId).toBeTruthy() + + const secondSelectedModule = sceneApi.get(secondSelectedId!)! + const secondDerivedRun = sceneApi.get(secondSelectedModule.parentId as AnyNodeId)! + const nodes = sceneApi.nodes() as Record + const firstDerivedWorld = resolveCabinetWorldTransform(firstDerivedRun, nodes) + const secondDerivedWorld = resolveCabinetWorldTransform(secondDerivedRun, nodes) + const secondModuleWorld = resolveCabinetWorldTransform(secondSelectedModule, nodes) + + expect(Math.abs(secondDerivedWorld.rotation - firstDerivedWorld.rotation)).toBeCloseTo( + Math.PI / 2, + ) + expect( + Math.hypot( + secondDerivedWorld.position[0] - firstDerivedWorld.position[0], + secondDerivedWorld.position[2] - firstDerivedWorld.position[2], + ), + ).toBeGreaterThan(0.3) + expect( + Math.hypot( + secondModuleWorld.position[0] - secondDerivedWorld.position[0], + secondModuleWorld.position[2] - secondDerivedWorld.position[2], + ), + ).toBeGreaterThan(0.1) + expect((secondSelectedModule.metadata as Record).nodeSelectionProxyId).toBe( + secondDerivedRun.id, + ) + }) + + test('turns left from the outer right end of an extended right-corner leg', () => { + const levelId = 'level_corner-extended-leg-left-turn' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-extended-leg-left-turn', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-extended-leg-left-turn'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-extended-leg-left-turn', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-extended-leg-left-turn', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const firstSelectedId = addCornerRun({ module, run, sceneApi, side: 'right' }) + const firstSelectedModule = sceneApi.get(firstSelectedId!)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + const extendedId = addCabinetModuleSide({ + anchorModule: firstSelectedModule, + run: firstDerivedRun, + sceneApi, + side: 'right', + }) + const extendedModule = sceneApi.get(extendedId!)! + + const secondSelectedId = addCornerRun({ + module: extendedModule, + run: firstDerivedRun, + sceneApi, + side: 'left', + }) + + expect(secondSelectedId).toBeTruthy() + + const secondSelectedModule = sceneApi.get(secondSelectedId!)! + const secondDerivedRun = sceneApi.get(secondSelectedModule.parentId as AnyNodeId)! + const nodes = sceneApi.nodes() as Record + const firstDerivedWorld = resolveCabinetWorldTransform(firstDerivedRun, nodes) + const secondDerivedWorld = resolveCabinetWorldTransform(secondDerivedRun, nodes) + + expect(secondDerivedWorld.rotation - firstDerivedWorld.rotation).toBeCloseTo(Math.PI / 2) + expect( + (secondDerivedRun.metadata as Record).cabinetCornerDerivedRun, + ).toMatchObject({ + side: 'right', + turnSide: 'left', + }) + }) + + test('shortens the generated corner leg when a wall blocks the new span', () => { + const levelId = 'level_corner-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-clearance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(module.id)! + const legCabinet = modulesOut.find((node) => node.id === selectedId) + const wallLegCabinet = modulesOut.find((node) => node.name === 'Wall Cabinet') + + expect(sourceAfter.width).toBeCloseTo(0.56) + expect(legCabinet?.width).toBeCloseTo(0.56) + expect(wallLegCabinet?.width).toBeCloseTo(0.56) + }) + + test('shrinks the source corner cabinet when a side wall blocks the turn pocket', () => { + const levelId = 'level_corner-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-side-wall-clearance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-side-blocker', + parentId: levelId, + start: [0.82, -1], + end: [0.82, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(module.id)! + const legCabinet = modulesOut.find((node) => node.id === selectedId) + const wallLegCabinet = modulesOut.find( + (node) => node.name === 'Wall Cabinet' && node.parentId === legCabinet?.id, + ) + + expect(sourceAfter.width).toBeCloseTo(0.59) + expect(legCabinet?.width).toBeCloseTo(0.59) + expect(wallLegCabinet?.width).toBeCloseTo(0.59) + }) + + test('shrinks the source corner cabinet when a left side wall blocks the turn pocket', () => { + const levelId = 'level_corner-left-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-left-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-left-side-wall-clearance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-left-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-left-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-left-side-blocker', + parentId: levelId, + start: [-0.82, -1], + end: [-0.82, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'left', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(module.id)! + const selectedModule = sceneApi.get(selectedId!)! + const legCabinet = modulesOut.find( + (node) => node.name === 'Base Cabinet' && node.parentId === selectedModule.parentId, + ) + const wallLegCabinet = modulesOut.find( + (node) => node.name === 'Wall Cabinet' && node.parentId === legCabinet?.id, + ) + + expect(sourceAfter.width).toBeCloseTo(0.59) + expect(legCabinet?.width).toBeCloseTo(0.59) + expect(wallLegCabinet?.width).toBeCloseTo(0.59) + }) + + test('adds the corner after a tight side wall trims the source below standard width', () => { + const levelId = 'level_corner-tight-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-tight-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-tight-side-wall-clearance', + 'cabinet-module_mid-tight-side-wall-clearance', + 'cabinet-module_right-tight-side-wall-clearance', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-tight-side-wall-clearance', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const middle = CabinetModuleNode.parse({ + id: 'cabinet-module_mid-tight-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-tight-side-wall-clearance', + parentId: run.id, + position: [0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-tight-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-tight-side-blocker', + parentId: levelId, + start: [1.3, -1], + end: [1.3, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + middle as AnyNode, + right as AnyNode, + blockingWall as AnyNode, + ]) + + const selectedId = addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(right.id)! + const legCabinet = modulesOut.find((node) => node.id === selectedId) + + expect(sourceAfter.width).toBeCloseTo(0.17) + expect(legCabinet?.width).toBeCloseTo(0.17) + }) + + test('adds the left corner after a tight side wall trims the source below standard width', () => { + const levelId = 'level_corner-tight-left-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-tight-left-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-tight-left-side-wall-clearance', + 'cabinet-module_mid-tight-left-side-wall-clearance', + 'cabinet-module_right-tight-left-side-wall-clearance', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-tight-left-side-wall-clearance', + parentId: run.id, + position: [-0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-tight-left-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const middle = CabinetModuleNode.parse({ + id: 'cabinet-module_mid-tight-left-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-tight-left-side-wall-clearance', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-tight-left-side-blocker', + parentId: levelId, + start: [-1.3, -1], + end: [-1.3, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + middle as AnyNode, + right as AnyNode, + blockingWall as AnyNode, + ]) + + const selectedId = addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(left.id)! + const selectedModule = sceneApi.get(selectedId!)! + const legCabinet = modulesOut.find( + (node) => node.name === 'Base Cabinet' && node.parentId === selectedModule.parentId, + ) + + expect(sourceAfter.width).toBeCloseTo(0.17) + expect(legCabinet?.width).toBeCloseTo(0.17) + }) + + test('reports the trimmed corner width during preview before adding the corner run', () => { + const levelId = 'level_corner-wall-clearance-preview' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-clearance-preview', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-clearance-preview'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-clearance-preview', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-clearance-preview', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker-preview', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const preview = previewCornerAdditionLayout({ + module, + run, + nodes: sceneApi.nodes(), + side: 'right', + }) + + expect(preview).toBeTruthy() + expect(preview?.connectedWidth).toBeCloseTo(0.56) + expect(preview?.sourceWidth).toBeCloseTo(0.56) + }) + + test('does not add a corner leg when a blocking wall leaves no usable cabinet width', () => { + const levelId = 'level_corner-wall-blocked' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-blocked', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-blocked'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-blocked', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-blocked', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-too-close', + parentId: levelId, + start: [-1, 0.65], + end: [2, 0.65], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeNull() + + const runs = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => node.type === 'cabinet', + ) + expect(runs).toHaveLength(1) + }) + + test('keeps an existing wall top aligned when the source corner cabinet is trimmed to fit', () => { + const levelId = 'level_corner-wall-top-trim' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-top-trim', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-top-trim'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-top-trim', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_source-wall-top-trim'], + stack: [{ id: 'door-source-wall-top-trim', type: 'door', shelfCount: 2 }], + }) + const wallTop = CabinetModuleNode.parse({ + id: 'cabinet-module_source-wall-top-trim', + parentId: module.id, + position: [0, wallBottomHeightForTallAlignment() - module.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-top-door', type: 'door', shelfCount: 1 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker-wall-top', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + module as AnyNode, + wallTop as AnyNode, + blockingWall as AnyNode, + ]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(sceneApi.get(module.id)!.width).toBeCloseTo(0.56) + expect(sceneApi.get(wallTop.id)!.width).toBeCloseTo(0.56) + + const wallTopAfter = sceneApi.get(wallTop.id)! + const bridgeFiller = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + expect(bridgeFiller).toBeTruthy() + + const nodes = sceneApi.nodes() as Record + const wallTopWorld = resolveCabinetWorldTransform(wallTopAfter, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + const wallTopRightEdge = wallTopWorld.position[0] + wallTopAfter.width / 2 + const bridgeLeftEdge = bridgeWorld.position[0] - bridgeFiller!.width / 2 + expect(bridgeLeftEdge).toBeCloseTo(wallTopRightEdge) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts new file mode 100644 index 00000000..720b202c --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -0,0 +1,491 @@ +import { describe, expect, test } from 'bun:test' +import { cabinetPresetById } from '../presets' +import { CabinetNode } from '../schema' +import { + backAnchoredModuleZ, + type CabinetCompartment, + COOKTOP_DEFAULT_GAS_LAYOUT, + COOKTOP_DEFAULT_HEIGHT, + COOKTOP_DEFAULT_INDUCTION_LAYOUT, + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + HOOD_CURVED_TOTAL_HEIGHT, + HOOD_PYRAMID_CANOPY_HEIGHT, + hoodCompartmentHeight, + MICROWAVE_DEFAULT_HEIGHT, + MICROWAVE_STANDARD_HEIGHT, + MICROWAVE_STANDARD_WIDTH, + minCabinetCarcassHeightForStack, + newCabinetCompartment, + normalizeCabinetStack, + OVEN_DEFAULT_HEIGHT, + PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, + PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + PULL_OUT_PANTRY_STANDARD_WIDTH, + reflowCabinetRunModules, + replaceCabinetCompartmentStack, + resizeCabinetCompartmentStack, + TALL_CABINET_CARCASS_HEIGHT, +} from '../stack' + +const stack: CabinetCompartment[] = [ + { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 }, + { id: 'shelf', type: 'shelf', height: 0.2, shelfCount: 1 }, + { id: 'door', type: 'door', height: 0.56, doorType: 'double', shelfCount: 2 }, +] + +describe('resizeCabinetCompartmentStack', () => { + test('keeps total height constant and redistributes remaining compartments', () => { + const resized = resizeCabinetCompartmentStack({ width: 0.6, carcassHeight: 1.2, stack }, 0, 0.5) + const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized }).map( + (row) => row.height, + ) + + expect(heights[0]).toBeCloseTo(0.5) + expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(1.2) + expect(heights[1]).toBeGreaterThanOrEqual(0.1) + expect(heights[2]).toBeGreaterThanOrEqual(0.1) + }) + + test('clamps the edited compartment so all siblings keep a minimum height', () => { + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 0.72, stack }, + 2, + 0.7, + ) + const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: resized }).map( + (row) => row.height, + ) + + expect(heights[0]).toBeCloseTo(0.1) + expect(heights[1]).toBeCloseTo(0.1) + expect(heights[2]).toBeCloseTo(0.52) + expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(0.72) + }) + + test('keeps fixed appliance siblings unchanged when resizing another row', () => { + const applianceStack: CabinetCompartment[] = [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + ] + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 1.2, stack: applianceStack }, + 0, + 0.3, + ) + + expect(resized[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized }) + expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2) + }) +}) + +describe('appliance compartments', () => { + test('newCabinetCompartment seeds fixed appliance heights', () => { + const oven = newCabinetCompartment('oven') + const microwave = newCabinetCompartment('microwave') + const dishwasher = newCabinetCompartment('dishwasher') + const gasCooktop = newCabinetCompartment('cooktop-gas') + const inductionCooktop = newCabinetCompartment('cooktop-induction') + const pullOutPantry = newCabinetCompartment('pull-out-pantry') + + expect(oven.type).toBe('oven') + expect(oven.height).toBe(OVEN_DEFAULT_HEIGHT) + expect(microwave.type).toBe('microwave') + expect(microwave.height).toBe(MICROWAVE_DEFAULT_HEIGHT) + expect(dishwasher.type).toBe('dishwasher') + expect(dishwasher.height).toBe(DISHWASHER_STANDARD_HEIGHT) + expect(gasCooktop.type).toBe('cooktop-gas') + expect(gasCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT) + expect(gasCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_GAS_LAYOUT) + expect(gasCooktop.cooktopBurnersOn).toBe(false) + expect(gasCooktop.cooktopActiveBurners).toEqual([]) + expect(gasCooktop.cooktopKnobProgress).toEqual([]) + expect(gasCooktop.cooktopShowGrate).toBe(true) + expect(inductionCooktop.type).toBe('cooktop-induction') + expect(inductionCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT) + expect(inductionCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_INDUCTION_LAYOUT) + expect(inductionCooktop.cooktopBurnersOn).toBe(false) + expect(inductionCooktop.cooktopActiveBurners).toEqual([]) + expect(inductionCooktop.cooktopKnobProgress).toEqual([]) + expect(inductionCooktop.cooktopShowGrate).toBe(true) + expect(pullOutPantry.type).toBe('pull-out-pantry') + expect(pullOutPantry.height).toBe(TALL_CABINET_CARCASS_HEIGHT) + expect(pullOutPantry.shelfCount).toBe(PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT) + expect(pullOutPantry.pantryRackStyle).toBe(PULL_OUT_PANTRY_DEFAULT_RACK_STYLE) + expect(MICROWAVE_STANDARD_WIDTH).toBeCloseTo(0.61) + expect(MICROWAVE_STANDARD_HEIGHT).toBeCloseTo(0.39) + expect(DISHWASHER_STANDARD_WIDTH).toBeCloseTo(0.6) + expect(DISHWASHER_STANDARD_HEIGHT).toBeCloseTo(0.72) + expect(COOKTOP_STANDARD_WIDTH).toBeCloseTo(0.75) + expect(PULL_OUT_PANTRY_STANDARD_WIDTH).toBeCloseTo(0.3) + }) + + test('newCabinetCompartment seeds fixed refrigerator column heights', () => { + const single = newCabinetCompartment('fridge-single') + const double = newCabinetCompartment('fridge-double') + const topFreezer = newCabinetCompartment('fridge-top-freezer') + const bottomFreezer = newCabinetCompartment('fridge-bottom-freezer') + + expect(single.type).toBe('fridge-single') + expect(double.type).toBe('fridge-double') + expect(topFreezer.type).toBe('fridge-top-freezer') + expect(bottomFreezer.type).toBe('fridge-bottom-freezer') + expect(single.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(double.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(topFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(bottomFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(FRIDGE_COLUMN_WIDTH).toBeCloseTo(0.76) + expect(FRIDGE_WIDE_WIDTH).toBeCloseTo(0.91) + expect(FRIDGE_STANDARD_DEPTH).toBeCloseTo(0.76) + expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78) + }) + + test('fridgeCabinetStack fills the tall-cabinet remainder with a drawer front', () => { + const stack = fridgeCabinetStack('fridge-single') + const rows = normalizeCabinetStack({ + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack, + }) + + expect(stack).toHaveLength(2) + expect(stack[0]!.type).toBe('fridge-single') + expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(stack[1]!.type).toBe('drawer') + expect(stack[1]!.drawerCount).toBe(1) + expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) + }) + + test('fridge preset inherits the run depth instead of using appliance depth', () => { + const run = CabinetNode.parse({ depth: 0.58 }) + + const patch = cabinetPresetById('fridge-single').createPatch(run) + expect(patch.depth).toBeCloseTo(run.depth) + expect(patch.carcassHeight).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + expect(patch.stack).toHaveLength(2) + expect(patch.stack?.[0]?.type).toBe('fridge-single') + expect(patch.stack?.[1]?.type).toBe('drawer') + expect(patch.stack?.[1]?.drawerCount).toBe(1) + }) + + test('cooktop stack keeps storage below a countertop-mounted overlay', () => { + const stack = cooktopCabinetStack('cooktop-gas') + const rows = normalizeCabinetStack({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack, + }) + + expect(stack).toHaveLength(2) + expect(stack[0]!.type).toBe('drawer') + expect(stack[1]!.type).toBe('cooktop-gas') + expect(rows[0]!.height).toBeCloseTo(0.72) + expect(rows[1]!.height).toBeCloseTo(0) + expect(rows[1]!.y0).toBeCloseTo(0.72) + }) + + test('cooktop presets create standard base modules', () => { + const run = CabinetNode.parse({ depth: 0.58 }) + const gas = cabinetPresetById('cooktop-gas').createPatch(run) + const induction = cabinetPresetById('cooktop-induction').createPatch(run) + + expect(gas.cabinetType).toBe('base') + expect(gas.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH) + expect(gas.stack?.[1]?.type).toBe('cooktop-gas') + expect(induction.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH) + expect(induction.stack?.[1]?.type).toBe('cooktop-induction') + }) + + test('normalizeCabinetStack keeps the oven row fixed and free rows absorb the remainder', () => { + const applianceStack: CabinetCompartment[] = [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + ] + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 2.07, stack: applianceStack }) + + expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2) + expect(rows[2]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2) + expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(2.07) + }) + + test('normalizeCabinetStack keeps fixed appliance rows at their explicit height', () => { + const rows = normalizeCabinetStack({ + width: 0.6, + carcassHeight: 0.5, + stack: [ + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + ], + }) + + expect(rows[0]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.y1).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + }) + + test('minCabinetCarcassHeightForStack reserves fixed appliances plus flexible row minimums', () => { + expect( + minCabinetCarcassHeightForStack({ + width: 0.6, + stack: [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'microwave', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + { id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + { id: 'pullout', type: 'pull-out-pantry', height: TALL_CABINET_CARCASS_HEIGHT }, + { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + ], + }), + ).toBeCloseTo( + 0.1 + + OVEN_DEFAULT_HEIGHT + + MICROWAVE_DEFAULT_HEIGHT + + DISHWASHER_STANDARD_HEIGHT + + TALL_CABINET_CARCASS_HEIGHT + + FRIDGE_COLUMN_HEIGHT, + ) + }) + + test('replacing a single base compartment with microwave adds a flexible drawer filler', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: replaced }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('microwave') + expect(rows[0]!.height).toBeCloseTo(0.72 - MICROWAVE_DEFAULT_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(MICROWAVE_DEFAULT_HEIGHT) + }) + + test('replacing a single compartment with dishwasher keeps only the fixed washer row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('dishwasher') + expect(replaced[0]!.height).toBe(DISHWASHER_STANDARD_HEIGHT) + }) + + test('replacing a single base compartment with cooktop adds a flexible drawer below', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'cooktop-induction', height: COOKTOP_DEFAULT_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: replaced, + }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('cooktop-induction') + expect(rows[0]!.height).toBeCloseTo(0.72) + expect(rows[1]!.height).toBeCloseTo(0) + }) + + test('replacing one of several compartments with dishwasher lets flexible siblings absorb the remainder', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'shelf', type: 'shelf', shelfCount: 2 }, + ], + }, + 1, + { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: replaced, + }) + + expect(replaced).toHaveLength(3) + expect(replaced[1]!.type).toBe('dishwasher') + expect(rows[1]!.height).toBeCloseTo(DISHWASHER_STANDARD_HEIGHT) + expect(rows[0]!.height).toBeCloseTo( + (TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2, + ) + expect(rows[2]!.height).toBeCloseTo( + (TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2, + ) + }) + + test('replacing a row with microwave reuses existing flexible siblings', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 1.2, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + { id: 'door', type: 'door', doorType: 'double' }, + ], + }, + 1, + { id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('microwave') + }) + + test('replacing a single compartment with a refrigerator does not add a filler row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.76, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('fridge-single') + }) + + test('replacing a tall cabinet compartment with a refrigerator adds a drawer filler', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: replaced, + }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('fridge-single') + expect(replaced[1]!.type).toBe('drawer') + expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) + }) + + test('newCabinetCompartment seeds fixed range hood heights', () => { + const pyramid = newCabinetCompartment('hood-pyramid') + const curved = newCabinetCompartment('hood-curved-glass') + + expect(pyramid.type).toBe('hood-pyramid') + expect(pyramid.height).toBe(HOOD_PYRAMID_CANOPY_HEIGHT) + expect(curved.type).toBe('hood-curved-glass') + expect(curved.height).toBe(HOOD_CURVED_TOTAL_HEIGHT) + expect(hoodCompartmentHeight('hood-pyramid')).toBeCloseTo(0.38) + expect(hoodCompartmentHeight('hood-curved-glass')).toBeCloseTo(0.44) + }) + + test('replacing a single compartment with a range hood does not add a filler row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: HOOD_PYRAMID_CANOPY_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('hood-pyramid') + }) + + test('normalizeCabinetStack keeps the hood row at its explicit height', () => { + const rows = normalizeCabinetStack({ + width: 0.6, + carcassHeight: 1.0, + stack: [ + { id: 'hood', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT }, + { id: 'shelf', type: 'shelf', shelfCount: 1 }, + ], + }) + + expect(rows[0]!.height).toBeCloseTo(HOOD_PYRAMID_CANOPY_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(1.0 - HOOD_PYRAMID_CANOPY_HEIGHT) + }) +}) + +describe('reflowCabinetRunModules', () => { + test('keeps neighboring modules flush when the selected module width changes', () => { + const modules = [ + { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.6 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.6 }, + { id: 'right', position: [0.6, 0.1, 0] as [number, number, number], width: 0.6 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.9) + + expect(reflowed.map((module) => module.id)).toEqual(['left', 'middle', 'right']) + expect(reflowed[0]!.position[0] + reflowed[0]!.width / 2).toBeCloseTo( + reflowed[1]!.position[0] - reflowed[1]!.width / 2, + ) + expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo( + reflowed[2]!.position[0] - reflowed[2]!.width / 2, + ) + expect(reflowed[1]!.width).toBeCloseTo(0.9) + expect(reflowed[0]!.position[1]).toBeCloseTo(0.1) + expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) + }) +}) + +describe('backAnchoredModuleZ', () => { + test('moves a deeper module forward so the rear face stays aligned', () => { + const currentZ = 0 + const currentDepth = 0.58 + const nextDepth = FRIDGE_STANDARD_DEPTH + const nextZ = backAnchoredModuleZ(currentZ, currentDepth, nextDepth) + + expect(nextZ - nextDepth / 2).toBeCloseTo(currentZ - currentDepth / 2) + expect(nextZ).toBeGreaterThan(currentZ) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts new file mode 100644 index 00000000..e8ac5d90 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { CabinetModuleNode, CabinetNode } from '../schema' +import { cabinetTreeChildIds, cabinetTreeHidden } from '../tree-structure' + +function nodeNames(ids: AnyNodeId[], nodes: Record) { + return ids.map((id) => nodes[id]?.name) +} + +function treeChildIds(nodeId: AnyNodeId, nodes: Record): AnyNodeId[] { + const node = nodes[nodeId] + return node ? cabinetTreeChildIds(node, nodes) : [] +} + +function treeContainsDescendant( + nodeId: AnyNodeId, + targetId: AnyNodeId, + nodes: Record, +): boolean { + for (const childId of treeChildIds(nodeId, nodes)) { + if (childId === targetId) return true + if (treeContainsDescendant(childId, targetId, nodes)) return true + } + return false +} + +describe('cabinet tree structure', () => { + test('flattens hidden corner runs from the real scene graph into the requested sidebar hierarchy', () => { + const sourceRun = { + ...CabinetNode.parse({ + id: 'cabinet_source-run-tree', + parentId: 'level_tree' as AnyNodeId, + }), + children: ['cabinet-module_source-base', 'cabinet_corner-base-run'], + } + const sourceBase = CabinetModuleNode.parse({ + id: 'cabinet-module_source-base', + parentId: sourceRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_source-wall'], + }) + const sourceWall = CabinetModuleNode.parse({ + id: 'cabinet-module_source-wall', + parentId: sourceBase.id, + name: 'Wall Cabinet', + }) + const baseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-base-run', + parentId: sourceRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_corner-filler', 'cabinet-module_corner-base'], + } + const cornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler', + parentId: baseLegRun.id, + name: 'Corner Filler', + children: ['cabinet_corner-bridge-run', 'cabinet_corner-wall-run'], + }) + const cornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base', + parentId: baseLegRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_corner-wall-cabinet'], + }) + const bridgeRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-bridge-run', + parentId: cornerFiller.id, + name: 'Corner Wall Bridge', + metadata: { + cabinetCornerDerivedRun: { + role: 'bridge', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_bridge-filler'], + } + const bridgeFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_bridge-filler', + parentId: bridgeRun.id, + name: 'Wall Bridge Filler', + }) + const wallLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-wall-run', + parentId: cornerFiller.id, + name: 'Corner Wall Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'wall-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_corner-wall-filler'], + } + const cornerWallFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-wall-filler', + parentId: wallLegRun.id, + name: 'Corner Wall Filler', + }) + const cornerWallCabinet = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-wall-cabinet', + parentId: wallLegRun.id, + name: 'Wall Cabinet', + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceBase.id]: sourceBase, + [sourceWall.id]: sourceWall, + [baseLegRun.id]: baseLegRun, + [cornerFiller.id]: cornerFiller, + [cornerBase.id]: cornerBase, + [bridgeRun.id]: bridgeRun, + [bridgeFiller.id]: bridgeFiller, + [wallLegRun.id]: wallLegRun, + [cornerWallFiller.id]: cornerWallFiller, + [cornerWallCabinet.id]: cornerWallCabinet, + } as Record + + expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([ + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + ]) + expect(nodeNames(treeChildIds(sourceBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(nodeNames(treeChildIds(cornerFiller.id, nodes), nodes)).toEqual([ + 'Wall Bridge Filler', + 'Corner Wall Filler', + ]) + expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(cabinetTreeHidden(baseLegRun, nodes)).toBe(true) + expect(cabinetTreeHidden(bridgeRun, nodes)).toBe(true) + expect(cabinetTreeHidden(wallLegRun, nodes)).toBe(true) + expect(treeContainsDescendant(sourceRun.id, bridgeFiller.id, nodes)).toBe(true) + expect(treeContainsDescendant(cornerFiller.id, cornerWallFiller.id, nodes)).toBe(true) + }) + + test('surfaces nested hidden corner runs under the base cabinet they were created from', () => { + const sourceRun = { + ...CabinetNode.parse({ + id: 'cabinet_source-run-nested-tree', + parentId: 'level_nested_tree' as AnyNodeId, + }), + children: ['cabinet-module_source-base-nested', 'cabinet_corner-base-run-nested'], + } + const sourceBase = CabinetModuleNode.parse({ + id: 'cabinet-module_source-base-nested', + parentId: sourceRun.id, + name: 'Base Cabinet', + }) + const baseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-base-run-nested', + parentId: sourceRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: [ + 'cabinet-module_corner-filler-nested', + 'cabinet-module_corner-base-nested', + 'cabinet_corner-second-base-run-nested', + ], + } + const cornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-nested', + parentId: baseLegRun.id, + name: 'Corner Filler', + }) + const cornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-nested', + parentId: baseLegRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_corner-base-wall-nested'], + }) + const cornerBaseWall = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-wall-nested', + parentId: cornerBase.id, + name: 'Wall Cabinet', + }) + const nestedBaseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-second-base-run-nested', + parentId: baseLegRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: cornerBase.id, + sourceRunId: baseLegRun.id, + }, + }, + }), + children: ['cabinet-module_corner-filler-second', 'cabinet-module_corner-base-second'], + } + const secondCornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-second', + parentId: nestedBaseLegRun.id, + name: 'Corner Filler', + }) + const secondCornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-second', + parentId: nestedBaseLegRun.id, + name: 'Base Cabinet', + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceBase.id]: sourceBase, + [baseLegRun.id]: baseLegRun, + [cornerFiller.id]: cornerFiller, + [cornerBase.id]: cornerBase, + [cornerBaseWall.id]: cornerBaseWall, + [nestedBaseLegRun.id]: nestedBaseLegRun, + [secondCornerFiller.id]: secondCornerFiller, + [secondCornerBase.id]: secondCornerBase, + } as Record + + expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([ + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + ]) + expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(treeContainsDescendant(sourceRun.id, secondCornerBase.id, nodes)).toBe(true) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts new file mode 100644 index 00000000..1b2bf66a --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -0,0 +1,611 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' +import type { WallHit } from '../../shared/wall-attach-target' +import { CabinetModuleNode, CabinetNode } from '../schema' +import { + collectCabinetWallSnapNeighbors, + resolveCabinetModuleWallSnapLocal, + resolveCabinetRunWallSnap, + resolveCabinetWallFaceOffset, + resolveCabinetWallSnapPlacement, +} from '../wall-snap' + +function wallHit(overrides: Partial = {}): WallHit { + const wall = WallNode.parse({ + id: 'wall_snap-test', + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + return { + wall, + localX: 0.73, + perpDistance: 0.25, + side: 'front', + dirX: 1, + dirY: 0, + wallLength: 2, + itemRotation: 0, + ...overrides, + } +} + +describe('resolveCabinetWallSnapPlacement', () => { + test('places the cabinet back flush to the selected wall face', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.position[0]).toBeCloseTo(0.73) + expect(placement!.position[2]).toBeCloseTo(0.39) + expect(placement!.yaw).toBeCloseTo(0) + }) + + test('snaps along the wall axis when grid snap is active', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + gridStep: 0.5, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(0.5) + expect(placement!.position[0]).toBeCloseTo(0.5) + }) + + test('clamps the cabinet center so its edges stay inside the wall span', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 1.95 }), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(1.7) + expect(placement!.position[0]).toBeCloseTo(1.7) + }) + + test('snaps cabinet edges to adjacent cabinet edges on the same wall span', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 1.13 }), + neighbors: [{ minX: 0.2, maxX: 0.8 }], + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(1.1) + expect(placement!.snapReason).toBe('cabinet-edge') + }) + + test('snaps cabinet edges cleanly to wall corners', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 0.34 }), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(0.3) + expect(placement!.snapReason).toBe('corner') + }) + + test('places the cabinet back against a resolved wall face offset', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + faceOffset: 0.08, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.position[2]).toBeCloseTo(0.37) + }) + + test('resolves the visible face offset from mitered wall footprint', () => { + const level = LevelNode.parse({ + id: 'level_wall-snap-test', + children: ['wall_snap-test', 'wall_snap-cross' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_snap-test', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const crossWall = WallNode.parse({ + id: 'wall_snap-cross', + parentId: level.id, + start: [1, -1], + end: [1, 1], + thickness: 0.2, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [crossWall.id]: crossWall, + } as Record + + const offset = resolveCabinetWallFaceOffset({ + hit: wallHit({ localX: 1, wall }), + nodes, + parentLevelId: level.id, + }) + + expect(offset).toBeGreaterThan(0.09) + }) +}) + +/** + * L-corner fixture: wall A runs (0,0)→(2,0), wall B joins at (2,0) and runs + * to (2,2) — into wall A's front (+plan-y) side. Both 0.2 m thick. + */ +function cornerFixture() { + const level = LevelNode.parse({ + id: 'level_corner', + children: ['wall_corner-a', 'wall_corner-b' as AnyNodeId], + }) + const wallA = WallNode.parse({ + id: 'wall_corner-a', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const wallB = WallNode.parse({ + id: 'wall_corner-b', + parentId: level.id, + start: [2, 0], + end: [2, 2], + thickness: 0.2, + }) + const nodes = { + [level.id]: level, + [wallA.id]: wallA, + [wallB.id]: wallB, + } as Record + return { level, wallA, wallB, nodes } +} + +describe('resolveCabinetWallFaceOffset', () => { + test('resolves half the wall thickness on a straight wall face, signed by side', () => { + const level = LevelNode.parse({ + id: 'level_straight', + children: ['wall_snap-test' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_snap-test', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const nodes = { [level.id]: level, [wall.id]: wall } as Record + + const front = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall }), + nodes, + parentLevelId: level.id, + }) + const back = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall, side: 'back' }), + nodes, + parentLevelId: level.id, + }) + + expect(front).toBeCloseTo(0.1) + expect(back).toBeCloseTo(-0.1) + }) + + test('follows the miter diagonal on the joined face of an L-corner', () => { + const { level, wallA, nodes } = cornerFixture() + const offsetAt = (localX: number, side: WallHit['side'] = 'front') => + resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX, side }), + nodes, + parentLevelId: level.id, + }) + + // Away from the junction the front face is the plain half-thickness. + expect(offsetAt(0.5)).toBeCloseTo(0.1) + // The miter cuts the front face back linearly toward the corner point. + expect(offsetAt(1.95)).toBeCloseTo(0.05) + expect(offsetAt(2)).toBeCloseTo(0) + // The back face is untouched by a front-side junction. + expect(offsetAt(1.95, 'back')).toBeCloseTo(-0.1) + }) + + test('falls back to half the wall thickness when the ray misses the footprint', () => { + const { level, wallA, nodes } = cornerFixture() + + const front = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX: -1 }), + nodes, + parentLevelId: level.id, + }) + const back = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX: -1, side: 'back' }), + nodes, + parentLevelId: level.id, + }) + + expect(front).toBeCloseTo(0.1) + expect(back).toBeCloseTo(-0.1) + }) + + test('falls back to half the wall thickness when the level has no walls', () => { + const offset = resolveCabinetWallFaceOffset({ + hit: wallHit(), + nodes: {} as Record, + parentLevelId: 'level_missing' as AnyNodeId, + }) + + expect(offset).toBeCloseTo(0.1) + }) +}) + +describe('collectCabinetWallSnapNeighbors', () => { + const levelId = 'level_neighbors' as AnyNodeId + + function neighborFixture(cabinetOverrides: { + position?: [number, number, number] + rotation?: number + parentId?: AnyNodeId + }) { + const level = LevelNode.parse({ + id: levelId, + children: ['wall_snap-test' as AnyNodeId], + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_neighbor', + parentId: cabinetOverrides.parentId ?? level.id, + // Back flush against the front face of the [0,0]→[2,0] wall: + // z = thickness/2 + depth/2 = 0.1 + 0.29. + position: cabinetOverrides.position ?? [0.7, 0, 0.39], + rotation: cabinetOverrides.rotation ?? 0, + width: 0.6, + depth: 0.58, + }) + return { + level, + cabinet, + nodes: { [level.id]: level, [cabinet.id]: cabinet } as Record, + } + } + + test('collects a same-face cabinet as a local-x edge interval', () => { + const { nodes } = neighborFixture({}) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(1) + expect(neighbors[0]!.minX).toBeCloseTo(0.4) + expect(neighbors[0]!.maxX).toBeCloseTo(1.0) + }) + + test('tolerates rotation within the yaw threshold', () => { + const { nodes } = neighborFixture({ rotation: 0.05 }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(1) + }) + + test('ignores cabinets whose rotation does not match the wall face yaw', () => { + const { nodes } = neighborFixture({ rotation: Math.PI / 2 }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets standing off the hit wall face', () => { + // Right yaw, but 21 cm proud of the flush position — past the face-match threshold. + const { nodes } = neighborFixture({ position: [0.7, 0, 0.6] }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets parented to another level', () => { + const { nodes } = neighborFixture({ parentId: 'level_other' as AnyNodeId }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets whose span cannot reach the moving cabinet on the wall', () => { + // Entirely left of the wall start: maxX = -0.7 < width / 2. + const { nodes } = neighborFixture({ position: [-1, 0, 0.39] }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('measures a run with modules from the module span, not the run node width', () => { + const { level } = neighborFixture({}) + const run = CabinetNode.parse({ + id: 'cabinet_neighbor', + parentId: level.id, + position: [0.5, 0, 0.39], + rotation: 0, + width: 0.6, + depth: 0.58, + children: ['cabinet-module_a', 'cabinet-module_b' as AnyNodeId], + }) + const moduleA = CabinetModuleNode.parse({ + id: 'cabinet-module_a', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + const moduleB = CabinetModuleNode.parse({ + id: 'cabinet-module_b', + parentId: run.id, + position: [0.9, 0.1, 0], + width: 0.6, + }) + const nodes = { + [level.id]: level, + [run.id]: run, + [moduleA.id]: moduleA, + [moduleB.id]: moduleB, + } as Record + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + // Module span is run-local [0, 1.2] → plan [0.5, 1.7] along the wall. + expect(neighbors).toHaveLength(1) + expect(neighbors[0]!.minX).toBeCloseTo(0.5) + expect(neighbors[0]!.maxX).toBeCloseTo(1.7) + }) +}) + +describe('resolveCabinetRunWallSnap', () => { + test('snaps a moved cabinet run flush to the nearest wall while ignoring moving peers', () => { + const level = LevelNode.parse({ + id: 'level_group-wall-snap', + children: ['wall_group-snap' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_group-snap', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const movingCabinet = CabinetNode.parse({ + id: 'cabinet_group-snap', + parentId: level.id, + position: [1.2, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_group-snap'], + }) + const movingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_group-snap', + parentId: movingCabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const peerMovingCabinet = CabinetNode.parse({ + id: 'cabinet_peer-moving', + parentId: level.id, + position: [2.4, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_peer-moving'], + }) + const peerMovingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_peer-moving', + parentId: peerMovingCabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [movingCabinet.id]: movingCabinet, + [movingModule.id]: movingModule, + [peerMovingCabinet.id]: peerMovingCabinet, + [peerMovingModule.id]: peerMovingModule, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet: movingCabinet, + candidatePosition: [1.2, 0, 0.32], + excludeIds: [movingCabinet.id as AnyNodeId, peerMovingCabinet.id as AnyNodeId], + gridStep: 0.5, + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(snapped![0]).toBeCloseTo(1) + expect(snapped![2]).toBeCloseTo(0.39) + }) + + test('does not snap to a wall that is moving with the same group', () => { + const level = LevelNode.parse({ + id: 'level_group-wall-moving', + children: ['wall_group-moving' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_group-moving', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_group-moving', + parentId: level.id, + position: [1.2, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_group-moving'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_group-moving', + parentId: cabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [cabinet.id]: cabinet, + [module.id]: module, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: [1.2, 0, 0.32], + excludeIds: [cabinet.id as AnyNodeId, wall.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).toBeNull() + }) +}) + +describe('resolveCabinetModuleWallSnapLocal', () => { + function moduleDragFixture(runOverrides: { position?: [number, number, number] } = {}) { + const level = LevelNode.parse({ + id: 'level_module-drag', + children: ['wall_module-drag' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_module-drag', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const run = CabinetNode.parse({ + id: 'cabinet_module-drag', + parentId: level.id, + position: runOverrides.position ?? [1, 0, 0.39], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_dragged'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_dragged', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [run.id]: run, + [module.id]: module, + } as Record + return { level, wall, run, module, nodes } + } + + test('pulls a dragged module flush to the wall in run-local coordinates', () => { + const { level, run, module, nodes } = moduleDragFixture() + + // Cursor drifted 10 cm toward the wall (run-local z = plan z - 0.39, + // so plan z = 0.29 — within the 0.4 m wall-snap range). + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, -0.1], + module, + nodes, + parentLevelId: level.id, + run, + }) + + expect(snapped).not.toBeNull() + // Local x preserved (no neighbor stops), local z back to flush = 0. + expect(snapped![0]).toBeCloseTo(0.5) + expect(snapped![1]).toBeCloseTo(0.1) + expect(snapped![2]).toBeCloseTo(0) + }) + + test('returns null when the module faces away from the closest wall', () => { + const { level, module, nodes } = moduleDragFixture() + const rotatedRun = CabinetNode.parse({ + id: 'cabinet_module-drag', + parentId: level.id, + position: [1, 0, 0.39], + rotation: Math.PI / 2, + depth: 0.58, + children: ['cabinet-module_dragged'], + }) + + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, 0.2], + module, + nodes: { ...nodes, [rotatedRun.id]: rotatedRun } as Record, + parentLevelId: level.id, + run: rotatedRun, + }) + + expect(snapped).toBeNull() + }) + + test('returns null when the closest wall is out of snap range', () => { + const { level, run, module, nodes } = moduleDragFixture() + + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, 2], + module, + nodes, + parentLevelId: level.id, + run, + }) + + expect(snapped).toBeNull() + }) +}) diff --git a/packages/nodes/src/cabinet/animation.ts b/packages/nodes/src/cabinet/animation.ts new file mode 100644 index 00000000..8aac88f0 --- /dev/null +++ b/packages/nodes/src/cabinet/animation.ts @@ -0,0 +1,78 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import * as THREE from 'three' + +type CabinetPose = + | { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number } + | { type: 'translate'; axis: 'x' | 'y' | 'z'; distance: number } + +const POSE_EPSILON = 1e-5 + +export function poseCabinetMovingParts(root: THREE.Object3D, openScale: number): boolean { + let posed = false + root.traverse((obj) => { + const pose = obj.userData.cabinetPose as CabinetPose | undefined + if (!pose) return + posed = true + if (pose.type === 'rotate') obj.rotation[pose.axis] = pose.angle * openScale + else obj.position[pose.axis] = pose.distance * openScale + }) + return posed +} + +export function bakeCabinetAnimationClip( + node: CabinetNode | CabinetModuleNode, + object: THREE.Object3D, +): THREE.AnimationClip | null { + if (!poseCabinetMovingParts(object, 0)) return null + + const objects: THREE.Object3D[] = [] + object.traverse((child) => objects.push(child)) + const basePoses = objects.map((child) => ({ + position: child.position.clone(), + quaternion: child.quaternion.clone(), + scale: child.scale.clone(), + })) + + poseCabinetMovingParts(object, 1) + + const tracks: THREE.KeyframeTrack[] = [] + for (let i = 0; i < objects.length; i++) { + const child = objects[i]! + const base = basePoses[i]! + + if (child.position.distanceToSquared(base.position) > POSE_EPSILON) { + tracks.push( + new THREE.VectorKeyframeTrack( + `${child.uuid}.position`, + [0, 1], + [...base.position.toArray(), ...child.position.toArray()], + ), + ) + } + if (child.quaternion.angleTo(base.quaternion) > POSE_EPSILON) { + tracks.push( + new THREE.QuaternionKeyframeTrack( + `${child.uuid}.quaternion`, + [0, 1], + [...base.quaternion.toArray(), ...child.quaternion.toArray()], + ), + ) + } + if (child.scale.distanceToSquared(base.scale) > POSE_EPSILON) { + tracks.push( + new THREE.VectorKeyframeTrack( + `${child.uuid}.scale`, + [0, 1], + [...base.scale.toArray(), ...child.scale.toArray()], + ), + ) + } + } + + poseCabinetMovingParts(object, 0) + + if (tracks.length === 0) return null + const clip = new THREE.AnimationClip(`${node.id}: open`, 1, tracks) + clip.userData = { loop: false } + return clip +} diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx new file mode 100644 index 00000000..42405a13 --- /dev/null +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -0,0 +1,486 @@ +'use client' + +import { SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor' +import { ArrowDown, ArrowUp, Minus, Plus, Trash } from 'lucide-react' +import { + type CabinetCompartment, + type CabinetCompartmentType, + type CabinetCooktopCompartmentType, + type CabinetFridgeCompartmentType, + COOKTOP_DEFAULT_GAS_LAYOUT, + COOKTOP_DEFAULT_INDUCTION_LAYOUT, + type CooktopLayout, + compartmentCooktopBurnersOn, + compartmentCooktopElementCount, + compartmentCooktopLayout, + compartmentCooktopShowGrate, + compartmentDoorType, + compartmentDrawerCount, + compartmentPullOutPantryRackStyle, + compartmentShelfCount, + compartmentSinkLayout, + FRIDGE_COLUMN_HEIGHT, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + newCabinetCompartment, + type PULL_OUT_PANTRY_RACK_STYLES, + patchCompartment, + type SinkLayout, +} from './stack' + +const COMPARTMENT_TYPE_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, + { value: 'oven', label: 'Oven' }, + { value: 'microwave', label: 'Micro' }, + { value: 'dishwasher', label: 'Washer' }, + { value: 'cooktop', label: 'Hob' }, + { value: 'sink', label: 'Sink' }, + { value: 'pull-out-pantry', label: 'Pullout' }, +] as const + +const FRIDGE_TYPE_OPTION = { value: 'fridge', label: 'Fridge' } as const +const HOOD_TYPE_OPTION = { value: 'hood', label: 'Chimney' } as const +const COMPARTMENT_TYPE_CONTROL_OPTIONS = [...COMPARTMENT_TYPE_OPTIONS, FRIDGE_TYPE_OPTION] as const +const WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, + HOOD_TYPE_OPTION, +] as const + +const FRIDGE_STYLE_OPTIONS = [ + { value: 'fridge-single', label: 'Single' }, + { value: 'fridge-double', label: 'Double' }, + { value: 'fridge-top-freezer', label: 'Top Freezer' }, + { value: 'fridge-bottom-freezer', label: 'Bottom Freezer' }, +] as const + +const COOKTOP_STYLE_OPTIONS = [ + { value: 'cooktop-gas', label: 'Gas' }, + { value: 'cooktop-induction', label: 'Induction' }, +] as const + +const GAS_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'gas-2burner', label: '2' }, + { value: 'gas-4burner', label: '4' }, + { value: 'gas-5burner-wok', label: '5' }, + { value: 'gas-6burner', label: '6' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const INDUCTION_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'induction-2zone', label: '2' }, + { value: 'induction-4zone', label: '4' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const SINK_LAYOUT_OPTIONS = [ + { value: 'single', label: 'Single' }, + { value: 'double', label: 'Double' }, + { value: 'double-offset', label: '60/40' }, +] as const satisfies Array<{ value: SinkLayout; label: string }> + +const PULL_OUT_PANTRY_RACK_STYLE_OPTIONS = [ + { value: 'wire', label: 'Wire' }, + { value: 'tray', label: 'Tray' }, + { value: 'glass', label: 'Glass' }, +] as const satisfies Array<{ value: (typeof PULL_OUT_PANTRY_RACK_STYLES)[number]; label: string }> + +const DOOR_TYPE_OPTIONS = [ + { value: 'single-left', label: 'Left' }, + { value: 'single-right', label: 'Right' }, + { value: 'double', label: 'Double' }, + { value: 'glass', label: 'Glass' }, +] as const + +const ICON_BUTTON_CLASS = + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' + +const STEPPER_BUTTON_CLASS = + 'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground' + +function Stepper({ + label, + value, + onChange, + min, + max, +}: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number +}) { + return ( +
+ {label} +
+ + + {value} + + +
+
+ ) +} + +function CompartmentTypeControl({ + value, + onChange, + includeHood = false, + wallCabinet = false, +}: { + value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop' + onChange: (value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop') => void + includeHood?: boolean + wallCabinet?: boolean +}) { + const options = wallCabinet + ? WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS + : includeHood + ? [...COMPARTMENT_TYPE_CONTROL_OPTIONS, HOOD_TYPE_OPTION] + : COMPARTMENT_TYPE_CONTROL_OPTIONS + return ( +
+ {options.map((option) => { + const isSelected = value === option.value + return ( + + ) + })} +
+ ) +} + +export function CompartmentCard({ + compartment, + index, + displayIndex, + total, + carcassHeight, + resolvedHeight, + width, + onReplace, + onResizeHeight, + onRemove, + onMove, + allowHood = false, + wallCabinet = false, +}: { + compartment: CabinetCompartment + index: number + displayIndex: number + total: number + carcassHeight: number + resolvedHeight: number + width: number + onReplace: (next: CabinetCompartment) => void + onResizeHeight: (height: number) => void + onRemove: () => void + onMove: (delta: -1 | 1) => void + allowHood?: boolean + wallCabinet?: boolean +}) { + const type = compartment.type as CabinetCompartmentType + const isFridge = isFridgeCompartmentType(type) + const isHood = isHoodCompartmentType(type) + const isCooktop = isCooktopCompartmentType(type) + return ( +
+
+ + {displayIndex === 0 + ? 'Top' + : displayIndex === total - 1 + ? 'Bottom' + : `#${total - displayIndex}`} + +
+ + + +
+
+ +
+
+ Type +
+ { + const nextType: CabinetCompartmentType = + value === 'fridge' + ? 'fridge-single' + : value === 'hood' + ? 'hood-pyramid' + : value === 'cooktop' + ? 'cooktop-gas' + : (value as CabinetCompartmentType) + onReplace({ + ...newCabinetCompartment(nextType), + id: compartment.id, + }) + }} + value={isFridge ? 'fridge' : isHood ? 'hood' : isCooktop ? 'cooktop' : type} + /> +
+ + {!isHood && !isCooktop && type !== 'sink' && ( +
+ +
+ )} + + {type === 'shelf' && ( + onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> + )} + + {type === 'drawer' && ( + onReplace(patchCompartment(compartment, { drawerCount: value }))} + value={compartmentDrawerCount(compartment)} + /> + )} + + {type === 'door' && ( +
+
+
+ Style +
+ onReplace(patchCompartment(compartment, { doorType: value }))} + options={DOOR_TYPE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentDoorType(compartment, width)} + /> +
+ onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> +
+ )} + + {isFridge && ( +
+
+ Style +
+ + onReplace( + patchCompartment(compartment, { + type: value as CabinetFridgeCompartmentType, + height: compartment.height ?? FRIDGE_COLUMN_HEIGHT, + }), + ) + } + options={FRIDGE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+ )} + + {isHood && ( +
+ Chimney +
+ )} + + {isCooktop && ( +
+
+ Surface +
+ + onReplace( + patchCompartment(compartment, { + type: value as CabinetCooktopCompartmentType, + cooktopLayout: + value === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT, + height: compartment.height ?? 0.08, + cooktopBurnersOn: compartmentCooktopBurnersOn(compartment), + cooktopShowGrate: compartmentCooktopShowGrate(compartment), + }), + ) + } + options={COOKTOP_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+
+ Layout +
+ + onReplace(patchCompartment(compartment, { cooktopLayout: value })) + } + options={(type === 'cooktop-gas' + ? GAS_COOKTOP_LAYOUT_OPTIONS + : INDUCTION_COOKTOP_LAYOUT_OPTIONS + ).map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentCooktopLayout(compartment, type as CabinetCooktopCompartmentType)} + /> +
+ { + const count = compartmentCooktopElementCount( + compartment, + type as CabinetCooktopCompartmentType, + ) + onReplace( + patchCompartment(compartment, { + cooktopBurnersOn: checked, + cooktopActiveBurners: checked + ? Array.from({ length: count }, (_, index) => index) + : [], + cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)), + }), + ) + }} + /> + {type === 'cooktop-gas' && ( + + onReplace(patchCompartment(compartment, { cooktopShowGrate: checked })) + } + /> + )} +
+ )} + + {type === 'sink' && ( +
+
+ Bowls +
+ onReplace(patchCompartment(compartment, { sinkLayout: value }))} + options={SINK_LAYOUT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentSinkLayout(compartment)} + /> +
+ )} + + {type === 'pull-out-pantry' && ( +
+ onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> +
+
+ Rack +
+ + onReplace(patchCompartment(compartment, { pantryRackStyle: value })) + } + options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentPullOutPantryRackStyle(compartment)} + /> +
+
+ )} +
+ ) +} diff --git a/packages/nodes/src/cabinet/continuous-placement.ts b/packages/nodes/src/cabinet/continuous-placement.ts new file mode 100644 index 00000000..23456cf4 --- /dev/null +++ b/packages/nodes/src/cabinet/continuous-placement.ts @@ -0,0 +1,186 @@ +import type { FloorPlacementClickTriggerEvent } from '../shared/floor-placement' +import { planToRunLocal, runLocalToPlan } from './run-layout' +import { CABINET_BASE_WIDTH } from './run-ops' + +export type CabinetStretchPreview = { + modules: { x: number; width: number }[] + length: number + centerLocalX: number + direction: 1 | -1 +} + +export type StretchAnchor = { + position: [number, number, number] + yaw: number + snappedToWall: boolean + wallSurfaceNormal?: [number, number, number] + forcedDirection?: 1 | -1 + leadingWidth?: number +} + +export type StretchContinuation = { + hingePosition: [number, number, number] + sourceYaw: number + sourceDirection: 1 | -1 + straightAnchor: StretchAnchor + turnAnchor: StretchAnchor +} + +type PlacementCollisionResult = { + conflictIds: string[] + valid: boolean +} + +const MIN_END_MODULE_WIDTH = 0.1 + +export function isForcePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { + const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent + return native?.altKey === true +} + +// Fill a span with standard-width modules; the remainder becomes a narrower +// end module (dropped entirely below MIN_END_MODULE_WIDTH). +export function fillCabinetContinuousSpan(length: number): number[] { + const full = Math.max(1, Math.floor((length + 1e-6) / CABINET_BASE_WIDTH)) + const widths: number[] = new Array(full).fill(CABINET_BASE_WIDTH) + const remainder = length - full * CABINET_BASE_WIDTH + if (remainder >= MIN_END_MODULE_WIDTH) widths.push(remainder) + return widths +} + +export function planCabinetContinuousStretch({ + anchor, + previewWidth, + rawPlanPosition, +}: { + anchor: StretchAnchor + previewWidth: number + rawPlanPosition: [number, number, number] +}): CabinetStretchPreview { + const runLike = { position: anchor.position, rotation: anchor.yaw } + const localX = planToRunLocal(runLike, rawPlanPosition[0], 0, rawPlanPosition[2])[0] + const dir: 1 | -1 = anchor.forcedDirection ?? (localX >= 0 ? 1 : -1) + const firstWidth = anchor.leadingWidth ?? previewWidth + const halfFirst = firstWidth / 2 + const hasLeadingWidth = anchor.leadingWidth != null + const projected = anchor.forcedDirection ? Math.max(0, localX * dir) : Math.abs(localX) + const minTotalLength = hasLeadingWidth ? firstWidth + previewWidth : firstWidth + const length = Math.max(projected + halfFirst, minTotalLength) + const trailingLength = Math.max(0, length - firstWidth) + const trailingWidths = trailingLength <= 1e-6 ? [] : fillCabinetContinuousSpan(trailingLength) + const widths = [firstWidth, ...trailingWidths] + const total = widths.reduce((sum, width) => sum + width, 0) + let cum = 0 + const modules = widths.map((width) => { + const x = dir * (cum + width / 2 - halfFirst) + cum += width + return { x, width } + }) + return { + modules, + length: total, + centerLocalX: dir * (total / 2 - halfFirst), + direction: dir, + } +} + +export function cabinetStretchExitSide(stretch: CabinetStretchPreview): 'left' | 'right' { + return stretch.direction === 1 ? 'right' : 'left' +} + +export function cabinetStretchEndLocalX( + stretch: CabinetStretchPreview, + previewWidth: number, +): number { + return stretch.direction * (stretch.length - previewWidth / 2) +} + +export function createCabinetContinuousContinuation({ + anchor, + previewDepth, + previewWidth, + stretch, +}: { + anchor: StretchAnchor + previewDepth: number + previewWidth: number + stretch: CabinetStretchPreview +}): StretchContinuation { + const endLocalX = cabinetStretchEndLocalX(stretch, previewWidth) + const hingePosition = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + endLocalX, + 0, + 0, + ]) + const straightAnchor = { + position: runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + endLocalX + stretch.direction * (previewWidth / 2), + 0, + 0, + ]), + yaw: anchor.yaw, + snappedToWall: anchor.snappedToWall, + wallSurfaceNormal: anchor.wallSurfaceNormal, + } satisfies StretchAnchor + + const exitSide = cabinetStretchExitSide(stretch) + const sourceAxis: [number, number] = [Math.cos(anchor.yaw), -Math.sin(anchor.yaw)] + const corner = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + endLocalX, + 0, + -previewDepth / 2, + ]) + const sign = exitSide === 'right' ? 1 : -1 + const shiftedCorner: [number, number] = [ + corner[0] + sign * previewDepth * sourceAxis[0], + corner[2] + sign * previewDepth * sourceAxis[1], + ] + const yaw = exitSide === 'right' ? anchor.yaw - Math.PI / 2 : anchor.yaw + Math.PI / 2 + const turnAnchor = { + position: runLocalToPlan( + { + position: [shiftedCorner[0], anchor.position[1], shiftedCorner[1]], + rotation: yaw, + }, + [previewDepth / 2, 0, previewDepth / 2], + ), + yaw, + snappedToWall: false, + forcedDirection: 1, + leadingWidth: previewDepth, + } satisfies StretchAnchor + + return { + hingePosition, + sourceYaw: anchor.yaw, + sourceDirection: stretch.direction, + straightAnchor, + turnAnchor, + } +} + +export function chooseCabinetContinuousAnchor( + continuation: StretchContinuation, + rawPlanPosition: [number, number, number], +): StretchAnchor { + const [localX, , localZ] = planToRunLocal( + { position: continuation.hingePosition, rotation: continuation.sourceYaw }, + rawPlanPosition[0], + 0, + rawPlanPosition[2], + ) + const forward = localX * continuation.sourceDirection + const lateral = Math.abs(localZ) + return forward >= lateral ? continuation.straightAnchor : continuation.turnAnchor +} + +export function resolveCabinetContinuousValidity( + result: PlacementCollisionResult, + forcePlace: boolean, +): PlacementCollisionResult { + return forcePlace ? { conflictIds: [], valid: true } : result +} + +export function isCabinetContinuousFollowUpClick(clickCount: number): boolean { + return clickCount >= 2 +} diff --git a/packages/nodes/src/cabinet/cooktop-flame.ts b/packages/nodes/src/cabinet/cooktop-flame.ts new file mode 100644 index 00000000..049500c9 --- /dev/null +++ b/packages/nodes/src/cabinet/cooktop-flame.ts @@ -0,0 +1,145 @@ +import { BufferAttribute, BufferGeometry, CatmullRomCurve3, Sphere, Vector3 } from 'three' + +// Real gas-burner flames are a ring of small jets: each one leaves its port, +// sweeps outward as it rises, then bends back up at the tip. Most of the body +// is blue/cyan; only the top ~25% picks up the orange-yellow nip. Each flame +// is a tapered tube along a CatmullRom spine, vertex-coloured along its +// length; per frame the spine control points wobble + breathe so the flame +// licks and flickers without shaders. + +export const COOKTOP_FLAME_COUNT = 22 +export const COOKTOP_FLAME_SEG = 12 +export const COOKTOP_FLAME_RAD = 5 + +export type CooktopFlameSeed = { + phase: number + speed: number + height: number + reach: number +} + +export function cooktopFlameSeed(index: number): CooktopFlameSeed { + return { + phase: (index * 1.37) % (Math.PI * 2), + speed: 0.45 + ((index * 7) % 5) * 0.06, + height: 0.85 + ((index * 3) % 5) * 0.07 + (index % 2) * 0.12, + reach: 0.92 + ((index * 11) % 4) * 0.05, + } +} + +const C_DEEP_BLUE = [0.15, 0.45, 1.7] as const +const C_BRIGHT_CYAN = [0.55, 1.1, 1.95] as const +const C_ORANGE = [1.85, 0.7, 0.18] as const +const C_YELLOW = [2.05, 1.65, 0.4] as const + +function lerpRGB( + a: readonly [number, number, number], + b: readonly [number, number, number], + k: number, + out: Float32Array, + offset: number, +) { + out[offset] = a[0] + (b[0] - a[0]) * k + out[offset + 1] = a[1] + (b[1] - a[1]) * k + out[offset + 2] = a[2] + (b[2] - a[2]) * k +} + +// Colour at curve parameter u (0 = port, 1 = tip): blue body, narrow swing +// to orange, yellow only at the very tip. +function flameColourAt(u: number, out: Float32Array, offset: number) { + if (u < 0.45) lerpRGB(C_DEEP_BLUE, C_BRIGHT_CYAN, u / 0.45, out, offset) + else if (u < 0.75) lerpRGB(C_BRIGHT_CYAN, C_ORANGE, (u - 0.45) / 0.3, out, offset) + else lerpRGB(C_ORANGE, C_YELLOW, (u - 0.75) / 0.25, out, offset) +} + +// Pre-allocated tube geometry — positions are mutated in place every frame; +// colours depend only on u so they are written once here. +export function createCooktopFlameGeometry(): BufferGeometry { + const vCount = (COOKTOP_FLAME_SEG + 1) * (COOKTOP_FLAME_RAD + 1) + const positions = new Float32Array(vCount * 3) + const colors = new Float32Array(vCount * 3) + for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) { + const u = i / COOKTOP_FLAME_SEG + for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) { + flameColourAt(u, colors, ((COOKTOP_FLAME_RAD + 1) * i + j) * 3) + } + } + const indices: number[] = [] + for (let i = 1; i <= COOKTOP_FLAME_SEG; i += 1) { + for (let j = 1; j <= COOKTOP_FLAME_RAD; j += 1) { + const a = (COOKTOP_FLAME_RAD + 1) * (i - 1) + (j - 1) + const b = (COOKTOP_FLAME_RAD + 1) * i + (j - 1) + const c = (COOKTOP_FLAME_RAD + 1) * i + j + const d = (COOKTOP_FLAME_RAD + 1) * (i - 1) + j + indices.push(a, b, d, b, c, d) + } + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(positions, 3)) + geometry.setAttribute('color', new BufferAttribute(colors, 3)) + geometry.setIndex(indices) + // Static generous bound — flames are a few cm; skips per-frame recompute. + geometry.boundingSphere = new Sphere(new Vector3(0.02, 0.03, 0), 0.12) + return geometry +} + +// Scratch objects reused across calls so the per-frame allocator stays quiet. +const _spineVecs = [new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3()] +const _curve = new CatmullRomCurve3(_spineVecs, false, 'catmullrom', 0.5) +const _pt = new Vector3() + +// Rebuild a flame tube's vertices for time `t`. Built in the flame's LOCAL +// frame — the parent group sits at the port and is rotated so local +X +// points outward from the burner centre. +export function updateCooktopFlameTube( + positions: Float32Array, + t: number, + seed: CooktopFlameSeed, + burnerR: number, +) { + const tt = t * seed.speed + seed.phase + + // Compact gas jet (3-4 cm): the dominant motion is this breathing factor — + // the tongue bobs like a real burner flame rather than strobing. + const breathe = 0.93 + 0.09 * Math.sin(tt * 2.6) + 0.03 * Math.sin(tt * 5.1) + const length = 0.038 * seed.height * breathe + const reach = length * 0.4 * seed.reach + + // Tiny spine perturbations so the hook shape holds and just trembles. + const wobX = Math.sin(tt * 2.1) * 0.0008 + const wobY = Math.cos(tt * 2.4 + 0.7) * 0.0015 + const sway = Math.sin(tt * 1.9) * 0.0022 + + _spineVecs[0]!.set(0, 0, 0) + _spineVecs[1]!.set(reach * 0.28 + wobX, length * 0.2, sway * 0.25) + _spineVecs[2]!.set(reach * 0.62, length * 0.45 + wobY, sway * 0.55) + _spineVecs[3]!.set(reach * 0.55 - wobX, length * 0.75, sway * 0.35) + _spineVecs[4]!.set(reach * 0.35, length * 0.98 + wobY, sway * 0.08) + + _curve.points = _spineVecs + _curve.updateArcLengths() + const frames = _curve.computeFrenetFrames(COOKTOP_FLAME_SEG, false) + + // Slim at the port, slight mid-body bulge, sharp tip — the teardrop + // silhouette of a real gas jet. Scales with the burner head. + const baseR = burnerR * 0.11 + const tipR = burnerR * 0.018 + + for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) { + const u = i / COOKTOP_FLAME_SEG + _curve.getPointAt(u, _pt) + const N = frames.normals[i]! + const B = frames.binormals[i]! + const taper = baseR * (1 - u) + tipR * u + Math.sin(u * Math.PI) * burnerR * 0.018 + const baseV = (COOKTOP_FLAME_RAD + 1) * i + for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) { + const ang = (j / COOKTOP_FLAME_RAD) * Math.PI * 2 + const sn = Math.sin(ang) + const cs = -Math.cos(ang) + const vIdx = (baseV + j) * 3 + positions[vIdx] = _pt.x + taper * (cs * N.x + sn * B.x) + positions[vIdx + 1] = _pt.y + taper * (cs * N.y + sn * B.y) + positions[vIdx + 2] = _pt.z + taper * (cs * N.z + sn * B.z) + } + } +} diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts new file mode 100644 index 00000000..3079d663 --- /dev/null +++ b/packages/nodes/src/cabinet/definition.ts @@ -0,0 +1,1245 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, + DuplicateSubtreeCloneArgs, + DuplicateSubtreeCloneResult, + FloorPlacedFootprint, + HandleDescriptor, + NodeDefinition, + SceneApi, +} from '@pascal-app/core' +import { findLevelAncestorId, selectionProxyIdFromMetadata } from '@pascal-app/core' +import { bakeCabinetAnimationClip } from './animation' +import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' +import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' +import { cabinetFloorplanSiblingOverrides } from './floorplan-overrides' +import { buildCabinetGeometry } from './geometry' +import { toggleCabinetOperationState } from './interaction' +import { cabinetModuleParentFrame } from './move-frame' +import { cabinetPaint } from './paint' +import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' +import useCabinetPlacementType from './placement-type' +import { cabinetQuickActions } from './quick-actions' +import { moduleSideOpen } from './run-layout' +import { + backAlignZ, + bumpCabinetRunLayoutRevision, + cabinetMetadataRecord, + cabinetModulesForRun, + totalCabinetHeight as cabinetTotalHeight, + cornerLinkedSourceModuleForRun, + resolveCabinetType, + runModuleBaseY, + syncCornerRunsFromSourceModule, + wallChildOf, +} from './run-ops' +import { cabinetSceneAction } from './scene-action' +import { CabinetModuleNode, CabinetNode } from './schema' +import { cabinetSlots } from './slots' +import { + backAnchoredModuleZ, + isHoodCompartmentType, + minCabinetCarcassHeightForStack, + stackForCabinet, +} from './stack' +import { + cabinetFloorplanAffectedIds, + cabinetTreeChildIds, + cabinetTreeHidden, + cabinetTreeLabel, +} from './tree-structure' +import { resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap } from './wall-snap' + +type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +type CabinetDuplicableNode = AnyNode & { + type: 'cabinet' | 'cabinet-module' + position: [number, number, number] + rotation: number +} +type CabinetLocalBounds = { + minX: number + maxX: number + minY: number + maxY: number + minZ: number + maxZ: number + size: [number, number, number] + center: [number, number, number] +} + +function isCabinetDuplicableNode(node: AnyNode | null | undefined): node is CabinetDuplicableNode { + return ( + (node?.type === 'cabinet' || node?.type === 'cabinet-module') && + Array.isArray((node as { position?: unknown }).position) && + typeof (node as { rotation?: unknown }).rotation === 'number' + ) +} + +function stripCabinetDuplicateMetadata(metadata: unknown): Record { + const { + cabinetCornerDerivedRun: _derived, + cabinetCornerSourceLink: _source, + nodeSelectionProxyId: _proxy, + ...rest + } = cabinetMetadataRecord(metadata as CabinetEditableNode['metadata']) + return rest +} + +function cleanCabinetDuplicateNode(node: N): N { + return { + ...node, + metadata: stripCabinetDuplicateMetadata(node.metadata), + } as N +} + +function composeCabinetDuplicatePose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation: number, +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + return { + position: [ + parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin, + parentPosition[1] + childPosition[1], + parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos, + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +function cabinetDuplicateWorldPose( + node: AnyNode, + nodes: Readonly>, +): { position: [number, number, number]; rotation: number } | null { + if (!isCabinetDuplicableNode(node)) return null + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (isCabinetDuplicableNode(parent)) { + const parentPose = cabinetDuplicateWorldPose(parent, nodes) + return parentPose + ? composeCabinetDuplicatePose( + parentPose.position, + parentPose.rotation, + node.position, + node.rotation, + ) + : null + } + return { position: [...node.position], rotation: node.rotation } +} + +function prepareCabinetSubtreeClone(args: DuplicateSubtreeCloneArgs): DuplicateSubtreeCloneResult { + const parent = args.root.parentId ? args.nodes[args.root.parentId as AnyNodeId] : null + const nestedRun = args.root.type === 'cabinet' && isCabinetDuplicableNode(parent) + const worldPose = nestedRun ? cabinetDuplicateWorldPose(args.root, args.nodes) : null + const levelId = nestedRun ? findLevelAncestorId(args.rootId, args.nodes) : null + const root = { + ...cleanCabinetDuplicateNode(args.root), + ...(worldPose ? { position: worldPose.position, rotation: worldPose.rotation } : null), + ...(levelId ? { parentId: levelId as AnyNodeId } : null), + } as AnyNode + return { + root, + descendants: args.descendants.map((node) => cleanCabinetDuplicateNode(node)), + parentId: levelId ? (levelId as AnyNodeId) : undefined, + } +} + +function appendCabinetFloorPlacedFootprints( + run: CabinetNodeType, + nodes: Readonly>, + parentPosition: [number, number, number], + parentRotation: number, + footprints: FloorPlacedFootprint[], +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const runPosition: [number, number, number] = [ + parentPosition[0] + run.position[0] * cos + run.position[2] * sin, + parentPosition[1] + run.position[1], + parentPosition[2] - run.position[0] * sin + run.position[2] * cos, + ] + const runRotation = parentRotation + run.rotation + + const modules = cabinetModulesForRun(run, nodes) + if (modules.length > 0) { + const runCos = Math.cos(runRotation) + const runSin = Math.sin(runRotation) + for (const module of modules) { + const modulePosition: [number, number, number] = [ + runPosition[0] + module.position[0] * runCos + module.position[2] * runSin, + runPosition[1] + module.position[1], + runPosition[2] - module.position[0] * runSin + module.position[2] * runCos, + ] + footprints.push({ + position: modulePosition, + dimensions: [module.width, cabinetTotalHeight(module), module.depth], + rotation: [0, runRotation + module.rotation, 0], + }) + } + } else { + footprints.push({ + position: runPosition, + dimensions: [run.width, cabinetTotalHeight(run), run.depth], + rotation: [0, runRotation, 0], + }) + } + + for (const childId of run.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetRun(child)) { + appendCabinetFloorPlacedFootprints(child, nodes, runPosition, runRotation, footprints) + } + } +} + +export function cabinetFloorPlacedFootprints( + node: CabinetNodeType, + nodes?: Readonly>, +): FloorPlacedFootprint[] { + if (!nodes) { + return [ + { + position: [...node.position] as [number, number, number], + dimensions: [node.width, cabinetTotalHeight(node), node.depth], + rotation: [0, node.rotation, 0], + }, + ] + } + + const footprints: FloorPlacedFootprint[] = [] + appendCabinetFloorPlacedFootprints(node, nodes, [0, 0, 0], 0, footprints) + return footprints +} + +const SIDE_HANDLE_OFFSET = 0.18 +const HEIGHT_HANDLE_OFFSET = 0.22 +const ROTATE_CORNER_OFFSET = 0.32 +const ROTATE_RING_OFFSET = 0.04 +const MIN_CABINET_WIDTH = 0.3 +const MIN_CABINET_DEPTH = 0.3 +const MIN_CABINET_CARCASS_HEIGHT = 0.4 +const CABINET_ADJACENCY_EPSILON = 1e-4 + +function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { + return node?.type === 'cabinet-module' +} + +function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { + return node?.type === 'cabinet' +} + +function hasCabinetParentId(node: Pick): boolean { + const parentId = node.parentId + return ( + typeof parentId === 'string' && + (parentId.startsWith('cabinet_') || parentId.startsWith('cabinet-module_')) + ) +} + +function resolveCabinetGroupMoveSnap({ + candidatePosition, + levelId, + movingIds, + node, + nodes, +}: { + candidatePosition: [number, number, number] + levelId: AnyNodeId | null + movingIds: readonly AnyNodeId[] + node: AnyNode + nodes: Readonly> +}): [number, number, number] | null { + if (node.type !== 'cabinet' || !levelId) return null + return resolveCabinetRunWallSnap({ + cabinet: node, + candidatePosition, + excludeIds: movingIds, + gridStep: 0, + nodes: nodes as Record, + parentLevelId: levelId, + }) +} + +/** + * Wall snap for a single dragged module. `parentFrame` kinds exchange + * `candidatePosition` in the run's LOCAL frame with the move tool (it + * converts through `planToLocal` before and `localToPlan` after), so this + * resolver works local-in / local-out. + */ +function resolveCabinetModuleGroupMoveSnap({ + candidatePosition, + levelId, + movingIds, + node, + nodes, +}: { + candidatePosition: [number, number, number] + levelId: AnyNodeId | null + movingIds: readonly AnyNodeId[] + node: AnyNode + nodes: Readonly> +}): [number, number, number] | null { + if (node.type !== 'cabinet-module' || !node.parentId) return null + const run = nodes[node.parentId] + if (!isCabinetRun(run)) return null + const parentLevelId = (levelId ?? run.parentId ?? null) as AnyNodeId | null + if (!parentLevelId) return null + return resolveCabinetModuleWallSnapLocal({ + candidateLocal: candidatePosition, + excludeIds: movingIds, + module: node, + nodes: nodes as Record, + parentLevelId, + run, + }) +} + +function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { + return cabinetMetadataRecord(metadata).cabinetLayoutRevision ?? null +} + +function cabinetAdjacencyRevision(metadata: CabinetNodeType['metadata']): unknown { + return cabinetMetadataRecord(metadata).cabinetAdjacencyRevision ?? null +} + +// Margin added to each run's reach when deciding whether two sibling runs can +// influence each other's countertop join — generous relative to any plausible +// `countertopOverhang` so a run sliding away still re-keys the neighbor it +// just un-joined from. +const CABINET_NEIGHBOR_JOIN_MARGIN = 0.5 + +export type CabinetRunFootprint = { + parentId: AnyNodeId | null + x: number + z: number + reach: number +} + +export function cabinetRunFootprint( + run: CabinetNodeType, + nodes: Readonly>, +): CabinetRunFootprint { + const bounds = cabinetLocalBounds(run, nodes) + return { + parentId: (run.parentId ?? null) as AnyNodeId | null, + x: run.position[0], + z: run.position[2], + reach: + Math.hypot(bounds.size[0], bounds.size[2]) / 2 + + Math.hypot(bounds.center[0], bounds.center[2]) + + CABINET_NEIGHBOR_JOIN_MARGIN, + } +} + +/** + * Re-key sibling cabinet runs whose countertop join could be affected by a + * run that moved / resized / re-flowed. A run's overhang trims against + * adjacent sibling runs (`siblingCabinetSpansInRunLocal`), but a neighbor's + * own `geometryKey` doesn't change when THIS run moves — marking it dirty + * alone would be swallowed by the geometry system's key-skip cache. Bumping + * `cabinetAdjacencyRevision` (folded into the run geometryKey) both dirties + * and re-keys it. History is paused: the counter is derived presentation + * state, and an undo of the triggering move re-fires the watcher anyway. + */ +export function bumpCabinetRunsNear( + sceneApi: SceneApi, + footprints: readonly CabinetRunFootprint[], + moverIds: ReadonlySet, +) { + const nodes = sceneApi.nodes() + const targets = new Set() + for (const footprint of footprints) { + if (!footprint.parentId) continue + const parent = nodes[footprint.parentId] + const childIds = (parent as unknown as { children?: AnyNodeId[] } | undefined)?.children + if (!Array.isArray(childIds)) continue + for (const childId of childIds) { + if (moverIds.has(childId) || targets.has(childId)) continue + const sibling = nodes[childId] + if (!isCabinetRun(sibling)) continue + const siblingFootprint = cabinetRunFootprint(sibling, nodes) + const distance = Math.hypot( + siblingFootprint.x - footprint.x, + siblingFootprint.z - footprint.z, + ) + if (distance <= siblingFootprint.reach + footprint.reach) targets.add(childId) + } + } + if (targets.size === 0) return + // A mover's own trim also depends on its offset to those neighbors, and + // `position` is not in its geometryKey — re-key it alongside them. Movers + // with no neighbors in range never reach here, keeping lone-cabinet moves + // rebuild-free. + for (const id of moverIds) { + if (isCabinetRun(nodes[id as AnyNodeId])) targets.add(id as AnyNodeId) + } + + sceneApi.pauseHistory() + try { + for (const id of targets) { + const run = sceneApi.get(id) + if (!isCabinetRun(run)) continue + const metadataRecord = cabinetMetadataRecord(run.metadata) + const currentRevision = + typeof metadataRecord.cabinetAdjacencyRevision === 'number' + ? metadataRecord.cabinetAdjacencyRevision + : 0 + sceneApi.update(id, { + metadata: { ...metadataRecord, cabinetAdjacencyRevision: currentRevision + 1 }, + } as Partial) + sceneApi.markDirty(id) + } + } finally { + sceneApi.resumeHistory() + } +} + +/** Inputs of a run that can change a NEIGHBOR's countertop join. */ +export function cabinetRunNeighborSignature(run: CabinetNodeType): string { + return JSON.stringify([ + run.position[0], + run.position[2], + run.rotation, + run.width, + run.depth, + run.carcassHeight, + run.runTier, + run.countertopOverhang, + run.children ?? [], + cabinetLayoutRevision(run.metadata), + ]) +} + +function includeCabinetModuleBounds( + module: CabinetModuleNodeType, + nodes: Readonly>, + origin: readonly [number, number, number], + bounds: Pick, +) { + const x = origin[0] + module.position[0] + const y = origin[1] + module.position[1] + const z = origin[2] + module.position[2] + bounds.minX = Math.min(bounds.minX, x - module.width / 2) + bounds.maxX = Math.max(bounds.maxX, x + module.width / 2) + bounds.minY = Math.min(bounds.minY, y - (module.showPlinth ? module.plinthHeight : 0)) + bounds.maxY = Math.max( + bounds.maxY, + y + module.carcassHeight + (module.withCountertop ? module.countertopThickness : 0), + ) + bounds.minZ = Math.min(bounds.minZ, z - module.depth / 2) + bounds.maxZ = Math.max(bounds.maxZ, z + module.depth / 2) + + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) includeCabinetModuleBounds(child, nodes, [x, y, z], bounds) + } +} + +/** + * Fold a child cabinet run (an L-corner leg parented to this run) into the + * parent's local bounds: take the child's own local bounds, rotate its XZ + * corners by the child's local rotation, and offset by its local position. + */ +function includeChildRunBounds( + child: CabinetNodeType, + nodes: Readonly>, + bounds: Pick, +) { + const childBounds = cabinetLocalBounds(child, nodes) + const cos = Math.cos(child.rotation) + const sin = Math.sin(child.rotation) + for (const [lx, lz] of [ + [childBounds.minX, childBounds.minZ], + [childBounds.maxX, childBounds.minZ], + [childBounds.maxX, childBounds.maxZ], + [childBounds.minX, childBounds.maxZ], + ] as const) { + const x = child.position[0] + lx * cos + lz * sin + const z = child.position[2] - lx * sin + lz * cos + bounds.minX = Math.min(bounds.minX, x) + bounds.maxX = Math.max(bounds.maxX, x) + bounds.minZ = Math.min(bounds.minZ, z) + bounds.maxZ = Math.max(bounds.maxZ, z) + } + bounds.minY = Math.min(bounds.minY, child.position[1] + childBounds.minY) + bounds.maxY = Math.max(bounds.maxY, child.position[1] + childBounds.maxY) +} + +function cabinetLocalBounds( + node: CabinetEditableNode, + nodes?: Readonly>, +): CabinetLocalBounds { + const bounds = { + minX: -node.width / 2, + maxX: node.width / 2, + minY: 0, + maxY: cabinetTotalHeight(node), + minZ: -node.depth / 2, + maxZ: node.depth / 2, + } + + if (isCabinetRun(node) && nodes) { + const modules = cabinetModulesForRun(node, nodes) + if (modules.length > 0) { + bounds.minX = Number.POSITIVE_INFINITY + bounds.maxX = Number.NEGATIVE_INFINITY + bounds.minY = 0 + bounds.maxY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + bounds.minZ = Number.POSITIVE_INFINITY + bounds.maxZ = Number.NEGATIVE_INFINITY + for (const module of modules) { + includeCabinetModuleBounds(module, nodes, [0, 0, 0], bounds) + } + bounds.maxY += node.withCountertop ? node.countertopThickness : 0 + // A seating back overhang (unlike the small front/side overhang) is + // deep enough to matter for selection and collision. + if (node.withCountertop && node.barLedge?.edge !== 'back') { + bounds.minZ -= node.countertopBackOverhang + } + if (node.withFinishedBack) bounds.minZ -= node.boardThickness + if (node.barLedge) { + const edge = node.barLedge.edge + if (edge === 'back') bounds.minZ -= node.barLedge.depth + if (edge === 'left') bounds.minX -= node.barLedge.depth + if (edge === 'right') bounds.maxX += node.barLedge.depth + bounds.maxY = Math.max(bounds.maxY, node.barLedge.height) + } + } + // L-corner leg runs are cabinet children of the source run — fold them in + // so the run's rotate ring / pivot / drag box covers the whole L. + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetRun(child)) includeChildRunBounds(child, nodes, bounds) + } + } + + const width = Math.max(0.01, bounds.maxX - bounds.minX) + const height = Math.max(0.01, bounds.maxY - bounds.minY) + const depth = Math.max(0.01, bounds.maxZ - bounds.minZ) + return { + ...bounds, + size: [width, height, depth], + center: [ + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, + (bounds.minZ + bounds.maxZ) / 2, + ], + } +} + +function cabinetPlanBoundsAabb( + node: CabinetNodeType, + nodes?: Readonly>, +) { + const bounds = cabinetLocalBounds(node, nodes) + const cos = Math.cos(node.rotation) + const sin = Math.sin(node.rotation) + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [lx, lz] of [ + [bounds.minX, bounds.minZ], + [bounds.maxX, bounds.minZ], + [bounds.maxX, bounds.maxZ], + [bounds.minX, bounds.maxZ], + ] as const) { + const x = node.position[0] + lx * cos + lz * sin + const z = node.position[2] - lx * sin + lz * cos + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + + return { minX, maxX, minZ, maxZ } +} + +function cabinetModuleSideOpen( + module: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined + if (!isCabinetRun(parent)) return true + return moduleSideOpen( + cabinetModulesForRun(parent, sceneApi.nodes()), + module.id, + side, + CABINET_ADJACENCY_EPSILON, + ) +} + +function commitRunResize( + run: CabinetNodeType, + patch: Partial, + sceneApi: SceneApi, +) { + sceneApi.update(run.id as AnyNodeId, patch as Partial) + const nextRun = { ...run, ...patch } + const syncDepth = typeof patch.depth === 'number' + const syncHeight = typeof patch.carcassHeight === 'number' + const syncPosition = patch.showPlinth !== undefined || typeof patch.plinthHeight === 'number' + + if (syncDepth || syncHeight || syncPosition) { + for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { + const modulePatch: Partial = {} + if (syncDepth) { + modulePatch.depth = nextRun.depth + modulePatch.position = [ + module.position[0], + module.position[1], + backAnchoredModuleZ(module.position[2], module.depth, nextRun.depth), + ] + } + if (syncHeight) { + modulePatch.carcassHeight = Math.max( + nextRun.carcassHeight, + minCabinetCarcassHeightForStack(module), + ) + } + if (syncPosition) { + modulePatch.position = [ + modulePatch.position?.[0] ?? module.position[0], + runModuleBaseY(nextRun), + modulePatch.position?.[2] ?? module.position[2], + ] + } + if (Object.keys(modulePatch).length > 0) { + sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild && typeof modulePatch.depth === 'number') { + sceneApi.update( + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial, + ) + } + } + } + } + + if (syncDepth || syncHeight || syncPosition) { + bumpCabinetRunLayoutRevision(sceneApi, nextRun) + const cornerSource = cornerLinkedSourceModuleForRun(nextRun, sceneApi.nodes()) + if (cornerSource) { + syncCornerRunsFromSourceModule({ + module: cornerSource, + run: nextRun, + sceneApi, + }) + } + } +} + +function commitModuleResize( + module: CabinetModuleNodeType, + patch: Partial, + sceneApi: SceneApi, +) { + const nodes = sceneApi.nodes() + const parent = module.parentId ? nodes[module.parentId as AnyNodeId] : undefined + const parentRun = isCabinetRun(parent) ? parent : undefined + + if (!parentRun) { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const parentModule = isCabinetModule(parent) ? parent : undefined + if (parentModule) sceneApi.markDirty(parentModule.id as AnyNodeId) + return + } + + if (typeof patch.width === 'number') { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update( + wallChild.id as AnyNodeId, + { + width: patch.width, + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(patch.depth ?? module.depth, wallChild.depth), + ], + } as Partial, + ) + } + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) + return + } + + const modulePatch: Partial = { ...patch } + if (typeof patch.depth === 'number') { + modulePatch.position = [ + patch.position?.[0] ?? module.position[0], + patch.position?.[1] ?? module.position[1], + backAnchoredModuleZ(module.position[2], module.depth, patch.depth), + ] + } + + sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) + + if (resolveCabinetType(module, parentRun) === 'base') { + const runPatch: Partial = {} + if (typeof patch.depth === 'number') runPatch.depth = patch.depth + if (typeof patch.carcassHeight === 'number') { + runPatch.carcassHeight = patch.carcassHeight + } + if (Object.keys(runPatch).length > 0) { + commitRunResize(parentRun, runPatch, sceneApi) + } else { + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + } + } else { + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + } + + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) + + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild && typeof modulePatch.depth === 'number') { + sceneApi.update( + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial, + ) + } +} + +function commitCabinetResize( + node: CabinetEditableNode, + patch: Partial, + sceneApi: SceneApi, +) { + const liveNode = sceneApi.get(node.id as AnyNodeId) ?? node + if (isCabinetRun(liveNode)) { + commitRunResize(liveNode, patch as Partial, sceneApi) + return + } + if (isCabinetModule(liveNode)) { + commitModuleResize(liveNode, patch as Partial, sceneApi) + return + } + sceneApi.update(node.id as AnyNodeId, patch as Partial) +} + +function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_CABINET_WIDTH, + currentValue: (node) => node.width, + apply: (node, width) => ({ + width, + position: [ + node.position[0] + (sign * (width - node.width)) / 2, + node.position[1], + node.position[2], + ], + }), + commit: commitCabinetResize, + visible: (node, sceneApi) => + !isCabinetModule(node) || cabinetModuleSideOpen(node, side, sceneApi), + placement: { + position: (node) => [ + sign * (node.width / 2 + SIDE_HANDLE_OFFSET), + cabinetTotalHeight(node) / 2, + 0, + ], + rotationY: () => (side === 'left' ? Math.PI : 0), + }, + } +} + +function cabinetDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'min', + min: MIN_CABINET_DEPTH, + currentValue: (node) => node.depth, + apply: (node, depth) => ({ + depth, + position: [node.position[0], node.position[1], node.position[2] + (depth - node.depth) / 2], + }), + commit: commitCabinetResize, + placement: { + position: (node) => [0, cabinetTotalHeight(node) / 2, node.depth / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +function cabinetHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_CABINET_CARCASS_HEIGHT, + currentValue: (node) => node.carcassHeight, + apply: (_node, carcassHeight) => ({ carcassHeight }), + commit: commitCabinetResize, + placement: { + position: (node) => [0, cabinetTotalHeight(node) + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +function cabinetRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta, sceneApi) => { + const rotation = (initial.rotation ?? 0) - delta + const bounds = cabinetLocalBounds(initial, sceneApi.nodes()) + const [centerX, , centerZ] = bounds.center + const previousRotation = initial.rotation ?? 0 + const previousCos = Math.cos(previousRotation) + const previousSin = Math.sin(previousRotation) + const nextCos = Math.cos(rotation) + const nextSin = Math.sin(rotation) + const pivotWorldX = initial.position[0] + centerX * previousCos + centerZ * previousSin + const pivotWorldZ = initial.position[2] - centerX * previousSin + centerZ * previousCos + return { + rotation, + position: [ + pivotWorldX - centerX * nextCos - centerZ * nextSin, + initial.position[1], + pivotWorldZ + centerX * nextSin - centerZ * nextCos, + ], + } + }, + placement: { + position: (node, sceneApi) => { + const bounds = cabinetLocalBounds(node, sceneApi.nodes()) + return [bounds.maxX, bounds.center[1], bounds.maxZ + ROTATE_CORNER_OFFSET] + }, + rotationY: () => -Math.PI / 4, + }, + rotationCenter: (node, sceneApi) => cabinetLocalBounds(node, sceneApi.nodes()).center, + decoration: { + kind: 'ring', + radius: (node, sceneApi) => { + const bounds = cabinetLocalBounds(node, sceneApi.nodes()) + return Math.hypot(bounds.size[0] / 2, bounds.size[2] / 2) + ROTATE_RING_OFFSET + }, + y: (node) => cabinetTotalHeight(node) / 2, + center: (node, sceneApi) => cabinetLocalBounds(node, sceneApi.nodes()).center, + }, + } +} + +function cabinetHandles(node: CabinetNodeType): HandleDescriptor[] { + if ((node.children ?? []).length > 0) { + return [cabinetRotateHandle()] as HandleDescriptor[] + } + const handles: HandleDescriptor[] = [ + cabinetDepthHandle(), + cabinetHeightHandle(), + cabinetRotateHandle(), + ] + if ((node.children ?? []).length === 0) { + handles.unshift(cabinetWidthHandle('left'), cabinetWidthHandle('right')) + } + return handles as HandleDescriptor[] +} + +function isHoodOnlyCabinet(node: CabinetEditableNode): boolean { + const stack = stackForCabinet(node) + return stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) +} + +function cabinetModuleHandles( + node: CabinetModuleNodeType, +): HandleDescriptor[] { + const handles: HandleDescriptor[] = [ + cabinetWidthHandle('left'), + cabinetWidthHandle('right'), + cabinetRotateHandle(), + ] + if (!isHoodOnlyCabinet(node)) { + handles.splice(1, 0, cabinetDepthHandle(), cabinetHeightHandle()) + } + return handles as HandleDescriptor[] +} + +export const cabinetDefinition: NodeDefinition = { + kind: 'cabinet', + schemaVersion: 7, + schema: CabinetNode, + category: 'furnish', + surfaceRole: 'joinery', + snapProfile: 'item', + facingIndicator: true, + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + runTier: 'base', + children: [], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + operationState: 0, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, + withWaterfall: false, + frontThickness: 0.018, + frontGap: 0.003, + frontStyle: 'slab', + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + withBottomPanel: true, + showPlinth: true, + withCountertop: true, + // material / materialPreset left undefined — paint mode writes slot refs. + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { + axes: ['x', 'z'], + gridSnap: true, + groupMoveSnap: resolveCabinetGroupMoveSnap, + override: ({ node }) => + selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + ? { axes: [], gridSnap: false } + : null, + }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: { subtree: true, prepareSubtreeClone: prepareCabinetSubtreeClone }, + deletable: true, + surfaces: { + top: { + height: (node) => { + const n = node as CabinetNodeType + return n.plinthHeight + n.carcassHeight + (n.withCountertop ? n.countertopThickness : 0) + }, + }, + }, + floorPlaced: { + applies: (node) => !hasCabinetParentId(node as CabinetNodeType), + footprints: (node, ctx) => + cabinetFloorPlacedFootprints( + node as CabinetNodeType, + ctx?.nodes as Readonly> | undefined, + ), + collides: true, + }, + alignmentFootprint: (node, nodes) => { + const n = node as CabinetNodeType + return { shape: 'aabb', ...cabinetPlanBoundsAabb(n, nodes) } + }, + dragBounds: (node, nodes) => { + const bounds = cabinetLocalBounds(node as CabinetNodeType, nodes) + return { size: bounds.size, center: bounds.center } + }, + paint: cabinetPaint, + sceneAction: cabinetSceneAction, + slots: () => cabinetSlots(), + }, + + // Dirty-cascade: a dirtied run re-marks its hosted modules so their + // composite geometry re-flows with the run (see `cascadeDirty`). + relations: { + hosts: ['cabinet-module'], + }, + + parametrics: cabinetParametrics, + handles: cabinetHandles, + geometry: buildCabinetGeometry, + exportAnimation: ({ node, object }) => bakeCabinetAnimationClip(node, object), + system: { + module: () => import('./system'), + priority: 2, + }, + // `operationState` is deliberately absent — door/drawer poses are applied + // per-frame by the cabinet animation system, not by geometry rebuilds. + geometryKey: (n) => + JSON.stringify([ + n.width, + n.depth, + n.carcassHeight, + n.runTier, + n.plinthHeight, + n.toeKickDepth, + n.boardThickness, + n.countertopThickness, + n.countertopOverhang, + n.countertopBackOverhang, + n.withFinishedBack, + n.withWaterfall, + JSON.stringify(n.barLedge ?? null), + n.frontThickness, + n.frontGap, + n.frontStyle, + n.handleStyle, + n.handlePosition, + n.frontOverlay, + n.withBottomPanel, + n.showPlinth, + n.withCountertop, + JSON.stringify(n.material ?? null), + JSON.stringify(n.materialPreset ?? null), + JSON.stringify(n.slots ?? null), + JSON.stringify(cabinetLayoutRevision(n.metadata)), + // Bumped when a sibling run moves/resizes nearby, so the countertop + // overhang re-trims against the neighbor's new spans (the neighbor's + // own fields never appear in this key). + JSON.stringify(cabinetAdjacencyRevision(n.metadata)), + JSON.stringify(n.children ?? []), + JSON.stringify(n.stack ?? null), + ]), + floorplan: buildCabinetFloorplan, + floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, + floorplanAffectedIds: cabinetFloorplanAffectedIds, + quickActions: cabinetQuickActions, + // Corner-derived leg runs hide their own tree rows; their modules are + // flattened into the source run's hierarchy. + tree: { + label: cabinetTreeLabel, + hidden: cabinetTreeHidden, + childIds: cabinetTreeChildIds, + }, + // E operates the run: every child module's doors/drawers swing together. + keyboardActions: { + e: { + appliesTo: (node: AnyNode) => node.type === 'cabinet', + run: (node: AnyNode) => toggleCabinetOperationState(node.id as AnyNodeId), + }, + }, + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place cabinet' }, + { + key: 'I', + label: 'Placement type', + chip: { + subscribe: (onChange) => useCabinetPlacementType.subscribe(onChange), + value: () => useCabinetPlacementType.getState().type, + cycle: () => void useCabinetPlacementType.getState().cycleType(), + labels: { cabinet: 'Type: Cabinet', island: 'Type: Island' }, + icons: { cabinet: 'lucide:rectangle-horizontal', island: 'lucide:table-2' }, + tooltip: 'Placement type — click or press I to toggle', + }, + }, + { key: 'Alt', label: 'Force place' }, + { key: 'R / T', label: 'Rotate' }, + { key: 'Esc', label: 'Cancel run / exit' }, + ], + + presentation: { + label: 'Modular Cabinet', + description: 'A configurable parametric base cabinet.', + icon: { kind: 'url', src: '/icons/item.webp' }, + paletteSection: 'furnish', + paletteOrder: 34, + }, + + mcp: { + description: + 'A configurable parametric base cabinet with plinth, carcass, front panels, optional countertop, and editable dimensions.', + }, +} + +export const cabinetModuleDefinition: NodeDefinition = { + kind: 'cabinet-module', + schemaVersion: 4, + schema: CabinetModuleNode, + category: 'furnish', + surfaceRole: 'joinery', + snapProfile: 'item', + facingIndicator: true, + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + children: [], + cabinetType: 'base', + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + operationState: 0, + plinthHeight: 0, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, + frontThickness: 0.018, + frontGap: 0.003, + moduleKind: 'standard' as const, + openSide: undefined, + cornerShelf: false, + frontStyle: 'slab', + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + withBottomPanel: true, + showPlinth: false, + withCountertop: false, + // material / materialPreset left undefined — paint mode writes slot refs. + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: cabinetModuleParentFrame, + groupMoveSnap: resolveCabinetModuleGroupMoveSnap, + override: ({ node }) => + selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + ? { axes: [], gridSnap: false } + : null, + }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: { subtree: true, prepareSubtreeClone: prepareCabinetSubtreeClone }, + deletable: true, + floorPlaced: { + applies: (node) => !hasCabinetParentId(node as CabinetModuleNodeType), + footprint: (node) => { + const n = node as CabinetModuleNodeType + return { + dimensions: [ + n.width, + (n.showPlinth ? n.plinthHeight : 0) + + n.carcassHeight + + (n.withCountertop ? n.countertopThickness : 0), + n.depth, + ] as [number, number, number], + rotation: [0, n.rotation, 0] as [number, number, number], + } + }, + collides: true, + }, + dragBounds: (node) => { + const n = node as CabinetModuleNodeType + const height = cabinetTotalHeight(n) + return { + size: [n.width, height, n.depth] as [number, number, number], + center: [0, height / 2, 0] as [number, number, number], + } + }, + paint: cabinetPaint, + sceneAction: cabinetSceneAction, + slots: () => cabinetSlots(), + }, + + parametrics: cabinetModuleParametrics, + handles: cabinetModuleHandles, + geometry: buildCabinetGeometry, + exportAnimation: ({ node, object }) => bakeCabinetAnimationClip(node, object), + // `operationState` is deliberately absent — see cabinetDefinition.geometryKey. + geometryKey: (n) => + JSON.stringify([ + n.cabinetType, + n.moduleKind, + n.width, + n.depth, + n.carcassHeight, + n.plinthHeight, + n.toeKickDepth, + n.boardThickness, + n.countertopThickness, + n.countertopOverhang, + n.frontThickness, + n.frontGap, + n.frontStyle, + n.handleStyle, + n.handlePosition, + n.frontOverlay, + n.withBottomPanel, + n.showPlinth, + n.withCountertop, + n.openSide ?? null, + n.cornerShelf ?? false, + JSON.stringify(n.material ?? null), + JSON.stringify(n.materialPreset ?? null), + JSON.stringify(n.slots ?? null), + JSON.stringify(n.children ?? []), + JSON.stringify(n.stack ?? null), + ]), + floorplan: buildCabinetModuleFloorplan, + floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, + floorplanAffectedIds: cabinetFloorplanAffectedIds, + // 2D ↔ 3D parity: module position is run-local, so the generic overlay's + // plan-space translate would corrupt it on any rotated / offset run. + floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, + quickActions: cabinetQuickActions, + tree: { + label: cabinetTreeLabel, + childIds: cabinetTreeChildIds, + }, + // Corner-generated modules keep a proxy so grouped move/rotate + // affordances can still key off the run, but a direct body click should + // stay on the clicked module so added legs / wall cabinets remain + // individually selectable. + selectionProxy: { + bypassDirectPick: (node, proxyTarget) => + node.type === 'cabinet-module' && proxyTarget.type === 'cabinet', + }, + // E animates this module's doors/drawers open ↔ closed (hood-only + // modules have nothing to operate). + keyboardActions: { + e: { + appliesTo: (node: AnyNode) => + node.type === 'cabinet-module' && !isHoodOnlyCabinet(node as CabinetModuleNodeType), + run: (node: AnyNode) => toggleCabinetOperationState(node.id as AnyNodeId), + }, + }, + + presentation: { + label: 'Cabinet Module', + description: 'An editable module inside a modular cabinet run.', + icon: { kind: 'url', src: '/icons/item.webp' }, + paletteSection: 'furnish', + paletteOrder: 35, + }, + + mcp: { + description: 'A single editable cabinet module inside a modular cabinet run.', + }, +} diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts new file mode 100644 index 00000000..50382040 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -0,0 +1,249 @@ +import { + type AlignmentGuide, + type AnyNode, + type AnyNodeId, + type CabinetModuleNode as CabinetModuleNodeType, + type CabinetNode as CabinetNodeType, + createSceneApi, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + type ParentFrameSnapMatch, + type SceneApi, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { cabinetModuleParentFrame } from './move-frame' +import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' +import { resolveCabinetModuleWallSnapLocal } from './wall-snap' + +type SceneUpdate = { id: AnyNodeId; data: Partial } + +function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide { + return { + axis: match.axis, + coord: match.axis === 'x' ? match.from.x : match.from.z, + from: match.from, + to: match.to, + anchor: match.from, + movingAnchorKind: 'corner', + candidateAnchorKind: 'corner', + candidateNodeId: match.candidateNodeId, + distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z), + } +} + +function mergeSceneUpdate( + updates: Map>, + id: AnyNodeId, + patch: Partial, +) { + updates.set(id, { + ...((updates.get(id) ?? {}) as Record), + ...patch, + } as Partial) +} + +function collectCabinetModuleMoveCommitUpdates({ + lastLocal, + moduleId, + runId, +}: { + lastLocal: [number, number, number] + moduleId: AnyNodeId + runId: AnyNodeId +}): SceneUpdate[] | null { + const baseNodes = useScene.getState().nodes as Record + const nodes: Record = { ...baseNodes } + const updates = new Map>() + let unsupportedMutation = false + + const sceneApi: SceneApi = { + get(id: AnyNodeId): N | undefined { + return nodes[id] as N | undefined + }, + nodes() { + return nodes + }, + update(id, patch) { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + mergeSceneUpdate(updates, id, patch) + }, + upsert(node) { + if (!nodes[node.id as AnyNodeId]) { + unsupportedMutation = true + return node.id as AnyNodeId + } + nodes[node.id as AnyNodeId] = node + mergeSceneUpdate(updates, node.id as AnyNodeId, node as Partial) + return node.id as AnyNodeId + }, + delete() { + unsupportedMutation = true + }, + restore() {}, + restoreAll() {}, + markDirty() {}, + pauseHistory() {}, + resumeHistory() {}, + getSubtree() { + return null + }, + cloneNodesInto() { + unsupportedMutation = true + return null + }, + } + + sceneApi.update(moduleId, { position: lastLocal } as Partial) + const liveRun = sceneApi.get(runId) + if (liveRun?.type !== 'cabinet') return Array.from(updates, ([id, data]) => ({ id, data })) + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + + const liveModule = sceneApi.get(moduleId) + if (liveModule?.type === 'cabinet-module') { + syncCornerRunsFromSourceModule({ + module: liveModule, + run: sceneApi.get(runId) ?? liveRun, + sceneApi, + }) + } + + if (unsupportedMutation) return null + return Array.from(updates, ([id, data]) => ({ id, data })) +} + +/** + * 2D floor-plan move for a cabinet module — the parity twin of the 3D + * `movable.parentFrame` path. A module's `position` is run-local (rotated + * frame), so the generic overlay translate — which writes plan-space + * coordinates — teleports modules of any rotated / offset run and skips + * sibling edge-mating. Each tick: grid-snap the cursor in plan frame, + * convert through `planToLocal`, magnet against sibling modules, write the + * local position, and bump the run's layout revision so spans / countertop + * re-flow live (module position is not in the run's geometryKey). History is + * paused by the overlay; its snapshot-diff commit makes the drag one undo + * step covering both the module and the run metadata. + */ +export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget = ({ + node, + nodes, +}) => { + const moduleId = node.id as AnyNodeId + const run = cabinetModuleParentFrame.resolveParent( + node as AnyNode, + nodes, + ) as CabinetNodeType | null + const originalLocal = [...node.position] as [number, number, number] + let lastLocal: [number, number, number] = originalLocal + + const session: FloorplanMoveTargetSession = { + affectedIds: run ? [moduleId, run.id as AnyNodeId] : [moduleId], + apply({ planPoint }) { + const snap = (value: number) => + isGridSnapActive() + ? Math.round(value / useEditor.getState().gridSnapStep) * + useEditor.getState().gridSnapStep + : value + const planX = snap(planPoint[0]) + const planZ = snap(planPoint[1]) + + // Orphan module (no cabinet run parent): plain plan-frame translate, + // same as the generic overlay would have done. + if (!run) { + lastLocal = [planX, originalLocal[1], planZ] + useLiveNodeOverrides.getState().set(moduleId, { position: lastLocal }) + return + } + + let local = cabinetModuleParentFrame.planToLocal( + run, + planX, + originalLocal[1], + planZ, + useScene.getState().nodes, + ) + if (isMagneticSnapActive() || isGridSnapActive()) { + const snapFn = cabinetModuleParentFrame.magneticSnap + if (snapFn) { + const preSnapLocal = local + local = snapFn(node as AnyNode, run, local, useScene.getState().nodes) + if (isAlignmentGuideActive()) { + const guides = + cabinetModuleParentFrame + .magneticSnapMatches?.( + node as AnyNode, + run, + preSnapLocal, + local, + useScene.getState().nodes, + ) + .map(alignmentGuideFromParentFrameMatch) ?? [] + if (guides.length > 0) useAlignmentGuides.getState().set(guides) + else useAlignmentGuides.getState().clear() + } + } + } + // Wall attachment snap — 2D parity with the 3D move tool's + // `groupMoveSnap` pass: active in every snapping mode except Off. + if ((isGridSnapActive() || isMagneticSnapActive()) && run.parentId) { + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: local, + module: node, + nodes: useScene.getState().nodes, + parentLevelId: run.parentId as AnyNodeId, + run, + }) + if (snapped) local = snapped + } + lastLocal = local + useLiveNodeOverrides.getState().set(moduleId, { position: local }) + useScene.getState().markDirty(run.id as AnyNodeId) + }, + canCommit() { + const live = useScene.getState().nodes[moduleId] + if (live?.type !== 'cabinet-module') return false + return lastLocal[0] !== originalLocal[0] || lastLocal[2] !== originalLocal[2] + }, + commit() { + const scene = useScene.getState() + useLiveNodeOverrides.getState().clear(moduleId) + useAlignmentGuides.getState().clear() + if (!run) { + scene.updateNodes([{ id: moduleId, data: { position: lastLocal } }]) + return + } + const runId = run.id as AnyNodeId + const updates = collectCabinetModuleMoveCommitUpdates({ lastLocal, moduleId, runId }) + if (updates) { + scene.updateNodes(updates) + return + } + + scene.updateNodes([{ id: moduleId, data: { position: lastLocal } }]) + const liveRun = useScene.getState().nodes[runId] + if (liveRun?.type !== 'cabinet') return + const sceneApi = createSceneApi(useScene) + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + // 2D ↔ 3D parity with `cabinetModuleParentFrame.onCommit`: re-anchor + // linked L-corner runs to the moved module's new edge. + const liveModule = useScene.getState().nodes[moduleId] + if (liveModule?.type === 'cabinet-module') { + syncCornerRunsFromSourceModule({ + module: liveModule, + run: sceneApi.get(runId) ?? liveRun, + sceneApi, + }) + } + }, + } + return session +} diff --git a/packages/nodes/src/cabinet/floorplan-overrides.ts b/packages/nodes/src/cabinet/floorplan-overrides.ts new file mode 100644 index 00000000..0ae49d33 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan-overrides.ts @@ -0,0 +1,51 @@ +import type { AnyNode, AnyNodeId, LiveTransformLike } from '@pascal-app/core' + +/** + * Cabinet floor-plan symbols resolve their full cabinet ancestry through + * `ctx.parent` / `ctx.resolve` (run -> module -> child run ...). During live + * drags only the directly moved cabinet nodes publish overrides, so we need to + * project those cabinet/cabinet-module patches into the context snapshot that + * the floor-plan builders read. That keeps every related cabinet symbol in + * lockstep while the drag is still in flight. + */ +export function cabinetFloorplanSiblingOverrides(args: { + nodeId: AnyNodeId + nodes: Record + liveTransforms: Map + liveOverrides: Map> +}): Record { + const { nodes, liveOverrides, liveTransforms } = args + if (liveOverrides.size === 0 && liveTransforms.size === 0) return nodes + + let out: Record | null = null + const applyLiveTransform = (node: AnyNode, live: LiveTransformLike): AnyNode => { + if (!Array.isArray((node as { position?: unknown }).position)) return node + const rotation = (node as { rotation?: unknown }).rotation + return { + ...node, + position: live.position, + rotation: + typeof rotation === 'number' + ? live.rotation + : Array.isArray(rotation) + ? [(rotation[0] as number) ?? 0, live.rotation, (rotation[2] as number) ?? 0] + : rotation, + } as AnyNode + } + + for (const [id, live] of liveTransforms) { + const existing = nodes[id as AnyNodeId] + if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue + if (!out) out = { ...nodes } + out[id as AnyNodeId] = applyLiveTransform(out?.[id as AnyNodeId] ?? existing, live) + } + for (const [id, override] of liveOverrides) { + const existing = (out?.[id as AnyNodeId] ?? nodes[id as AnyNodeId]) as AnyNode | undefined + if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue + if (Object.keys(override).length === 0) continue + if (!out) out = { ...nodes } + out[id as AnyNodeId] = { ...existing, ...override } as AnyNode + } + + return out ?? nodes +} diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts new file mode 100644 index 00000000..e4e9b1c8 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -0,0 +1,390 @@ +import type { + AnyNodeId, + CabinetModuleNode, + CabinetNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' +import { GAS_HOB_BURNER_RADIUS, gasHobBurners, inductionZones } from './geometry/cooktop' +import { FAUCET_SETBACK, sinkBowls } from './geometry/sink' +import { getRunSpanEnds, getRunSpans } from './run-layout' +import { + type CabinetCompartment, + compartmentCooktopLayout, + compartmentSinkLayout, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + stackForCabinet, +} from './stack' + +const BODY_FILL = '#ffffff' +const BODY_STROKE = '#7c7468' +const SYMBOL_STROKE = '#6f675b' +const LABEL_FILL = '#6f675b' +// Architectural convention: elements above the ~1.2m cut plane (wall +// cabinets, hoods) draw with a dashed outline; floor-standing units solid. +const ABOVE_CUT_DASH = '0.08 0.06' +const SYMBOL_STROKE_WIDTH = 0.014 + +export function buildCabinetFloorplan( + node: CabinetNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const modules = ctx.children.filter( + (child): child is CabinetModuleNode => child.type === 'cabinet-module', + ) + + const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false + const stroke = + showSelectedChrome && ctx.viewState?.palette + ? ctx.viewState.palette.selectedStroke + : BODY_STROKE + + const spans = + modules.length > 0 + ? getRunSpans(modules, { runTier: node.runTier }) + : [ + { + minX: -node.width / 2, + maxX: node.width / 2, + centerX: 0, + centerZ: 0, + width: node.width, + depth: node.depth, + minZ: -node.depth / 2, + maxZ: node.depth / 2, + topY: node.carcassHeight, + hasCountertop: node.runTier === 'base' && node.withCountertop, + }, + ] + const overhang = node.withCountertop ? node.countertopOverhang : 0 + const barEdge = node.barLedge?.edge + const backOverhang = node.withCountertop && barEdge !== 'back' ? node.countertopBackOverhang : 0 + const spanEnds = getRunSpanEnds(node, ctx, spans) + const children: FloorplanGeometry[] = [] + + for (const span of spans) { + const spanIndex = spans.indexOf(span) + const ends = spanEnds[spanIndex]! + const hasSlab = node.withCountertop && span.hasCountertop + // Countertop slab outline — the heavier line a kitchen plan reads first. + // Tall spans (no countertop) fall back to their carcass footprint. Side + // overhangs come from the shared span-end math so neighbor runs, L-corner + // legs, and side bars trim the plan outline exactly like the 3D slab. + const front = span.maxZ + (hasSlab ? overhang : 0) + const slabBack = span.minZ - (hasSlab ? backOverhang : 0) + // A finished decorative back panel adds real depth behind the carcass. + const back = Math.min( + slabBack, + node.withFinishedBack ? span.minZ - node.boardThickness : span.minZ, + ) + const left = span.minX - (hasSlab ? ends.leftOverhang : 0) + const right = span.maxX + (hasSlab ? ends.rightOverhang : 0) + children.push({ + kind: 'rect', + x: left, + y: back, + width: Math.max(0.01, right - left), + height: Math.max(0.01, front - back), + fill: node.runTier === 'wall' ? 'none' : BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.022, + strokeDasharray: node.runTier === 'wall' ? ABOVE_CUT_DASH : undefined, + opacity: 0.95, + }) + + // Raised bar slab reads as its own counter band along the chosen edge + // (bar height sits below the ~1.2m cut plane, so it draws solid). Side + // bars apply only to the end span on that side. + const spanHasBar = + node.barLedge && + span.hasCountertop && + (barEdge === 'back' || + (barEdge === 'left' && spanIndex === 0) || + (barEdge === 'right' && spanIndex === spans.length - 1)) + if (node.barLedge && spanHasBar) { + const bar = + barEdge === 'back' + ? { + x: left, + y: + span.minZ - (node.withFinishedBack ? node.boardThickness : 0) - node.barLedge.depth, + width: Math.max(0.01, right - left), + height: node.barLedge.depth, + } + : { + x: barEdge === 'left' ? span.minX - node.barLedge.depth : span.maxX, + y: slabBack, + width: node.barLedge.depth, + height: Math.max(0.01, front - slabBack), + } + children.push({ + kind: 'rect', + ...bar, + fill: BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.022, + opacity: 0.95, + }) + } + } + + const world = resolveCabinetWorldPose(node, ctx) + return withWorldChrome(world.position, world.rotation, children, ctx, showSelectedChrome) +} + +export function buildCabinetModuleFloorplan( + node: CabinetModuleNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const world = resolveCabinetWorldPose(node, ctx) + const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx) + return buildModuleSymbol(node, world.position, world.rotation, ctx, { + aboveCutPlane: + parent?.type === 'cabinet-module' + ? true + : parent?.type === 'cabinet' && parent.runTier === 'wall', + }) +} + +function composeChild( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +): { position: [number, number, number]; rotation: number } { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const [lx, ly, lz] = childPosition + return { + position: [ + parentPosition[0] + lx * cos + lz * sin, + parentPosition[1] + ly, + parentPosition[2] - lx * sin + lz * cos, + ], + rotation: parentRotation + childRotation, + } +} + +function resolveCabinetParent( + id: AnyNodeId | undefined, + ctx: GeometryContext, +): CabinetNode | CabinetModuleNode | null { + if (!id) return null + if ( + ctx.parent?.id === id && + (ctx.parent.type === 'cabinet' || ctx.parent.type === 'cabinet-module') + ) { + return ctx.parent + } + const resolved = ctx.resolve(id) + return resolved?.type === 'cabinet' || resolved?.type === 'cabinet-module' ? resolved : null +} + +function resolveCabinetWorldPose( + node: Pick, + ctx: GeometryContext, +): { position: [number, number, number]; rotation: number } { + const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx) + if (parent) { + const worldParent = resolveCabinetWorldPose(parent, ctx) + return composeChild(worldParent.position, worldParent.rotation, node.position, node.rotation) + } + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +/** + * Wrap module-local symbol children in the plan transform and append + * world-space chrome (labels, selection handle). Plan rotate is `-rotation` + * so a Three.js Y-rotation (CCW top-down) turns the same way in the SVG plan. + */ +function withWorldChrome( + position: readonly [number, number, number], + rotation: number, + localChildren: FloorplanGeometry[], + ctx: GeometryContext, + showSelectedChrome: boolean, + worldChildren: FloorplanGeometry[] = [], +): FloorplanGeometry { + const [cx, , cz] = position + const children: FloorplanGeometry[] = [ + { + kind: 'group', + transform: { translate: [cx, cz], rotate: -rotation }, + children: localChildren, + }, + ...worldChildren, + ] + if (showSelectedChrome) { + children.push({ kind: 'move-handle', point: [cx, cz] as FloorplanPoint }) + } + return { kind: 'group', children } +} + +function buildModuleSymbol( + node: CabinetModuleNode, + position: readonly [number, number, number], + rotation: number, + ctx: GeometryContext, + opts: { aboveCutPlane: boolean }, +): FloorplanGeometry { + const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false + const stroke = + showSelectedChrome && ctx.viewState?.palette + ? ctx.viewState.palette.selectedStroke + : BODY_STROKE + + const stack = stackForCabinet(node) + const showCompartments = node.moduleKind !== 'corner-filler' + const hoodOnly = stack.length > 0 && stack.every((c) => isHoodCompartmentType(c.type)) + const dashed = opts.aboveCutPlane || hoodOnly + + const hw = node.width / 2 + const hd = node.depth / 2 + const children: FloorplanGeometry[] = [ + { + kind: 'rect', + x: -hw, + y: -hd, + width: node.width, + height: node.depth, + // Above-cut-plane units draw as a dashed open outline so the base + // cabinet underneath stays readable. + fill: dashed ? 'none' : BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.018, + strokeDasharray: dashed ? ABOVE_CUT_DASH : undefined, + opacity: dashed ? 0.85 : 0.95, + }, + ] + + if (!dashed && showCompartments) { + // Cabinet front edge, inset from the countertop line the run draws. + children.push({ + kind: 'line', + x1: -hw, + y1: hd, + x2: hw, + y2: hd, + stroke, + strokeWidth: 0.03, + opacity: 0.5, + }) + for (const compartment of stack) { + children.push(...compartmentSymbol(compartment, node)) + } + } + + // Appliance labels live in world space with `upright` so they read + // horizontally regardless of run rotation and plan-view rotation. + const worldChildren: FloorplanGeometry[] = [] + const label = dashed || !showCompartments ? null : moduleLabel(stack) + if (label) { + worldChildren.push({ + kind: 'text', + x: position[0], + y: position[2], + text: label, + fontSize: Math.min(0.16, node.width * 0.3), + fill: LABEL_FILL, + fontWeight: 600, + textAnchor: 'middle', + dominantBaseline: 'middle', + opacity: 0.9, + upright: true, + }) + } + + return withWorldChrome(position, rotation, children, ctx, showSelectedChrome, worldChildren) +} + +/** Plan symbol for one compartment, in module-local metres (front = +y). */ +function compartmentSymbol( + compartment: CabinetCompartment, + node: Pick, +): FloorplanGeometry[] { + if (compartment.type === 'sink') { + const innerWidth = Math.max(0.01, node.width - 2 * node.boardThickness) + const bowls = sinkBowls(compartmentSinkLayout(compartment), innerWidth, node.depth) + const children: FloorplanGeometry[] = bowls.map((bowl) => ({ + kind: 'rect', + x: bowl.centerX - bowl.width / 2, + y: -bowl.depth / 2, + width: bowl.width, + height: bowl.depth, + rx: 0.04, + ry: 0.04, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + })) + // Faucet dot behind the bowls (back = -y), aligned with the 3D faucet + // setback so the plan symbol stays centered in the rear strip. + children.push({ + kind: 'circle', + cx: 0, + cy: -(bowls[0]?.depth ?? node.depth * 0.6) / 2 - FAUCET_SETBACK, + r: 0.02, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + }) + return children + } + + if (isCooktopCompartmentType(compartment.type)) { + const layout = compartmentCooktopLayout(compartment, compartment.type) + const rings = + compartment.type === 'cooktop-gas' + ? gasHobBurners(layout).map((burner) => ({ + x: burner.x, + y: burner.z, + r: GAS_HOB_BURNER_RADIUS * burner.size, + })) + : inductionZones(layout).map((zone) => ({ x: zone.x, y: zone.z, r: zone.radius })) + return rings.flatMap((ring): FloorplanGeometry[] => [ + { + kind: 'circle', + cx: ring.x, + cy: ring.y, + r: ring.r, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + }, + { + kind: 'circle', + cx: ring.x, + cy: ring.y, + r: ring.r * 0.45, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH * 0.8, + opacity: 0.7, + }, + ]) + } + + return [] +} + +/** Standard plan abbreviation for the module's appliance content. */ +function moduleLabel(stack: CabinetCompartment[]): string | null { + if (stack.some((c) => isFridgeCompartmentType(c.type))) return 'REF' + if (stack.some((c) => c.type === 'dishwasher')) return 'DW' + const hasOven = stack.some((c) => c.type === 'oven') + const hasMicrowave = stack.some((c) => c.type === 'microwave') + if (hasOven && hasMicrowave) return 'OV/MW' + if (hasOven) return 'OV' + if (hasMicrowave) return 'MW' + if (stack.some((c) => c.type === 'pull-out-pantry')) return 'PAN' + return null +} diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts new file mode 100644 index 00000000..b26819ad --- /dev/null +++ b/packages/nodes/src/cabinet/geometry.ts @@ -0,0 +1,514 @@ +import type { CabinetNode, GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' +import { Group } from 'three' +import { addCooktopCompartment } from './geometry/cooktop' +import { addDishwasherCompartment } from './geometry/dishwasher' +import { addFridgeCompartment } from './geometry/fridge' +import { + addDoorFronts, + addDrawerFronts, + addShelfBoards, + addSinkFalseFront, +} from './geometry/fronts' +import { addRangeHoodCompartment } from './geometry/hood' +import { addApplianceCompartment } from './geometry/oven-microwave' +import { addPullOutPantryCompartment } from './geometry/pantry' +import { buildCabinetRunGeometry } from './geometry/run' +import { addBox, type CabinetGeometryNode, getCabinetSlotMaterials } from './geometry/shared' +import { addSinkCompartment, cutSinkIntoCountertop, sinkBowls } from './geometry/sink' +import { + type CabinetHoodCompartmentType, + compartmentDoorType, + compartmentDrawerCount, + compartmentShelfCount, + compartmentSinkLayout, + isHoodCompartmentType, + normalizeCabinetStack, + stackForCabinet, +} from './stack' + +const CORNER_FILLER_TOP_INSET = 0.001 +const CORNER_FILLER_SIDE_INSET = 0.001 +const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 +const SINK_FALSE_FRONT_HEIGHT = 0.22 + +export function buildCabinetGeometry( + node: CabinetGeometryNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { + if (node.type === 'cabinet') { + const run = buildCabinetRunGeometry(node, ctx, shading, textures, colorPreset, sceneTheme) + if (run) return run + return new Group() + } + + const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) + + const hoodRows = normalizeCabinetStack(node) + if (hoodRows.length > 0 && hoodRows.every((row) => isHoodCompartmentType(row.compartment.type))) { + const hoodPlinth = node.showPlinth ? node.plinthHeight : 0 + hoodRows.forEach((row) => { + addRangeHoodCompartment( + group, + node, + materials, + row.compartment.type as CabinetHoodCompartmentType, + hoodPlinth + row.y0, + row.height, + ctx, + row.index, + ) + }) + return group + } + + const width = node.width + const depth = node.depth + const board = node.boardThickness + const plinth = node.showPlinth ? node.plinthHeight : 0 + const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, depth - board * 2) : 0 + const carcassHeight = node.carcassHeight + const frontThickness = node.frontThickness + const frontGap = node.frontGap + const countertopThickness = node.withCountertop ? node.countertopThickness : 0 + const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0 + const bodyCenterY = plinth + carcassHeight / 2 + const topY = plinth + carcassHeight + const bottomLift = node.withBottomPanel ? board : 0 + const backThickness = Math.min(0.006, board / 2) + const backInset = Math.min(0.012, depth * 0.08) + const frontRecess = 0.0015 + const inset = node.frontOverlay === 'inset' + const insetInteriorClearance = inset ? Math.max(0.012, frontThickness + frontRecess + 0.006) : 0 + // Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening. + const frontZ = inset + ? depth / 2 - frontThickness / 2 - frontRecess + : depth / 2 + frontThickness / 2 - frontRecess + const openLeft = node.openSide === 'left' + const openRight = node.openSide === 'right' + const innerLeft = -width / 2 + (openLeft ? 0 : board) + const innerRight = width / 2 - (openRight ? 0 : board) + const innerWidth = Math.max(0.01, innerRight - innerLeft) + const innerCenterX = (innerLeft + innerRight) / 2 + const openingWidth = innerWidth + const openingDepth = Math.max(0.01, depth - backInset - 0.02 - insetInteriorClearance) + const drawerBoxBackZ = -depth / 2 + backInset + 0.02 + const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance + const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) + const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetNode) : null + const isWallCornerFiller = node.moduleKind === 'corner-filler' && parentRun?.runTier === 'wall' + + if (node.moduleKind === 'corner-filler') { + const filler = new Group() + const shelfDepth = Math.max(0.01, depth - backInset - 0.02) + const shelfCount = Math.max( + 1, + stackForCabinet(node) + .filter((compartment) => compartment.type === 'door') + .reduce((best, compartment) => Math.max(best, compartmentShelfCount(compartment)), 0), + ) + if (!openLeft) { + const leftSideInset = isWallCornerFiller && openRight ? CORNER_FILLER_SIDE_INSET / 2 : 0 + addBox( + filler, + [board, carcassHeight, depth], + [-width / 2 + board / 2 + leftSideInset, bodyCenterY, 0], + materials.carcass, + 'cabinet-corner-filler-side-left', + 'carcass', + ) + } + if (!openRight) { + const rightSideInset = isWallCornerFiller && openLeft ? CORNER_FILLER_SIDE_INSET / 2 : 0 + addBox( + filler, + [board, carcassHeight, depth], + [width / 2 - board / 2 - rightSideInset, bodyCenterY, 0], + materials.carcass, + 'cabinet-corner-filler-side-right', + 'carcass', + ) + } + if (node.withBottomPanel) { + addBox( + filler, + [innerWidth, board, depth - backInset], + [innerCenterX, plinth + board / 2, backInset / 2], + materials.carcass, + 'cabinet-corner-filler-bottom', + 'carcass', + ) + } + // Keep open-side corner-filler tops just inside the shared boundary so + // neighboring wall-top fillers/cabinets don't land coplanar and shimmer. + const topLeft = innerLeft + (openLeft ? CORNER_FILLER_TOP_INSET : 0) + const topRight = innerRight - (openRight ? CORNER_FILLER_TOP_INSET : 0) + addBox( + filler, + [Math.max(0.01, topRight - topLeft), board, depth], + [(topLeft + topRight) / 2, topY - board / 2, 0], + materials.carcass, + 'cabinet-corner-filler-top', + 'carcass', + ) + addBox( + filler, + [innerWidth, Math.max(0.001, carcassHeight - board), backThickness], + [innerCenterX, plinth + carcassHeight / 2, -depth / 2 + backInset + backThickness / 2], + materials.carcass, + 'cabinet-corner-filler-back', + 'carcass', + ) + const frontExtension = board / 2 + frontGap + const wallFrontSharedInset = isWallCornerFiller ? frontThickness + frontGap : 0 + const frontLeft = + -width / 2 - + (openLeft ? frontExtension : 0) + + (isWallCornerFiller && openRight ? wallFrontSharedInset : 0) + const frontRight = + width / 2 + + (openRight ? frontExtension : 0) - + (isWallCornerFiller && openLeft ? wallFrontSharedInset : 0) + const frontWidth = Math.max(0.01, frontRight - frontLeft) + const frontHeight = isWallCornerFiller + ? Math.max(0.01, carcassHeight - WALL_CORNER_FILLER_FRONT_HEIGHT_INSET * 2) + : carcassHeight + addBox( + filler, + [frontWidth, frontHeight, frontThickness], + [(frontLeft + frontRight) / 2, bodyCenterY, frontZ], + materials.front, + 'cabinet-corner-filler-front', + 'front', + ) + if (node.cornerShelf) { + addShelfBoards( + filler, + materials, + innerWidth, + shelfDepth, + board, + plinth + board, + Math.max(0.01, carcassHeight - board * 2), + shelfCount, + innerCenterX, + ) + } + return filler + } + + const rows = normalizeCabinetStack(node) + const sinkRow = rows.find((row) => row.compartment.type === 'sink') + const sinkBowlSpecs = sinkRow + ? sinkBowls(compartmentSinkLayout(sinkRow.compartment), innerWidth, depth) + : null + + if (!openLeft) { + addBox( + group, + [board, carcassHeight, depth], + [-width / 2 + board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-left', + 'carcass', + ) + } + if (!openRight) { + addBox( + group, + [board, carcassHeight, depth], + [width / 2 - board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-right', + 'carcass', + ) + } + if (node.withBottomPanel) { + addBox( + group, + [innerWidth, board, depth - backInset], + [innerCenterX, plinth + board / 2, backInset / 2], + materials.carcass, + 'cabinet-bottom', + 'carcass', + ) + } + // Sink bases skip the top panel — the basin hangs through that plane. + if (!sinkRow) { + addBox( + group, + [innerWidth, board, depth], + [innerCenterX, topY - board / 2, 0], + materials.carcass, + 'cabinet-top', + 'carcass', + ) + } + if (node.showPlinth && plinth > 0) { + addBox( + group, + [width - board * 2, plinth, Math.max(board, depth - toeKickDepth)], + [0, plinth / 2, -(toeKickDepth / 2)], + materials.plinth, + 'cabinet-plinth', + 'plinth', + ) + } + + if (node.withCountertop && countertopThickness > 0) { + const countertop = addBox( + group, + [width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang], + [0, topY + countertopThickness / 2, 0.01], + materials.countertop, + 'cabinet-countertop', + 'countertop', + ) + if (sinkBowlSpecs) { + group.remove(countertop) + const cut = cutSinkIntoCountertop(countertop, sinkBowlSpecs, 0, 0, countertopThickness) + countertop.geometry.dispose() + group.add(cut) + } + } + if (sinkBowlSpecs && sinkRow) { + // Modules inside a run don't own a countertop (the run draws and cuts + // it), so the faucet rises above the run's slab thickness instead. + const slabThickness = + countertopThickness > 0 + ? countertopThickness + : parentRun?.withCountertop + ? parentRun.countertopThickness + : 0.02 + addSinkCompartment( + group, + sinkBowlSpecs, + 0, + 0, + topY, + slabThickness, + sinkRow.index, + materials.appliance, + ) + const falseFrontHeight = Math.min( + Math.max(0.01, carcassHeight - frontGap * 2), + SINK_FALSE_FRONT_HEIGHT, + ) + addSinkFalseFront( + group, + node, + materials, + inset ? openingWidth : Math.max(0.01, width - frontGap), + falseFrontHeight, + topY - falseFrontHeight / 2, + frontZ, + sinkRow.index, + ) + } + rows.forEach((row, index) => { + // Sink rows are zero-height; the basin/faucet render against the + // countertop plane above, so the row contributes no carcass geometry. + if (row.compartment.type === 'sink') return + if (row.compartment.type === 'cooktop-gas' || row.compartment.type === 'cooktop-induction') { + const countertopClearance = 0.001 + const effectiveCountertopThickness = Math.max(countertopThickness, 0.02) + addCooktopCompartment( + group, + node, + row.compartment, + row.compartment.type, + topY + effectiveCountertopThickness + countertopClearance, + index, + ) + return + } + + const isFirst = index === 0 + const isLast = index === rows.length - 1 + const bottomOccupancy = isFirst ? bottomLift : board / 2 + const topOccupancy = isLast ? board : board / 2 + const subCellBottomY = plinth + row.y0 + const openingBottomY = subCellBottomY + bottomOccupancy + const openingHeight = Math.max(0.01, row.height - bottomOccupancy - topOccupancy) + const openingCenterY = openingBottomY + openingHeight / 2 + + addBox( + group, + [openingWidth, Math.max(0.001, row.height - board), backThickness], + [innerCenterX, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], + materials.carcass, + `cabinet-back-${index}`, + 'carcass', + ) + + // No deck below a sink row — the basin hangs through that plane. + if (index < rows.length - 1 && rows[index + 1]!.compartment.type !== 'sink') { + const deckY = plinth + row.y1 + addBox( + group, + [openingWidth, board, openingDepth], + [innerCenterX, deckY, board / 2], + materials.carcass, + `cabinet-deck-${index}`, + 'carcass', + ) + } + + const faceWidth = inset ? openingWidth : Math.max(0.01, width - frontGap) + const faceHeight = inset ? openingHeight : Math.max(0.01, row.height) + const faceCenterY = inset ? openingCenterY : subCellBottomY + row.height / 2 + + if (row.compartment.type === 'door') { + addDoorFronts( + group, + node, + materials, + faceWidth, + faceHeight, + 0, + faceCenterY, + frontZ, + compartmentDoorType(row.compartment, node.width), + ) + if ((row.compartment.shelfCount ?? 0) > 0) { + addShelfBoards( + group, + materials, + openingWidth, + openingDepth, + board, + openingBottomY, + openingHeight, + row.compartment.shelfCount ?? 0, + innerCenterX, + ) + } + return + } + + if (row.compartment.type === 'shelf') { + addShelfBoards( + group, + materials, + openingWidth, + openingDepth, + board, + openingBottomY, + openingHeight, + compartmentShelfCount(row.compartment), + innerCenterX, + ) + return + } + + if (row.compartment.type === 'drawer') { + addDrawerFronts( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + inset ? openingBottomY : subCellBottomY, + openingWidth, + frontZ, + compartmentDrawerCount(row.compartment), + drawerBoxBackZ, + drawerBoxDepth, + ) + return + } + + if (row.compartment.type === 'dishwasher') { + addDishwasherCompartment( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) + return + } + + if (row.compartment.type === 'pull-out-pantry') { + addPullOutPantryCompartment( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + row.compartment, + index, + ) + return + } + + if (row.compartment.type === 'oven' || row.compartment.type === 'microwave') { + addApplianceCompartment( + group, + node, + materials, + row.compartment.type, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) + return + } + + if ( + row.compartment.type === 'fridge-single' || + row.compartment.type === 'fridge-double' || + row.compartment.type === 'fridge-top-freezer' || + row.compartment.type === 'fridge-bottom-freezer' + ) { + addFridgeCompartment( + group, + node, + materials, + row.compartment.type, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) + return + } + + if (isHoodCompartmentType(row.compartment.type)) { + addRangeHoodCompartment( + group, + node, + materials, + row.compartment.type, + plinth + row.y0, + row.height, + ctx, + index, + ) + } + }) + + return group +} diff --git a/packages/nodes/src/cabinet/geometry/cooktop.ts b/packages/nodes/src/cabinet/geometry/cooktop.ts new file mode 100644 index 00000000..3687e3ea --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/cooktop.ts @@ -0,0 +1,568 @@ +import { + AdditiveBlending, + BoxGeometry, + CylinderGeometry, + DoubleSide, + type Float32BufferAttribute, + Group, + Mesh, + MeshBasicMaterial, + RingGeometry, + SphereGeometry, + TorusGeometry, +} from 'three' +import { + COOKTOP_FLAME_COUNT, + cooktopFlameSeed, + createCooktopFlameGeometry, + updateCooktopFlameTube, +} from '../cooktop-flame' +import { + type CabinetCompartment, + type CabinetCooktopCompartmentType, + type CooktopLayout, + compartmentCooktopActiveBurners, + compartmentCooktopBurnersOn, + compartmentCooktopKnobProgress, + compartmentCooktopLayout, + compartmentCooktopShowGrate, +} from '../stack' +import { + addBox, + applianceDisplayMaterial, + type CabinetGeometryNode, + cooktopBurnerMaterial, + cooktopGlassMaterial, + cooktopGrateMaterial, + cooktopInductionActiveZoneMaterial, + cooktopInductionZoneMaterial, + cooktopKnobHitMaterial, + cooktopKnobOnMaterial, + cooktopTrimMaterial, + createWorldScaleBoxGeometry, + stampSlot, +} from './shared' + +export const GAS_HOB_BURNER_RADIUS = 0.052 +export type CooktopBurnerSpec = { x: number; z: number; size: number } +const GAS_HOB_BURNER_LAYOUTS: Record< + Extract, + CooktopBurnerSpec[] +> = { + 'gas-2burner': [ + { x: -0.11, z: 0, size: 1 }, + { x: 0.11, z: 0, size: 1 }, + ], + 'gas-4burner': [ + { x: -0.144, z: -0.096, size: 1 }, + { x: -0.144, z: 0.096, size: 0.85 }, + { x: 0.144, z: -0.096, size: 0.85 }, + { x: 0.144, z: 0.096, size: 1 }, + ], + 'gas-5burner-wok': [ + { x: -0.24, z: -0.11, size: 0.85 }, + { x: 0.24, z: -0.11, size: 1 }, + { x: -0.24, z: 0.11, size: 1 }, + { x: 0.24, z: 0.11, size: 0.85 }, + { x: 0, z: 0, size: 1.5 }, + ], + 'gas-6burner': [ + { x: -0.3, z: -0.11, size: 1 }, + { x: 0, z: -0.11, size: 0.85 }, + { x: 0.3, z: -0.11, size: 1 }, + { x: -0.3, z: 0.11, size: 0.85 }, + { x: 0, z: 0.11, size: 1 }, + { x: 0.3, z: 0.11, size: 0.85 }, + ], +} +export type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number } +const INDUCTION_ZONE_LAYOUTS: Record< + Extract, + InductionZoneSpec[] +> = { + 'induction-2zone': [ + { x: -0.13, z: 0, radius: 0.072 }, + { x: 0.13, z: 0, radius: 0.072 }, + ], + 'induction-4zone': [ + { x: -0.18, z: 0.108, radius: 0.066 }, + { x: 0.18, z: 0.108, radius: 0.054 }, + { x: -0.18, z: -0.108, radius: 0.054 }, + { x: 0.18, z: -0.108, radius: 0.066 }, + ], +} +export function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { + return layout in GAS_HOB_BURNER_LAYOUTS + ? GAS_HOB_BURNER_LAYOUTS[ + layout as Extract< + CooktopLayout, + 'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner' + > + ] + : GAS_HOB_BURNER_LAYOUTS['gas-5burner-wok'] +} + +export function inductionZones(layout: CooktopLayout): InductionZoneSpec[] { + return layout in INDUCTION_ZONE_LAYOUTS + ? INDUCTION_ZONE_LAYOUTS[ + layout as Extract + ] + : INDUCTION_ZONE_LAYOUTS['induction-4zone'] +} + +function addCooktopFrameBorder( + group: Group, + name: string, + width: number, + depth: number, + y: number, +) { + const t = 0.014 + const h = 0.012 + addBox( + group, + [width, h, t], + [0, y, -depth / 2 + t / 2], + cooktopTrimMaterial, + `${name}-frame-back`, + 'appliance', + ) + addBox( + group, + [width, h, t], + [0, y, depth / 2 - t / 2], + cooktopTrimMaterial, + `${name}-frame-front`, + 'appliance', + ) + addBox( + group, + [t, h, depth - 2 * t], + [-width / 2 + t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-left`, + 'appliance', + ) + addBox( + group, + [t, h, depth - 2 * t], + [width / 2 - t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-right`, + 'appliance', + ) +} + +function addGasHobBurner( + group: Group, + name: string, + center: [number, number, number], + size: number, + burnerIndex: number, + active: boolean, + progress: number, +) { + const r = GAS_HOB_BURNER_RADIUS * size + const [x, y, z] = center + const base = stampSlot( + new Mesh(new CylinderGeometry(r, r * 1.1, 0.012, 28), cooktopBurnerMaterial), + 'appliance', + ) + base.name = `${name}-burner-${burnerIndex}-base` + base.position.set(x, y, z) + base.castShadow = true + base.receiveShadow = true + group.add(base) + + const ring = stampSlot( + new Mesh(new TorusGeometry(r * 0.72, 0.011, 10, 30), cooktopGrateMaterial), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y + 0.012, z) + ring.castShadow = true + group.add(ring) + + const cap = stampSlot( + new Mesh(new CylinderGeometry(r * 0.48, r * 0.6, 0.012, 24), cooktopGrateMaterial), + 'hardware', + ) + cap.name = `${name}-burner-${burnerIndex}-cap` + cap.position.set(x, y + 0.017, z) + cap.castShadow = true + group.add(cap) + + if (active || progress > 0.04) { + addCooktopCurvedFlames(group, name, x, y, z, r, burnerIndex, progress) + } +} + +function addContinuousCooktopGrate( + group: Group, + name: string, + width: number, + depth: number, + y: number, + burners: CooktopBurnerSpec[], +) { + const t = 0.011 + const bar = 0.008 + addBox( + group, + [width, bar, t], + [0, y, -depth / 2 + t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-back`, + 'appliance', + ) + addBox( + group, + [width, bar, t], + [0, y, depth / 2 - t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-front`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [-width / 2 + t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-left`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [width / 2 - t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-right`, + 'appliance', + ) + + const rowZs = [...new Set(burners.map((burner) => Number(burner.z.toFixed(3))))].sort( + (a, b) => a - b, + ) + const colXs = [...new Set(burners.map((burner) => Number(burner.x.toFixed(3))))].sort( + (a, b) => a - b, + ) + for (let i = 0; i < rowZs.length - 1; i += 1) { + addBox( + group, + [width, bar, t], + [0, y, (rowZs[i]! + rowZs[i + 1]!) / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-row-${i}`, + 'appliance', + ) + } + for (let i = 0; i < colXs.length - 1; i += 1) { + addBox( + group, + [t, bar, depth], + [(colXs[i]! + colXs[i + 1]!) / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-column-${i}`, + 'appliance', + ) + } +} + +function createCooktopFlameMaterial(color: string, opacity: number) { + return new MeshBasicMaterial({ + color, + transparent: true, + opacity, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} + +function createCooktopFlameBodyMaterial() { + return new MeshBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.92, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} + +function addCooktopCurvedFlames( + group: Group, + name: string, + x: number, + y: number, + z: number, + radius: number, + burnerIndex: number, + progress: number, +) { + const flameRoot = new Group() + flameRoot.name = `${name}-burner-${burnerIndex}-flames` + flameRoot.position.set(x, y + 0.028, z) + flameRoot.userData.cabinetFlameRoot = { progress } + flameRoot.scale.setScalar(Math.max(0.18, progress)) + group.add(flameRoot) + + // Faint heat shimmer only — anything stronger reads as a solid dome that + // hides the flames inside it. + const halo = stampSlot( + new Mesh( + new SphereGeometry(radius * 1.08, 14, 10), + createCooktopFlameMaterial('#ff7a3a', 0.05), + ), + 'appliance', + ) + halo.name = `${name}-burner-${burnerIndex}-flame-halo` + halo.userData.cabinetFlamePulse = { phase: 0.2, amplitude: 0.05, base: 1 } + halo.userData.cabinetFlameMaterialPulse = { phase: 1.1, base: 0.05, amplitude: 0.015 } + flameRoot.add(halo) + + // Flat ignition glow lapping the burner crown (not a torus — reads as the + // hot ring at the base of the flames in reference photos). + const ring = stampSlot( + new Mesh( + new RingGeometry(radius * 0.25, radius * 0.95, 32), + createCooktopFlameMaterial('#ff8838', 0.6), + ), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-flame-ring` + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.001 + ring.userData.cabinetFlameMaterialPulse = { phase: 1.7, base: 0.55, amplitude: 0.1 } + flameRoot.add(ring) + + const core = stampSlot( + new Mesh(new SphereGeometry(radius * 0.34, 14, 10), createCooktopFlameMaterial('#7eb8ff', 0.6)), + 'appliance', + ) + core.name = `${name}-burner-${burnerIndex}-flame-core` + core.position.y = 0.022 + core.userData.cabinetFlamePulse = { phase: 0.8, amplitude: 0.08, base: 0.95 } + core.userData.cabinetFlameMaterialPulse = { phase: 0.8, base: 0.55, amplitude: 0.08 } + flameRoot.add(core) + + for (let flameIndex = 0; flameIndex < COOKTOP_FLAME_COUNT; flameIndex += 1) { + const angle = (Math.PI * 2 * flameIndex) / COOKTOP_FLAME_COUNT + const seed = cooktopFlameSeed(flameIndex) + const flameGroup = new Group() + flameGroup.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}` + flameGroup.position.set(Math.cos(angle) * radius * 0.55, 0, Math.sin(angle) * radius * 0.55) + flameGroup.rotation.y = -angle + + const geometry = createCooktopFlameGeometry() + const positions = geometry.getAttribute('position') as Float32BufferAttribute + // Bake a resting pose so static builds (tests, screenshots) show flames + // even before the animation system's first tick. + updateCooktopFlameTube(positions.array as Float32Array, 0, seed, radius) + const body = stampSlot(new Mesh(geometry, createCooktopFlameBodyMaterial()), 'appliance') + body.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}-body` + body.userData.cabinetFlameJet = { seed, burnerR: radius } + flameGroup.add(body) + + flameRoot.add(flameGroup) + } +} + +function addInductionZone( + group: Group, + name: string, + zone: InductionZoneSpec, + y: number, + zoneIndex: number, + active: boolean, +) { + const material = active ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial + if (zone.w && zone.d) { + addBox( + group, + [zone.w, 0.002, zone.d], + [zone.x, y, zone.z], + material, + `${name}-zone-${zoneIndex}-flex-pad`, + 'appliance', + ) + } + + const fill = stampSlot( + new Mesh(new CylinderGeometry(zone.radius * 0.92, zone.radius * 0.92, 0.002, 64), material), + 'appliance', + ) + fill.name = `${name}-zone-${zoneIndex}-fill` + fill.position.set(zone.x, y + 0.001, zone.z) + group.add(fill) + + for (let ringIndex = 0; ringIndex < 3; ringIndex += 1) { + const ring = stampSlot( + new Mesh(new TorusGeometry(zone.radius * (1 - ringIndex * 0.24), 0.0022, 8, 72), material), + 'appliance', + ) + ring.name = `${name}-zone-${zoneIndex}-ring-${ringIndex}` + ring.rotation.x = Math.PI / 2 + ring.position.set(zone.x, y + 0.003 + ringIndex * 0.0006, zone.z) + group.add(ring) + } +} + +export function addCooktopCompartment( + group: Group, + node: CabinetGeometryNode, + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, + topY: number, + index: number, +) { + const layout = compartmentCooktopLayout(compartment, type) + const activeBurners = new Set(compartmentCooktopActiveBurners(compartment, type)) + const knobProgress = compartmentCooktopKnobProgress(compartment, type) + const burnersOn = activeBurners.size > 0 || compartmentCooktopBurnersOn(compartment) + const name = + type === 'cooktop-gas' ? `cabinet-cooktop-gas-${index}` : `cabinet-cooktop-induction-${index}` + const frameWidth = Math.max(0.32, Math.min(node.width - 0.01, 0.76)) + const frameDepth = Math.max(0.28, Math.min(node.depth - 0.04, 0.53)) + const surfaceWidth = Math.max(0.28, frameWidth - 0.026) + const surfaceDepth = Math.max(0.24, frameDepth - 0.026) + const surfaceThickness = 0.012 + const surfaceY = topY + surfaceThickness / 2 - 0.002 + addCooktopFrameBorder(group, name, frameWidth, frameDepth, topY + 0.006) + const surface = stampSlot( + new Mesh( + createWorldScaleBoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth), + cooktopGlassMaterial, + ), + 'appliance', + ) + surface.name = `${name}-surface` + surface.position.set(0, surfaceY, 0) + surface.castShadow = true + surface.receiveShadow = true + group.add(surface) + + if (type === 'cooktop-gas') { + const burners = gasHobBurners(layout) + burners.forEach((burner, burnerIndex) => { + const progress = knobProgress[burnerIndex] ?? (activeBurners.has(burnerIndex) ? 1 : 0) + addGasHobBurner( + group, + name, + [burner.x, topY + surfaceThickness + 0.004, burner.z], + burner.size, + burnerIndex, + activeBurners.has(burnerIndex), + progress, + ) + }) + if (compartmentCooktopShowGrate(compartment)) { + addContinuousCooktopGrate( + group, + name, + surfaceWidth + 0.02, + surfaceDepth + 0.02, + topY + surfaceThickness + 0.036, + burners, + ) + } + + const knobMargin = 0.06 + const knobSpan = surfaceWidth - knobMargin * 2 + const knobStep = knobSpan / Math.max(1, burners.length - 1) + const knobZ = surfaceDepth * 0.42 + for (let knobIndex = 0; knobIndex < burners.length; knobIndex += 1) { + const knobX = -knobSpan / 2 + knobIndex * knobStep + const progress = knobProgress[knobIndex] ?? (activeBurners.has(knobIndex) ? 1 : 0) + const knobAngle = -2.3 * progress + const knobUserData = { + type: 'gas', + compartmentIndex: index, + burnerIndex: knobIndex, + } + const hit = stampSlot( + new Mesh(new CylinderGeometry(0.03, 0.03, 0.06, 12), cooktopKnobHitMaterial), + 'hardware', + ) + hit.name = `${name}-knob-${knobIndex}-hit` + hit.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + hit.userData.cabinetCooktopKnob = knobUserData + group.add(hit) + + const collar = stampSlot( + new Mesh(new CylinderGeometry(0.016, 0.018, 0.006, 20), cooktopTrimMaterial), + 'hardware', + ) + collar.name = `${name}-knob-${knobIndex}-collar` + collar.position.set(knobX, topY + surfaceThickness + 0.006, knobZ) + collar.userData.cabinetCooktopKnob = knobUserData + group.add(collar) + + const knob = stampSlot( + new Mesh(new CylinderGeometry(0.012, 0.015, 0.02, 20), cooktopGrateMaterial), + 'hardware', + ) + knob.name = `${name}-knob-${knobIndex}` + knob.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + knob.rotation.y = knobAngle + knob.userData.cabinetCooktopKnob = knobUserData + knob.castShadow = true + group.add(knob) + + // Child of the knob so it turns with it and keeps pointing radially. + const notch = stampSlot( + new Mesh( + new BoxGeometry(0.003, 0.004, 0.011), + progress > 0.5 ? cooktopKnobOnMaterial : cooktopTrimMaterial, + ), + 'hardware', + ) + notch.name = `${name}-knob-${knobIndex}-notch` + notch.position.set(0, 0.012, 0.011) + knob.add(notch) + } + return + } + + const zones = inductionZones(layout) + zones.forEach((zone, zoneIndex) => { + addInductionZone( + group, + name, + zone, + topY + surfaceThickness + 0.002, + zoneIndex, + activeBurners.has(zoneIndex), + ) + }) + + const controlBar = stampSlot( + new Mesh( + new BoxGeometry(Math.min(0.24, surfaceWidth * 0.38), 0.003, 0.012), + applianceDisplayMaterial, + ), + 'appliance', + ) + controlBar.name = `${name}-touch-control-bar` + controlBar.position.set(0, topY + surfaceThickness + 0.003, surfaceDepth / 2 - 0.045) + group.add(controlBar) + for (let dotIndex = 0; dotIndex < zones.length + 2; dotIndex += 1) { + const dot = stampSlot( + new Mesh( + new CylinderGeometry(0.005, 0.005, 0.002, 14), + burnersOn ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial, + ), + 'appliance', + ) + dot.name = `${name}-touch-dot-${dotIndex}` + dot.position.set( + -0.015 * (zones.length + 1) + dotIndex * 0.03, + topY + surfaceThickness + 0.005, + surfaceDepth / 2 - 0.066, + ) + group.add(dot) + } +} diff --git a/packages/nodes/src/cabinet/geometry/dishwasher.ts b/packages/nodes/src/cabinet/geometry/dishwasher.ts new file mode 100644 index 00000000..c73af7ec --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/dishwasher.ts @@ -0,0 +1,322 @@ +import { BoxGeometry, CylinderGeometry, Group, Mesh } from 'three' +import { + APPLIANCE_CAVITY_WALL, + addBox, + addMicrowaveDisplaySegments, + addWireRack, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveButtonMaterial, + microwavePanelMaterial, + microwaveScreenMaterial, + OVEN_OPEN_ANGLE, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorLinerMaterial, + refrigeratorSealMaterial, + refrigeratorSilverMaterial, + roundedButtonGeometry, + stampSlot, +} from './shared' + +export function addDishwasherCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-dishwasher-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const doorWidth = Math.max(0.01, faceWidth - gap * 2) + const doorHeight = Math.max(0.01, faceHeight - gap * 2) + const doorCenterY = faceCenterY + const wall = APPLIANCE_CAVITY_WALL + const tubWidth = Math.max(0.05, Math.min(openingWidth, doorWidth) - wall * 2) + const tubHeight = Math.max(0.05, doorHeight - wall * 2) + const tubDepth = Math.max(0.08, Math.min(0.5, openingDepth - 0.04)) + const tubFrontZ = frontZ - frontThickness / 2 - 0.006 + const tubBackZ = tubFrontZ - tubDepth + const tubCenterZ = tubBackZ + tubDepth / 2 + const topBandHeight = Math.min(0.07, doorHeight * 0.1) + const faceZ = frontThickness / 2 + + addBox( + group, + [tubWidth + wall * 2, tubHeight + wall * 2, wall], + [0, doorCenterY, tubBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-tub-back`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY + tubHeight / 2 + wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-top`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY - tubHeight / 2 - wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [-tubWidth / 2 - wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [tubWidth / 2 + wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-right`, + 'applianceInterior', + ) + + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY + tubHeight * 0.18, + tubCenterZ, + `${name}-upper-rack`, + ) + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY - tubHeight * 0.18, + tubCenterZ, + `${name}-lower-rack`, + ) + + const sprayArmY = doorCenterY - tubHeight * 0.36 + const sprayArm = stampSlot( + new Mesh(new BoxGeometry(tubWidth * 0.64, 0.008, 0.012), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayArm.name = `${name}-spray-arm` + sprayArm.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayArm) + const sprayHub = stampSlot( + new Mesh(new CylinderGeometry(0.018, 0.018, 0.012, 24), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayHub.name = `${name}-spray-hub` + sprayHub.rotation.x = Math.PI / 2 + sprayHub.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayHub) + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + hingeGroup.position.set(0, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(0, doorHeight / 2, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth, + doorHeight, + frontThickness, + Math.min(doorWidth, doorHeight) * 0.035, + ), + materials.appliance, + ), + 'appliance', + ) + panel.name = `${name}-door-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const trimThickness = Math.max(0.006, Math.min(0.01, Math.min(doorWidth, doorHeight) * 0.018)) + const trimZ = faceZ + 0.004 + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.18], + [0, doorHeight / 2 - trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.16], + [0, -doorHeight / 2 + trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-bottom`, + 'appliance', + ) + for (const side of [-1, 1]) { + addBox( + leaf, + [trimThickness, doorHeight - trimThickness * 2.4, frontThickness * 0.14], + [side * (doorWidth / 2 - trimThickness * 1.2), 0, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-${side < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + + const bandWidth = doorWidth - trimThickness * 5 + const bandHeight = Math.max(0.045, topBandHeight * 0.82) + const bandY = doorHeight / 2 - trimThickness * 3.3 - bandHeight / 2 + const controlPanel = stampSlot( + new Mesh( + roundedButtonGeometry(bandWidth, bandHeight, frontThickness * 0.18, bandHeight * 0.18), + microwavePanelMaterial, + ), + 'appliance', + ) + controlPanel.name = `${name}-control-panel` + controlPanel.position.set(0, bandY, faceZ + 0.006) + controlPanel.castShadow = true + leaf.add(controlPanel) + + const displayWidth = Math.min(0.105, doorWidth * 0.2) + const display = stampSlot( + new Mesh(roundedButtonGeometry(displayWidth, 0.018, 0.004, 0.003), microwaveScreenMaterial), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(-bandWidth * 0.28, bandY, faceZ + 0.018) + leaf.add(display) + addMicrowaveDisplaySegments(leaf, -bandWidth * 0.28, bandY, faceZ + 0.014, displayWidth, name) + for (let i = 0; i < 4; i += 1) + addBox( + leaf, + [0.018, 0.006, 0.003], + [bandWidth * 0.03 + i * 0.034, bandY, faceZ + 0.019], + microwaveButtonMaterial, + `${name}-cycle-button-${i}`, + 'appliance', + ) + + const handleY = bandY - bandHeight / 2 - 0.018 + addBox( + leaf, + [doorWidth * 0.66, 0.018, 0.008], + [0, handleY, faceZ + 0.008], + microwavePanelMaterial, + `${name}-pocket-handle-shadow`, + 'appliance', + ) + addBox( + leaf, + [doorWidth * 0.58, 0.007, 0.006], + [0, handleY + 0.005, faceZ + 0.017], + refrigeratorSilverMaterial, + `${name}-pocket-handle-lip`, + 'appliance', + ) + + const lowerVisualTop = handleY - 0.025 + const toeVentY = -doorHeight / 2 + 0.042 + const centerPanelHeight = Math.max(0.08, lowerVisualTop - toeVentY - 0.052) + const centerPanelY = toeVentY + 0.042 + centerPanelHeight / 2 + const centerPanel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth - trimThickness * 7, + centerPanelHeight, + frontThickness * 0.1, + Math.min(0.012, centerPanelHeight * 0.05), + ), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + centerPanel.name = `${name}-brushed-front-panel` + centerPanel.position.set(0, centerPanelY, faceZ + 0.009) + centerPanel.castShadow = true + centerPanel.receiveShadow = true + leaf.add(centerPanel) + addBox( + leaf, + [doorWidth - trimThickness * 10, 0.01, 0.002], + [0, centerPanelY + centerPanelHeight * 0.42, faceZ + 0.016], + refrigeratorSealMaterial, + `${name}-front-highlight`, + 'appliance', + ) + for (const offsetX of [-0.5, 0.5]) { + addBox( + leaf, + [0.004, centerPanelHeight * 0.86, 0.002], + [offsetX * (doorWidth - trimThickness * 8), centerPanelY, faceZ + 0.015], + refrigeratorSealMaterial, + `${name}-front-groove-${offsetX < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + for (let i = 0; i < 3; i += 1) { + addBox( + leaf, + [0.003, centerPanelHeight * 0.82, 0.002], + [(-0.12 + i * 0.12) * doorWidth, centerPanelY, faceZ + 0.014], + refrigeratorSealMaterial, + `${name}-brushed-line-${i}`, + 'appliance', + ) + } + addBox( + leaf, + [Math.min(0.052, doorWidth * 0.1), 0.012, 0.004], + [doorWidth * 0.31, centerPanelY + centerPanelHeight * 0.34, faceZ + 0.019], + refrigeratorBrassAccentMaterial, + `${name}-badge`, + 'appliance', + ) + addBox( + leaf, + [Math.min(0.18, doorWidth * 0.34), 0.046, 0.012], + [doorWidth * 0.22, -doorHeight * 0.1, -frontThickness / 2 - 0.018], + microwaveScreenMaterial, + `${name}-detergent-cup`, + 'applianceInterior', + ) + + addBox( + leaf, + [doorWidth * 0.54, 0.024, frontThickness * 0.2], + [0, toeVentY, faceZ + 0.008], + refrigeratorDarkTrimMaterial, + `${name}-toe-vent`, + 'appliance', + ) + for (let i = 0; i < 5; i += 1) { + addBox( + leaf, + [0.03, 0.0035, 0.004], + [-doorWidth * 0.16 + i * doorWidth * 0.08, toeVentY, faceZ + 0.015], + microwaveScreenMaterial, + `${name}-toe-vent-slat-${i}`, + 'appliance', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/fridge.ts b/packages/nodes/src/cabinet/geometry/fridge.ts new file mode 100644 index 00000000..cf404b83 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/fridge.ts @@ -0,0 +1,1109 @@ +import { BoxGeometry, Group, Mesh, type Object3D } from 'three' +import type { CabinetFridgeCompartmentType } from '../stack' +import { + addApplianceHandle, + addBox, + applianceDisplayMaterial, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveScreenMaterial, + refrigeratorBinMaterial, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorDrawerMaterial, + refrigeratorLightMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorLinerMaterial, + refrigeratorSealMaterial, + refrigeratorSilverMaterial, + refrigeratorWaterMaterial, + roundedButtonGeometry, + stampSlot, +} from './shared' + +type FridgeSection = 'fresh' | 'freezer' + +function addFridgeWireBasket( + group: Group, + materials: CabinetSlotMaterials, + x: number, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, +) { + const bar = 0.006 + const railHeight = 0.07 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [x, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [x, y, zCenter - depth / 2 + bar / 2] }, + { + size: [bar, railHeight, depth], + position: [x - width / 2 + bar / 2, y - railHeight / 2, zCenter], + }, + { + size: [bar, railHeight, depth], + position: [x + width / 2 - bar / 2, y - railHeight / 2, zCenter], + }, + ] + frame.forEach((piece, i) => { + addBox( + group, + piece.size, + piece.position, + refrigeratorLinerAccentMaterial, + `${name}-frame-${i}`, + 'applianceInterior', + ) + }) + for (let i = 1; i <= 7; i += 1) { + const barX = x - width / 2 + (width * i) / 8 + addBox( + group, + [0.004, railHeight * 0.82, Math.max(0.01, depth - bar * 2)], + [barX, y - railHeight / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-bar-${i}`, + 'applianceInterior', + ) + } +} + +function addFridgeControlStrip( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const stripWidth = Math.min(0.32, width * 0.72) + addBox( + group, + [stripWidth, 0.028, 0.012], + [x, y, z], + refrigeratorLinerAccentMaterial, + `${name}-control-strip`, + 'applianceInterior', + ) + const displayWidth = stripWidth * 0.2 + addBox( + group, + [displayWidth, 0.014, 0.006], + [x - stripWidth * 0.26, y, z + 0.008], + applianceDisplayMaterial, + `${name}-control-display`, + 'appliance', + ) + for (let i = 0; i < 5; i += 1) { + addBox( + group, + [0.018, 0.012, 0.006], + [x - stripWidth * 0.04 + i * 0.026, y, z + 0.008], + materials.appliance, + `${name}-control-button-${i}`, + 'appliance', + ) + } +} + +function addFridgeIceMaker( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const boxWidth = Math.min(width * 0.72, 0.2) + const boxHeight = 0.09 + const boxDepth = Math.min(depth * 0.42, 0.16) + addBox( + group, + [boxWidth, boxHeight, boxDepth], + [x, y, zCenter - depth * 0.22], + refrigeratorDrawerMaterial, + `${name}-ice-maker-box`, + 'applianceInterior', + ) + addBox( + group, + [boxWidth * 0.74, 0.014, 0.012], + [x, y - boxHeight * 0.18, zCenter - depth * 0.22 + boxDepth / 2 + 0.008], + materials.appliance, + `${name}-ice-maker-pull`, + 'appliance', + ) +} + +function addFridgeVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.04, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.005, 0.005), refrigeratorDarkTrimMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2 + i * gap, y, z + 0.004) + group.add(slat) + } +} + +function addFridgeShelfAssembly( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const shelfThickness = 0.008 + const rail = 0.008 + addBox(group, [width, shelfThickness, depth], [x, y, zCenter], materials.glass, name, 'glass') + addBox( + group, + [width + rail, rail, rail], + [x, y + shelfThickness / 2 + rail / 2, zCenter + depth / 2 - rail / 2], + refrigeratorLinerAccentMaterial, + `${name}-front-lip`, + 'applianceInterior', + ) + addBox( + group, + [rail, rail, depth], + [x - width / 2 + rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-left-rim`, + 'applianceInterior', + ) + addBox( + group, + [rail, rail, depth], + [x + width / 2 - rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-right-rim`, + 'applianceInterior', + ) +} + +function addFridgeShelfRails( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const railWidth = 0.012 + const railHeight = 0.012 + for (const side of [-1, 1]) { + addBox( + group, + [railWidth, railHeight, depth * 0.82], + [x + side * (width / 2 - railWidth / 2), y - 0.006, zCenter - depth * 0.02], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-support`, + 'applianceInterior', + ) + } +} + +function addFridgeLinerRibs( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const ribWidth = 0.007 + const ribHeight = Math.max(0.04, height * 0.74) + const ribDepth = 0.01 + for (const side of [-1, 1]) { + for (let i = 0; i < 3; i += 1) { + addBox( + group, + [ribWidth, ribHeight, ribDepth], + [ + x + side * (width / 2 - ribWidth / 2), + y - height * 0.02, + zCenter - depth * 0.27 + i * depth * 0.2, + ], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-liner-rib-${i}`, + 'applianceInterior', + ) + } + } +} + +function addFridgeRearDiffuser( + group: Group, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const diffuserWidth = Math.min(0.18, width * 0.42) + const diffuserHeight = Math.min(0.52, height * 0.46) + addBox( + group, + [diffuserWidth, diffuserHeight, 0.008], + [x, y + height * 0.08, z], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-panel`, + 'applianceInterior', + ) + + const channelWidth = diffuserWidth * 0.72 + for (let i = 0; i < 4; i += 1) { + addBox( + group, + [channelWidth, 0.006, 0.006], + [x, y + height * 0.22 - i * diffuserHeight * 0.16, z + 0.006], + refrigeratorLightMaterial, + `${name}-rear-diffuser-channel-${i}`, + 'applianceInterior', + ) + } + + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x - diffuserWidth / 2 + 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-left-spine`, + 'applianceInterior', + ) + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x + diffuserWidth / 2 - 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-right-spine`, + 'applianceInterior', + ) +} + +function addFridgeCrisperDrawer( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const wall = 0.008 + addBox( + group, + [width, height, depth], + [x, y, zCenter], + refrigeratorDrawerMaterial, + name, + 'applianceInterior', + ) + addBox( + group, + [width + wall * 2, wall, depth + wall], + [x, y + height / 2 + wall / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-top-rim`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.72, 0.012, 0.01], + [x, y + height * 0.12, zCenter + depth / 2 + 0.009], + materials.appliance, + `${name}-handle`, + 'appliance', + ) + addBox( + group, + [width * 0.32, 0.004, 0.004], + [x, y - height * 0.08, zCenter + depth / 2 + 0.014], + refrigeratorLinerAccentMaterial, + `${name}-label-plate`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.38, 0.006, 0.005], + [x, y + height * 0.36, zCenter + depth / 2 + 0.015], + refrigeratorLinerAccentMaterial, + `${name}-humidity-track`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.12, 0.012, 0.008], + [x + width * 0.13, y + height * 0.36, zCenter + depth / 2 + 0.019], + materials.appliance, + `${name}-humidity-slider`, + 'appliance', + ) +} + +function addFridgeDoorShelf( + leaf: Group, + width: number, + height: number, + y: number, + z: number, + name: string, + scale = 1, +) { + const binWidth = Math.max(0.04, width * 0.72 * scale) + const binDepth = Math.max(0.026, width * 0.09 * scale) + const lipHeight = Math.max(0.026, height * 0.035 * scale) + addBox( + leaf, + [binWidth, 0.012, binDepth], + [0, y - lipHeight / 2, z], + refrigeratorBinMaterial, + `${name}-base`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth, lipHeight, 0.012], + [0, y, z - binDepth / 2], + refrigeratorBinMaterial, + name, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [-binWidth / 2 + 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-left-end`, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [binWidth / 2 - 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-right-end`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth * 0.84, 0.008, 0.008], + [0, y + lipHeight / 2 + 0.014, z - binDepth / 2 - 0.004], + refrigeratorLinerAccentMaterial, + `${name}-retainer`, + 'applianceInterior', + ) +} + +function addFridgeDoorWireBasket( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + y: number, + z: number, + name: string, +) { + const basketWidth = Math.max(0.04, width * 0.66) + const basketHeight = Math.max(0.045, height * 0.055) + const basketDepth = Math.max(0.026, width * 0.08) + addBox( + leaf, + [basketWidth, 0.006, basketDepth], + [0, y - basketHeight / 2, z], + refrigeratorLinerAccentMaterial, + `${name}-base-rail`, + 'applianceInterior', + ) + addBox( + leaf, + [basketWidth, 0.006, 0.006], + [0, y + basketHeight / 2, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-top-rail`, + 'applianceInterior', + ) + for (let i = 0; i < 6; i += 1) { + addBox( + leaf, + [0.004, basketHeight, 0.004], + [-basketWidth / 2 + (basketWidth * i) / 5, y, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-wire-${i}`, + 'applianceInterior', + ) + } +} + +function addFridgeDoorStorage( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + z: number, + name: string, + section: FridgeSection, +) { + if (section === 'freezer') { + addBox( + leaf, + [width * 0.56, height * 0.055, width * 0.08], + [0, height * 0.34, z], + refrigeratorDrawerMaterial, + `${name}-door-ice-box`, + 'applianceInterior', + ) + for (let i = 0; i < 4; i += 1) { + addFridgeDoorWireBasket( + leaf, + materials, + width, + height, + height * 0.18 - i * height * 0.18, + z, + `${name}-door-wire-bin-${i}`, + ) + } + return + } + + addBox( + leaf, + [width * 0.64, height * 0.065, width * 0.09], + [0, height * 0.32, z], + refrigeratorDrawerMaterial, + `${name}-door-dairy-box`, + 'applianceInterior', + ) + addBox( + leaf, + [width * 0.56, 0.01, 0.01], + [0, height * 0.35, z - width * 0.045], + refrigeratorLinerAccentMaterial, + `${name}-door-dairy-cover`, + 'applianceInterior', + ) + for (let i = 0; i < 3; i += 1) { + addFridgeDoorShelf( + leaf, + width, + height, + height * 0.13 - i * height * 0.18, + z, + `${name}-door-bin-${i}`, + ) + } + addFridgeDoorShelf(leaf, width, height, -height * 0.41, z, `${name}-door-bottle-bin`, 1.12) +} + +function addFridgeInterior( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, + section: FridgeSection = 'fresh', +) { + const shelfWidth = Math.max(0.04, width - 0.06) + const shelfDepth = Math.max(0.04, depth - 0.08) + addFridgeLinerRibs(group, x, y, zCenter, width, height, depth, name) + addFridgeRearDiffuser(group, x, y, zCenter - depth / 2 + 0.018, width, height, name) + + if (section === 'fresh') { + addFridgeControlStrip( + group, + materials, + x, + y + height / 2 - 0.055, + zCenter - depth / 2 + 0.032, + width, + name, + ) + } + + const shelfCount = section === 'freezer' ? 2 : 3 + for (let i = 1; i <= shelfCount; i += 1) { + const shelfY = y - height / 2 + (height * i) / (shelfCount + 1.6) + addFridgeShelfRails(group, x, shelfY, zCenter, width, depth, `${name}-${section}-rail-${i}`) + addFridgeShelfAssembly( + group, + materials, + x, + shelfY, + zCenter, + shelfWidth, + shelfDepth, + `${name}-${section}-shelf-${i}`, + ) + } + + if (section === 'freezer') { + const basketHeight = Math.min(0.15, height * 0.22) + addFridgeIceMaker( + group, + materials, + x, + y + height / 2 - Math.min(0.11, height * 0.16), + zCenter, + shelfWidth, + shelfDepth, + name, + ) + addFridgeWireBasket( + group, + materials, + x, + shelfWidth * 0.86, + shelfDepth * 0.82, + y - height / 2 + basketHeight + 0.025, + zCenter + shelfDepth * 0.05, + `${name}-freezer-wire-basket`, + ) + addBox( + group, + [shelfWidth * 0.86, basketHeight * 0.54, shelfDepth * 0.82], + [x, y - height / 2 + basketHeight / 2 + 0.025, zCenter + shelfDepth * 0.05], + refrigeratorDrawerMaterial, + `${name}-freezer-basket`, + 'applianceInterior', + ) + for (let i = 1; i <= 5; i += 1) { + addBox( + group, + [0.004, basketHeight * 0.78, shelfDepth * 0.76], + [ + x - shelfWidth * 0.34 + (shelfWidth * 0.68 * i) / 6, + y - height / 2 + basketHeight / 2 + 0.025, + zCenter + shelfDepth * 0.05, + ], + refrigeratorLinerAccentMaterial, + `${name}-freezer-basket-divider-${i}`, + 'applianceInterior', + ) + } + return + } + + const drawerHeight = Math.min(0.13, height * 0.12) + const drawerWidth = Math.max(0.04, shelfWidth * 0.42) + const drawerY = y - height / 2 + drawerHeight / 2 + 0.02 + for (let i = 0; i < 2; i += 1) { + const drawerX = x + (i === 0 ? -1 : 1) * drawerWidth * 0.58 + addFridgeCrisperDrawer( + group, + materials, + drawerX, + drawerY, + zCenter + shelfDepth * 0.08, + drawerWidth, + drawerHeight, + shelfDepth * 0.72, + `${name}-crisper-drawer-${i}`, + ) + } + + const deliHeight = Math.min(0.08, height * 0.07) + addBox( + group, + [shelfWidth * 0.86, deliHeight, shelfDepth * 0.66], + [x, drawerY + drawerHeight / 2 + deliHeight / 2 + 0.025, zCenter + shelfDepth * 0.04], + refrigeratorDrawerMaterial, + `${name}-deli-drawer`, + 'applianceInterior', + ) + addBox( + group, + [shelfWidth * 0.68, 0.01, 0.01], + [x, drawerY + drawerHeight / 2 + deliHeight * 0.62 + 0.025, zCenter + shelfDepth * 0.38], + materials.appliance, + `${name}-deli-drawer-handle`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.12, width * 0.25), 0.02, 0.012, 0.006), + refrigeratorLightMaterial, + ), + 'applianceInterior', + ) + lamp.name = `${name}-fresh-light` + lamp.position.set(x, y + height / 2 - 0.04, zCenter - depth / 2 + 0.04) + group.add(lamp) +} + +function addFridgeDoorCues(leaf: Group, width: number, height: number, name: string) { + const badge = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.09, width * 0.24), 0.018, 0.004, 0.004), + refrigeratorBrassAccentMaterial, + ), + 'appliance', + ) + badge.name = `${name}-badge` + badge.position.set(0, height / 2 - 0.09, 0.025) + leaf.add(badge) + + if (width < 0.28 || height < 0.72) return + + const dispenserWidth = Math.min(0.16, width * 0.42) + const dispenserHeight = Math.min(0.24, height * 0.16) + const dispenser = stampSlot( + new Mesh( + roundedButtonGeometry(dispenserWidth, dispenserHeight, 0.01, dispenserWidth * 0.08), + microwaveScreenMaterial, + ), + 'appliance', + ) + dispenser.name = `${name}-water-dispenser` + dispenser.position.set(0, height * 0.12, 0.03) + leaf.add(dispenser) + + const spout = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.34, 0.012, 0.01), refrigeratorDarkTrimMaterial), + 'appliance', + ) + spout.name = `${name}-ice-spout` + spout.position.set(0, height * 0.12 + dispenserHeight * 0.24, 0.039) + leaf.add(spout) + + const dripTray = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.68, 0.012, 0.012), refrigeratorWaterMaterial), + 'appliance', + ) + dripTray.name = `${name}-blue-drip-tray` + dripTray.position.set(0, height * 0.12 - dispenserHeight * 0.32, 0.041) + leaf.add(dripTray) +} + +function addFridgeLeaf( + group: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + section: FridgeSection, + openScale: number, +) { + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62) * openScale + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62), + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = name + leaf.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(width, height, 0.026, Math.min(width, height) * 0.035), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + panel.name = `${name}-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const inset = Math.max(0.012, Math.min(width, height) * 0.025) + addBox( + leaf, + [width - inset * 2, Math.max(0.01, height - inset * 2), 0.006], + [0, 0, 0.017], + materials.appliance, + `${name}-brushed-center`, + 'appliance', + ) + addFridgeDoorCues(leaf, width, height, name) + + const gasketWidth = Math.max(0.008, Math.min(width, height) * 0.018) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, height / 2 - gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-top`, + 'applianceInterior', + ) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, -height / 2 + gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-bottom`, + 'applianceInterior', + ) + addBox( + leaf, + [gasketWidth, height, 0.011], + [hinge === 'left' ? -width / 2 + gasketWidth / 2 : width / 2 - gasketWidth / 2, 0, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-hinge`, + 'applianceInterior', + ) + + addApplianceHandle( + leaf, + refrigeratorBrassAccentMaterial, + [(hinge === 'left' ? 1 : -1) * (width / 2 - 0.04), 0, 0.018], + Math.min(0.72, height * 0.58), + true, + `${name}-handle`, + ) + + const hingeCapX = (hinge === 'left' ? -1 : 1) * (width / 2 - 0.03) + for (const [capKey, capY] of [ + ['top', height / 2 - 0.012], + ['bottom', -height / 2 + 0.012], + ] as const) { + addBox( + leaf, + [0.05, 0.018, 0.02], + [hingeCapX, capY, 0.02], + refrigeratorBrassAccentMaterial, + `${name}-hinge-cap-${capKey}`, + 'appliance', + ) + } + addFridgeDoorStorage(leaf, materials, width, height, -0.035, name, section) +} + +function fridgeDoorLayout( + kind: CabinetFridgeCompartmentType, + faceHeight: number, +): Array<{ + key: string + y: number + height: number + widthFraction: number + hinge: 'left' | 'right' + xFraction: number + section: FridgeSection +}> { + if (kind === 'fridge-double') { + return [ + { + key: 'left', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'left', + xFraction: -0.25, + section: 'freezer', + }, + { + key: 'right', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'right', + xFraction: 0.25, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-top-freezer') { + const freezerHeight = faceHeight * 0.34 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'freezer', + y: faceHeight / 2 - freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + { + key: 'fresh', + y: -faceHeight / 2 + fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-bottom-freezer') { + const freezerHeight = faceHeight * 0.32 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'fresh', + y: faceHeight / 2 - fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + { + key: 'freezer', + y: -faceHeight / 2 + freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + ] + } + return [ + { + key: 'single', + y: 0, + height: faceHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] +} + +export function addFridgeCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetFridgeCompartmentType, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const wall = Math.max(0.018, node.boardThickness) + const shellInsetX = Math.max(0.04, Math.min(0.06, faceWidth * 0.065)) + const shellWidth = Math.max(0.05, faceWidth - shellInsetX * 2) + const topClearance = node.boardThickness + 0.055 + const bottomClearance = Math.max(0.026, node.boardThickness * 0.85) + const shellFaceHeight = Math.max(0.05, faceHeight - topClearance - bottomClearance) + const shellCenterY = faceCenterY + (bottomClearance - topClearance) / 2 + const applianceFrontInset = Math.max(0.036, node.frontThickness + 0.018) + const shellFrontZ = frontZ - applianceFrontInset + const interiorDepth = Math.max(0.08, Math.min(0.56, openingDepth - 0.11)) + const cavityFrontZ = shellFrontZ - 0.012 + const cavityBackZ = cavityFrontZ - interiorDepth + const cavityCenterZ = cavityBackZ + interiorDepth / 2 + const shellDepth = Math.max(0.12, Math.min(node.depth * 0.78, openingDepth - 0.085)) + const shellCenterZ = shellFrontZ - shellDepth / 2 + const shellSide = Math.max(0.018, Math.min(0.032, shellWidth * 0.04)) + const capHeight = Math.max(0.018, Math.min(0.04, faceHeight * 0.025)) + const kickHeight = Math.max(0.045, Math.min(0.075, faceHeight * 0.045)) + const seamGap = Math.max(0.0025, node.frontGap) + const shellTopY = shellCenterY + shellFaceHeight / 2 + const shellBottomY = shellCenterY - shellFaceHeight / 2 + const sideTopY = shellTopY - capHeight - seamGap + const sideBottomY = shellBottomY + kickHeight + seamGap + const shellSideHeight = Math.max(0.05, sideTopY - sideBottomY) + const shellSideCenterY = (sideTopY + sideBottomY) / 2 + const cavityOuterTopY = sideTopY - seamGap + const cavityOuterBottomY = sideBottomY + seamGap + const cavityOuterHeight = Math.max(0.05, cavityOuterTopY - cavityOuterBottomY) + const cavityShellCenterY = (cavityOuterTopY + cavityOuterBottomY) / 2 + const interiorWidth = Math.max( + 0.05, + Math.min(openingWidth, shellWidth) - shellSide * 2 - wall * 2, + ) + const interiorHeight = Math.max(0.05, cavityOuterHeight - wall * 2) + + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [-shellWidth / 2 + shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-left`, + 'appliance', + ) + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [shellWidth / 2 - shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-right`, + 'appliance', + ) + addBox( + group, + [shellWidth, capHeight, shellDepth], + [0, shellCenterY + shellFaceHeight / 2 - capHeight / 2, shellCenterZ], + materials.appliance, + `${name}-appliance-top-cap`, + 'appliance', + ) + addBox( + group, + [shellWidth, kickHeight, shellDepth], + [0, shellCenterY - shellFaceHeight / 2 + kickHeight / 2, shellCenterZ], + refrigeratorDarkTrimMaterial, + `${name}-appliance-toe-grille`, + 'appliance', + ) + + addBox( + group, + [interiorWidth + wall * 2, interiorHeight + wall * 2, wall], + [0, cavityShellCenterY, cavityBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY + interiorHeight / 2 + wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY - interiorHeight / 2 - wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [-interiorWidth / 2 - wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [interiorWidth / 2 + wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-right`, + 'applianceInterior', + ) + const interiorRows = fridgeDoorLayout(kind, cavityOuterHeight - node.frontGap * 2) + const layoutRows = fridgeDoorLayout(kind, shellFaceHeight - node.frontGap * 2) + if (kind === 'fridge-double') { + addBox( + group, + [wall, interiorHeight, interiorDepth], + [0, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-center-divider`, + 'applianceInterior', + ) + } else if (kind === 'fridge-top-freezer' || kind === 'fridge-bottom-freezer') { + const divider = interiorRows.find((row) => row.key === 'freezer') + if (divider) { + const dividerY = + kind === 'fridge-top-freezer' + ? cavityShellCenterY + cavityOuterHeight / 2 - divider.height - node.frontGap + : cavityShellCenterY - cavityOuterHeight / 2 + divider.height + node.frontGap + addBox( + group, + [interiorWidth, wall, interiorDepth], + [0, dividerY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-horizontal-divider`, + 'applianceInterior', + ) + } + } + + for (const layout of interiorRows) { + const sectionWidth = Math.max(0.05, interiorWidth * layout.widthFraction - wall) + const sectionHeight = Math.max(0.05, layout.height - wall * 1.4) + const sectionX = interiorWidth * layout.xFraction + const sectionY = cavityShellCenterY + layout.y + addFridgeInterior( + group, + materials, + sectionX, + sectionY, + cavityCenterZ, + sectionWidth, + sectionHeight, + interiorDepth, + `${name}-${layout.key}`, + layout.section, + ) + } + addFridgeVentSlats( + group, + 0, + shellCenterY - shellFaceHeight / 2 + 0.04, + shellFrontZ + 0.01, + shellWidth, + name, + ) + + const doorGap = node.frontGap + for (const layout of layoutRows) { + const doorWidth = Math.max(0.01, shellWidth * layout.widthFraction - doorGap * 2) + const doorHeight = Math.max(0.01, layout.height - doorGap * 2) + const doorCenterX = shellWidth * layout.xFraction + const doorCenterY = shellCenterY + layout.y + addFridgeLeaf( + group, + materials, + doorWidth, + doorHeight, + layout.hinge, + doorCenterX, + doorCenterY, + frontZ, + `${name}-door-${layout.key}`, + layout.section, + node.operationState ?? 0, + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts new file mode 100644 index 00000000..b1575237 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -0,0 +1,851 @@ +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + ExtrudeGeometry, + Group, + type Material, + Mesh, + MeshBasicMaterial, + type MeshStandardMaterial, + type Object3D, + Shape, + SphereGeometry, +} from 'three' +import { + addBox, + type CabinetGeometryNode, + type CabinetSlotMaterials, + createWorldScaleBoxGeometry, + stampSlot, +} from './shared' + +const DRAWER_MIN_OPEN = 0.32 +const HANDLE_EDGE_INSET = 0.045 +const HANDLE_TOP_INSET = 0.05 +const HANDLE_SLOT_LONG = 0.09 +const HANDLE_SLOT_SHORT = 0.016 +const HANDLE_CUTOUT_WIDTH = 0.13 +const HANDLE_CUTOUT_DIP = 0.014 +const SHAKER_FRAME_MIN = 0.045 +const SHAKER_FRAME_MAX = 0.085 +const SHAKER_RECESS_MIN = 0.004 +const RAISED_ARCH_FRAME_MIN = 0.048 +const RAISED_ARCH_FRAME_MAX = 0.09 +const RAISED_ARCH_RECESS_MIN = 0.004 +const holeDummyMaterial = new MeshBasicMaterial() + +function resolveShakerFrameSize(width: number, height: number): number { + return Math.min( + SHAKER_FRAME_MAX, + Math.max(SHAKER_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.16 : 0.2)), + ) +} + +function resolveRaisedArchFrameSize(width: number, height: number): number { + return Math.min( + RAISED_ARCH_FRAME_MAX, + Math.max(RAISED_ARCH_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21)), + ) +} + +function prepareCsgGeometry(geometry: BufferGeometry) { + const indexCount = geometry.getIndex()?.count ?? 0 + geometry.clearGroups() + if (indexCount > 0) geometry.addGroup(0, indexCount, 0) +} + +function subtractFrontCutters( + base: BufferGeometry, + cutters: BufferGeometry[], + label: string, +): BufferGeometry { + prepareCsgGeometry(base) + for (const cutter of cutters) prepareCsgGeometry(cutter) + + const baseBrush = new Brush(base, holeDummyMaterial as unknown as MeshStandardMaterial) + baseBrush.updateMatrixWorld() + prepareBrushForCSG(baseBrush) + + const cutterBrushes = cutters.map((geometry) => { + const brush = new Brush(geometry, holeDummyMaterial as unknown as MeshStandardMaterial) + brush.updateMatrixWorld() + prepareBrushForCSG(brush) + return brush + }) + + let current: Brush = baseBrush + const intermediates: Brush[] = [] + try { + for (const cutter of cutterBrushes) { + const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush + prepareBrushForCSG(next) + if (current !== baseBrush) intermediates.push(current) + current = next + } + + const result = csgGeometry(current).clone() + prepareCsgGeometry(result) + result.computeVertexNormals() + + base.dispose() + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return result + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[cabinet] ${label} CSG failed:`, error) + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return base + } +} + +function buildCutoutFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + const cutoutWidth = Math.min( + drawer ? HANDLE_CUTOUT_WIDTH : 0.11, + Math.max(0.045, width * (drawer ? 0.32 : 0.24)), + ) + const dip = Math.min(HANDLE_CUTOUT_DIP, Math.max(0.007, height * 0.14)) + const frontShape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const half = cutoutWidth / 2 + const flatHalf = half * 0.18 + if (drawer || hinge == null) { + const shoulderY = halfHeight - dip * 0.08 + const bottomY = halfHeight - dip + + frontShape.moveTo(-halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(half, halfHeight) + frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY) + frontShape.lineTo(-flatHalf, bottomY) + frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, -halfHeight) + } else { + const side = hinge === 'left' ? 1 : -1 + const edgeX = side * halfWidth + const innerX = edgeX - side * dip + + frontShape.moveTo(-halfWidth, -halfHeight) + if (side > 0) { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -half) + frontShape.bezierCurveTo(edgeX, -half * 0.76, innerX, -half * 0.52, innerX, -flatHalf) + frontShape.lineTo(innerX, flatHalf) + frontShape.bezierCurveTo(innerX, half * 0.52, edgeX, half * 0.76, edgeX, half) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + } else { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, half) + frontShape.bezierCurveTo(edgeX, half * 0.76, innerX, half * 0.52, innerX, flatHalf) + frontShape.lineTo(innerX, -flatHalf) + frontShape.bezierCurveTo(innerX, -half * 0.52, edgeX, -half * 0.76, edgeX, -half) + frontShape.lineTo(-halfWidth, -halfHeight) + } + frontShape.lineTo(-halfWidth, -halfHeight) + } + + const geometry = new ExtrudeGeometry(frontShape, { + depth: node.frontThickness, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + geometry.translate(0, 0, -node.frontThickness / 2) + geometry.computeVertexNormals() + return geometry +} + +function buildBaseFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + if (node.handleStyle === 'cutout') + return buildCutoutFrontGeometry(node, width, height, drawer, hinge) + if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge) + return createWorldScaleBoxGeometry(width, height, node.frontThickness) +} + +function applyShakerFrontProfile( + node: CabinetGeometryNode, + base: BufferGeometry, + width: number, + height: number, +): BufferGeometry { + const frame = resolveShakerFrameSize(width, height) + const panelWidth = width - frame * 2 + const panelHeight = height - frame * 2 + if (panelWidth <= 0.01 || panelHeight <= 0.01) return base + + const recessDepth = Math.min( + Math.max(SHAKER_RECESS_MIN, node.frontThickness * 0.4), + Math.max(SHAKER_RECESS_MIN, node.frontThickness - 0.004), + ) + const cutter = new BoxGeometry(panelWidth, panelHeight, recessDepth + 0.012) + cutter.translate(0, 0, node.frontThickness / 2 - recessDepth / 2 + 0.006) + return subtractFrontCutters(base, [cutter], 'shaker panel recess') +} + +function buildRaisedArchPanelShape(panelWidth: number, panelHeight: number): Shape { + const halfWidth = panelWidth / 2 + const halfHeight = panelHeight / 2 + const targetArchRise = Math.min(0.07, Math.max(0.03, panelWidth * 0.22)) + const archRise = Math.min(targetArchRise, Math.max(0.02, panelHeight * 0.26)) + const springY = halfHeight - archRise + const archShoulderControlY = springY + archRise * 0.72 + const archCenterControlX = halfWidth * 0.56 + + const shape = new Shape() + shape.moveTo(-halfWidth, -halfHeight) + shape.lineTo(halfWidth, -halfHeight) + shape.lineTo(halfWidth, springY) + shape.bezierCurveTo( + halfWidth, + archShoulderControlY, + archCenterControlX, + halfHeight, + 0, + halfHeight, + ) + shape.bezierCurveTo( + -archCenterControlX, + halfHeight, + -halfWidth, + archShoulderControlY, + -halfWidth, + springY, + ) + shape.lineTo(-halfWidth, -halfHeight) + return shape +} + +function buildRectangleShape(width: number, height: number): Shape { + const halfWidth = width / 2 + const halfHeight = height / 2 + const shape = new Shape() + shape.moveTo(-halfWidth, -halfHeight) + shape.lineTo(halfWidth, -halfHeight) + shape.lineTo(halfWidth, halfHeight) + shape.lineTo(-halfWidth, halfHeight) + shape.lineTo(-halfWidth, -halfHeight) + return shape +} + +function buildRaisedArchGlassDoorGeometry( + node: CabinetGeometryNode, + width: number, + height: number, +): { frame: BufferGeometry; glass: BufferGeometry; frameWidth: number; glassDepth: number } { + const frameWidth = resolveRaisedArchFrameSize(width, height) + const glassWidth = Math.max(0.01, width - frameWidth * 2) + const glassHeight = Math.max(0.01, height - frameWidth * 2) + const glassDepth = Math.max(0.003, node.frontThickness * 0.25) + + const frameShape = buildRectangleShape(width, height) + frameShape.holes.push(buildRaisedArchPanelShape(glassWidth, glassHeight)) + + const frame = new ExtrudeGeometry(frameShape, { + depth: node.frontThickness, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + frame.translate(0, 0, -node.frontThickness / 2) + frame.computeVertexNormals() + + const glass = new ExtrudeGeometry(buildRaisedArchPanelShape(glassWidth, glassHeight), { + depth: glassDepth, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + glass.translate(0, 0, -glassDepth / 2) + glass.computeVertexNormals() + + return { frame, glass, frameWidth, glassDepth } +} + +function applyRaisedArchFrontProfile( + node: CabinetGeometryNode, + base: BufferGeometry, + width: number, + height: number, +): BufferGeometry { + const frame = resolveRaisedArchFrameSize(width, height) + const panelWidth = width - frame * 2 + const panelHeight = height - frame * 2 + if (panelWidth <= 0.01 || panelHeight <= 0.01) return base + + const recessDepth = Math.min( + Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness * 0.42), + Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness - 0.004), + ) + const cutter = new ExtrudeGeometry(buildRaisedArchPanelShape(panelWidth, panelHeight), { + depth: recessDepth + 0.012, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + const cutterCenterZ = node.frontThickness / 2 - recessDepth / 2 + 0.006 + cutter.translate(0, 0, cutterCenterZ - (recessDepth + 0.012) / 2) + cutter.computeVertexNormals() + return subtractFrontCutters(base, [cutter], 'raised arch panel recess') +} + +export function buildFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null = null, +): BufferGeometry { + const base = buildBaseFrontGeometry(node, width, height, drawer, hinge) + switch (node.frontStyle ?? 'slab') { + case 'shaker': + return applyShakerFrontProfile(node, base, width, height) + case 'raised-arch': + return applyRaisedArchFrontProfile(node, base, width, height) + default: + return base + } +} + +function buildHoleFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + const base = createWorldScaleBoxGeometry(width, height, node.frontThickness) + const radius = drawer ? 0.011 : 0.01 + const { x, y } = resolveHandlePlacement(node, width, height, drawer, hinge) + const holeOffsets = drawer ? [-0.022, 0.022] : [0] + const cutters = holeOffsets.map((offset) => { + const cutter = new CylinderGeometry(radius, radius, node.frontThickness + 0.012, 24) + cutter.rotateX(Math.PI / 2) + cutter.translate(x + offset, y, 0) + return cutter + }) + return subtractFrontCutters(base, cutters, 'hole handle') +} + +export function addBarHandle( + group: Object3D, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, + material: Material, +) { + const mesh = stampSlot( + new Mesh(new CylinderGeometry(0.006, 0.006, length, 16), material), + 'hardware', + ) + mesh.name = name + mesh.position.set(position[0], position[1], position[2] + 0.028) + if (!vertical) mesh.rotation.z = Math.PI / 2 + mesh.castShadow = true + group.add(mesh) + + const standOffDistance = length * 0.38 + for (const offset of [-standOffDistance, standOffDistance]) { + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), material), + 'hardware', + ) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.014, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +function addKnobHandle( + group: Object3D, + position: [number, number, number], + name: string, + material: Material, +) { + const stem = stampSlot( + new Mesh(new CylinderGeometry(0.005, 0.005, 0.02, 12), material), + 'hardware', + ) + stem.name = `${name}-stem` + stem.position.set(position[0], position[1], position[2] + 0.01) + stem.rotation.x = Math.PI / 2 + stem.castShadow = true + group.add(stem) + + const knob = stampSlot(new Mesh(new SphereGeometry(0.011, 16, 12), material), 'hardware') + knob.name = name + knob.position.set(position[0], position[1], position[2] + 0.022) + knob.castShadow = true + group.add(knob) +} + +function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boolean): number { + const position = node.handlePosition ?? 'auto' + const topY = drawer + ? height / 2 - HANDLE_TOP_INSET + : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 + if (position === 'center') return 0 + if (position === 'top') return topY + // 'auto': drawers pull from the top, doors from mid-height. + return drawer ? topY : 0 +} + +function resolveHandlePlacement( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): { x: number; y: number } { + const defaultX = + hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + const defaultY = resolveHandleY(node, height, drawer) + const frontStyle = node.frontStyle ?? 'slab' + const frame = + frontStyle === 'shaker' + ? resolveShakerFrameSize(width, height) + : frontStyle === 'raised-arch' + ? resolveRaisedArchFrameSize(width, height) + : 0 + if (frame <= 0) return { x: defaultX, y: defaultY } + + if (hinge != null) { + return { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), + y: defaultY, + } + } + + const position = node.handlePosition ?? 'auto' + const frameY = height / 2 - frame / 2 + return { + x: defaultX, + y: drawer ? (position === 'center' ? 0 : frameY) : position === 'center' ? 0 : defaultY, + } +} + +function addHandleFeature( + group: Object3D, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right' | null, + vertical: boolean, + drawer = false, + name = 'handle', + placement?: { x?: number; y?: number }, +) { + const style = node.handleStyle ?? 'bar' + if (style === 'none') return + const resolvedPlacement = resolveHandlePlacement(node, width, height, drawer, hinge) + + if (style === 'bar') { + const x = placement?.x ?? resolvedPlacement.x + const y = placement?.y ?? resolvedPlacement.y + const z = node.frontThickness / 2 + addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name, materials.hardware) + return + } + + if (style === 'knob') { + const x = placement?.x ?? resolvedPlacement.x + const y = placement?.y ?? resolvedPlacement.y + const z = node.frontThickness / 2 + addKnobHandle(group, [x, y, z], name, materials.hardware) + return + } + + // 'hole' and 'cutout' are carved by the CSG pass on the front panel itself + // (see stampSlot callers) — no separate handle mesh. +} + +function addDoorLeaf( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + glass = false, +) { + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI / 2), + } + group.add(hingeGroup) + + if (glass) { + const leafGroup = new Group() + leafGroup.name = name + leafGroup.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leafGroup) + + if ((node.frontStyle ?? 'slab') === 'raised-arch') { + const { frame, glass, frameWidth, glassDepth } = buildRaisedArchGlassDoorGeometry( + node, + width, + height, + ) + const frameMesh = stampSlot(new Mesh(frame, materials.front), 'front') + frameMesh.name = `${name}-frame` + frameMesh.castShadow = true + frameMesh.receiveShadow = true + leafGroup.add(frameMesh) + + const glassMesh = stampSlot(new Mesh(glass, materials.glass), 'glass') + glassMesh.name = `${name}-glass` + glassMesh.position.set(0, 0, node.frontThickness / 2 - glassDepth / 2 - 0.001) + glassMesh.renderOrder = 2 + leafGroup.add(glassMesh) + + addHandleFeature( + leafGroup, + { ...node, handleStyle: 'bar' }, + materials, + width, + height, + hinge, + true, + false, + `${name}-handle`, + { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frameWidth / 2), + y: 0, + }, + ) + return + } + + const frame = Math.max(0.03, Math.min(width, height) * 0.12) + const glassWidth = Math.max(0.01, width - frame * 2) + const glassHeight = Math.max(0.01, height - frame * 2) + const glassDepth = Math.max(0.003, node.frontThickness * 0.25) + addBox( + leafGroup, + [width, frame, node.frontThickness], + [0, height / 2 - frame / 2, 0], + materials.front, + `${name}-frame-top`, + 'front', + ) + addBox( + leafGroup, + [width, frame, node.frontThickness], + [0, -height / 2 + frame / 2, 0], + materials.front, + `${name}-frame-bottom`, + 'front', + ) + addBox( + leafGroup, + [frame, glassHeight, node.frontThickness], + [-width / 2 + frame / 2, 0, 0], + materials.front, + `${name}-frame-left`, + 'front', + ) + addBox( + leafGroup, + [frame, glassHeight, node.frontThickness], + [width / 2 - frame / 2, 0, 0], + materials.front, + `${name}-frame-right`, + 'front', + ) + const glassMesh = stampSlot( + new Mesh(createWorldScaleBoxGeometry(glassWidth, glassHeight, glassDepth), materials.glass), + 'glass', + ) + glassMesh.name = `${name}-glass` + glassMesh.position.set(0, 0, node.frontThickness / 2 - glassDepth / 2 - 0.001) + glassMesh.renderOrder = 2 + leafGroup.add(glassMesh) + addHandleFeature( + leafGroup, + { ...node, handleStyle: 'bar' }, + materials, + width, + height, + hinge, + true, + false, + `${name}-handle`, + { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), + y: 0, + }, + ) + return + } + + const mesh = stampSlot( + new Mesh(buildFrontGeometry(node, width, height, false, hinge), materials.front), + 'front', + ) + mesh.name = name + mesh.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + mesh.castShadow = true + mesh.receiveShadow = true + hingeGroup.add(mesh) + + addHandleFeature(mesh, node, materials, width, height, hinge, true, false, `${name}-handle`) +} + +export function addDoorFronts( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + openingWidth: number, + openingHeight: number, + centerX: number, + centerY: number, + frontZ: number, + doorType: 'single-left' | 'single-right' | 'double' | 'glass', +) { + const frontHeight = Math.max(0.01, openingHeight - 2 * node.frontGap) + if (doorType === 'double' || doorType === 'glass') { + const leafWidth = Math.max(0.01, (openingWidth - 3 * node.frontGap) / 2) + const offset = leafWidth / 2 + node.frontGap / 2 + addDoorLeaf( + group, + node, + materials, + leafWidth, + frontHeight, + 'left', + centerX - offset, + centerY, + frontZ, + `cabinet-door-left-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + addDoorLeaf( + group, + node, + materials, + leafWidth, + frontHeight, + 'right', + centerX + offset, + centerY, + frontZ, + `cabinet-door-right-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + return + } + addDoorLeaf( + group, + node, + materials, + openingWidth - 2 * node.frontGap, + frontHeight, + doorType === 'single-left' ? 'left' : 'right', + centerX, + centerY, + frontZ, + `cabinet-door-single-${centerY.toFixed(3)}`, + ) +} + +export function addSinkFalseFront( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + centerY: number, + frontZ: number, + index: number, +) { + const frontWidth = Math.max(0.01, faceWidth - 2 * node.frontGap) + const frontHeight = Math.max(0.01, faceHeight - 2 * node.frontGap) + const mesh = stampSlot( + new Mesh( + buildFrontGeometry({ ...node, handleStyle: 'none' }, frontWidth, frontHeight, true), + materials.front, + ), + 'front', + ) + mesh.name = `cabinet-sink-false-front-${index}` + mesh.position.set(0, centerY, frontZ + 0.001) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) +} + +export function addShelfBoards( + group: Group, + materials: CabinetSlotMaterials, + openingWidth: number, + openingDepth: number, + board: number, + y0: number, + height: number, + count: number, + centerX = 0, +) { + if (count <= 0) return + for (let i = 0; i < count; i++) { + const y = y0 + (height * (i + 1)) / (count + 1) + addBox( + group, + [openingWidth, board, openingDepth], + [centerX, y, board / 2], + materials.carcass, + `cabinet-shelf-${y.toFixed(3)}-${i}`, + 'carcass', + ) + } +} + +function drawerOpenScale(index: number, count: number) { + if (count <= 1) return 1 + return 1 - (index / (count - 1)) * (1 - DRAWER_MIN_OPEN) +} + +export function addDrawerFronts( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + centerY: number, + y0: number, + boxOpeningWidth: number, + frontZ: number, + count: number, + boxBackZ: number, + boxDepth: number, +) { + const usableHeight = Math.max(0.01, faceHeight - 2 * node.frontGap) + const drawerHeight = Math.max(0.01, (usableHeight - (count - 1) * node.frontGap) / count) + const drawerSideThickness = Math.min(0.012, node.boardThickness * 0.7) + const boxWidth = Math.max(0.01, boxOpeningWidth - 0.026) + const boxHeight = Math.max(0.02, drawerHeight - 0.012) + const boxCenterZ = boxBackZ + boxDepth / 2 + for (let i = 0; i < count; i++) { + const openDistance = Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count) + const openOffset = (node.operationState ?? 0) * openDistance + const y = y0 + node.frontGap + drawerHeight / 2 + i * (drawerHeight + node.frontGap) + const frontWidth = faceWidth - 2 * node.frontGap + + const slideGroup = new Group() + slideGroup.name = `cabinet-drawer-slide-${centerY.toFixed(3)}-${i}` + slideGroup.position.set(0, 0, openOffset) + slideGroup.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(slideGroup) + + const frontMesh = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, drawerHeight, true), materials.front), + 'front', + ) + frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}` + frontMesh.position.set(0, y, frontZ) + frontMesh.castShadow = true + frontMesh.receiveShadow = true + slideGroup.add(frontMesh) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleGroup = new Group() + handleGroup.position.set(0, y, frontZ) + handleGroup.name = `cabinet-drawer-handle-group-${centerY.toFixed(3)}-${i}` + addHandleFeature( + handleGroup, + node, + materials, + frontWidth, + drawerHeight, + null, + false, + true, + `cabinet-drawer-handle-${centerY.toFixed(3)}-${i}`, + ) + slideGroup.add(handleGroup) + } + + addBox( + slideGroup, + [drawerSideThickness, boxHeight, boxDepth], + [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ], + materials.carcass, + `cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [drawerSideThickness, boxHeight, boxDepth], + [boxWidth / 2 - drawerSideThickness / 2, y, boxCenterZ], + materials.carcass, + `cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness], + [0, y, boxBackZ + drawerSideThickness / 2], + materials.carcass, + `cabinet-drawer-back-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness], + [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ], + materials.carcass, + `cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/hood.ts b/packages/nodes/src/cabinet/geometry/hood.ts new file mode 100644 index 00000000..73e72a00 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/hood.ts @@ -0,0 +1,175 @@ +import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core' +import { + BufferGeometry, + ExtrudeGeometry, + Float32BufferAttribute, + type Group, + Mesh, + Shape, +} from 'three' +import { + type CabinetHoodCompartmentType, + DEFAULT_CEILING_HEIGHT, + HOOD_CANOPY_DEPTH, + HOOD_CURVED_BODY_HEIGHT, + HOOD_DUCT_SIZE, +} from '../stack' +import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared' + +function buildFrustumGeometry( + bottomWidth: number, + bottomDepth: number, + topWidth: number, + topDepth: number, + height: number, + topOffsetZ: number, +): BufferGeometry { + const bx = bottomWidth / 2 + const bz = bottomDepth / 2 + const tx = topWidth / 2 + const tz = topDepth / 2 + const b0 = [-bx, 0, -bz] + const b1 = [bx, 0, -bz] + const b2 = [bx, 0, bz] + const b3 = [-bx, 0, bz] + const t0 = [-tx, height, topOffsetZ - tz] + const t1 = [tx, height, topOffsetZ - tz] + const t2 = [tx, height, topOffsetZ + tz] + const t3 = [-tx, height, topOffsetZ + tz] + const quads: number[][][] = [ + [b3, b2, t2, t3], + [b1, b0, t0, t1], + [b0, b3, t3, t0], + [b2, b1, t1, t2], + [t0, t3, t2, t1], + [b0, b1, b2, b3], + ] + const positions: number[] = [] + for (const [a, b, c, d] of quads) { + positions.push(...a!, ...b!, ...c!, ...a!, ...c!, ...d!) + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + return geometry +} + +function resolveHoodDuctTopY(node: CabinetGeometryNode, ctx: GeometryContext | undefined): number { + let baseY = node.position?.[1] ?? 0 + let cursor: AnyNode | null = ctx?.parent ?? null + let guard = 0 + while (cursor && (cursor.type === 'cabinet' || cursor.type === 'cabinet-module') && guard < 8) { + const position = (cursor as { position?: unknown }).position + if (Array.isArray(position) && typeof position[1] === 'number') baseY += position[1] + cursor = cursor.parentId ? (ctx?.resolve(cursor.parentId as AnyNodeId) ?? null) : null + guard += 1 + } + let ceiling = DEFAULT_CEILING_HEIGHT + const levelChildren = (cursor as { children?: unknown } | null)?.children + if (Array.isArray(levelChildren)) { + const wallHeights = levelChildren + .map((id) => ctx?.resolve(id as AnyNodeId)) + .filter((child): child is AnyNode => child?.type === 'wall') + .map((wall) => (wall as { height?: number }).height ?? DEFAULT_CEILING_HEIGHT) + if (wallHeights.length > 0) ceiling = Math.max(...wallHeights) + } + return ceiling - baseY +} + +export function addRangeHoodCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetHoodCompartmentType, + bottomY: number, + height: number, + ctx: GeometryContext | undefined, + index: number, +) { + const width = node.width + const backZ = -node.depth / 2 + const canopyCenterZ = backZ + HOOD_CANOPY_DEPTH / 2 + const ductCenterZ = backZ + HOOD_DUCT_SIZE / 2 + 0.02 + const name = `cabinet-${kind}-${index}` + + let ductBottomY: number + if (kind === 'hood-pyramid') { + const frustum = buildFrustumGeometry( + width, + HOOD_CANOPY_DEPTH, + HOOD_DUCT_SIZE + 0.04, + HOOD_DUCT_SIZE + 0.04, + height, + ductCenterZ - canopyCenterZ, + ) + const canopy = stampSlot(new Mesh(frustum, materials.appliance), 'appliance') + canopy.name = `${name}-canopy` + canopy.position.set(0, bottomY, canopyCenterZ) + canopy.castShadow = true + canopy.receiveShadow = true + group.add(canopy) + + addBox( + group, + [width, 0.02, HOOD_CANOPY_DEPTH], + [0, bottomY + 0.01, canopyCenterZ], + materials.appliance, + `${name}-rim`, + 'appliance', + ) + ductBottomY = bottomY + height + } else { + const bodyDepth = Math.min(0.3, HOOD_CANOPY_DEPTH) + const bodyCenterZ = backZ + bodyDepth / 2 + addBox( + group, + [width, HOOD_CURVED_BODY_HEIGHT, bodyDepth], + [0, bottomY + HOOD_CURVED_BODY_HEIGHT / 2, bodyCenterZ], + materials.appliance, + `${name}-body`, + 'appliance', + ) + + const glassThickness = 0.008 + const zFront = backZ + bodyDepth + const zBack = backZ + 0.04 + const yBottom = bottomY + HOOD_CURVED_BODY_HEIGHT + const yTop = bottomY + height + const profile = new Shape() + profile.moveTo(zFront, yBottom) + profile.quadraticCurveTo(zFront, yTop, zBack, yTop) + profile.lineTo(zBack, yTop - glassThickness) + profile.quadraticCurveTo( + zFront - glassThickness, + yTop - glassThickness, + zFront - glassThickness, + yBottom, + ) + profile.lineTo(zFront, yBottom) + const visorGeometry = new ExtrudeGeometry(profile, { + depth: width, + bevelEnabled: false, + curveSegments: 24, + steps: 1, + }) + visorGeometry.rotateY(-Math.PI / 2) + visorGeometry.translate(width / 2, 0, 0) + visorGeometry.computeVertexNormals() + const visor = stampSlot(new Mesh(visorGeometry, materials.glass), 'glass') + visor.name = `${name}-glass-visor` + visor.castShadow = true + group.add(visor) + ductBottomY = bottomY + HOOD_CURVED_BODY_HEIGHT + } + + const ductTopY = Math.max(ductBottomY + 0.05, resolveHoodDuctTopY(node, ctx)) + const ductHeight = ductTopY - ductBottomY + addBox( + group, + [HOOD_DUCT_SIZE, ductHeight, HOOD_DUCT_SIZE], + [0, ductBottomY + ductHeight / 2, ductCenterZ], + materials.appliance, + `${name}-duct`, + 'appliance', + ) +} diff --git a/packages/nodes/src/cabinet/geometry/oven-microwave.ts b/packages/nodes/src/cabinet/geometry/oven-microwave.ts new file mode 100644 index 00000000..4c6f0933 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/oven-microwave.ts @@ -0,0 +1,827 @@ +import { + BoxGeometry, + CylinderGeometry, + Group, + type Material, + Mesh, + type Object3D, + TorusGeometry, +} from 'three' +import { + APPLIANCE_CAVITY_WALL, + addApplianceHandle, + addBox, + addMicrowaveDisplaySegments, + addWireRack, + applianceLampMaterial, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveButtonMaterial, + microwaveCancelButtonMaterial, + microwavePanelMaterial, + microwaveScreenMaterial, + microwaveStartButtonMaterial, + OVEN_OPEN_ANGLE, + ovenDialMaterial, + ovenHeatElementMaterial, + ovenIndicatorMaterial, + ovenStatusLightMaterials, + roundedButtonGeometry, + stampSlot, +} from './shared' + +function addMicrowaveVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.018, width * 0.52) + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.0035, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x, y - i * 0.009, z + 0.002) + group.add(slat) + } +} + +export function addMicrowaveButton( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + material: Material, + name: string, +) { + const button = stampSlot( + new Mesh(roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), material), + 'appliance', + ) + button.name = name + button.position.set(x, y, z + 0.004) + button.castShadow = true + group.add(button) + + const highlight = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.58, height * 0.16, 0.002, height * 0.06), + microwaveScreenMaterial, + ), + 'appliance', + ) + highlight.name = `${name}-highlight` + highlight.position.set(x, y + height * 0.22, z + 0.008) + group.add(highlight) +} + +function addMicrowaveControls( + group: Object3D, + x: number, + y: number, + z: number, + panelWidth: number, + panelHeight: number, + name: string, +) { + const shellWidth = panelWidth * 0.82 + const shellHeight = Math.min(panelHeight * 0.7, 0.27) + const shellY = y + const panelBack = stampSlot( + new Mesh( + roundedButtonGeometry(shellWidth, shellHeight, 0.004, panelWidth * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + panelBack.name = `${name}-control-panel` + panelBack.position.set(x, shellY, z + 0.001) + group.add(panelBack) + + const displayWidth = Math.min(0.085, panelWidth * 0.56) + const displayHeight = Math.min(0.024, shellHeight * 0.12) + const displayY = shellY + shellHeight * 0.32 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.002) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonSize = Math.max(0.009, Math.min(0.014, panelWidth * 0.105)) + const gap = buttonSize * 1.55 + const quickY = shellY + shellHeight * 0.18 + const startY = shellY + shellHeight * 0.04 + + addMicrowaveButton( + group, + x - gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-30s`, + ) + addMicrowaveButton( + group, + x + gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-power`, + ) + + for (let row = 0; row < 4; row += 1) { + for (let col = 0; col < 3; col += 1) { + addMicrowaveButton( + group, + x + (col - 1) * gap, + startY - row * gap, + z, + buttonSize, + buttonSize, + microwaveButtonMaterial, + `${name}-button-${row}-${col}`, + ) + } + } + + const actionY = startY - gap * 4.05 + addMicrowaveButton( + group, + x - gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveCancelButtonMaterial, + `${name}-cancel-button`, + ) + addMicrowaveButton( + group, + x + gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveStartButtonMaterial, + `${name}-start-button`, + ) +} + +function addOvenVentSlots( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.045, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 6; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.004, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2.5 + i * gap, y, z + 0.002) + group.add(slat) + } +} + +function addOvenRotaryDial( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const dial = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.018, 36), ovenDialMaterial), + 'appliance', + ) + dial.name = name + dial.rotation.x = Math.PI / 2 + dial.position.set(x, y, z + 0.009) + dial.castShadow = true + group.add(dial) + + const face = stampSlot( + new Mesh( + roundedButtonGeometry(radius * 1.36, radius * 0.28, 0.002, radius * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + face.name = `${name}-grip` + face.position.set(x, y, z + 0.02) + group.add(face) + + const indicator = stampSlot( + new Mesh(new BoxGeometry(radius * 0.16, radius * 0.68, 0.0025), ovenIndicatorMaterial), + 'appliance', + ) + indicator.name = `${name}-indicator` + indicator.position.set(x, y + radius * 0.34, z + 0.022) + group.add(indicator) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 1.25, 0.002, 8, 36), microwaveScreenMaterial), + 'appliance', + ) + ring.name = `${name}-ring` + ring.position.set(x, y, z + 0.003) + group.add(ring) +} + +function addOvenStatusLights( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + gap: number, + name: string, +) { + ovenStatusLightMaterials.forEach((material, index) => { + const light = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.003, 16), material), + 'appliance', + ) + light.name = `${name}-status-light-${index}` + light.rotation.x = Math.PI / 2 + light.position.set(x + index * gap, y, z + 0.004) + group.add(light) + }) +} + +function addOvenControls( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const panelWidth = width * 0.96 + const panelHeight = height * 0.88 + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(panelWidth, panelHeight, 0.004, height * 0.14), + microwavePanelMaterial, + ), + 'appliance', + ) + panel.name = `${name}-control-panel` + panel.position.set(x, y, z + 0.001) + group.add(panel) + + const dialRadius = Math.min(0.021, height * 0.26, width * 0.04) + addOvenRotaryDial(group, x - width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-0`) + addOvenRotaryDial(group, x + width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-1`) + + const displayWidth = Math.min(0.14, width * 0.24) + const displayHeight = Math.min(0.024, height * 0.28) + const displayY = y + height * 0.12 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.004) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonWidth = Math.min(0.032, width * 0.055) + const buttonHeight = Math.min(0.011, height * 0.14) + const buttonY = y - height * 0.16 + for (let i = 0; i < 3; i += 1) { + addMicrowaveButton( + group, + x - buttonWidth * 1.3 + i * buttonWidth * 1.3, + buttonY, + z, + buttonWidth, + buttonHeight, + microwaveButtonMaterial, + `${name}-mode-button-${i}`, + ) + } + + const lightRadius = Math.min(0.0045, height * 0.055) + const lightGap = lightRadius * 3.1 + addOvenStatusLights( + group, + x + displayWidth / 2 + lightGap * 0.9, + displayY, + z, + lightRadius, + lightGap, + name, + ) + addOvenVentSlots(group, x, y - height * 0.35, z, width * 0.82, name) +} + +function addOvenDoorDetails( + leaf: Object3D, + materials: CabinetSlotMaterials, + width: number, + height: number, + glassWidth: number, + glassHeight: number, + frontThickness: number, + name: string, +) { + const gasketBar = Math.max(0.006, Math.min(0.011, Math.min(width, height) * 0.018)) + const gasketWidth = Math.max(0.01, glassWidth + gasketBar) + const gasketHeight = Math.max(0.01, glassHeight + gasketBar) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-top`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, -gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-bottom`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [-gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-left`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-right`, + 'appliance', + ) + + const lowerRail = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.72, Math.max(0.009, height * 0.026), 0.006, height * 0.01), + materials.appliance, + ), + 'appliance', + ) + lowerRail.name = `${name}-door-lower-rail` + lowerRail.position.set(0, -height * 0.43, frontThickness / 2 + 0.006) + leaf.add(lowerRail) +} + +function addOvenInteriorDetails( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number, + name: string, +) { + const fanRadius = Math.min(width, height) * 0.18 + const fanRing = stampSlot( + new Mesh(new TorusGeometry(fanRadius, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + fanRing.name = `${name}-convection-fan-ring` + fanRing.position.set(x, y, z + 0.012) + group.add(fanRing) + + const hub = stampSlot( + new Mesh( + new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24), + materials.applianceInterior, + ), + 'applianceInterior', + ) + hub.name = `${name}-convection-fan-hub` + hub.rotation.x = Math.PI / 2 + hub.position.set(x, y, z + 0.018) + group.add(hub) + + for (let i = 0; i < 4; i += 1) { + const blade = stampSlot( + new Mesh(new BoxGeometry(fanRadius * 0.72, 0.006, 0.003), materials.applianceInterior), + 'applianceInterior', + ) + blade.name = `${name}-convection-fan-blade-${i}` + blade.rotation.z = (i * Math.PI) / 2 + blade.position.set(x, y, z + 0.02) + group.add(blade) + } + + const element = stampSlot( + new Mesh( + new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64), + ovenHeatElementMaterial, + ), + 'applianceInterior', + ) + element.name = `${name}-top-heating-element` + element.rotation.x = Math.PI / 2 + element.scale.y = 0.58 + element.position.set(x, y + height * 0.34, z + depth * 0.2) + group.add(element) +} + +function addMicrowaveDoorMesh( + leaf: Object3D, + width: number, + height: number, + z: number, + name: string, +) { + const columns = 7 + const rows = 5 + const dotSize = Math.max(0.0035, Math.min(width, height) * 0.018) + const meshWidth = width * 0.7 + const meshHeight = height * 0.55 + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + const dot = stampSlot( + new Mesh(new BoxGeometry(dotSize, dotSize, 0.002), microwaveScreenMaterial), + 'glass', + ) + dot.name = `${name}-window-dot-${row}-${col}` + dot.position.set( + -meshWidth / 2 + (meshWidth * col) / (columns - 1), + -meshHeight / 2 + (meshHeight * row) / (rows - 1), + z + 0.003, + ) + leaf.add(dot) + } + } +} + +function addMicrowaveTurntable( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const plate = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.006, 48), materials.glass), + 'glass', + ) + plate.name = `${name}-turntable` + plate.position.set(x, y, z) + plate.renderOrder = 2 + group.add(plate) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 0.72, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + ring.name = `${name}-roller-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y - 0.006, z) + group.add(ring) +} + +export function addApplianceCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: 'oven' | 'microwave', + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const fasciaFrontZ = frontZ + frontThickness / 2 + + let doorWidth: number + let doorHeight: number + let doorCenterX: number + let doorCenterY: number + + if (kind === 'oven') { + const fasciaHeight = Math.min(0.08, faceHeight * 0.18) + const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2 + addBox( + group, + [faceWidth, fasciaHeight, frontThickness], + [0, fasciaY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name) + + doorWidth = faceWidth + doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap) + doorCenterX = 0 + doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2 + } else { + const fasciaWidth = Math.min(0.15, faceWidth * 0.28) + const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2 + addBox( + group, + [fasciaWidth, faceHeight, frontThickness], + [fasciaCenterX, faceCenterY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY + faceHeight / 2 - 0.017, + fasciaFrontZ, + fasciaWidth, + `${name}-top`, + ) + addMicrowaveControls( + group, + fasciaCenterX, + faceCenterY, + fasciaFrontZ, + fasciaWidth, + faceHeight, + name, + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY - faceHeight / 2 + 0.046, + fasciaFrontZ, + fasciaWidth, + `${name}-bottom`, + ) + + doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap) + doorHeight = faceHeight + doorCenterX = -faceWidth / 2 + doorWidth / 2 + doorCenterY = faceCenterY + } + + const wall = APPLIANCE_CAVITY_WALL + const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2) + const cavityHeight = Math.max(0.05, doorHeight - wall * 2) + const cavityFrontZ = frontZ - frontThickness / 2 - 0.001 + const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04)) + const cavityBackZ = cavityFrontZ - cavityDepth + const cavityCenterZ = cavityBackZ + cavityDepth / 2 + + addBox( + group, + [cavityWidth + wall * 2, cavityHeight + wall * 2, wall], + [doorCenterX, doorCenterY, cavityBackZ + wall / 2], + materials.applianceInterior, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-right`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-top`, + 'appliance', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-bottom`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-left`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-right`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial), + 'applianceInterior', + ) + lamp.name = `${name}-lamp` + lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06) + group.add(lamp) + + const rackWidth = Math.max(0.02, cavityWidth - 0.01) + const rackDepth = Math.max(0.02, cavityDepth - 0.04) + if (kind === 'oven') { + for (const fraction of [1 / 3, 2 / 3]) { + addWireRack( + group, + materials, + rackWidth, + rackDepth, + doorCenterY - cavityHeight / 2 + cavityHeight * fraction, + cavityCenterZ, + `${name}-rack-${fraction < 0.5 ? 0 : 1}`, + ) + } + addOvenInteriorDetails( + group, + materials, + doorCenterX, + doorCenterY, + cavityBackZ, + cavityWidth, + cavityHeight, + cavityDepth, + name, + ) + } else { + addMicrowaveTurntable( + group, + materials, + doorCenterX, + doorCenterY - cavityHeight / 2 + 0.028, + cavityCenterZ, + Math.min(rackWidth, rackDepth) * 0.28, + name, + ) + } + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + if (kind === 'oven') { + hingeGroup.position.set(doorCenterX, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } + } else { + hingeGroup.position.set(doorCenterX - doorWidth / 2, doorCenterY, frontZ) + hingeGroup.rotation.y = -(Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'y', angle: -(Math.PI / 2) } + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(kind === 'oven' ? 0 : doorWidth / 2, kind === 'oven' ? doorHeight / 2 : 0, 0) + hingeGroup.add(leaf) + + const frame = + kind === 'oven' + ? Math.max(0.022, Math.min(0.042, Math.min(doorWidth, doorHeight) * 0.075)) + : Math.max(0.03, Math.min(doorWidth, doorHeight) * 0.14) + const glassWidth = Math.max(0.01, doorWidth - frame * 2) + const glassHeight = Math.max(0.01, doorHeight - frame * 2) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, doorHeight / 2 - frame / 2, 0], + materials.appliance, + `${name}-door-frame-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, -doorHeight / 2 + frame / 2, 0], + materials.appliance, + `${name}-door-frame-bottom`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [-doorWidth / 2 + frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-left`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [doorWidth / 2 - frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-right`, + 'appliance', + ) + const glassMesh = stampSlot( + new Mesh( + new BoxGeometry(glassWidth, glassHeight, Math.max(0.003, frontThickness * 0.5)), + materials.glass, + ), + 'glass', + ) + glassMesh.name = `${name}-door-glass` + glassMesh.position.set(0, 0, 0) + glassMesh.renderOrder = 2 + leaf.add(glassMesh) + if (kind === 'microwave') { + addMicrowaveDoorMesh(leaf, glassWidth, glassHeight, frontThickness / 2, name) + } else { + addOvenDoorDetails( + leaf, + materials, + doorWidth, + doorHeight, + glassWidth, + glassHeight, + frontThickness, + name, + ) + } + + if (kind === 'oven') { + addApplianceHandle( + leaf, + materials.appliance, + [0, doorHeight / 2 - 0.035, frontThickness / 2], + doorWidth * 0.85, + false, + `${name}-handle`, + ) + } else { + addApplianceHandle( + leaf, + materials.appliance, + [doorWidth / 2 - 0.035, 0, frontThickness / 2], + Math.min(0.35, doorHeight * 0.55), + true, + `${name}-handle`, + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/pantry.ts b/packages/nodes/src/cabinet/geometry/pantry.ts new file mode 100644 index 00000000..e959c5a1 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/pantry.ts @@ -0,0 +1,226 @@ +import { Group, Mesh } from 'three' +import { + type CabinetCompartment, + compartmentPullOutPantryRackStyle, + compartmentShelfCount, + type PullOutPantryRackStyle, +} from '../stack' +import { addBarHandle, buildFrontGeometry } from './fronts' +import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared' + +function addPullOutPantryBasket( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, + rackStyle: PullOutPantryRackStyle, + toLocal: (position: [number, number, number]) => [number, number, number], +) { + const rail = 0.008 + const basketHeight = 0.045 + const panelHeight = 0.07 + if (rackStyle !== 'wire') { + const material = rackStyle === 'glass' ? materials.glass : materials.carcass + const panelThickness = rackStyle === 'glass' ? 0.006 : 0.012 + addBox( + group, + [width, panelThickness, depth], + toLocal([0, y, zCenter]), + material, + `${name}-${rackStyle}-tray-floor`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter + depth / 2 - panelThickness / 2]), + material, + `${name}-${rackStyle}-front-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter - depth / 2 + panelThickness / 2]), + material, + `${name}-${rackStyle}-back-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + for (const side of [-1, 1]) { + addBox( + group, + [panelThickness, panelHeight, depth], + toLocal([side * (width / 2 - panelThickness / 2), y + panelHeight / 2, zCenter]), + material, + `${name}-${rackStyle}-${side < 0 ? 'left' : 'right'}-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + } + return + } + + addBox( + group, + [width, rail, depth], + toLocal([0, y, zCenter]), + materials.hardware, + `${name}-floor`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter + depth / 2 - rail / 2]), + materials.hardware, + `${name}-front-rail`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter - depth / 2 + rail / 2]), + materials.hardware, + `${name}-back-rail`, + 'hardware', + ) + for (const side of [-1, 1]) { + addBox( + group, + [rail, basketHeight, depth], + toLocal([side * (width / 2 - rail / 2), y + basketHeight / 2, zCenter]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-side-rail`, + 'hardware', + ) + } + for (let i = 1; i <= 3; i += 1) { + addBox( + group, + [rail, basketHeight * 0.72, depth * 0.84], + toLocal([-width / 2 + (width * i) / 4, y + basketHeight * 0.48, zCenter]), + materials.hardware, + `${name}-divider-${i}`, + 'hardware', + ) + } +} + +export function addPullOutPantryCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + compartment: CabinetCompartment, + index: number, +) { + const name = `cabinet-pull-out-pantry-${index}` + const rackStyle = compartmentPullOutPantryRackStyle(compartment) + const frontWidth = Math.max(0.01, faceWidth - node.frontGap * 2) + const frontHeight = Math.max(0.01, faceHeight - node.frontGap * 2) + const rackWidth = Math.max(0.04, Math.min(openingWidth - 0.05, frontWidth - 0.04)) + const rackDepth = Math.max(0.08, openingDepth - 0.08) + const rackHeight = Math.max(0.12, frontHeight - 0.14) + const rackCenterY = faceCenterY + const rackCenterZ = frontZ - node.frontThickness / 2 - rackDepth / 2 - 0.025 + const openDistance = Math.min(rackDepth * 0.82, 0.48) + const openScale = node.operationState ?? 0 + const motion = new Group() + motion.name = `${name}-slide` + motion.position.set(0, 0, openDistance * openScale) + motion.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(motion) + const toLocal = (position: [number, number, number]): [number, number, number] => position + + const front = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, frontHeight, false, null), materials.front), + 'front', + ) + front.name = `${name}-front` + front.position.set(...toLocal([0, faceCenterY, frontZ])) + front.castShadow = true + front.receiveShadow = true + motion.add(front) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleLength = Math.max(0.18, Math.min(frontHeight * 0.52, 0.72)) + addBarHandle( + motion, + toLocal([0, faceCenterY, frontZ + node.frontThickness / 2]), + handleLength, + true, + `${name}-handle`, + materials.hardware, + ) + } + + const rail = 0.01 + for (const side of [-1, 1]) { + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ - rackDepth / 2 + rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-rear-upright`, + 'hardware', + ) + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ + rackDepth / 2 - rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-front-upright`, + 'hardware', + ) + } + + const basketCount = Math.max(2, Math.min(8, Math.floor(compartmentShelfCount(compartment)))) + const bottomY = rackCenterY - rackHeight / 2 + 0.08 + const usableHeight = Math.max(0.1, rackHeight - 0.16) + for (let i = 0; i < basketCount; i += 1) { + const y = bottomY + (usableHeight * i) / Math.max(1, basketCount - 1) + addPullOutPantryBasket( + motion, + materials, + rackWidth, + rackDepth, + y, + rackCenterZ, + `${name}-basket-${i}`, + rackStyle, + toLocal, + ) + } + + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY + rackHeight / 2 - 0.018, rackCenterZ]), + materials.hardware, + `${name}-top-tie`, + 'hardware', + ) + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY - rackHeight / 2 + 0.018, rackCenterZ]), + materials.hardware, + `${name}-bottom-tie`, + 'hardware', + ) +} diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts new file mode 100644 index 00000000..aa2624d3 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -0,0 +1,209 @@ +import type { CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' +import { Group, type Mesh } from 'three' +import { getRunSpanEnds, getRunSpans } from '../run-layout' +import { compartmentSinkLayout, stackForCabinet } from '../stack' +import { addBox, getCabinetSlotMaterials } from './shared' +import { cutSinkIntoCountertop, type SinkBowlSpec, sinkBowls } from './sink' + +export function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { + return (ctx?.children ?? []).filter( + (child): child is CabinetModuleNode => child.type === 'cabinet-module', + ) +} + +export function buildCabinetRunGeometry( + node: CabinetNode, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Group | null { + const modules = getRunModules(ctx) + if (modules.length === 0) return null + + const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) + const plinth = node.showPlinth ? node.plinthHeight : 0 + // A back-edge bar ledge occupies the back edge, superseding the seating + // overhang; side-edge bars leave it alone. + const backOverhang = + node.withCountertop && node.barLedge?.edge !== 'back' ? node.countertopBackOverhang : 0 + const spans = getRunSpans(modules, { runTier: node.runTier }) + const spanEnds = getRunSpanEnds(node, ctx, spans) + + for (const span of spans) { + const spanIndex = spans.indexOf(span) + const barEdge = node.barLedge?.edge + const { leftOverhang, rightOverhang, exposedLeft, exposedRight } = spanEnds[spanIndex]! + const toeKickDepth = node.showPlinth + ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) + : 0 + const plinthDepth = Math.max(node.boardThickness, span.depth - toeKickDepth) + if (node.showPlinth && plinth > 0) { + addBox( + group, + [span.width, plinth, plinthDepth], + [span.centerX, plinth / 2, span.minZ + plinthDepth / 2], + materials.plinth, + 'cabinet-run-plinth', + 'plinth', + ) + } + + // Finished decorative back panel (island backs are visible) — floor to + // countertop plane, flush against the carcass back face. + if (node.withFinishedBack) { + addBox( + group, + [span.width, span.topY, node.boardThickness], + [span.centerX, span.topY / 2, span.minZ - node.boardThickness / 2], + materials.front, + 'cabinet-run-back-panel', + 'front', + ) + } + + // Raised bar counter: knee wall against one run face topped by a slab at + // bar height, cantilevered outward as knee space for stools. Side bars + // apply only to the run's end span on that side. + const spanHasBar = + node.barLedge && + span.hasCountertop && + (barEdge === 'back' || + (barEdge === 'left' && spanIndex === 0) || + (barEdge === 'right' && spanIndex === spans.length - 1)) + if (node.barLedge && spanHasBar) { + const slabThickness = Math.max(node.countertopThickness, 0.02) + const supportHeight = Math.max(0.1, node.barLedge.height - slabThickness) + // Knee wall spans the slab's full footprint on the shared axis so the + // two faces stay flush — a carcass-sized wall reads as a seam under + // the wider slab. + if (barEdge === 'back') { + const backZ = span.minZ - (node.withFinishedBack ? node.boardThickness : 0) + const barWidth = span.width + leftOverhang + rightOverhang + const barCenterX = span.centerX + (rightOverhang - leftOverhang) / 2 + addBox( + group, + [barWidth, supportHeight, node.boardThickness], + [barCenterX, supportHeight / 2, backZ - node.boardThickness / 2], + materials.front, + 'cabinet-run-bar-support', + 'front', + ) + addBox( + group, + [barWidth, slabThickness, node.barLedge.depth], + [barCenterX, supportHeight + slabThickness / 2, backZ - node.barLedge.depth / 2], + materials.countertop, + 'cabinet-run-bar-slab', + 'countertop', + ) + } else { + const sign = barEdge === 'left' ? -1 : 1 + const edgeX = barEdge === 'left' ? span.minX : span.maxX + const slabDepth = span.depth + node.countertopOverhang + backOverhang + const slabCenterZ = span.centerZ + (node.countertopOverhang - backOverhang) / 2 + addBox( + group, + [node.boardThickness, supportHeight, slabDepth], + [edgeX + sign * (node.boardThickness / 2), supportHeight / 2, slabCenterZ], + materials.front, + 'cabinet-run-bar-support', + 'front', + ) + addBox( + group, + [node.barLedge.depth, slabThickness, slabDepth], + [ + edgeX + sign * (node.barLedge.depth / 2), + supportHeight + slabThickness / 2, + slabCenterZ, + ], + materials.countertop, + 'cabinet-run-bar-slab', + 'countertop', + ) + } + } + + // Waterfall ends: the slab material drops to the floor on exposed run + // ends (skipped where a neighbor abuts or a side bar occupies the end). + if (node.withWaterfall && span.hasCountertop && node.countertopThickness > 0) { + const slabDepth = span.depth + node.countertopOverhang + backOverhang + const slabCenterZ = span.centerZ + (node.countertopOverhang - backOverhang) / 2 + for (const side of ['left', 'right'] as const) { + if (side === 'left' ? !exposedLeft : !exposedRight) continue + const sign = side === 'left' ? -1 : 1 + const outerX = side === 'left' ? span.minX - leftOverhang : span.maxX + rightOverhang + // Outer face flush with the slab edge; the slab covers the panel top. + addBox( + group, + [node.countertopThickness, span.topY, slabDepth], + [outerX - sign * (node.countertopThickness / 2), span.topY / 2, slabCenterZ], + materials.countertop, + `cabinet-run-waterfall-${side}`, + 'countertop', + ) + } + } + + if (node.withCountertop && span.hasCountertop && node.countertopThickness > 0) { + const countertop = addBox( + group, + [ + span.width + leftOverhang + rightOverhang, + node.countertopThickness, + span.depth + node.countertopOverhang + backOverhang, + ], + [ + span.centerX + (rightOverhang - leftOverhang) / 2, + span.topY + node.countertopThickness / 2, + span.centerZ + (node.countertopOverhang - backOverhang) / 2, + ], + materials.countertop, + 'cabinet-run-countertop', + 'countertop', + ) + + // Undermount sink modules cut their bowl openings out of the run's + // slab (modules inside a run never own a countertop themselves). + const sinkCuts: Array<{ bowls: SinkBowlSpec[]; x: number; z: number }> = [] + for (const module of modules) { + if (module.position[0] < span.minX - 1e-4 || module.position[0] > span.maxX + 1e-4) { + continue + } + const sink = stackForCabinet(module).find((compartment) => compartment.type === 'sink') + if (!sink) continue + sinkCuts.push({ + bowls: sinkBowls( + compartmentSinkLayout(sink), + Math.max(0.01, module.width - 2 * module.boardThickness), + module.depth, + ), + x: module.position[0], + z: module.position[2], + }) + } + if (sinkCuts.length > 0) { + group.remove(countertop) + let cut: Mesh = countertop + for (const sinkCut of sinkCuts) { + const next = cutSinkIntoCountertop( + cut, + sinkCut.bowls, + sinkCut.x, + sinkCut.z, + node.countertopThickness, + ) + cut.geometry.dispose() + cut = next + } + group.add(cut) + } + } + } + + return group +} diff --git a/packages/nodes/src/cabinet/geometry/shared.ts b/packages/nodes/src/cabinet/geometry/shared.ts new file mode 100644 index 00000000..62643304 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/shared.ts @@ -0,0 +1,561 @@ +import { + type CabinetModuleNode, + type CabinetNode, + type GeometryContext, + getMaterialPresetByRef, + type MaterialSchema, +} from '@pascal-app/core' +import { + applyMaterialPresetToMaterials, + applyWorldScaleBoxUVs, + type ColorPreset, + createDefaultMaterial, + createMaterial, + createSurfaceRoleMaterial, + glassMaterial as defaultGlassMaterial, + type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, +} from '@pascal-app/viewer' +import { + BoxGeometry, + CylinderGeometry, + ExtrudeGeometry, + FrontSide, + type Group, + type Material, + Mesh, + MeshBasicMaterial, + MeshStandardMaterial, + type Object3D, + Shape, +} from 'three' +import { type CabinetSlotId, cabinetSlots } from '../slots' + +export type CabinetGeometryNode = CabinetNode | CabinetModuleNode +export type CabinetSlotMaterials = Record + +const CABINET_SLOT_DEFAULTS = Object.fromEntries( + cabinetSlots().map((slot) => [slot.slotId, slot.default]), +) as Record + +export function createWorldScaleBoxGeometry( + width: number, + height: number, + depth: number, +): BoxGeometry { + const geometry = new BoxGeometry(width, height, depth) + applyWorldScaleBoxUVs(geometry, width, height, depth) + return geometry +} + +function getLegacyCabinetMaterial( + node: CabinetGeometryNode, + shading: RenderShading, +): Material | null { + if (node.materialPreset) { + const preset = getMaterialPresetByRef(node.materialPreset) + if (preset) { + const base = createDefaultMaterial('#ffffff', 0.6, shading) + applyMaterialPresetToMaterials(base, preset) + return base + } + } + if (node.material) return createMaterial(node.material as MaterialSchema, shading) + return null +} + +function getCabinetSlotMaterial( + node: CabinetGeometryNode, + slotId: CabinetSlotId, + materials: GeometryContext['materials'], + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Material { + if (!textures) { + if (slotId === 'glass') return defaultGlassMaterial + return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme) + } + + const slotRef = node.slots?.[slotId] + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, materials, shading) + if (resolved) return resolved + } + + if ( + slotId === 'front' || + slotId === 'carcass' || + slotId === 'countertop' || + slotId === 'plinth' + ) { + const legacy = getLegacyCabinetMaterial(node, shading) + if (legacy) return legacy + } + + return resolveSlotDefaultMaterial( + CABINET_SLOT_DEFAULTS[slotId], + shading, + slotId === 'hardware' || slotId === 'appliance' ? 0.45 : 0.8, + ) +} + +export function getCabinetSlotMaterials( + node: CabinetGeometryNode, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): CabinetSlotMaterials { + return { + front: getCabinetSlotMaterial( + node, + 'front', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + carcass: getCabinetSlotMaterial( + node, + 'carcass', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + countertop: getCabinetSlotMaterial( + node, + 'countertop', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + plinth: getCabinetSlotMaterial( + node, + 'plinth', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + hardware: getCabinetSlotMaterial( + node, + 'hardware', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + glass: getCabinetSlotMaterial( + node, + 'glass', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + appliance: getCabinetSlotMaterial( + node, + 'appliance', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + applianceInterior: getCabinetSlotMaterial( + node, + 'applianceInterior', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + } +} + +export function stampSlot(mesh: T, slotId: CabinetSlotId): T { + mesh.userData.slotId = slotId + return mesh +} + +export function addBox( + group: Group, + size: [number, number, number], + position: [number, number, number], + materialOrColor: Material | string, + name: string, + slotId: CabinetSlotId = 'carcass', +) { + const material = + typeof materialOrColor === 'string' + ? new MeshStandardMaterial({ color: materialOrColor, metalness: 0.08, roughness: 0.72 }) + : materialOrColor + const geometry = createWorldScaleBoxGeometry(size[0], size[1], size[2]) + const mesh = stampSlot(new Mesh(geometry, material), slotId) + mesh.name = name + mesh.position.set(position[0], position[1], position[2]) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + return mesh +} + +export const OVEN_OPEN_ANGLE = (88 * Math.PI) / 180 +export const APPLIANCE_CAVITY_WALL = 0.02 + +export const applianceDisplayMaterial = new MeshStandardMaterial({ + color: '#120c05', + emissive: '#ff9a3d', + emissiveIntensity: 0.85, + roughness: 0.3, +}) +export const applianceLampMaterial = new MeshStandardMaterial({ + color: '#2b2417', + emissive: '#ffd9a0', + emissiveIntensity: 0.6, + roughness: 0.4, +}) +export const microwaveScreenMaterial = new MeshStandardMaterial({ + color: '#05070a', + emissive: '#111827', + emissiveIntensity: 0.2, + metalness: 0.05, + roughness: 0.32, +}) +export const microwaveButtonMaterial = new MeshStandardMaterial({ + color: '#2f3338', + metalness: 0.35, + roughness: 0.42, +}) +export const microwaveStartButtonMaterial = new MeshStandardMaterial({ + color: '#1d6f45', + emissive: '#16a34a', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +export const microwaveCancelButtonMaterial = new MeshStandardMaterial({ + color: '#7f1d1d', + emissive: '#ef4444', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +export const microwavePanelMaterial = new MeshStandardMaterial({ + color: '#16191d', + metalness: 0.55, + roughness: 0.36, +}) +export const cooktopGlassMaterial = new MeshStandardMaterial({ + color: '#17191c', + metalness: 0.45, + roughness: 0.07, +}) +export const cooktopBurnerMaterial = new MeshStandardMaterial({ + color: '#7d8389', + metalness: 0.85, + roughness: 0.3, +}) +export const cooktopTrimMaterial = new MeshStandardMaterial({ + color: '#3a3d41', + metalness: 0.85, + roughness: 0.3, +}) +export const cooktopGrateMaterial = new MeshStandardMaterial({ + color: '#0d0e10', + metalness: 0.55, + roughness: 0.5, +}) +export const cooktopInductionZoneMaterial = new MeshStandardMaterial({ + color: '#07111f', + emissive: '#2563eb', + emissiveIntensity: 0.22, + metalness: 0.05, + roughness: 0.2, +}) +export const cooktopInductionActiveZoneMaterial = new MeshStandardMaterial({ + color: '#0b1f3a', + emissive: '#38bdf8', + emissiveIntensity: 0.75, + metalness: 0.05, + roughness: 0.18, +}) +export const cooktopKnobOnMaterial = new MeshStandardMaterial({ + color: '#ffb86b', + emissive: '#f97316', + emissiveIntensity: 0.45, + metalness: 0.2, + roughness: 0.24, +}) +export const cooktopKnobHitMaterial = new MeshBasicMaterial({ + colorWrite: false, + depthWrite: false, + transparent: true, + opacity: 0, +}) +export const refrigeratorSilverMaterial = new MeshStandardMaterial({ + color: '#c9ccd0', + metalness: 0.78, + roughness: 0.32, +}) +export const refrigeratorBrassAccentMaterial = new MeshStandardMaterial({ + color: '#b3925f', + metalness: 0.75, + roughness: 0.3, +}) +export const refrigeratorDarkTrimMaterial = new MeshStandardMaterial({ + color: '#4d5257', + metalness: 0.6, + roughness: 0.42, +}) +export const refrigeratorSealMaterial = new MeshStandardMaterial({ + color: '#d9dbdd', + metalness: 0.02, + roughness: 0.6, +}) +export const refrigeratorLinerMaterial = new MeshStandardMaterial({ + color: '#f7f8f9', + metalness: 0.02, + roughness: 0.55, +}) +export const refrigeratorLinerAccentMaterial = new MeshStandardMaterial({ + color: '#eef0f2', + metalness: 0.02, + roughness: 0.48, +}) +export const refrigeratorDrawerMaterial = new MeshStandardMaterial({ + color: '#eef4f8', + transparent: true, + opacity: 0.45, + metalness: 0.02, + roughness: 0.18, +}) +export const refrigeratorBinMaterial = new MeshStandardMaterial({ + color: '#f4f6f8', + transparent: true, + opacity: 0.62, + metalness: 0.02, + roughness: 0.24, +}) +export const refrigeratorLightMaterial = new MeshStandardMaterial({ + color: '#fff7d6', + emissive: '#fff1a8', + emissiveIntensity: 0.32, + roughness: 0.24, +}) +export const refrigeratorWaterMaterial = new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#0ea5e9', + emissiveIntensity: 0.18, + metalness: 0.05, + roughness: 0.22, +}) +export const ovenDialMaterial = new MeshStandardMaterial({ + color: '#d5d7d8', + metalness: 0.72, + roughness: 0.24, +}) +export const ovenIndicatorMaterial = new MeshStandardMaterial({ + color: '#f8fafc', + emissive: '#f8fafc', + emissiveIntensity: 0.12, + metalness: 0.1, + roughness: 0.32, +}) +export const ovenHeatElementMaterial = new MeshStandardMaterial({ + color: '#4a1f16', + emissive: '#ff6b35', + emissiveIntensity: 0.28, + metalness: 0.35, + roughness: 0.42, +}) +export const ovenStatusLightMaterials = ['#f97316', '#22c55e', '#38bdf8'].map( + (color) => + new MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.42, + roughness: 0.28, + }), +) + +// These module-level materials are shared by every cabinet instance in the +// scene. Without the cached flag, the geometry system's disposeChildren would +// dispose them on each rebuild, breaking every other cabinet still using them +// and forcing scene-wide shader recompiles. +for (const material of [ + applianceDisplayMaterial, + applianceLampMaterial, + microwaveScreenMaterial, + microwaveButtonMaterial, + microwaveStartButtonMaterial, + microwaveCancelButtonMaterial, + microwavePanelMaterial, + cooktopGlassMaterial, + cooktopBurnerMaterial, + cooktopTrimMaterial, + cooktopGrateMaterial, + cooktopInductionZoneMaterial, + cooktopInductionActiveZoneMaterial, + cooktopKnobOnMaterial, + cooktopKnobHitMaterial, + refrigeratorSilverMaterial, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorSealMaterial, + refrigeratorLinerMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorDrawerMaterial, + refrigeratorBinMaterial, + refrigeratorLightMaterial, + refrigeratorWaterMaterial, + ovenDialMaterial, + ovenIndicatorMaterial, + ovenHeatElementMaterial, + ...ovenStatusLightMaterials, +]) { + material.userData.__pascalCachedMaterial = true +} + +export function addApplianceHandle( + group: Object3D, + material: Material, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, +) { + const tube = stampSlot( + new Mesh(new CylinderGeometry(0.009, 0.009, length, 16), material), + 'appliance', + ) + tube.name = name + tube.position.set(position[0], position[1], position[2] + 0.042) + if (!vertical) tube.rotation.z = Math.PI / 2 + tube.castShadow = true + group.add(tube) + + const standoffDistance = length * 0.38 + for (const offset of [-standoffDistance, standoffDistance]) { + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.006, 0.006, 0.04, 10), material), + 'appliance', + ) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.02, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +export function roundedButtonGeometry( + width: number, + height: number, + depth: number, + radius: number, +) { + const shape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const r = Math.min(radius, halfWidth, halfHeight) + shape.moveTo(-halfWidth + r, -halfHeight) + shape.lineTo(halfWidth - r, -halfHeight) + shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + r) + shape.lineTo(halfWidth, halfHeight - r) + shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - r, halfHeight) + shape.lineTo(-halfWidth + r, halfHeight) + shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - r) + shape.lineTo(-halfWidth, -halfHeight + r) + shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + r, -halfHeight) + + const geometry = new ExtrudeGeometry(shape, { + depth, + bevelEnabled: true, + bevelThickness: Math.min(0.0015, depth * 0.3), + bevelSize: Math.min(0.0015, r * 0.35), + bevelSegments: 2, + curveSegments: 8, + steps: 1, + }) + geometry.translate(0, 0, -depth / 2) + geometry.computeVertexNormals() + return geometry +} + +export function addMicrowaveDisplaySegments( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const segmentWidth = width * 0.16 + const segmentHeight = 0.004 + for (let i = 0; i < 3; i += 1) { + const segment = stampSlot( + new Mesh(new BoxGeometry(segmentWidth, segmentHeight, 0.002), applianceDisplayMaterial), + 'appliance', + ) + segment.name = `${name}-display-segment-${i}` + segment.position.set(x - width * 0.22 + i * width * 0.22, y, z + 0.006) + group.add(segment) + } +} + +export function addWireRack( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, +) { + const bar = 0.006 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [0, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [0, y, zCenter - depth / 2 + bar / 2] }, + { size: [bar, bar, depth], position: [-width / 2 + bar / 2, y, zCenter] }, + { size: [bar, bar, depth], position: [width / 2 - bar / 2, y, zCenter] }, + ] + frame.forEach((piece, i) => { + addBox( + group, + piece.size, + piece.position, + materials.applianceInterior, + `${name}-frame-${i}`, + 'applianceInterior', + ) + }) + for (let i = 1; i <= 7; i++) { + const x = -width / 2 + (width * i) / 8 + addBox( + group, + [0.004, 0.004, Math.max(0.01, depth - bar * 2)], + [x, y, zCenter], + materials.applianceInterior, + `${name}-bar-${i}`, + 'applianceInterior', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/sink.ts b/packages/nodes/src/cabinet/geometry/sink.ts new file mode 100644 index 00000000..8badd100 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/sink.ts @@ -0,0 +1,453 @@ +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import { + BoxGeometry, + CylinderGeometry, + Group, + type Material, + Mesh, + SphereGeometry, + TorusGeometry, +} from 'three' +import type { SinkLayout } from '../stack' +import { createWorldScaleBoxGeometry, stampSlot } from './shared' + +const BASIN_WALL = 0.012 +const BASIN_DEPTH = 0.19 +const BASIN_CORNER_MARGIN = 0.06 +// Centers the faucet base in the strip between the bowl's back edge and the +// countertop's back edge (BASIN_CORNER_MARGIN wide). +export const FAUCET_SETBACK = 0.03 + +export type SinkBowlSpec = { centerX: number; width: number; depth: number } + +/** + * Bowl rects in module-local X/Z given the usable countertop footprint. + * Shared by the 3D cut, the run-countertop cut, and the 2D floorplan symbol. + */ +export function sinkBowls( + layout: SinkLayout, + usableWidth: number, + usableDepth: number, +): SinkBowlSpec[] { + const depth = Math.max(0.1, usableDepth - BASIN_CORNER_MARGIN * 2) + const full = Math.max(0.15, usableWidth - BASIN_CORNER_MARGIN * 2) + if (layout === 'single') { + const width = Math.min(0.7, full) + return [{ centerX: 0, width, depth }] + } + const divider = 0.03 + if (layout === 'double') { + const width = Math.min(0.42, (full - divider) / 2) + return [ + { centerX: -(width + divider) / 2, width, depth }, + { centerX: (width + divider) / 2, width, depth }, + ] + } + // double-offset: 60/40 split + const total = Math.min(0.86, full) + const main = (total - divider) * 0.6 + const side = (total - divider) * 0.4 + return [ + { centerX: -(total / 2) + main / 2, width: main, depth }, + { centerX: total / 2 - side / 2, width: side, depth }, + ] +} + +/** + * Subtract the sink bowl openings from a countertop mesh via three-bvh-csg. + * `cutCenterX/Z` position the sink footprint in the countertop mesh's local + * frame (the run countertop spans several modules, so the sink is off-center + * there). Returns a replacement mesh; the caller swaps it into the group. + */ +export function cutSinkIntoCountertop( + countertop: Mesh, + bowls: SinkBowlSpec[], + cutCenterX: number, + cutCenterZ: number, + countertopThickness: number, +): Mesh { + const slotId = countertop.userData.slotId + let result = new Brush(countertop.geometry, countertop.material) + result.position.copy(countertop.position) + prepareBrushForCSG(result) + + for (const bowl of bowls) { + // Rim reveal: the opening is slightly smaller than the basin shell so + // the undermount lip tucks under the countertop. + const cutter = new Brush( + new BoxGeometry(bowl.width - BASIN_WALL, countertopThickness * 4, bowl.depth - BASIN_WALL), + ) + cutter.position.set(cutCenterX + bowl.centerX, countertop.position.y, cutCenterZ) + prepareBrushForCSG(cutter) + const next = csgEvaluator.evaluate(result, cutter, SUBTRACTION) as Brush + prepareBrushForCSG(next) + cutter.geometry.dispose() + if (result.geometry !== countertop.geometry) result.geometry.dispose() + result = next + } + + const mesh = new Mesh(csgGeometry(result), countertop.material) + mesh.userData.slotId = slotId + mesh.name = countertop.name + // Brush geometry is baked in brush-local space with the brush transform + // applied at evaluate time — position stays at the original mesh position. + mesh.position.copy(countertop.position) + mesh.castShadow = true + mesh.receiveShadow = true + return mesh +} + +function addBasinShell( + group: Group, + bowl: SinkBowlSpec, + centerX: number, + centerZ: number, + rimY: number, + name: string, + applianceMaterial: Material, +) { + const x = centerX + bowl.centerX + const bottomY = rimY - BASIN_DEPTH + const innerWidth = bowl.width - BASIN_WALL * 2 + const innerDepth = bowl.depth - BASIN_WALL * 2 + + const walls: Array<{ + size: [number, number, number] + position: [number, number, number] + suffix: string + }> = [ + { + size: [bowl.width, BASIN_WALL, bowl.depth], + position: [x, bottomY + BASIN_WALL / 2, centerZ], + suffix: 'bottom', + }, + { + size: [BASIN_WALL, BASIN_DEPTH, bowl.depth], + position: [x - bowl.width / 2 + BASIN_WALL / 2, rimY - BASIN_DEPTH / 2, centerZ], + suffix: 'left', + }, + { + size: [BASIN_WALL, BASIN_DEPTH, bowl.depth], + position: [x + bowl.width / 2 - BASIN_WALL / 2, rimY - BASIN_DEPTH / 2, centerZ], + suffix: 'right', + }, + { + size: [innerWidth, BASIN_DEPTH, BASIN_WALL], + position: [x, rimY - BASIN_DEPTH / 2, centerZ - bowl.depth / 2 + BASIN_WALL / 2], + suffix: 'back', + }, + { + size: [innerWidth, BASIN_DEPTH, BASIN_WALL], + position: [x, rimY - BASIN_DEPTH / 2, centerZ + bowl.depth / 2 - BASIN_WALL / 2], + suffix: 'front', + }, + ] + for (const wall of walls) { + const mesh = stampSlot( + new Mesh(createWorldScaleBoxGeometry(...wall.size), applianceMaterial), + 'appliance', + ) + mesh.name = `${name}-basin-${wall.suffix}` + mesh.position.set(...wall.position) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + } + + const drain = stampSlot( + new Mesh(new CylinderGeometry(0.024, 0.024, 0.004, 24), applianceMaterial), + 'appliance', + ) + drain.name = `${name}-drain` + drain.position.set(x, bottomY + BASIN_WALL + 0.002, centerZ) + group.add(drain) + + const trap = stampSlot( + new Mesh( + new CylinderGeometry(0.02, 0.02, Math.max(0.05, BASIN_DEPTH * 0.7), 16), + applianceMaterial, + ), + 'appliance', + ) + trap.name = `${name}-drain-pipe` + trap.position.set(x, bottomY - Math.max(0.05, BASIN_DEPTH * 0.7) / 2, centerZ) + group.add(trap) +} + +// Proportions from Kohler Simplice / Moen Align / Delta Essa spec sheets: +// Ø52mm body ~95mm tall, straight riser to ~305mm, ~105mm-radius arc peaking +// near 400mm, spray-head outlet ~240mm above deck, ~210mm reach. Handle is a +// Grohe Minta-style horizontal pin lever off the side of the body. +const FAUCET_BODY_RADIUS = 0.026 +const FAUCET_BODY_HEIGHT = 0.095 +const FAUCET_TUBE_RADIUS = 0.0125 +const FAUCET_ARC_RADIUS = 0.105 +const FAUCET_RISER_TOP = 0.305 + +function addFaucetHandle( + group: Group, + x: number, + y: number, + z: number, + name: string, + applianceMaterial: Material, +) { + const handle = new Group() + handle.name = `${name}-faucet-handle` + handle.position.set(x, y, z) + + const barrelRadius = 0.021 + const barrelLength = 0.064 + const rootInset = 0.022 + + const saddle = stampSlot( + new Mesh(new SphereGeometry(barrelRadius * 1.08, 24, 14), applianceMaterial), + 'appliance', + ) + saddle.name = `${name}-faucet-handle-saddle` + saddle.scale.set(0.62, 1.05, 1.05) + saddle.position.set(-0.003, 0, 0) + saddle.castShadow = true + handle.add(saddle) + + const barrel = stampSlot( + new Mesh( + new CylinderGeometry(barrelRadius, barrelRadius, barrelLength + rootInset, 28), + applianceMaterial, + ), + 'appliance', + ) + barrel.name = `${name}-faucet-handle-barrel` + barrel.rotation.z = Math.PI / 2 + barrel.position.set((barrelLength - rootInset) / 2, 0, 0) + barrel.castShadow = true + handle.add(barrel) + + const endCapThickness = 0.007 + const endCap = stampSlot( + new Mesh( + new CylinderGeometry(barrelRadius * 1.03, barrelRadius * 1.03, endCapThickness, 28), + applianceMaterial, + ), + 'appliance', + ) + endCap.name = `${name}-faucet-handle-cap` + endCap.rotation.z = Math.PI / 2 + endCap.position.set(barrelLength + endCapThickness * 0.35, 0, 0) + endCap.castShadow = true + handle.add(endCap) + + const pinRadius = 0.0042 + const pinHeight = 0.072 + const pinX = barrelLength - 0.012 + const pin = stampSlot( + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinHeight, 14), applianceMaterial), + 'appliance', + ) + pin.name = `${name}-faucet-handle-pin` + pin.position.set(pinX, barrelRadius + pinHeight / 2 - 0.001, 0) + pin.castShadow = true + handle.add(pin) + + const pinCapHeight = 0.003 + const pinCap = stampSlot( + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinCapHeight, 14), applianceMaterial), + 'appliance', + ) + pinCap.name = `${name}-faucet-handle-pin-tip` + pinCap.position.set(pinX, barrelRadius + pinHeight + pinCapHeight / 2 - 0.001, 0) + pinCap.castShadow = true + handle.add(pinCap) + + group.add(handle) +} + +function addFaucet( + group: Group, + x: number, + z: number, + rimY: number, + name: string, + applianceMaterial: Material, +) { + const bodyTopY = rimY + FAUCET_BODY_HEIGHT + const riserTopY = rimY + FAUCET_RISER_TOP + const reach = FAUCET_ARC_RADIUS * 2 + + // Round base flare where the body meets the countertop. + const flare = stampSlot( + new Mesh(new CylinderGeometry(FAUCET_BODY_RADIUS + 0.002, 0.032, 0.008, 28), applianceMaterial), + 'appliance', + ) + flare.name = `${name}-faucet-escutcheon` + flare.position.set(x, rimY + 0.004, z) + flare.castShadow = true + group.add(flare) + + // Mixer body: straight Ø52mm column; riser, spout, and handle grow from it. + const body = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_BODY_RADIUS, FAUCET_BODY_RADIUS, FAUCET_BODY_HEIGHT, 28), + applianceMaterial, + ), + 'appliance', + ) + body.name = `${name}-faucet-base` + body.position.set(x, rimY + FAUCET_BODY_HEIGHT / 2, z) + body.castShadow = true + group.add(body) + + // Domed shoulder capping the body. + const shoulder = stampSlot( + new Mesh(new SphereGeometry(FAUCET_BODY_RADIUS, 24, 16), applianceMaterial), + 'appliance', + ) + shoulder.name = `${name}-faucet-shoulder` + shoulder.scale.y = 0.5 + shoulder.position.set(x, bodyTopY, z) + shoulder.castShadow = true + group.add(shoulder) + + // Taper collar easing the Ø52 body into the Ø25 spout tube. + const collarHeight = 0.022 + const collar = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_BODY_RADIUS * 0.62, collarHeight, 20), + applianceMaterial, + ), + 'appliance', + ) + collar.name = `${name}-faucet-collar` + collar.position.set(x, bodyTopY + collarHeight / 2, z) + collar.castShadow = true + group.add(collar) + + // Straight riser from the collar up to where the arc begins (~55% of height). + const riserLength = riserTopY - (bodyTopY + collarHeight) + 0.002 + const riser = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, riserLength, 18), + applianceMaterial, + ), + 'appliance', + ) + riser.name = `${name}-faucet-riser` + riser.position.set(x, bodyTopY + collarHeight + riserLength / 2 - 0.001, z) + riser.castShadow = true + group.add(riser) + + // Gooseneck: half-torus from the riser top over the apex (~400mm above + // deck) and back down toward the bowl (+Z). + const gooseneck = stampSlot( + new Mesh( + new TorusGeometry(FAUCET_ARC_RADIUS, FAUCET_TUBE_RADIUS, 14, 32, Math.PI), + applianceMaterial, + ), + 'appliance', + ) + gooseneck.name = `${name}-faucet-gooseneck` + gooseneck.rotation.y = Math.PI / 2 + gooseneck.position.set(x, riserTopY, z + FAUCET_ARC_RADIUS) + gooseneck.castShadow = true + group.add(gooseneck) + + // Down-leg: short tube, dark dock seam, then the conical pull-down spray + // head. Outlet lands ~240mm above the deck per spec. + const spoutZ = z + reach + const downTubeLength = 0.02 + const downTube = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, downTubeLength, 18), + applianceMaterial, + ), + 'appliance', + ) + downTube.name = `${name}-faucet-spout` + downTube.position.set(x, riserTopY - downTubeLength / 2, spoutZ) + downTube.castShadow = true + group.add(downTube) + + const seamHeight = 0.005 + const seam = stampSlot( + new Mesh( + new CylinderGeometry( + FAUCET_TUBE_RADIUS + 0.0008, + FAUCET_TUBE_RADIUS + 0.0008, + seamHeight, + 18, + ), + applianceMaterial, + ), + 'appliance', + ) + seam.name = `${name}-faucet-spray-seam` + seam.position.set(x, riserTopY - downTubeLength - seamHeight / 2, spoutZ) + group.add(seam) + + const headLength = 0.032 + const headTopY = riserTopY - downTubeLength - seamHeight + const sprayHead = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS + 0.0005, 0.019, headLength, 20), + applianceMaterial, + ), + 'appliance', + ) + sprayHead.name = `${name}-faucet-spray-head` + sprayHead.position.set(x, headTopY - headLength / 2, spoutZ) + sprayHead.castShadow = true + group.add(sprayHead) + + const faceHeight = 0.008 + const sprayFace = stampSlot( + new Mesh(new CylinderGeometry(0.019, 0.018, faceHeight, 20), applianceMaterial), + 'appliance', + ) + sprayFace.name = `${name}-faucet-aerator` + sprayFace.position.set(x, headTopY - headLength - faceHeight / 2, spoutZ) + group.add(sprayFace) + + addFaucetHandle( + group, + x + FAUCET_BODY_RADIUS - 0.016, + rimY + FAUCET_BODY_HEIGHT * 0.76, + z, + name, + applianceMaterial, + ) +} + +/** + * Undermount sink: basin shells + faucet, positioned under a countertop + * opening the caller has already cut via {@link cutSinkIntoCountertop}. + * `rimY` is the underside of the countertop (= carcass top); + * `countertopThickness` is the effective slab thickness above the rim (the + * parent run's when the module doesn't own its countertop). + */ +export function addSinkCompartment( + group: Group, + bowls: SinkBowlSpec[], + centerX: number, + centerZ: number, + rimY: number, + countertopThickness: number, + index: number, + applianceMaterial: Material, +) { + const name = `cabinet-sink-${index}` + for (const [bowlIndex, bowl] of bowls.entries()) { + addBasinShell(group, bowl, centerX, centerZ, rimY, `${name}-${bowlIndex}`, applianceMaterial) + } + + const bowlsMinX = Math.min(...bowls.map((bowl) => bowl.centerX - bowl.width / 2)) + const bowlsMaxX = Math.max(...bowls.map((bowl) => bowl.centerX + bowl.width / 2)) + const faucetX = centerX + (bowlsMinX + bowlsMaxX) / 2 + const faucetZ = centerZ - bowls[0]!.depth / 2 - FAUCET_SETBACK + addFaucet(group, faucetX, faucetZ, rimY + countertopThickness, name, applianceMaterial) +} diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts new file mode 100644 index 00000000..cc69a75b --- /dev/null +++ b/packages/nodes/src/cabinet/index.ts @@ -0,0 +1,7 @@ +export { bakeCabinetAnimationClip, poseCabinetMovingParts } from './animation' +export { cabinetDefinition, cabinetModuleDefinition } from './definition' +export { default as useCabinetPlacementStatus } from './placement-status' +export { + type CabinetPlacementType, + default as useCabinetPlacementType, +} from './placement-type' diff --git a/packages/nodes/src/cabinet/interaction.ts b/packages/nodes/src/cabinet/interaction.ts new file mode 100644 index 00000000..7eddfa07 --- /dev/null +++ b/packages/nodes/src/cabinet/interaction.ts @@ -0,0 +1,139 @@ +import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { isFridgeCompartmentType, stackForCabinet } from './stack' + +/** + * Shared open/close animator for cabinet runs and modules, used by both the + * panel's Play button and the registry `keyboardActions.e` interaction. + * + * Mid-flight frames publish through `useLiveNodeOverrides` rather than + * `scene.updateNode`: the temporal (undo) store records every updateNode, so + * per-rAF commits would burn ~20 undo entries per play. Stop/finish commits + * once. The cabinet animation system reads the effective node per frame, so + * overrides pose the doors in real time without geometry rebuilds. + */ + +type CabinetAnimation = { frame: number } + +const activeAnimations = new Map() +const listeners = new Set<(nodeId: string, running: boolean) => void>() + +function notify(nodeId: string, running: boolean) { + for (const listener of listeners) listener(nodeId, running) +} + +/** Subscribe to animation start/stop, e.g. for the panel's Play/Stop button. */ +export function onCabinetAnimationChange(listener: (nodeId: string, running: boolean) => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function isCabinetAnimationRunning(nodeId: AnyNodeId): boolean { + return activeAnimations.has(nodeId) +} + +function isCabinetLike(nodeId: AnyNodeId) { + const node = useScene.getState().nodes[nodeId] + return node?.type === 'cabinet' || node?.type === 'cabinet-module' ? node : null +} + +/** Cancel an in-flight animation, committing the current live frame once. */ +export function stopCabinetAnimation(nodeId: AnyNodeId) { + const animation = activeAnimations.get(nodeId) + if (!animation) return + window.cancelAnimationFrame(animation.frame) + activeAnimations.delete(nodeId) + + const overrides = useLiveNodeOverrides.getState() + const liveValue = overrides.get(nodeId)?.operationState + if (typeof liveValue === 'number' && isCabinetLike(nodeId)) { + useScene.getState().updateNode(nodeId, { operationState: liveValue }) + } + overrides.clearFields(nodeId, ['operationState']) + notify(nodeId, false) +} + +export function animateCabinetOperationState(nodeId: AnyNodeId, target: 0 | 1) { + const existing = activeAnimations.get(nodeId) + if (existing) { + window.cancelAnimationFrame(existing.frame) + activeAnimations.delete(nodeId) + } + + const node = isCabinetLike(nodeId) + if (!node) return + + const overrides = useLiveNodeOverrides.getState() + const liveValue = overrides.get(nodeId)?.operationState + const start = typeof liveValue === 'number' ? liveValue : (node.operationState ?? 0) + if (Math.abs(start - target) < 1e-4) { + overrides.clearFields(nodeId, ['operationState']) + useScene.getState().updateNode(nodeId, { operationState: target }) + return + } + + // Fridge doors are heavy — swing a touch slower. + const hasFridge = stackForCabinet(node).some((compartment) => + isFridgeCompartmentType(compartment.type), + ) + const duration = hasFridge ? 450 : 320 + const startTime = window.performance.now() + + const step = (time: number) => { + const animation = activeAnimations.get(nodeId) + if (!animation) return + const t = Math.min(1, (time - startTime) / duration) + const eased = 1 - (1 - t) ** 3 + const nextValue = start + (target - start) * eased + + if (t < 1) { + useLiveNodeOverrides.getState().set(nodeId, { operationState: nextValue }) + animation.frame = window.requestAnimationFrame(step) + return + } + + activeAnimations.delete(nodeId) + useScene.getState().updateNode(nodeId, { operationState: target }) + useLiveNodeOverrides.getState().clearFields(nodeId, ['operationState']) + notify(nodeId, false) + } + + activeAnimations.set(nodeId, { frame: window.requestAnimationFrame(step) }) + notify(nodeId, true) +} + +function effectiveOperationState(nodeId: AnyNodeId, nodeValue: number | undefined): number { + const liveValue = useLiveNodeOverrides.getState().get(nodeId)?.operationState + return typeof liveValue === 'number' ? liveValue : (nodeValue ?? 0) +} + +/** + * E-key interaction: animate toward open when mostly closed, toward closed + * when mostly open. Pressing E mid-animation reverses from the live frame. + * On a run, every child module swings together (the run's own + * `operationState` doesn't pose module doors — each module owns its own). + */ +export function toggleCabinetOperationState(nodeId: AnyNodeId) { + const node = isCabinetLike(nodeId) + if (!node) return + + if (node.type === 'cabinet') { + const nodes = useScene.getState().nodes + const modules = (node.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child) => child?.type === 'cabinet-module') + if (modules.length === 0) return + const anyOpen = modules.some( + (module) => effectiveOperationState(module!.id as AnyNodeId, module!.operationState) >= 0.5, + ) + const target = anyOpen ? 0 : 1 + for (const module of modules) { + animateCabinetOperationState(module!.id as AnyNodeId, target) + } + return + } + + const current = effectiveOperationState(nodeId, node.operationState) + animateCabinetOperationState(nodeId, current >= 0.5 ? 0 : 1) +} diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts new file mode 100644 index 00000000..aeeb86ba --- /dev/null +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -0,0 +1,329 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, + MovableParentFrame, + ParentFrameSnapMatch, +} from '@pascal-app/core' +import { planToRunLocal, runLocalToPlan } from './run-layout' +import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' + +/** Matches the generic move tool's Figma-alignment pull (8 cm). */ +const MAGNETIC_THRESHOLD_M = 0.08 +const GUIDE_EPSILON_M = 1e-4 +type PlanTransform = { position: [number, number, number]; rotation: number } +type PlanPoint = { x: number; z: number } + +function runParent( + node: AnyNode, + nodes: Readonly>, +): CabinetNodeType | null { + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = nodes[node.parentId] + return parent?.type === 'cabinet' ? (parent as CabinetNodeType) : null +} + +function isCabinetFrameNode( + node: AnyNode | undefined, +): node is CabinetNodeType | CabinetModuleNodeType { + return node?.type === 'cabinet' || node?.type === 'cabinet-module' +} + +function nodeRotationY(node: CabinetNodeType | CabinetModuleNodeType): number { + const rotation = (node as { rotation?: unknown }).rotation + if (typeof rotation === 'number') return rotation + if (Array.isArray(rotation)) return (rotation[1] as number | undefined) ?? 0 + return 0 +} + +function composePlanTransform(parent: PlanTransform, child: PlanTransform): PlanTransform { + const cos = Math.cos(parent.rotation) + const sin = Math.sin(parent.rotation) + const [x, y, z] = child.position + return { + position: [ + parent.position[0] + x * cos + z * sin, + parent.position[1] + y, + parent.position[2] - x * sin + z * cos, + ], + rotation: parent.rotation + child.rotation, + } +} + +function frameWorldTransform( + node: CabinetNodeType | CabinetModuleNodeType, + nodes?: Readonly>, + visited = new Set(), +): PlanTransform { + const own: PlanTransform = { + position: [...node.position] as [number, number, number], + rotation: nodeRotationY(node), + } + if (!nodes || !node.parentId || visited.has(node.id)) return own + visited.add(node.id) + const parent = nodes[node.parentId] + if (!isCabinetFrameNode(parent)) return own + return composePlanTransform(frameWorldTransform(parent, nodes, visited), own) +} + +function localToPlan( + parent: AnyNode, + local: readonly [number, number, number], + nodes?: Readonly>, +): [number, number, number] { + const run = parent as CabinetNodeType + if (!nodes || !run.parentId || !isCabinetFrameNode(nodes[run.parentId])) { + return runLocalToPlan(run, local) + } + const transform = frameWorldTransform(run, nodes) + const cos = Math.cos(transform.rotation) + const sin = Math.sin(transform.rotation) + const [lx, ly, lz] = local + return [ + transform.position[0] + lx * cos + lz * sin, + transform.position[1] + ly, + transform.position[2] - lx * sin + lz * cos, + ] +} + +function planToLocal( + parent: AnyNode, + planX: number, + localY: number, + planZ: number, + nodes?: Readonly>, +): [number, number, number] { + const run = parent as CabinetNodeType + if (!nodes || !run.parentId || !isCabinetFrameNode(nodes[run.parentId])) { + return planToRunLocal(run, planX, localY, planZ) + } + const transform = frameWorldTransform(run, nodes) + const dx = planX - transform.position[0] + const dz = planZ - transform.position[2] + const cos = Math.cos(transform.rotation) + const sin = Math.sin(transform.rotation) + return [dx * cos - dz * sin, localY, dx * sin + dz * cos] +} + +/** + * Edge-mating snap between sibling modules in the run's local frame: pull the + * dragged module flush against a sibling's side when within the magnetic + * threshold, and align depth (Z center / front / back) when width bands touch. + */ +function magneticSnap( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + nodes: Readonly>, +): [number, number, number] { + const run = parent as CabinetNodeType + const moving = node as CabinetModuleNodeType + const movingHalfWidth = moving.width / 2 + const movingHalfDepth = moving.depth / 2 + const movingMinX = local[0] - movingHalfWidth + const movingMaxX = local[0] + movingHalfWidth + const movingMinZ = local[2] - movingHalfDepth + const movingMaxZ = local[2] + movingHalfDepth + let bestDeltaX = 0 + let bestDistanceX = Number.POSITIVE_INFINITY + let bestDeltaZ = 0 + let bestDistanceZ = Number.POSITIVE_INFINITY + + const considerX = (delta: number) => { + const distance = Math.abs(delta) + if (distance > MAGNETIC_THRESHOLD_M) return + if (distance < bestDistanceX) { + bestDeltaX = delta + bestDistanceX = distance + } + } + const considerZ = (delta: number) => { + const distance = Math.abs(delta) + if (distance > MAGNETIC_THRESHOLD_M) return + if (distance < bestDistanceZ) { + bestDeltaZ = delta + bestDistanceZ = distance + } + } + + for (const childId of run.children ?? []) { + if (childId === node.id) continue + const sibling = nodes[childId as AnyNodeId] + if (sibling?.type !== 'cabinet-module') continue + const module = sibling as CabinetModuleNodeType + const siblingHalfWidth = module.width / 2 + const siblingHalfDepth = module.depth / 2 + const siblingMinX = module.position[0] - siblingHalfWidth + const siblingMaxX = module.position[0] + siblingHalfWidth + const siblingMinZ = module.position[2] - siblingHalfDepth + const siblingMaxZ = module.position[2] + siblingHalfDepth + + const depthBandsTouch = + movingMinZ <= siblingMaxZ + MAGNETIC_THRESHOLD_M && + movingMaxZ >= siblingMinZ - MAGNETIC_THRESHOLD_M + if (depthBandsTouch) { + considerX(siblingMinX - movingMaxX) + considerX(siblingMaxX - movingMinX) + } + + const widthBandsTouch = + movingMinX <= siblingMaxX + MAGNETIC_THRESHOLD_M && + movingMaxX >= siblingMinX - MAGNETIC_THRESHOLD_M + if (widthBandsTouch) { + considerZ(module.position[2] - local[2]) + considerZ(siblingMinZ - movingMinZ) + considerZ(siblingMaxZ - movingMaxZ) + } + } + + if (!Number.isFinite(bestDistanceX) && !Number.isFinite(bestDistanceZ)) { + return [local[0], local[1], local[2]] + } + return [local[0] + bestDeltaX, local[1], local[2] + bestDeltaZ] +} + +function planPoint( + parent: AnyNode, + localX: number, + localZ: number, + nodes: Readonly>, +): PlanPoint { + const [x, , z] = localToPlan(parent, [localX, 0, localZ], nodes) + return { x, z } +} + +function makeSnapMatch({ + axis, + candidateNodeId, + from, + to, +}: { + axis: 'x' | 'z' + candidateNodeId: AnyNodeId + from: PlanPoint + to: PlanPoint +}): ParentFrameSnapMatch { + return { + axis, + candidateNodeId, + from, + to, + } +} + +function magneticSnapMatches( + node: AnyNode, + parent: AnyNode, + _local: readonly [number, number, number], + snappedLocal: readonly [number, number, number], + nodes: Readonly>, +): ParentFrameSnapMatch[] { + const run = parent as CabinetNodeType + const moving = node as CabinetModuleNodeType + const movingHalfWidth = moving.width / 2 + const movingHalfDepth = moving.depth / 2 + const movingMinX = snappedLocal[0] - movingHalfWidth + const movingMaxX = snappedLocal[0] + movingHalfWidth + const movingMinZ = snappedLocal[2] - movingHalfDepth + const movingMaxZ = snappedLocal[2] + movingHalfDepth + const matches: ParentFrameSnapMatch[] = [] + + for (const childId of run.children ?? []) { + if (matches.length >= 2) break + if (childId === node.id) continue + const sibling = nodes[childId as AnyNodeId] + if (sibling?.type !== 'cabinet-module') continue + const module = sibling as CabinetModuleNodeType + const siblingHalfWidth = module.width / 2 + const siblingHalfDepth = module.depth / 2 + const siblingMinX = module.position[0] - siblingHalfWidth + const siblingMaxX = module.position[0] + siblingHalfWidth + const siblingMinZ = module.position[2] - siblingHalfDepth + const siblingMaxZ = module.position[2] + siblingHalfDepth + + if ( + matches.every((match) => match.axis !== 'x') && + movingMinZ <= siblingMaxZ + MAGNETIC_THRESHOLD_M && + movingMaxZ >= siblingMinZ - MAGNETIC_THRESHOLD_M + ) { + const sharedX = + Math.abs(movingMinX - siblingMaxX) <= GUIDE_EPSILON_M + ? movingMinX + : Math.abs(movingMaxX - siblingMinX) <= GUIDE_EPSILON_M + ? movingMaxX + : null + if (sharedX !== null) { + matches.push( + makeSnapMatch({ + axis: 'x', + candidateNodeId: module.id, + from: planPoint(parent, sharedX, Math.min(movingMinZ, siblingMinZ), nodes), + to: planPoint(parent, sharedX, Math.max(movingMaxZ, siblingMaxZ), nodes), + }), + ) + } + } + + if ( + matches.every((match) => match.axis !== 'z') && + movingMinX <= siblingMaxX + MAGNETIC_THRESHOLD_M && + movingMaxX >= siblingMinX - MAGNETIC_THRESHOLD_M + ) { + const sharedZ = + Math.abs(snappedLocal[2] - module.position[2]) <= GUIDE_EPSILON_M + ? snappedLocal[2] + : Math.abs(movingMinZ - siblingMinZ) <= GUIDE_EPSILON_M + ? movingMinZ + : Math.abs(movingMaxZ - siblingMaxZ) <= GUIDE_EPSILON_M + ? movingMaxZ + : null + if (sharedZ !== null) { + matches.push( + makeSnapMatch({ + axis: 'z', + candidateNodeId: module.id, + from: planPoint(parent, Math.min(movingMinX, siblingMinX), sharedZ, nodes), + to: planPoint(parent, Math.max(movingMaxX, siblingMaxX), sharedZ, nodes), + }), + ) + } + } + } + + return matches +} + +export const cabinetModuleParentFrame: MovableParentFrame = { + resolveParent: runParent, + parentRotationY: (parent, nodes) => + frameWorldTransform(parent as CabinetNodeType, nodes).rotation, + localToPlan, + planToLocal, + magneticSnap, + magneticSnapMatches, + // Module position isn't in the run's geometryKey, so a committed move must + // bump the layout revision to re-flow spans/countertop — and re-anchor any + // linked L-corner runs to the module's new edge. + onCommit: (node, parent, sceneApi) => { + if (node.type !== 'cabinet-module' || parent.type !== 'cabinet') return + bumpCabinetRunLayoutRevision(sceneApi, parent as CabinetNodeType) + syncCornerRunsFromSourceModule({ + module: node as CabinetModuleNodeType, + run: sceneApi.get(parent.id as AnyNodeId) ?? (parent as CabinetNodeType), + sceneApi, + }) + }, + floorplanLiveTransform: ({ node, live }) => { + const rotation = (node as { rotation?: unknown }).rotation + return { + ...node, + position: live.position, + rotation: Array.isArray(rotation) + ? [(rotation[0] as number) ?? 0, live.rotation, (rotation[2] as number) ?? 0] + : typeof rotation === 'number' + ? live.rotation + : rotation, + } as AnyNode + }, +} diff --git a/packages/nodes/src/cabinet/paint.ts b/packages/nodes/src/cabinet/paint.ts new file mode 100644 index 00000000..5fcbb452 --- /dev/null +++ b/packages/nodes/src/cabinet/paint.ts @@ -0,0 +1,17 @@ +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' + +export const cabinetPaint = createSlotPaintCapability({ + materialTarget: 'cabinet', + resolveRole: ({ hitObject }) => { + const slotId = (hitObject?.userData as { slotId?: string | null } | undefined)?.slotId + return typeof slotId === 'string' ? slotId : null + }, + applyPreview: previewGeometrySlot, + legacyEffective: (node, role) => { + if (role === 'hardware') return null + return { + material: (node as { material?: unknown }).material as never, + materialPreset: (node as { materialPreset?: string }).materialPreset, + } + }, +}) diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx new file mode 100644 index 00000000..831cdca6 --- /dev/null +++ b/packages/nodes/src/cabinet/panel.tsx @@ -0,0 +1,663 @@ +'use client' + +import type { + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { createSceneApi, useScene } from '@pascal-app/core' +import { + ActionButton, + PanelSection, + PanelWrapper, + SegmentedControl, + SliderControl, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Pause, Play, Plus } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { CompartmentCard } from './compartment-card' +import { + animateCabinetOperationState, + isCabinetAnimationRunning, + onCabinetAnimationChange, + stopCabinetAnimation, +} from './interaction' +import { CABINET_PRESETS, type CabinetPresetId } from './presets' +import { + addWallChildAbove, + backAlignZ, + resolveCabinetType, + runModuleBaseY, + switchCabinetToBase, + switchCabinetToTall, + syncCornerRunsFromSourceModule, + wallChildOf, +} from './run-ops' +import { + bumpRunLayoutRevisionViaStore, + type CabinetEditableNode, + CabinetRunPanel, + reflowRunModules, +} from './run-panel' +import { + backAnchoredModuleZ, + type CabinetCompartment, + isHoodCompartmentType, + minCabinetCarcassHeightForStack, + newCabinetCompartment, + normalizeCabinetStack, + resizeCabinetCompartmentStack, + stackForCabinet, +} from './stack' +import { resolveCompartmentTransition } from './stack-transitions' + +const HANDLE_STYLE_OPTIONS = [ + { value: 'bar', label: 'Bar' }, + { value: 'knob', label: 'Knob' }, + { value: 'cutout', label: 'Cutout' }, + { value: 'hole', label: 'Hole' }, + { value: 'none', label: 'None' }, +] as const + +const HANDLE_POSITION_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Center' }, +] as const + +const FRONT_OVERLAY_OPTIONS = [ + { value: 'full', label: 'Overlay' }, + { value: 'inset', label: 'Inset' }, +] as const + +const FRONT_STYLE_OPTIONS = [ + { value: 'slab', label: 'Slab' }, + { value: 'shaker', label: 'Shaker' }, + { value: 'raised-arch', label: 'Raised Arch' }, +] as const + +const CABINET_TIER_OPTIONS = [ + { value: 'base', label: 'Base Cabinet' }, + { value: 'tall', label: 'Tall Cabinet' }, +] as const + +const EMPTY_MODULES: CabinetModuleNodeType[] = [] +const EMPTY_MODULE_IDS: AnyNodeId[] = [] + +const PRESET_BUTTON_CLASS = + 'flex h-9 items-center justify-center rounded-md border border-border/40 bg-[#252527] px-3 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border/70 hover:bg-[#303033]' + +export default function CabinetPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const setSelection = useViewer((s) => s.setSelection) + const [isAnimating, setIsAnimating] = useState(false) + const node = useScene((s) => + selectedId ? (s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined) : undefined, + ) + const parentRun = useScene((s) => { + if (!selectedId) return undefined + const selected = s.nodes[selectedId as AnyNodeId] + if (selected?.type !== 'cabinet-module' || !selected.parentId) return undefined + const parent = s.nodes[selected.parentId as AnyNodeId] as CabinetEditableNode | undefined + return parent?.type === 'cabinet' ? parent : undefined + }) + const moduleIds = useScene((s) => { + if (!selectedId) return EMPTY_MODULE_IDS + const selected = s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined + const parent = + selected?.type === 'cabinet' + ? selected + : selected?.type === 'cabinet-module' && selected.parentId + ? (s.nodes[selected.parentId as AnyNodeId] as CabinetNodeType | undefined) + : undefined + if (parent?.type !== 'cabinet') return EMPTY_MODULE_IDS + return (parent.children ?? EMPTY_MODULE_IDS) as AnyNodeId[] + }) + // Select just the run's modules — subscribing to the whole `s.nodes` record + // re-rendered the panel on every scene mutation anywhere in the scene. + const modules = useScene( + useShallow((s) => { + if (moduleIds.length === 0) return EMPTY_MODULES + const found = moduleIds + .map((id) => s.nodes[id as AnyNodeId] as CabinetModuleNodeType | undefined) + .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module') + return found.length === 0 ? EMPTY_MODULES : found + }), + ) + const wallChild = useScene((s) => { + const selected = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined + return selected?.type === 'cabinet-module' + ? wallChildOf(selected, s.nodes as Record) + : undefined + }) + const parentIsModule = useScene((s) => { + const selected = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined + return ( + selected?.type === 'cabinet-module' && + selected.parentId != null && + s.nodes[selected.parentId as AnyNodeId]?.type === 'cabinet-module' + ) + }) + + const updateNode = useCallback( + (patch: Partial) => { + if (!selectedId) return + const scene = useScene.getState() + const liveBeforeUpdate = scene.nodes[selectedId as AnyNodeId] as + | CabinetEditableNode + | undefined + const nextPatch = { ...patch } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + typeof nextPatch.carcassHeight === 'number' + ) { + nextPatch.carcassHeight = Math.max( + nextPatch.carcassHeight, + minCabinetCarcassHeightForStack(liveBeforeUpdate), + ) + } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + parentRun?.type === 'cabinet' && + 'width' in nextPatch && + typeof nextPatch.width === 'number' + ) { + reflowRunModules({ + modules, + parentRun, + patch: nextPatch as Partial, + scene, + selected: liveBeforeUpdate, + }) + return + } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + parentRun?.type === 'cabinet' && + typeof nextPatch.depth === 'number' + ) { + const patchPosition = nextPatch.position as CabinetModuleNodeType['position'] | undefined + nextPatch.position = [ + patchPosition?.[0] ?? liveBeforeUpdate.position[0], + patchPosition?.[1] ?? liveBeforeUpdate.position[1], + backAnchoredModuleZ( + liveBeforeUpdate.position[2], + liveBeforeUpdate.depth, + nextPatch.depth, + ), + ] + } + scene.updateNode(selectedId as AnyNodeId, nextPatch) + const liveNode = scene.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined + if (liveNode?.type === 'cabinet-module' && liveNode.parentId) { + scene.markDirty(liveNode.parentId as AnyNodeId) + const parent = scene.nodes[liveNode.parentId as AnyNodeId] as + | CabinetEditableNode + | undefined + const affectsRunLayout = + 'stack' in nextPatch || + 'carcassHeight' in nextPatch || + 'cabinetType' in nextPatch || + 'position' in nextPatch || + 'depth' in nextPatch || + 'width' in nextPatch + if (parent?.type === 'cabinet' && affectsRunLayout) { + bumpRunLayoutRevisionViaStore(scene, parent) + if (liveNode?.type === 'cabinet-module') { + syncCornerRunsFromSourceModule({ + module: liveNode, + run: parent, + sceneApi: createSceneApi(useScene), + }) + } + } + } + // Keep a nested wall cabinet's back flush with its base when the base depth changes. + if ('depth' in nextPatch && liveNode?.type === 'cabinet-module') { + const wallChild = wallChildOf( + liveNode, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id as AnyNodeId, { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(liveNode.depth, wallChild.depth), + ], + }) + scene.markDirty(liveNode.id as AnyNodeId) + } + } + }, + [modules, parentRun, selectedId], + ) + + const close = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + // Selecting the run as the sole selection is enough: the selection-manager's + // parent-frame routing keeps clicks on child modules targeting the run while + // it stays the single selected node. + const backToRun = useCallback(() => { + if (node?.type === 'cabinet-module' && node.parentId) { + setSelection({ selectedIds: [node.parentId] }) + } + }, [node, setSelection]) + + // Animation lives in ./interaction.ts, shared with the registry E-key + // action; the panel only mirrors its running state for the Play button. + const stopAnimation = useCallback(() => { + if (selectedId) stopCabinetAnimation(selectedId as AnyNodeId) + }, [selectedId]) + + const animateOperationState = useCallback( + (target: 0 | 1) => { + if (selectedId) animateCabinetOperationState(selectedId as AnyNodeId, target) + }, + [selectedId], + ) + + useEffect(() => { + setIsAnimating(selectedId ? isCabinetAnimationRunning(selectedId as AnyNodeId) : false) + return onCabinetAnimationChange((nodeId, running) => { + if (nodeId === selectedId) setIsAnimating(running) + }) + }, [selectedId]) + + if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null + + const stack = stackForCabinet(node) + const isHoodOnlyNode = + stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) + const normalized = normalizeCabinetStack(node) + const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) + const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() + + const commitStack = ( + next: CabinetCompartment[], + extraPatch: Partial = {}, + ) => { + const patch = { ...extraPatch, stack: next } + const minCarcassHeight = minCabinetCarcassHeightForStack({ ...node, stack: next }) + const targetCarcassHeight = patch.carcassHeight ?? node.carcassHeight + if (targetCarcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight + if (node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && patch.width) { + reflowRunModules({ + modules, + parentRun, + patch, + scene: useScene.getState(), + selected: node, + }) + return + } + updateNode(patch) + } + const replaceAt = (index: number, next: CabinetCompartment) => { + const transition = resolveCompartmentTransition({ node, parentRun, index, next }) + commitStack(transition.stack, transition.modulePatch) + } + const resizeAt = (index: number, height: number) => + commitStack(resizeCabinetCompartmentStack(node, index, height)) + const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) + const addCompartment = () => commitStack([...stack, newCabinetCompartment('shelf')]) + const moveCompartment = (index: number, delta: -1 | 1) => { + const target = index + delta + if (target < 0 || target >= stack.length) return + const next = stack.slice() + ;[next[index], next[target]] = [next[target]!, next[index]!] + commitStack(next) + } + + // Structural run mutations live in run-ops.ts, shared with the quick-action + // menu so the two surfaces can't drift. + const runOpsApi = () => createSceneApi(useScene) + + const addWallCabinetOrHoodAbove = (kind: 'cabinet' | 'hood') => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + const id = addWallChildAbove({ kind, module: node, run: parentRun, sceneApi: runOpsApi() }) + if (id) setSelection({ selectedIds: [id] }) + } + + const addWallCabinetAbove = () => addWallCabinetOrHoodAbove('cabinet') + const addHoodAbove = () => addWallCabinetOrHoodAbove('hood') + + const removeWallCabinet = () => { + if (node?.type !== 'cabinet-module') return + const scene = useScene.getState() + const wall = wallChildOf(node, scene.nodes) + if (!wall) return + scene.deleteNode(wall.id as AnyNodeId) + scene.markDirty(node.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } + + const switchToTall = () => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + if (switchCabinetToTall({ module: node, run: parentRun, sceneApi: runOpsApi() })) { + setSelection({ selectedIds: [node.id] }) + } + } + + const switchToBase = () => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + if (switchCabinetToBase({ module: node, run: parentRun, sceneApi: runOpsApi() })) { + setSelection({ selectedIds: [node.id] }) + } + } + + const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false + + const isWallChildModule = node?.type === 'cabinet-module' && parentIsModule + + const applyPreset = (presetId: CabinetPresetId) => { + if (node?.type !== 'cabinet-module') return + const scene = useScene.getState() + const preset = CABINET_PRESETS.find((entry) => entry.id === presetId) + if (!preset) return + + const patch = preset.createPatch(parentRun) + const wallChild = wallChildOf( + node, + scene.nodes as Record, + ) + if (wallChild && patch.cabinetType === 'tall') { + scene.deleteNode(wallChild.id as AnyNodeId) + } + + const nextPatch: Partial = { + ...patch, + position: [ + node.position[0], + parentRun?.type === 'cabinet' ? runModuleBaseY(parentRun) : node.position[1], + typeof patch.depth === 'number' + ? backAnchoredModuleZ(node.position[2], node.depth, patch.depth) + : node.position[2], + ], + } + + if (parentRun?.type === 'cabinet') { + reflowRunModules({ + modules, + parentRun, + patch: nextPatch, + scene, + selected: node, + }) + } else { + scene.updateNode(node.id as AnyNodeId, nextPatch) + } + setSelection({ selectedIds: [node.id] }) + } + + if (node.type === 'cabinet' && modules.length > 0) { + return + } + + return ( + + {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && ( + +
+ {CABINET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + + updateNode({ width: value })} + precision={2} + step={0.05} + unit="m" + value={node.width} + /> + {!isHoodOnlyNode && ( + <> + updateNode({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + updateNode({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + + )} + + + {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && !isHoodOnlyNode && ( + +
+ { + if (value === 'tall') { + switchToTall() + return + } + switchToBase() + }} + options={CABINET_TIER_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={resolveCabinetType(node, parentRun)} + /> + {resolveCabinetType(node, parentRun) === 'base' && + (hasWallCabinet ? ( + + ) : ( + <> + + + + ))} +
+
+ )} + + {!isHoodOnlyNode && ( + +
+
+ { + if (isAnimating) stopAnimation() + updateNode({ operationState: value / 100 }) + }} + step={1} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+ +
+
+ )} + + +
+ {rows.map(({ compartment, index }, displayIndex) => ( + moveCompartment(index, delta)} + onRemove={() => removeAt(index)} + onReplace={(next) => replaceAt(index, next)} + onResizeHeight={(height) => resizeAt(index, height)} + resolvedHeight={ + rowHeights.get(index) ?? node.carcassHeight / Math.max(stack.length, 1) + } + total={rows.length} + width={node.width} + /> + ))} +
+
+ } + label="Add compartment" + onClick={addCompartment} + /> +
+
+ + {!isHoodOnlyNode && ( + <> + +
+
+
+ Style +
+ + updateNode({ frontStyle: value as CabinetNodeType['frontStyle'] }) + } + options={FRONT_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontStyle ?? 'slab'} + /> +
+
+
+ Mounting +
+ + updateNode({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) + } + options={FRONT_OVERLAY_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontOverlay ?? 'full'} + /> +
+
+
+ + +
+
+
+ Style +
+ + updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] }) + } + options={HANDLE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handleStyle} + /> +
+ {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( +
+
+ Position +
+ + updateNode({ handlePosition: value as CabinetNodeType['handlePosition'] }) + } + options={HANDLE_POSITION_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handlePosition ?? 'auto'} + /> +
+ )} +
+
+ + )} +
+ ) +} diff --git a/packages/nodes/src/cabinet/parametrics.ts b/packages/nodes/src/cabinet/parametrics.ts new file mode 100644 index 00000000..ce253b17 --- /dev/null +++ b/packages/nodes/src/cabinet/parametrics.ts @@ -0,0 +1,51 @@ +import type { CabinetModuleNode, CabinetNode, ParametricDescriptor } from '@pascal-app/core' +import { cabinetCornerUnlinkPatchesOnDelete, cabinetEmptyRunCascadeDeleteIds } from './run-ops' + +export const cabinetParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, + { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 2.4, step: 0.01 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + // Deleting one L-corner member removes only it; these patches keep the + // corner-link metadata on the survivors consistent. + onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), + // A derived leg run lives under its source run — deleting the last child + // of a run deletes the now-empty run group too. + onDeleteCascade: (node, nodes, pendingDeleteIds) => + cabinetEmptyRunCascadeDeleteIds(node, nodes, pendingDeleteIds), + customPanel: () => import('./panel'), +} + +export const cabinetModuleParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, + { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 2.4, step: 0.01 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + // Deleting one L-corner member removes only it; these patches keep the + // corner-link metadata on the survivors consistent. + onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), + // Deleting the run's last module deletes the now-empty run group too. + onDeleteCascade: (node, nodes, pendingDeleteIds) => + cabinetEmptyRunCascadeDeleteIds(node, nodes, pendingDeleteIds), + customPanel: () => import('./panel'), +} diff --git a/packages/nodes/src/cabinet/placement-status.ts b/packages/nodes/src/cabinet/placement-status.ts new file mode 100644 index 00000000..cd2db28f --- /dev/null +++ b/packages/nodes/src/cabinet/placement-status.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand' + +type CabinetPlacementStatusState = { + blocked: boolean + setBlocked(blocked: boolean): void +} + +const useCabinetPlacementStatus = create((set) => ({ + blocked: false, + setBlocked: (blocked) => set({ blocked }), +})) + +export default useCabinetPlacementStatus diff --git a/packages/nodes/src/cabinet/placement-type.ts b/packages/nodes/src/cabinet/placement-type.ts new file mode 100644 index 00000000..5ebe0f8a --- /dev/null +++ b/packages/nodes/src/cabinet/placement-type.ts @@ -0,0 +1,26 @@ +// Ephemeral placement option for the cabinet tool. + +import { create } from 'zustand' + +export type CabinetPlacementType = 'cabinet' | 'island' + +type CabinetPlacementTypeState = { + type: CabinetPlacementType + setType(type: CabinetPlacementType): void + cycleType(): CabinetPlacementType +} + +const nextCabinetPlacementType = (type: CabinetPlacementType): CabinetPlacementType => + type === 'cabinet' ? 'island' : 'cabinet' + +const useCabinetPlacementType = create((set, get) => ({ + type: 'cabinet', + setType: (type) => set({ type }), + cycleType: () => { + const next = nextCabinetPlacementType(get().type) + set({ type: next }) + return next + }, +})) + +export default useCabinetPlacementType diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts new file mode 100644 index 00000000..c9420425 --- /dev/null +++ b/packages/nodes/src/cabinet/presets.ts @@ -0,0 +1,202 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_WIDTH, + fridgeCabinetStack, + MICROWAVE_STANDARD_WIDTH, + newCabinetCompartment, + SINK_STANDARD_WIDTH, + sinkCabinetStack, + TALL_CABINET_CARCASS_HEIGHT, +} from './stack' + +export type CabinetPresetId = + | 'base-door' + | 'drawer-base' + | 'dishwasher' + | 'cooktop-gas' + | 'cooktop-induction' + | 'sink-base' + | 'tall-pantry' + | 'oven-tower' + | 'fridge-single' + +export type CabinetPreset = { + id: CabinetPresetId + label: string + createPatch: (run?: CabinetNode) => Partial +} + +const baseShared = (run?: CabinetNode): Partial => ({ + cabinetType: 'base', + depth: run?.depth ?? 0.58, + carcassHeight: run?.carcassHeight ?? 0.72, + plinthHeight: run?.plinthHeight ?? 0.1, + toeKickDepth: run?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, +}) + +const runDepth = (run?: CabinetNode) => run?.depth ?? 0.58 + +export const CABINET_PRESETS: CabinetPreset[] = [ + { + id: 'base-door', + label: 'Base', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Base Cabinet', + width: 0.6, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'inset', + stack: [ + { ...newCabinetCompartment('drawer'), height: 0.44, drawerCount: 3 }, + { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, + ], + }), + }, + { + id: 'drawer-base', + label: 'Drawer Base', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Drawer Base', + width: 0.6, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('drawer'), drawerCount: 3 }], + }), + }, + { + id: 'dishwasher', + label: 'Dishwasher', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Dishwasher', + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('dishwasher'), height: DISHWASHER_STANDARD_HEIGHT }], + }), + }, + { + id: 'cooktop-gas', + label: 'Gas Hob', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Gas Hob Base', + width: COOKTOP_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: cooktopCabinetStack('cooktop-gas'), + }), + }, + { + id: 'cooktop-induction', + label: 'Induction', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Induction Base', + width: COOKTOP_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: cooktopCabinetStack('cooktop-induction'), + }), + }, + { + id: 'sink-base', + label: 'Sink', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Sink Base', + width: SINK_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + stack: sinkCabinetStack(), + }), + }, + { + id: 'tall-pantry', + label: 'Tall Pantry', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Tall Pantry', + width: 0.6, + depth: run?.depth ?? 0.58, + carcassHeight: 2.07, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 4 }], + }), + }, + { + id: 'oven-tower', + label: 'Oven Tower', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Oven Tower', + width: MICROWAVE_STANDARD_WIDTH, + depth: run?.depth ?? 0.58, + carcassHeight: 2.07, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [ + { ...newCabinetCompartment('drawer'), height: 0.42, drawerCount: 2 }, + newCabinetCompartment('oven'), + newCabinetCompartment('microwave'), + { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, + ], + }), + }, + { + id: 'fridge-single', + label: 'Single Fridge', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Single Door Refrigerator', + width: FRIDGE_COLUMN_WIDTH, + depth: runDepth(run), + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'center', + frontOverlay: 'full', + stack: fridgeCabinetStack('fridge-single'), + }), + }, +] + +export function cabinetPresetById(id: CabinetPresetId): CabinetPreset { + return CABINET_PRESETS.find((preset) => preset.id === id) ?? CABINET_PRESETS[0]! +} diff --git a/packages/nodes/src/cabinet/quick-action-icons.tsx b/packages/nodes/src/cabinet/quick-action-icons.tsx new file mode 100644 index 00000000..5d07ca3d --- /dev/null +++ b/packages/nodes/src/cabinet/quick-action-icons.tsx @@ -0,0 +1,91 @@ +/** + * Cabinet-specific quick-action glyphs, lazy-loaded into the editor's + * floating action menus via `IconRef` (`kind: 'component'`) on each quick + * action — the menus render whatever mark the kind ships instead of + * hardcoding cabinet SVG. Stroke-based marks, so they can't collapse into + * the fill-only `svg`-kind IconRef. + */ + +function CabinetGlyph({ kind }: { kind: 'base' | 'tall' | 'wall' }) { + return ( + + ) +} + +function CornerTurnGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +export function CabinetBaseGlyph() { + return +} + +export function CabinetTallGlyph() { + return +} + +export function CabinetWallGlyph() { + return +} + +export function CornerTurnLeftGlyph() { + return +} + +export function CornerTurnRightGlyph() { + return +} diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts new file mode 100644 index 00000000..6f3bbad7 --- /dev/null +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -0,0 +1,263 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode, + CabinetNode, + IconRef, + NodeQuickAction, +} from '@pascal-app/core' +import { moduleSideOpen, sideInsertX } from './run-layout' +import { + addCabinetModuleSide, + addCornerRun, + addWallChildAbove, + CABINET_BASE_WIDTH, + CABINET_EDGE_EPSILON, + cabinetModulesForRun, + planCabinetModuleSideAddition, + previewCornerAdditionLayout, + resolveCabinetType, + switchCabinetToBase, + switchCabinetToTall, + wallChildOf, +} from './run-ops' + +type CabinetContext = { + run: CabinetNode + module: CabinetModuleNode | null +} + +function resolveRunEndModule( + runModules: CabinetModuleNode[], + run: CabinetNode, + side: 'left' | 'right', +): CabinetModuleNode | null { + const standardBaseModules = runModules.filter( + (module) => module.moduleKind === 'standard' && resolveCabinetType(module, run) === 'base', + ) + if (standardBaseModules.length === 0) return null + return side === 'left' ? (standardBaseModules[0] ?? null) : (standardBaseModules.at(-1) ?? null) +} + +// Lazy component IconRefs — the menus mount these behind Suspense, so the +// glyph module loads only when a cabinet quick action is actually shown. +const cabinetWallIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetWallGlyph })), +} +const cabinetTallIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetTallGlyph })), +} +const cabinetBaseIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetBaseGlyph })), +} +const cornerTurnLeftIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnLeftGlyph })), +} +const cornerTurnRightIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnRightGlyph })), +} + +function resolveCabinetContext( + node: AnyNode, + nodes: Readonly>>, +): CabinetContext | null { + if (node.type === 'cabinet') return { run: node, module: null } + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type === 'cabinet') return { run: parent, module: node } + return null +} + +export function cabinetQuickActions({ + node, + nodes, +}: { + node: CabinetNode | CabinetModuleNode + nodes: Readonly>> +}): NodeQuickAction[] { + const context = resolveCabinetContext(node, nodes) + if (!context) return [] + + const selectedCabinetType = context.module + ? resolveCabinetType(context.module, context.run) + : null + const standardModule = context.module == null || context.module.moduleKind === 'standard' + const hasWallCabinet = + context.module && standardModule && selectedCabinetType === 'base' + ? Boolean(wallChildOf(context.module, nodes)) + : false + const runModules = cabinetModulesForRun(context.run, nodes) + const leftCornerModule = + context.module && standardModule && selectedCabinetType === 'base' + ? context.module + : resolveRunEndModule(runModules, context.run, 'left') + const rightCornerModule = + context.module && standardModule && selectedCabinetType === 'base' + ? context.module + : resolveRunEndModule(runModules, context.run, 'right') + const leftHasInsertSlot = + sideInsertX({ + anchorModule: context.module, + modules: runModules, + side: 'left', + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) != null + const rightHasInsertSlot = + sideInsertX({ + anchorModule: context.module, + modules: runModules, + side: 'right', + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) != null + const leftAvailable = + leftHasInsertSlot && + planCabinetModuleSideAddition({ + anchorModule: context.module, + nodes, + run: context.run, + side: 'left', + }) != null + const rightAvailable = + rightHasInsertSlot && + planCabinetModuleSideAddition({ + anchorModule: context.module, + nodes, + run: context.run, + side: 'right', + }) != null + const canAddCornerLeft = + leftCornerModule != null && + context.run.runTier === 'base' && + moduleSideOpen(runModules, leftCornerModule.id, 'left', CABINET_EDGE_EPSILON) && + previewCornerAdditionLayout({ + module: leftCornerModule, + run: context.run, + nodes, + side: 'left', + }) != null + const canAddCornerRight = + rightCornerModule != null && + context.run.runTier === 'base' && + moduleSideOpen(runModules, rightCornerModule.id, 'right', CABINET_EDGE_EPSILON) && + previewCornerAdditionLayout({ + module: rightCornerModule, + run: context.run, + nodes, + side: 'right', + }) != null + + const actions: NodeQuickAction[] = [] + const pushSideAction = (side: 'left' | 'right', disabled: boolean) => { + actions.push({ + id: `cabinet:add-${side}`, + label: side === 'left' ? 'Left' : 'Right', + title: side === 'left' ? 'Add cabinet to the left' : 'Add cabinet to the right', + icon: side === 'left' ? 'add-left' : 'add-right', + disabled, + history: 'single', + run: ({ sceneApi }) => { + if (disabled) return undefined + const id = addCabinetModuleSide({ + anchorModule: context.module, + run: context.run, + sceneApi, + side, + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + const pushCornerAction = ( + module: CabinetModuleNode | null, + endSide: 'left' | 'right', + disabled: boolean, + ) => { + actions.push({ + id: `cabinet:add-corner-${endSide}`, + label: endSide === 'left' ? 'L Left' : 'L Right', + title: endSide === 'left' ? 'Turn an L corner to the left' : 'Turn an L corner to the right', + icon: endSide === 'left' ? cornerTurnLeftIcon : cornerTurnRightIcon, + disabled, + history: 'single', + run: ({ sceneApi }) => { + if (disabled || !module) return undefined + const id = addCornerRun({ + module, + run: context.run, + sceneApi, + side: endSide, + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + + pushSideAction('left', !leftAvailable) + + pushCornerAction(leftCornerModule, 'left', !canAddCornerLeft) + + if (context.module) { + if (standardModule && selectedCabinetType === 'base') { + actions.push({ + id: 'cabinet:add-wall', + label: 'Wall', + title: hasWallCabinet + ? 'A wall cabinet already exists above this cabinet' + : 'Add wall cabinet above', + icon: cabinetWallIcon, + disabled: hasWallCabinet, + run: ({ sceneApi }) => { + const id = addWallChildAbove({ + kind: 'cabinet', + module: context.module!, + run: context.run, + sceneApi, + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + actions.push({ + id: 'cabinet:to-tall', + label: 'Tall', + title: 'Switch to tall cabinet', + icon: cabinetTallIcon, + run: ({ sceneApi }) => + switchCabinetToTall({ + module: context.module!, + run: context.run, + sceneApi, + }) + ? { selectedIds: [context.module!.id] } + : undefined, + }) + } else if (standardModule) { + actions.push({ + id: 'cabinet:to-base', + label: 'Base', + title: 'Switch to base cabinet', + icon: cabinetBaseIcon, + run: ({ sceneApi }) => + switchCabinetToBase({ + module: context.module!, + run: context.run, + sceneApi, + }) + ? { selectedIds: [context.module!.id] } + : undefined, + }) + } + } + + pushCornerAction(rightCornerModule, 'right', !canAddCornerRight) + + pushSideAction('right', !rightAvailable) + + return actions +} diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts new file mode 100644 index 00000000..998782e3 --- /dev/null +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -0,0 +1,436 @@ +import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' + +/** + * Straight-line run layout math — the single home for the "modules sit on the + * run's local X axis" assumption. Ordering, edges, adjacency, spans, and + * insert positions all live here so a future corner (L-shape) module changes + * one file instead of five call sites. + */ + +export const RUN_ADJACENCY_EPSILON = 1e-4 + +const ADJACENT_RUN_EPSILON = 1e-4 +const ADJACENT_RUN_Z_TOLERANCE = 0.03 + +type ModuleLike = Pick + +export function sortRunModules(modules: readonly T[]): T[] { + return [...modules].sort((a, b) => a.position[0] - b.position[0]) +} + +export function moduleMinX(module: Pick): number { + return module.position[0] - module.width / 2 +} + +export function moduleMaxX(module: Pick): number { + return module.position[0] + module.width / 2 +} + +export function runMinX(modules: readonly ModuleLike[]): number { + return Math.min(...modules.map(moduleMinX)) +} + +export function runMaxX(modules: readonly ModuleLike[]): number { + return Math.max(...modules.map(moduleMaxX)) +} + +/** + * Whether a module's side has no flush neighbor — i.e. the side is free for a + * width-resize handle or an adjacent insert. + */ +export function moduleSideOpen( + modules: readonly T[], + moduleId: string, + side: 'left' | 'right', + epsilon = RUN_ADJACENCY_EPSILON, +): boolean { + const sorted = sortRunModules(modules) + const index = sorted.findIndex((entry) => entry.id === moduleId) + if (index < 0) return true + const module = sorted[index]! + const neighbor = side === 'left' ? sorted[index - 1] : sorted[index + 1] + if (!neighbor) return true + const edge = side === 'left' ? moduleMinX(module) : moduleMaxX(module) + const neighborEdge = side === 'left' ? moduleMaxX(neighbor) : moduleMinX(neighbor) + return Math.abs(edge - neighborEdge) > epsilon +} + +export type RunSpan = { + minX: number + maxX: number + centerX: number + centerZ: number + width: number + depth: number + minZ: number + maxZ: number + topY: number + hasCountertop: boolean +} + +/** + * Contiguous same-height module groups along the run — the units the + * countertop, plinth, and appliance-gap logic operate on. A gap, a + * base↔tall transition, or a top-height change starts a new span. + */ +export function getRunSpans( + modules: readonly Pick< + CabinetModuleNode, + 'position' | 'width' | 'depth' | 'carcassHeight' | 'cabinetType' + >[], + opts: { + runTier?: CabinetNode['runTier'] + } = {}, +): RunSpan[] { + const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) + const spans: RunSpan[] = [] + const runTier = opts.runTier ?? 'base' + + for (const module of sorted) { + const minX = module.position[0] - module.width / 2 + const maxX = module.position[0] + module.width / 2 + const minZ = module.position[2] - module.depth / 2 + const maxZ = module.position[2] + module.depth / 2 + const topY = module.position[1] + module.carcassHeight + const hasCountertop = runTier === 'base' && (module.cabinetType ?? 'base') !== 'tall' + const current = spans.at(-1) + if ( + !current || + minX - current.maxX > RUN_ADJACENCY_EPSILON || + current.hasCountertop !== hasCountertop || + Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON + ) { + spans.push({ + minX, + maxX, + centerX: module.position[0], + centerZ: module.position[2], + width: module.width, + depth: module.depth, + minZ, + maxZ, + topY, + hasCountertop, + }) + continue + } + + current.maxX = Math.max(current.maxX, maxX) + current.minZ = Math.min(current.minZ, minZ) + current.maxZ = Math.max(current.maxZ, maxZ) + current.width = Math.max(0.01, current.maxX - current.minX) + current.centerX = (current.minX + current.maxX) / 2 + current.depth = Math.max(0.01, current.maxZ - current.minZ) + current.centerZ = (current.minZ + current.maxZ) / 2 + current.topY = Math.max(current.topY, topY) + } + + return spans +} + +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +export function derivedCornerRole( + metadata: unknown, +): { role: 'base-leg' | 'wall-leg' | 'bridge'; side: 'left' | 'right' } | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') + ) { + return null + } + return { role, side } +} + +function childDerivedBaseLegSides(ctx?: GeometryContext): Set<'left' | 'right'> { + const sides = new Set<'left' | 'right'>() + for (const child of ctx?.children ?? []) { + if (child.type !== 'cabinet') continue + const link = derivedCornerRole(child.metadata) + if (link?.role === 'base-leg') sides.add(link.side) + } + return sides +} + +function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { + return (node.children ?? []) + .map((id) => ctx?.resolve(id)) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) { + if (!ctx) return [] + + const localX = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const localZ = [Math.sin(node.rotation), Math.cos(node.rotation)] as const + const spans: Array<{ minX: number; maxX: number; depth: number; z: number }> = [] + + for (const sibling of ctx.siblings) { + if (sibling.type !== 'cabinet' || sibling.id === node.id) continue + if (Math.abs(angleDelta(sibling.rotation, node.rotation)) > 1e-3) continue + + const siblingModules = modulesForRun(sibling, ctx) + const siblingSpans = + siblingModules.length > 0 + ? getRunSpans(siblingModules, { runTier: sibling.runTier }) + : [ + { + minX: -sibling.width / 2, + maxX: sibling.width / 2, + centerX: 0, + centerZ: 0, + width: sibling.width, + depth: sibling.depth, + minZ: -sibling.depth / 2, + maxZ: sibling.depth / 2, + topY: sibling.carcassHeight, + hasCountertop: sibling.runTier !== 'tall', + }, + ] + const dx = sibling.position[0] - node.position[0] + const dz = sibling.position[2] - node.position[2] + const originX = dx * localX[0] + dz * localX[1] + const originZ = dx * localZ[0] + dz * localZ[1] + + for (const span of siblingSpans) { + spans.push({ + minX: originX + span.minX, + maxX: originX + span.maxX, + depth: span.depth, + z: originZ + span.centerZ, + }) + } + } + + return spans +} + +function hasAdjacentCabinetSpan({ + depth, + edgeX, + overhang, + side, + siblingSpans, +}: { + depth: number + edgeX: number + overhang: number + side: 'left' | 'right' + siblingSpans: Array<{ minX: number; maxX: number; depth: number; z: number }> +}) { + return siblingSpans.some((sibling) => { + if (Math.abs(sibling.z) > (depth + sibling.depth) / 2 + ADJACENT_RUN_Z_TOLERANCE) { + return false + } + const gap = side === 'left' ? edgeX - sibling.maxX : sibling.minX - edgeX + return gap >= -ADJACENT_RUN_EPSILON && gap <= overhang + ADJACENT_RUN_EPSILON + }) +} + +export type RunSpanEnds = { + /** Countertop side overhang after neighbor / corner / bar suppression. */ + leftOverhang: number + rightOverhang: number + /** Run end with nothing abutting — where a waterfall panel would show. */ + exposedLeft: boolean + exposedRight: boolean +} + +/** + * Per-span end conditions shared by the 3D run geometry and the 2D plan + * outline, so the countertop reads identically in both views. The side + * overhang is suppressed where a span abuts a tall neighbor in the same run, + * an adjacent collinear run, a side bar ledge, or the mating edge of an + * L-corner leg (either direction of the link). + */ +export function getRunSpanEnds( + node: CabinetNode, + ctx: GeometryContext | undefined, + spans: readonly RunSpan[], +): RunSpanEnds[] { + const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) + const cornerLink = derivedCornerRole(node.metadata) + const childBaseLegSides = childDerivedBaseLegSides(ctx) + const barEdge = node.barLedge?.edge + + return spans.map((span, spanIndex) => { + const previousSpan = spans[spanIndex - 1] + const nextSpan = spans[spanIndex + 1] + const hasInternalLeftNeighbor = + !!previousSpan && + !previousSpan.hasCountertop && + span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON + const hasInternalRightNeighbor = + !!nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON + const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.minX, + overhang: node.countertopOverhang, + side: 'left', + siblingSpans, + }) + const hasExternalRightNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.maxX, + overhang: node.countertopOverhang, + side: 'right', + siblingSpans, + }) + // A side bar's knee wall sits flush on that end — no slab overhang there. + let leftOverhang = + hasInternalLeftNeighbor || hasExternalLeftNeighbor || barEdge === 'left' + ? 0 + : node.countertopOverhang + let rightOverhang = + hasInternalRightNeighbor || hasExternalRightNeighbor || barEdge === 'right' + ? 0 + : node.countertopOverhang + // A derived base leg mates back into the source run on its inner corner + // edge, so that edge should be flush instead of carrying the usual + // exposed countertop overhang. The source run stays flush there too. + if (cornerLink?.role === 'base-leg') { + if (cornerLink.side === 'right' && spanIndex === 0) leftOverhang = 0 + if (cornerLink.side === 'left' && spanIndex === spans.length - 1) rightOverhang = 0 + } + if (childBaseLegSides.has('left') && spanIndex === 0) leftOverhang = 0 + if (childBaseLegSides.has('right') && spanIndex === spans.length - 1) rightOverhang = 0 + + const exposedLeft = + spanIndex === 0 && !hasExternalLeftNeighbor && !hasInternalLeftNeighbor && barEdge !== 'left' + const exposedRight = + spanIndex === spans.length - 1 && + !hasExternalRightNeighbor && + !hasInternalRightNeighbor && + barEdge !== 'right' + + return { leftOverhang, rightOverhang, exposedLeft, exposedRight } + }) +} + +/** + * X center for inserting a `width`-wide module on the given side of the + * anchor (or on the run's outer edge with no anchor). Returns null when a + * flush neighbor leaves no room on that side. + */ +export function sideInsertX({ + anchorModule, + modules, + side, + width, + epsilon = RUN_ADJACENCY_EPSILON, +}: { + anchorModule: ModuleLike | null + modules: readonly ModuleLike[] + side: 'left' | 'right' + width: number + epsilon?: number +}): number | null { + if (modules.length === 0) { + return side === 'left' ? -width / 2 : width / 2 + } + + if (!anchorModule) { + const edge = side === 'left' ? runMinX(modules) : runMaxX(modules) + return side === 'left' ? edge - width / 2 : edge + width / 2 + } + + const selectedLeft = moduleMinX(anchorModule) + const selectedRight = moduleMaxX(anchorModule) + const siblings = modules.filter((module) => module.id !== anchorModule.id) + + if (side === 'left') { + const nearestLeft = siblings + .map(moduleMaxX) + .filter((edge) => edge <= selectedLeft + epsilon) + .reduce((best, edge) => (best == null || edge > best ? edge : best), null) + if (nearestLeft != null && selectedLeft - nearestLeft < width - epsilon) { + return null + } + return selectedLeft - width / 2 + } + + const nearestRight = siblings + .map(moduleMinX) + .filter((edge) => edge >= selectedRight - epsilon) + .reduce((best, edge) => (best == null || edge < best ? edge : best), null) + if (nearestRight != null && nearestRight - selectedRight < width - epsilon) { + return null + } + return selectedRight + width / 2 +} + +/** + * Re-pack the run left-to-right after one module's width changes, keeping + * every module flush with its left neighbor. Returns per-module patches. + */ +export function reflowRunModules( + modules: readonly T[], + selectedId: CabinetModuleNode['id'], + selectedWidth: number, +): Array<{ id: T['id']; position: T['position']; width: number }> { + const sorted = sortRunModules(modules) + if (!sorted.some((module) => module.id === selectedId)) return [] + + let nextLeft = runMinX(sorted) + return sorted.map((module) => { + const width = module.id === selectedId ? selectedWidth : module.width + const position: T['position'] = [ + nextLeft + width / 2, + module.position[1], + module.position[2], + ] as T['position'] + nextLeft += width + return { id: module.id, position, width } + }) +} + +/** Full-run bounds in run-local frame (X along the run). */ +export function runLocalXExtent(modules: readonly ModuleLike[]): { + minX: number + maxX: number + centerX: number + width: number +} | null { + if (modules.length === 0) return null + const minX = runMinX(modules) + const maxX = runMaxX(modules) + return { minX, maxX, centerX: (minX + maxX) / 2, width: Math.max(0.01, maxX - minX) } +} + +export type RunLike = Pick + +/** Rotate + translate a run-local point into the plan (level) frame. */ +export function runLocalToPlan( + run: RunLike, + local: readonly [number, number, number], +): [number, number, number] { + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + const [lx, ly, lz] = local + return [ + run.position[0] + lx * cos + lz * sin, + run.position[1] + ly, + run.position[2] - lx * sin + lz * cos, + ] +} + +/** Inverse of {@link runLocalToPlan}. */ +export function planToRunLocal( + run: RunLike, + planX: number, + localY: number, + planZ: number, +): [number, number, number] { + const dx = planX - run.position[0] + const dz = planZ - run.position[2] + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + return [dx * cos - dz * sin, localY, dx * sin + dz * cos] +} diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts new file mode 100644 index 00000000..896f9c26 --- /dev/null +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -0,0 +1,2089 @@ +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + type CabinetNode, + calculateLevelMiters, + getWallPlanFootprint, + resolveLevelId, + type SceneApi, + selectionProxyIdFromMetadata, + type WallNode, +} from '@pascal-app/core' +import { + moduleMaxX, + moduleMinX, + planToRunLocal, + runLocalToPlan, + runLocalXExtent, + sideInsertX, +} from './run-layout' +import { + CabinetModuleNode as CabinetModuleNodeSchema, + CabinetNode as CabinetNodeSchema, +} from './schema' +import { + backAnchoredModuleZ, + hoodCompartmentHeight, + newCabinetCompartment, + stackForCabinet, +} from './stack' + +/** + * Kind-owned cabinet run mutations, shared by the properties panel, the + * quick-action menu, and the placement tool. Everything routes through + * `SceneApi` so each caller (panel with `useScene`, actions with the + * registry's api) gets identical behavior — these used to be copy-pasted + * per surface and had already drifted (gap checks, hood support, revision + * scope). + */ + +export const CABINET_BASE_WIDTH = 0.6 +export const CABINET_WALL_DEPTH = 0.32 +export const CABINET_WALL_CARCASS_HEIGHT = 0.72 +export const CABINET_TALL_DEPTH = 0.58 +export const CABINET_TALL_PLINTH_HEIGHT = 0.1 +export const CABINET_TALL_CARCASS_HEIGHT = 2.07 +export const CABINET_EDGE_EPSILON = 1e-4 +const MIN_CORNER_CONNECTED_WIDTH = 0.3 +const MIN_TRIMMED_CORNER_CONNECTED_WIDTH = 0.05 +const CORNER_WIDTH_SEARCH_STEP = 0.01 +const WALL_CLEARANCE_EPSILON = 1e-5 + +export type CabinetEditableNode = CabinetNode | CabinetModuleNode +type CornerSide = 'left' | 'right' +type CornerDerivedRunRole = 'base-leg' | 'wall-leg' | 'bridge' + +type CornerSourceLink = { + side: CornerSide + linkedRunIds: AnyNodeId[] +} + +type CornerDerivedRunLink = { + role: CornerDerivedRunRole + side: CornerSide + turnSide: CornerSide + sourceModuleId: AnyNodeId + sourceRunId: AnyNodeId +} + +type CabinetRunStylePatch = Pick< + Partial, + 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' +> + +export function cabinetMetadataRecord( + metadata: CabinetEditableNode['metadata'], +): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function withSelectionProxyMetadata( + metadata: CabinetEditableNode['metadata'], + proxyId: AnyNodeId, +): Record { + return { + ...cabinetMetadataRecord(metadata), + nodeSelectionProxyId: proxyId, + } +} + +function cornerSourceLink(metadata: CabinetEditableNode['metadata']): CornerSourceLink | null { + const record = cabinetMetadataRecord(metadata) + const value = record.cabinetCornerSourceLink + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const side = (value as { side?: unknown }).side + const linkedRunIds = (value as { linkedRunIds?: unknown }).linkedRunIds + if ((side !== 'left' && side !== 'right') || !Array.isArray(linkedRunIds)) return null + return { + side, + linkedRunIds: linkedRunIds.filter( + (id): id is AnyNodeId => typeof id === 'string', + ) as AnyNodeId[], + } +} + +function cornerDerivedRunLink( + metadata: CabinetEditableNode['metadata'], +): CornerDerivedRunLink | null { + const record = cabinetMetadataRecord(metadata) + const value = record.cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + const turnSide = (value as { turnSide?: unknown }).turnSide + const sourceModuleId = (value as { sourceModuleId?: unknown }).sourceModuleId + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') || + typeof sourceModuleId !== 'string' || + typeof sourceRunId !== 'string' + ) { + return null + } + return { + role, + side, + turnSide: turnSide === 'left' || turnSide === 'right' ? turnSide : side, + sourceModuleId: sourceModuleId as AnyNodeId, + sourceRunId: sourceRunId as AnyNodeId, + } +} + +/** + * Deleting one member of an L-corner group removes ONLY that node (plus its + * normal descendants) — never the other corner runs. These patches keep the + * metadata links consistent afterwards: + * - deleting a derived leg run → drop its id from the source module's + * `cabinetCornerSourceLink.linkedRunIds` (drop the link when empty); + * - deleting the source module → strip `cabinetCornerDerivedRun` from the + * surviving legs so they become plain independent runs. + * Patches targeting nodes that are also being deleted are skipped by the + * store, so deleting the whole source run (subtree cascade) stays clean. + */ +export function cabinetCornerUnlinkPatchesOnDelete( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): Array<{ id: AnyNodeId; data: Partial }> { + const patches: Array<{ id: AnyNodeId; data: Partial }> = [] + + const sourceLink = cornerSourceLink(node.metadata) + if (sourceLink) { + for (const runId of sourceLink.linkedRunIds) { + const linkedRun = nodes[runId] + if (linkedRun?.type !== 'cabinet') continue + const metadata = cabinetMetadataRecord(linkedRun.metadata) + if (!('cabinetCornerDerivedRun' in metadata)) continue + const { cabinetCornerDerivedRun: _dropped, ...rest } = metadata + patches.push({ id: runId, data: { metadata: rest } as Partial }) + } + } + + const derivedLink = cornerDerivedRunLink(node.metadata) + if (derivedLink) { + const sourceModule = nodes[derivedLink.sourceModuleId] + if (sourceModule?.type === 'cabinet-module') { + const sourceModuleLink = cornerSourceLink(sourceModule.metadata) + if (sourceModuleLink) { + const remaining = sourceModuleLink.linkedRunIds.filter((id) => id !== node.id) + const metadata = cabinetMetadataRecord(sourceModule.metadata) + const { cabinetCornerSourceLink: _dropped, ...rest } = metadata + patches.push({ + id: sourceModule.id as AnyNodeId, + data: { + metadata: + remaining.length > 0 + ? { + ...rest, + cabinetCornerSourceLink: { + side: sourceModuleLink.side, + linkedRunIds: remaining, + }, + } + : rest, + } as Partial, + }) + } + } + } + + return patches +} + +/** + * A cabinet run is a grouping container — once its last child is deleted + * the empty run must go too, so no orphan group lingers in the scene graph + * or the persisted data. Children may be modules or derived corner leg + * runs (which position themselves relative to the run), so ANY survivor + * keeps the run alive. `pendingDeleteIds` covers multi-select deletes: + * siblings already part of the same gesture count as gone. + */ +export function cabinetEmptyRunCascadeDeleteIds( + node: CabinetEditableNode, + nodes: Readonly>>, + pendingDeleteIds: ReadonlySet, +): AnyNodeId[] { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (parent?.type !== 'cabinet') return [] + const hasSurvivingChild = (parent.children ?? []).some((childId) => { + const id = childId as AnyNodeId + if (id === node.id || pendingDeleteIds.has(id)) return false + return nodes[id] != null + }) + return hasSurvivingChild ? [] : [parent.id as AnyNodeId] +} + +/** + * Bump the run's layout revision — the geometryKey input that forces its + * composite geometry (spans, countertop, plinth) to re-flow when a child + * module changes in a way the run's own fields don't capture. Sibling runs + * are re-keyed separately by the adjacency watcher in `system.tsx`, so no + * level-wide sweep is needed here. + */ +export function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNode) { + const live = sceneApi.get(run.id as AnyNodeId) ?? run + const metadata = cabinetMetadataRecord(live.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + sceneApi.update( + run.id as AnyNodeId, + { + metadata: { ...metadata, cabinetLayoutRevision: currentRevision + 1 }, + } as Partial, + ) + sceneApi.markDirty(run.id as AnyNodeId) +} + +export function runModuleBaseY(run: Pick) { + return run.showPlinth ? run.plinthHeight : 0 +} + +export function totalCabinetHeight( + node: Pick< + CabinetEditableNode, + 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' + >, +) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +/** Y where a wall cabinet's bottom lands so its top aligns with a tall unit's top. */ +export function wallBottomHeightForTallAlignment() { + return ( + totalCabinetHeight({ + showPlinth: true, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + withCountertop: false, + countertopThickness: 0, + }) - CABINET_WALL_CARCASS_HEIGHT + ) +} + +/** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ +export function backAlignZ(baseDepth: number, wallDepth: number) { + return -(baseDepth - wallDepth) / 2 +} + +export function wallChildOf( + module: CabinetModuleNode, + nodes: Readonly>>, +): CabinetModuleNode | null { + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module') return child + } + return null +} + +export function resolveCabinetType(module: CabinetModuleNode, run?: CabinetNode): 'base' | 'tall' { + if (module.cabinetType) return module.cabinetType + return run?.runTier === 'tall' ? 'tall' : 'base' +} + +export function cabinetModulesForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +export function cornerSourceModulesForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode[] { + return cabinetModulesForRun(run, nodes).filter( + (module) => cornerSourceLink(module.metadata) != null, + ) +} + +function doorStack(shelfCount: number) { + return [{ ...newCabinetCompartment('door'), shelfCount }] +} + +function cloneWallCabinetStack( + sourceWallTop: CabinetModuleNode | null, + shelfCount: number, +): CabinetModuleNode['stack'] { + if (!sourceWallTop) return doorStack(shelfCount) + return stackForCabinet(sourceWallTop).map((compartment) => ({ ...compartment })) +} + +function inheritedShelfCount(module: CabinetModuleNode): number { + const door = stackForCabinet(module).find((compartment) => compartment.type === 'door') + return typeof door?.shelfCount === 'number' && door.shelfCount >= 0 ? door.shelfCount : 1 +} + +function runBackLineZ(modules: readonly Pick[]) { + return Math.min(...modules.map((module) => module.position[2] - module.depth / 2)) +} + +export function cornerLinkedSourceModuleForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode | null { + return cornerSourceModulesForRun(run, nodes)[0] ?? null +} + +export function cornerStyleSourceForRun( + run: CabinetNode, + nodes: Readonly>>, +): { module: CabinetModuleNode; run: CabinetNode } | null { + const directSourceModule = cornerLinkedSourceModuleForRun(run, nodes) + if (directSourceModule) return { module: directSourceModule, run } + + const derivedLink = cornerDerivedRunLink(run.metadata) + if (!derivedLink) return null + + const sourceRun = nodes[derivedLink.sourceRunId] + const sourceModule = nodes[derivedLink.sourceModuleId] + if (sourceRun?.type !== 'cabinet' || sourceModule?.type !== 'cabinet-module') return null + return { module: sourceModule, run: sourceRun } +} + +function applyCabinetRunStylePatch( + sceneApi: SceneApi, + run: CabinetNode, + patch: CabinetRunStylePatch, +) { + if (Object.keys(patch).length === 0) return + + sceneApi.update(run.id as AnyNodeId, patch as Partial) + for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, patch as Partial) + } + } +} + +/** + * Push a style patch onto every corner run linked to a source module. Styles + * must reach the legs even when `syncDerivedCornerRun`'s geometric re-layout + * bails (a wall drawn later blocks the layout, a leg gained extra modules), + * so this applies the patch directly instead of riding on the layout sync. + */ +function applyStylePatchToLinkedCornerRuns( + sceneApi: SceneApi, + sourceModule: CabinetModuleNode, + patch: CabinetRunStylePatch, +) { + const link = cornerSourceLink(sourceModule.metadata) + if (!link) return + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (linkedRun?.type !== 'cabinet') continue + applyCabinetRunStylePatch(sceneApi, linkedRun, patch) + } +} + +export function syncCornerStyleGroupFromRun({ + run, + patch, + sceneApi, +}: { + run: CabinetNode + patch: CabinetRunStylePatch + sceneApi: SceneApi +}): boolean { + if (Object.keys(patch).length === 0) return false + + const source = cornerStyleSourceForRun(run, sceneApi.nodes()) + if (!source) return false + + const sourceRun = sceneApi.get(source.run.id as AnyNodeId) ?? source.run + + applyCabinetRunStylePatch(sceneApi, sourceRun, patch) + const cornerSources = cornerSourceModulesForRun(sourceRun, sceneApi.nodes()) + const sourceModules = + cornerSources.length > 0 + ? cornerSources + : [sceneApi.get(source.module.id as AnyNodeId) ?? source.module] + + for (const sourceModule of sourceModules) { + const liveModule = sceneApi.get(sourceModule.id as AnyNodeId) ?? sourceModule + applyStylePatchToLinkedCornerRuns(sceneApi, liveModule, patch) + syncCornerRunsFromSourceModule({ + module: liveModule, + run: sceneApi.get(sourceRun.id as AnyNodeId) ?? sourceRun, + sceneApi, + }) + } + return true +} + +function chainModuleCenters(widths: number[]): number[] { + const centers: number[] = [] + for (let index = 0; index < widths.length; index += 1) { + if (index === 0) { + centers.push(0) + continue + } + centers.push(centers[index - 1]! + (widths[index - 1]! + widths[index]!) / 2) + } + return centers +} + +function moduleWidthsFromPatches( + patches: Array<{ + width: number + }>, +): number[] { + return patches.map((patch) => patch.width) +} + +function rangesOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { + return Math.min(maxA, maxB) - Math.max(minA, minB) > epsilon +} + +function angleDelta(a: number, b: number) { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function runPositionFromBackLeft({ + backLeft, + rotation, + firstWidth, + depth, + y, +}: { + backLeft: readonly [number, number] + rotation: number + firstWidth: number + depth: number + y: number +}): [number, number, number] { + const pseudoRun = { + position: [backLeft[0], y, backLeft[1]] as [number, number, number], + rotation, + } + return runLocalToPlan(pseudoRun, [firstWidth / 2, 0, depth / 2]) +} + +function composePose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const [lx, ly, lz] = childPosition + return { + position: [ + parentPosition[0] + lx * cos + lz * sin, + parentPosition[1] + ly, + parentPosition[2] - lx * sin + lz * cos, + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent: { position: [number, number, number]; rotation: number } = + resolveCabinetWorldTransform(parent, nodes) + return composePose(worldParent.position, worldParent.rotation, node.position, node.rotation) + } + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +function worldToCabinetLocalPosition( + parent: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, + worldPosition: [number, number, number], +): [number, number, number] { + const frame = resolveCabinetWorldTransform(parent, nodes) + return planToRunLocal( + frame, + worldPosition[0], + worldPosition[1] - frame.position[1], + worldPosition[2], + ) +} + +function worldToCabinetLocalRotation( + parent: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, + worldRotation: number, +) { + return worldRotation - resolveCabinetWorldTransform(parent, nodes).rotation +} + +function positionAlongWorldAxis( + origin: readonly [number, number, number], + axis: readonly [number, number], + distance: number, +): [number, number, number] { + return [origin[0] + axis[0] * distance, origin[1], origin[2] + axis[1] * distance] +} + +function anchoredBridgeRunWorldPosition({ + sourceWallTop, + sourceRun, + bridgeWidth, + side, + fallbackPosition, + nodes, +}: { + sourceWallTop: CabinetModuleNode | null + sourceRun: CabinetNode + bridgeWidth: number + side: CornerSide + fallbackPosition: [number, number, number] + nodes: Readonly>> +}): [number, number, number] { + const sourceWallWorld = + sourceWallTop?.type === 'cabinet-module' + ? resolveCabinetWorldTransform(sourceWallTop, nodes) + : null + const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, nodes) + const sourceAxis: [number, number] = [ + Math.cos(sourceRunWorld.rotation), + -Math.sin(sourceRunWorld.rotation), + ] + + return sourceWallWorld && typeof sourceWallTop?.width === 'number' + ? positionAlongWorldAxis( + sourceWallWorld.position, + sourceAxis, + (side === 'right' ? 1 : -1) * (sourceWallTop.width / 2 + bridgeWidth / 2), + ) + : fallbackPosition +} + +/** The cabinet-frame parent a derived corner run's placement is local to. */ +function cabinetFrameParent( + node: CabinetNode, + nodes: Readonly>>, +): CabinetNode | CabinetModuleNode | null { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + return parent?.type === 'cabinet' || parent?.type === 'cabinet-module' ? parent : null +} + +function cornerSourceModulePatch({ + module, + side, + width, +}: { + module: CabinetModuleNode + side: CornerSide + width: number +}): Pick { + const anchoredEdge = side === 'right' ? moduleMinX(module) : moduleMaxX(module) + return { + width, + position: [ + side === 'right' ? anchoredEdge + width / 2 : anchoredEdge - width / 2, + module.position[1], + module.position[2], + ], + } +} + +function adjustedCornerSourceModule( + module: CabinetModuleNode, + side: CornerSide, + width: number, +): CabinetModuleNode { + return { + ...module, + ...cornerSourceModulePatch({ module, side, width }), + } +} + +function resolveCornerEndSide({ + module, + modules, + preferredSide, +}: { + module: CabinetModuleNode + modules: CabinetModuleNode[] + preferredSide: CornerSide +}): CornerSide | null { + const extent = runLocalXExtent(modules) + if (!extent) return null + const atLeftEnd = Math.abs(moduleMinX(module) - extent.minX) <= CABINET_EDGE_EPSILON + const atRightEnd = Math.abs(moduleMaxX(module) - extent.maxX) <= CABINET_EDGE_EPSILON + + if (preferredSide === 'left' && atLeftEnd) return 'left' + if (preferredSide === 'right' && atRightEnd) return 'right' + if (atLeftEnd !== atRightEnd) return atLeftEnd ? 'left' : 'right' + return null +} + +function resolveCabinetHostLevelId( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): AnyNodeId | null { + const levelId = resolveLevelId(node as AnyNode, nodes as Record) + return levelId ? (levelId as AnyNodeId) : null +} + +function overlappingPolygonXRangeWithinStrip( + points: ReadonlyArray<{ x: number; z: number }>, + minZ: number, + maxZ: number, +): { minX: number; maxX: number } | null { + const xs: number[] = [] + const withinStrip = (z: number) => + z >= minZ - WALL_CLEARANCE_EPSILON && z <= maxZ + WALL_CLEARANCE_EPSILON + + for (const point of points) { + if (withinStrip(point.z)) xs.push(point.x) + } + + for (let index = 0; index < points.length; index += 1) { + const a = points[index]! + const b = points[(index + 1) % points.length]! + const dz = b.z - a.z + if (Math.abs(dz) <= WALL_CLEARANCE_EPSILON) continue + for (const boundary of [minZ, maxZ]) { + const t = (boundary - a.z) / dz + if (t < -WALL_CLEARANCE_EPSILON || t > 1 + WALL_CLEARANCE_EPSILON) continue + xs.push(a.x + (b.x - a.x) * t) + } + } + + if (xs.length === 0) return null + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + } +} + +function resolveWallLimitedWidth({ + backLeft, + desiredWidth, + depth, + leadingOffset, + nodes, + rotation, + sourceNode, +}: { + backLeft: readonly [number, number] + desiredWidth: number + depth: number + leadingOffset: number + nodes: Readonly>> + rotation: number + sourceNode: CabinetNode | CabinetModuleNode +}): number { + const hostLevelId = resolveCabinetHostLevelId(sourceNode, nodes) + if (!hostLevelId) return desiredWidth + + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, + ) + if (walls.length === 0) return desiredWidth + + const candidateRun = { + position: [backLeft[0], 0, backLeft[1]] as [number, number, number], + rotation, + } + const miterData = calculateLevelMiters(walls) + let blockingDistance = Number.POSITIVE_INFINITY + + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const localFootprint = footprint.map((point) => { + const local = planToRunLocal(candidateRun, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }) + const overlaps = [ + overlappingPolygonXRangeWithinStrip(localFootprint, 0, depth), + overlappingPolygonXRangeWithinStrip(localFootprint, -depth, 0), + ].filter((overlap): overlap is { minX: number; maxX: number } => overlap != null) + if (overlaps.length === 0) continue + + for (const overlap of overlaps) { + if (overlap.maxX <= WALL_CLEARANCE_EPSILON || overlap.minX <= WALL_CLEARANCE_EPSILON) { + continue + } + blockingDistance = Math.min(blockingDistance, Math.max(0, overlap.minX)) + } + } + + if (!Number.isFinite(blockingDistance)) return desiredWidth + const cappedWidth = Math.min(desiredWidth, blockingDistance - leadingOffset) + return Math.max(0, cappedWidth) +} + +function resolveSideAddedModuleWidth({ + centerX, + centerZ, + depth, + desiredWidth, + nodes, + run, + side, + sourceNode, +}: { + centerX: number + centerZ: number + depth: number + desiredWidth: number + nodes: Readonly>> + run: CabinetNode + side: 'left' | 'right' + sourceNode: CabinetNode | CabinetModuleNode +}): number { + const hostLevelId = resolveCabinetHostLevelId(sourceNode, nodes) + if (!hostLevelId) { + return desiredWidth + } + + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, + ) + if (walls.length === 0) return desiredWidth + + const runWorld = resolveCabinetWorldTransform(run, nodes) + const miterData = calculateLevelMiters(walls) + const minZ = centerZ - depth / 2 + const maxZ = centerZ + depth / 2 + const anchorEdge = side === 'right' ? centerX - desiredWidth / 2 : centerX + desiredWidth / 2 + let cappedWidth = desiredWidth + + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const overlap = overlappingPolygonXRangeWithinStrip( + footprint.map((point) => { + const local = planToRunLocal(runWorld, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }), + minZ, + maxZ, + ) + if (!overlap) continue + + if (side === 'right') { + if (overlap.minX <= anchorEdge + WALL_CLEARANCE_EPSILON) continue + cappedWidth = Math.min(cappedWidth, Math.max(0, overlap.minX - anchorEdge)) + continue + } + + if (overlap.maxX >= anchorEdge - WALL_CLEARANCE_EPSILON) continue + cappedWidth = Math.min(cappedWidth, Math.max(0, anchorEdge - overlap.maxX)) + } + + return cappedWidth +} + +function resolveCornerSourceSideWallLimitedWidth({ + desiredWidth, + module, + nodes, + run, + side, +}: { + desiredWidth: number + module: CabinetModuleNode + nodes: Readonly>> + run: CabinetNode + side: CornerSide +}): number { + const hostLevelId = resolveCabinetHostLevelId(module, nodes) + if (!hostLevelId) return desiredWidth + + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, + ) + if (walls.length === 0) return desiredWidth + + const runWorld = resolveCabinetWorldTransform(run, nodes) + const miterData = calculateLevelMiters(walls) + const minZ = module.position[2] - module.depth / 2 + const maxZ = module.position[2] + module.depth / 2 + const centerZ = module.position[2] + const fixedEdge = side === 'right' ? moduleMinX(module) : moduleMaxX(module) + let cappedWidth = desiredWidth + + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const localFootprint = footprint.map((point) => { + const local = planToRunLocal(runWorld, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }) + const footprintMinZ = Math.min(...localFootprint.map((point) => point.z)) + const footprintMaxZ = Math.max(...localFootprint.map((point) => point.z)) + if ( + centerZ < footprintMinZ - WALL_CLEARANCE_EPSILON || + centerZ > footprintMaxZ + WALL_CLEARANCE_EPSILON + ) { + continue + } + + const overlap = overlappingPolygonXRangeWithinStrip(localFootprint, minZ, maxZ) + if (!overlap) continue + + if (side === 'right') { + if (overlap.maxX <= fixedEdge + WALL_CLEARANCE_EPSILON) continue + const maxSourceRight = overlap.minX - run.depth + if (maxSourceRight <= fixedEdge + desiredWidth + WALL_CLEARANCE_EPSILON) { + cappedWidth = Math.min(cappedWidth, Math.max(0, maxSourceRight - fixedEdge)) + } + continue + } + + if (overlap.minX >= fixedEdge - WALL_CLEARANCE_EPSILON) continue + const minSourceLeft = overlap.maxX + run.depth + if (minSourceLeft >= fixedEdge - desiredWidth - WALL_CLEARANCE_EPSILON) { + cappedWidth = Math.min(cappedWidth, Math.max(0, fixedEdge - minSourceLeft)) + } + } + + return cappedWidth +} + +function computeCornerRunLayout({ + module, + run, + nodes, + side, + turnSide = side, + sourceModuleOverride, + minConnectedWidth = MIN_CORNER_CONNECTED_WIDTH, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide + turnSide?: CornerSide + sourceModuleOverride?: CabinetModuleNode + minConnectedWidth?: number +}) { + const sourceModule = sourceModuleOverride ?? module + const modules = cabinetModulesForRun(run, nodes).map((entry) => + entry.id === sourceModule.id ? sourceModule : entry, + ) + const extent = runLocalXExtent(modules) + if (!extent || modules.length === 0) return null + const runWorld = resolveCabinetWorldTransform(run, nodes) + + const backZ = runBackLineZ(modules) + const cornerX = side === 'right' ? extent.maxX : extent.minX + const corner = runLocalToPlan(runWorld, [cornerX, 0, backZ]) + const sourceAxis: [number, number] = [Math.cos(runWorld.rotation), -Math.sin(runWorld.rotation)] + const sign = side === 'right' ? 1 : -1 + const shiftedCorner: [number, number] = [ + corner[0] + sign * run.depth * sourceAxis[0], + corner[2] + sign * run.depth * sourceAxis[1], + ] + const legRotation = + turnSide === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 + const legAxis: [number, number] = [Math.cos(legRotation), -Math.sin(legRotation)] + const connectedWidth = resolveWallLimitedWidth({ + backLeft: + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * (run.depth + sourceModule.width), + shiftedCorner[1] - legAxis[1] * (run.depth + sourceModule.width), + ], + desiredWidth: sourceModule.width, + depth: run.depth, + leadingOffset: run.depth, + nodes, + rotation: legRotation, + sourceNode: sourceModule, + }) + if (connectedWidth < minConnectedWidth - WALL_CLEARANCE_EPSILON) return null + const connectedShelfCount = inheritedShelfCount(module) + + const baseLegLength = run.depth + connectedWidth + const baseFirstWidth = side === 'right' ? run.depth : connectedWidth + const baseBackLeft: [number, number] = + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * baseLegLength, + shiftedCorner[1] - legAxis[1] * baseLegLength, + ] + const baseRunPosition = runPositionFromBackLeft({ + backLeft: baseBackLeft, + rotation: legRotation, + firstWidth: baseFirstWidth, + depth: run.depth, + y: runWorld.position[1], + }) + + const wallLegLength = run.depth + connectedWidth + const wallFirstWidth = side === 'right' ? run.depth : connectedWidth + const wallBackLeft: [number, number] = + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * wallLegLength, + shiftedCorner[1] - legAxis[1] * wallLegLength, + ] + const wallRunPosition = runPositionFromBackLeft({ + backLeft: wallBackLeft, + rotation: legRotation, + firstWidth: wallFirstWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + + const bridgeWidth = Math.max(0.01, run.depth - CABINET_WALL_DEPTH) + const sourceCornerModule = side === 'right' ? modules.at(-1) : modules[0] + if (!sourceCornerModule) return null + const bridgeStartX = + side === 'right' ? moduleMinX(sourceCornerModule) : moduleMinX(sourceCornerModule) - bridgeWidth + const bridgeBackLeftPlan = runLocalToPlan(runWorld, [bridgeStartX, 0, backZ]) + const bridgeRunPosition = runPositionFromBackLeft({ + backLeft: [bridgeBackLeftPlan[0], bridgeBackLeftPlan[2]], + rotation: runWorld.rotation, + firstWidth: side === 'right' ? sourceCornerModule.width : bridgeWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + const bridgeFillerStartX = + side === 'right' ? moduleMaxX(sourceCornerModule) : moduleMinX(sourceCornerModule) - bridgeWidth + const bridgeFillerBackLeftPlan = runLocalToPlan(runWorld, [bridgeFillerStartX, 0, backZ]) + const bridgeFillerRunPosition = runPositionFromBackLeft({ + backLeft: [bridgeFillerBackLeftPlan[0], bridgeFillerBackLeftPlan[2]], + rotation: runWorld.rotation, + firstWidth: bridgeWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + + return { + baseRunPosition, + wallRunPosition, + bridgeRunPosition, + bridgeFillerRunPosition, + legRotation, + connectedWidth, + connectedShelfCount, + bridgeWidth, + sourceCornerWidth: sourceCornerModule.width, + } +} + +function resolveCornerAdditionLayout({ + module, + run, + nodes, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide +}): { + sourceModule: CabinetModuleNode + layout: NonNullable> + endSide: CornerSide + turnSide: CornerSide +} | null { + const modules = cabinetModulesForRun(run, nodes) + const endSide = resolveCornerEndSide({ module, modules, preferredSide: side }) + if (!endSide) return null + const turnSide = side + const maxSourceWidth = resolveCornerSourceSideWallLimitedWidth({ + desiredWidth: module.width, + module, + nodes, + run, + side: endSide, + }) + const sourceWasSideWallTrimmed = maxSourceWidth < module.width - CABINET_EDGE_EPSILON + const minConnectedWidth = sourceWasSideWallTrimmed + ? MIN_TRIMMED_CORNER_CONNECTED_WIDTH + : MIN_CORNER_CONNECTED_WIDTH + if (maxSourceWidth < minConnectedWidth - WALL_CLEARANCE_EPSILON) return null + const initialSourceModule = sourceWasSideWallTrimmed + ? adjustedCornerSourceModule(module, endSide, Number(maxSourceWidth.toFixed(4))) + : module + const directLayout = computeCornerRunLayout({ + module, + run, + nodes, + side: endSide, + turnSide, + sourceModuleOverride: initialSourceModule, + minConnectedWidth, + }) + if (directLayout) + return { sourceModule: initialSourceModule, layout: directLayout, endSide, turnSide } + + for ( + let sourceWidth = initialSourceModule.width - CORNER_WIDTH_SEARCH_STEP; + sourceWidth >= minConnectedWidth - WALL_CLEARANCE_EPSILON; + sourceWidth -= CORNER_WIDTH_SEARCH_STEP + ) { + const candidateModule = adjustedCornerSourceModule( + module, + endSide, + Number(sourceWidth.toFixed(4)), + ) + const layout = computeCornerRunLayout({ + module, + run, + nodes, + side: endSide, + turnSide, + sourceModuleOverride: candidateModule, + minConnectedWidth, + }) + if (layout) return { sourceModule: candidateModule, layout, endSide, turnSide } + } + + return null +} + +type CabinetModulePatch = { + name: string + width: number + moduleKind?: CabinetModuleNode['moduleKind'] + openSide?: CabinetModuleNode['openSide'] + cornerShelf?: boolean + stack?: CabinetModuleNode['stack'] +} + +function uncoveredWallRunSegments({ + depth, + modulePatches, + parentId, + position, + rotation, + sceneApi, +}: { + depth: number + modulePatches: CabinetModulePatch[] + parentId: AnyNodeId + position: [number, number, number] + rotation: number + sceneApi: SceneApi +}): Array<{ modulePatches: CabinetModulePatch[]; position: [number, number, number] }> { + const moduleWidths = moduleWidthsFromPatches(modulePatches) + const centers = chainModuleCenters(moduleWidths) + const candidateModules = centers.map((center, index) => ({ + index, + minX: center - moduleWidths[index]! / 2, + maxX: center + moduleWidths[index]! / 2, + minZ: -depth / 2, + maxZ: depth / 2, + })) + + const candidateRun = { position, rotation } + const existingModules: Array<{ + minX: number + maxX: number + minZ: number + maxZ: number + }> = [] + + for (const node of Object.values(sceneApi.nodes())) { + if (node.type !== 'cabinet' || node.runTier !== 'wall') continue + const nodeWorld = resolveCabinetWorldTransform(node, sceneApi.nodes()) + if (Math.abs(angleDelta(nodeWorld.rotation, rotation)) > 1e-3) continue + if (Math.abs(nodeWorld.position[1] - position[1]) > 1e-3) continue + + const modules = cabinetModulesForRun(node, sceneApi.nodes()) + if (modules.length === 0) continue + existingModules.push( + ...modules.map((module) => { + const world = runLocalToPlan(nodeWorld, module.position) + const local = planToRunLocal(candidateRun, world[0], 0, world[2]) + return { + minX: local[0] - module.width / 2, + maxX: local[0] + module.width / 2, + minZ: local[2] - module.depth / 2, + maxZ: local[2] + module.depth / 2, + } + }), + ) + } + + const uncoveredIndices = candidateModules + .filter( + (candidate) => + !existingModules.some( + (existing) => + rangesOverlap(candidate.minX, candidate.maxX, existing.minX, existing.maxX) && + rangesOverlap(candidate.minZ, candidate.maxZ, existing.minZ, existing.maxZ), + ), + ) + .map((candidate) => candidate.index) + + if (uncoveredIndices.length === 0) return [] + + const segments: Array<{ + modulePatches: CabinetModulePatch[] + position: [number, number, number] + }> = [] + let segmentStart = uncoveredIndices[0]! + let previous = uncoveredIndices[0]! + + const pushSegment = (startIndex: number, endIndex: number) => { + segments.push({ + modulePatches: modulePatches.slice(startIndex, endIndex + 1), + position: runLocalToPlan(candidateRun, [centers[startIndex] ?? 0, 0, 0]), + }) + } + + for (let index = 1; index < uncoveredIndices.length; index += 1) { + const current = uncoveredIndices[index]! + if (current === previous + 1) { + previous = current + continue + } + pushSegment(segmentStart, previous) + segmentStart = current + previous = current + } + + pushSegment(segmentStart, previous) + return segments +} + +function upsertCabinetRunWithModules({ + depth, + modulePatches, + name, + parentId, + position, + rotation, + runTier, + sceneApi, + sourceRun, +}: { + depth: number + modulePatches: CabinetModulePatch[] + name: string + parentId: AnyNodeId + position: [number, number, number] + rotation: number + runTier: CabinetNode['runTier'] + sceneApi: SceneApi + sourceRun: CabinetNode +}): { runId: AnyNodeId; moduleIds: AnyNodeId[] } { + const run = CabinetNodeSchema.parse({ + ...sourceRun, + id: undefined, + children: [], + parentId, + name, + position, + rotation, + runTier, + depth, + carcassHeight: runTier === 'wall' ? CABINET_WALL_CARCASS_HEIGHT : sourceRun.carcassHeight, + plinthHeight: runTier === 'base' ? sourceRun.plinthHeight : 0, + toeKickDepth: runTier === 'base' ? sourceRun.toeKickDepth : 0, + countertopThickness: runTier === 'base' ? sourceRun.countertopThickness : 0, + countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, + countertopBackOverhang: runTier === 'base' ? sourceRun.countertopBackOverhang : 0, + withFinishedBack: runTier === 'base' ? sourceRun.withFinishedBack : false, + showPlinth: runTier === 'base' ? sourceRun.showPlinth : false, + withCountertop: runTier === 'base' ? sourceRun.withCountertop : false, + barLedge: undefined, + withWaterfall: false, + }) + sceneApi.upsert(run as AnyNode, parentId) + + const centers = chainModuleCenters(modulePatches.map((module) => module.width)) + const moduleIds = modulePatches.map((patch, index) => { + const module = CabinetModuleNodeSchema.parse({ + ...CabinetModuleNodeSchema.parse({}), + name: patch.name, + parentId: run.id, + position: [centers[index] ?? 0, runTier === 'base' ? runModuleBaseY(run) : 0, 0], + cabinetType: runTier === 'tall' ? 'tall' : 'base', + width: patch.width, + depth, + carcassHeight: runTier === 'wall' ? CABINET_WALL_CARCASS_HEIGHT : run.carcassHeight, + plinthHeight: 0, + toeKickDepth: runTier === 'base' ? sourceRun.toeKickDepth : 0, + countertopThickness: runTier === 'base' ? sourceRun.countertopThickness : 0, + countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, + showPlinth: false, + withCountertop: false, + moduleKind: patch.moduleKind ?? 'standard', + ...(patch.openSide ? { openSide: patch.openSide } : {}), + ...(patch.cornerShelf ? { cornerShelf: true } : {}), + ...(patch.stack ? { stack: patch.stack } : {}), + }) + sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + return module.id as AnyNodeId + }) + + return { runId: run.id as AnyNodeId, moduleIds } +} + +function childModuleByName( + parent: CabinetNode | CabinetModuleNode, + name: string, + nodes: Readonly>>, +): CabinetModuleNode | null { + for (const childId of parent.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module' && child.name === name) return child + } + return null +} + +export function previewCornerAdditionLayout({ + module, + run, + nodes, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide +}): { + connectedWidth: number + sourceWidth: number +} | null { + const resolved = resolveCornerAdditionLayout({ module, run, nodes, side }) + if (!resolved) return null + return { + connectedWidth: resolved.layout.connectedWidth, + sourceWidth: Math.min(resolved.sourceModule.width, resolved.layout.connectedWidth), + } +} + +function setCabinetSelectionProxy(sceneApi: SceneApi, id: AnyNodeId, proxyId: AnyNodeId) { + const live = sceneApi.get(id) + if (!live || (live.type !== 'cabinet' && live.type !== 'cabinet-module')) return + sceneApi.update(id, { + metadata: withSelectionProxyMetadata(live.metadata, proxyId), + } as Partial) +} + +function cornerSelectionRootId(sourceRun: CabinetNode, derivedRunId: AnyNodeId): AnyNodeId { + return selectionProxyIdFromMetadata(sourceRun.metadata) + ? derivedRunId + : (sourceRun.id as AnyNodeId) +} + +function syncDerivedCornerRun({ + role, + run, + sourceModule, + sourceRun, + side, + turnSide, + sceneApi, +}: { + role: CornerDerivedRunRole + run: CabinetNode + sourceModule: CabinetModuleNode + sourceRun: CabinetNode + side: CornerSide + turnSide: CornerSide + sceneApi: SceneApi +}) { + const layout = computeCornerRunLayout({ + module: sourceModule, + run: sourceRun, + nodes: sceneApi.nodes(), + side, + turnSide, + }) + if (!layout) return + + const modules = [...cabinetModulesForRun(run, sceneApi.nodes())].sort( + (a, b) => a.position[0] - b.position[0], + ) + if (modules.length === 0) return + + const fullSpecs = + role === 'base-leg' + ? side === 'right' + ? [ + ['Corner Filler', run.depth, 'right', 'corner-filler', true], + ['Base Cabinet', layout.connectedWidth, 'left', 'standard', false], + ] + : [ + ['Base Cabinet', layout.connectedWidth, 'right', 'standard', false], + ['Corner Filler', run.depth, 'left', 'corner-filler', true], + ] + : role === 'wall-leg' + ? side === 'right' + ? [ + ['Corner Wall Filler', sourceRun.depth, 'right', 'corner-filler', true], + ['Wall Cabinet', layout.connectedWidth, 'left', 'standard', false], + ] + : [ + ['Wall Cabinet', layout.connectedWidth, 'right', 'standard', false], + ['Corner Wall Filler', sourceRun.depth, 'left', 'corner-filler', true], + ] + : side === 'right' + ? [ + ['Wall Corner Cabinet', layout.sourceCornerWidth, 'right', 'standard', false], + ['Wall Bridge Filler', layout.bridgeWidth, 'left', 'corner-filler', true], + ] + : [ + ['Wall Bridge Filler', layout.bridgeWidth, 'right', 'corner-filler', true], + ['Wall Corner Cabinet', layout.sourceCornerWidth, 'left', 'standard', false], + ] + + const fullNames = fullSpecs.map(([name]) => name) + const fullWidths = fullSpecs.map(([, width]) => width as number) + const fullCenters = chainModuleCenters(fullWidths) + const specByName = new Map( + fullSpecs.map(([name, width, openSide, moduleKind, cornerShelf]) => [ + name, + { + width: width as number, + openSide: openSide as CabinetModuleNode['openSide'], + moduleKind: moduleKind as CabinetModuleNode['moduleKind'], + cornerShelf: cornerShelf as boolean, + }, + ]), + ) + + const currentSpecs = modules.map((entry) => specByName.get(entry.name)).filter(Boolean) + if (currentSpecs.length !== modules.length) return + const currentWidths = currentSpecs.map((entry) => entry!.width) + const currentCenters = chainModuleCenters(currentWidths) + const firstName = modules[0]!.name + const firstIndex = fullNames.indexOf(firstName) + if (firstIndex < 0) return + + const sourceWallTop = wallChildOf(sourceModule, sceneApi.nodes()) + const isStandaloneBridgeFillerRun = + role === 'bridge' && modules.length === 1 && modules[0]?.name === 'Wall Bridge Filler' + const bridgeAnchorPosition = isStandaloneBridgeFillerRun + ? anchoredBridgeRunWorldPosition({ + sourceWallTop, + sourceRun, + bridgeWidth: layout.bridgeWidth, + side, + fallbackPosition: layout.bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + : null + + const anchorPosition = + role === 'base-leg' + ? layout.baseRunPosition + : role === 'wall-leg' + ? layout.wallRunPosition + : isStandaloneBridgeFillerRun + ? (bridgeAnchorPosition ?? layout.bridgeFillerRunPosition) + : layout.bridgeRunPosition + const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) + const rotation = role === 'bridge' ? sourceRunWorld.rotation : layout.legRotation + const depth = role === 'base-leg' ? sourceRun.depth : CABINET_WALL_DEPTH + const runWorldPosition = isStandaloneBridgeFillerRun + ? anchorPosition + : runLocalToPlan({ position: anchorPosition, rotation }, [fullCenters[firstIndex] ?? 0, 0, 0]) + // Place relative to the derived run's ACTUAL parent frame — source run for + // new scenes, source module for legacy scenes that nested legs under it. + const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun + const runPosition = worldToCabinetLocalPosition(frameParent, sceneApi.nodes(), runWorldPosition) + const localRotation = worldToCabinetLocalRotation(frameParent, sceneApi.nodes(), rotation) + + sceneApi.update( + run.id as AnyNodeId, + { + position: runPosition, + rotation: localRotation, + depth, + carcassHeight: role === 'base-leg' ? sourceRun.carcassHeight : CABINET_WALL_CARCASS_HEIGHT, + plinthHeight: role === 'base-leg' ? sourceRun.plinthHeight : 0, + toeKickDepth: role === 'base-leg' ? sourceRun.toeKickDepth : 0, + countertopThickness: role === 'base-leg' ? sourceRun.countertopThickness : 0, + countertopOverhang: role === 'base-leg' ? sourceRun.countertopOverhang : 0, + countertopBackOverhang: role === 'base-leg' ? sourceRun.countertopBackOverhang : 0, + withFinishedBack: role === 'base-leg' ? sourceRun.withFinishedBack : false, + showPlinth: role === 'base-leg' ? sourceRun.showPlinth : false, + withCountertop: role === 'base-leg' ? sourceRun.withCountertop : false, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, + } as Partial, + ) + + modules.forEach((entry, index) => { + const spec = specByName.get(entry.name) + if (!spec) return + sceneApi.update( + entry.id as AnyNodeId, + { + width: spec.width, + depth, + carcassHeight: role === 'base-leg' ? sourceRun.carcassHeight : CABINET_WALL_CARCASS_HEIGHT, + position: [ + currentCenters[index] ?? 0, + role === 'base-leg' ? runModuleBaseY(sourceRun) : 0, + 0, + ], + toeKickDepth: role === 'base-leg' ? sourceRun.toeKickDepth : 0, + countertopThickness: role === 'base-leg' ? sourceRun.countertopThickness : 0, + countertopOverhang: role === 'base-leg' ? sourceRun.countertopOverhang : 0, + moduleKind: spec.moduleKind, + openSide: spec.openSide, + cornerShelf: spec.cornerShelf, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, + stack: doorStack(layout.connectedShelfCount), + metadata: entry.metadata, + } as Partial, + ) + }) + + if (role === 'base-leg') { + const connectedBaseModule = childModuleByName( + sceneApi.get(run.id as AnyNodeId) ?? run, + 'Base Cabinet', + sceneApi.nodes(), + ) + if (connectedBaseModule) { + ensureWallCabinetAbove({ + module: connectedBaseModule, + run: sceneApi.get(run.id as AnyNodeId) ?? run, + sceneApi, + shelfCount: layout.connectedShelfCount, + openSide: connectedBaseModule.openSide, + }) + } + } + + bumpCabinetRunLayoutRevision(sceneApi, sceneApi.get(run.id as AnyNodeId) ?? run) +} + +export function syncCornerRunsFromSourceModule({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}) { + const link = cornerSourceLink(module.metadata) + if (!link) return + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (linkedRun?.type !== 'cabinet') continue + const derivedLink = cornerDerivedRunLink(linkedRun.metadata) + if (!derivedLink) continue + syncDerivedCornerRun({ + role: derivedLink.role, + run: linkedRun, + sourceModule: module, + sourceRun: run, + side: derivedLink.side, + turnSide: derivedLink.turnSide, + sceneApi, + }) + } +} + +/** + * Insert a new base module flush against the anchor's side (or the run's + * outer edge with no anchor). Gap-checked — returns null when a flush + * neighbor leaves no room for a standard-width unit. + */ +export function planCabinetModuleSideAddition({ + anchorModule, + nodes, + run, + side, +}: { + anchorModule: CabinetModuleNode | null + nodes: Readonly>> + run: CabinetNode + side: 'left' | 'right' +}): CabinetModuleNode | null { + const modules = cabinetModulesForRun(run, nodes) + const x = sideInsertX({ + anchorModule, + modules, + side, + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) + if (x == null) return null + const depth = run.depth + const z = anchorModule + ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) + : 0 + const width = resolveSideAddedModuleWidth({ + centerX: x, + centerZ: z, + depth, + desiredWidth: CABINET_BASE_WIDTH, + nodes, + run, + side, + sourceNode: anchorModule ?? run, + }) + if (width < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null + return CabinetModuleNodeSchema.parse({ + name: `Base Cabinet ${modules.length + 1}`, + parentId: run.id, + position: [ + side === 'left' ? x + (CABINET_BASE_WIDTH - width) / 2 : x - (CABINET_BASE_WIDTH - width) / 2, + runModuleBaseY(run), + z, + ], + width, + depth, + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + showPlinth: false, + withCountertop: false, + }) +} + +export function addCabinetModuleSide({ + anchorModule, + run, + sceneApi, + side, +}: { + anchorModule: CabinetModuleNode | null + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + const module = planCabinetModuleSideAddition({ + anchorModule, + nodes: sceneApi.nodes(), + run, + side, + }) + if (!module) return null + sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + bumpCabinetRunLayoutRevision(sceneApi, run) + return module.id +} + +/** + * Spawn an L corner off one open end of a base run: a perpendicular base leg + * with a corner pocket filler plus cabinet, a matching wall leg, and a short + * wall bridge above the source run's corner cabinet so the top corner doesn't + * read empty. + */ +export function addCornerRun({ + module, + run, + sceneApi, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + const liveRun = sceneApi.get(run.id as AnyNodeId) ?? run + const liveModule = sceneApi.get(module.id as AnyNodeId) ?? module + if (liveRun.runTier !== 'base' || resolveCabinetType(liveModule, liveRun) !== 'base') { + return null + } + + const resolved = resolveCornerAdditionLayout({ + module: liveModule, + run: liveRun, + nodes: sceneApi.nodes(), + side, + }) + if (!resolved) return null + const { layout, sourceModule: resolvedSourceModule, endSide, turnSide } = resolved + let sourceModule = liveModule + let sourceRun = liveRun + if ( + resolvedSourceModule.width < liveModule.width - CABINET_EDGE_EPSILON || + layout.connectedWidth < liveModule.width - CABINET_EDGE_EPSILON + ) { + const sourcePatch = cornerSourceModulePatch({ + module: liveModule, + side: endSide, + width: Math.min(resolvedSourceModule.width, layout.connectedWidth), + }) + sceneApi.update(liveModule.id as AnyNodeId, sourcePatch as Partial) + const existingWallTop = wallChildOf(liveModule, sceneApi.nodes()) + if (existingWallTop) { + sceneApi.update( + existingWallTop.id as AnyNodeId, + { + width: layout.connectedWidth, + } as Partial, + ) + } + sourceModule = sceneApi.get(liveModule.id as AnyNodeId) ?? liveModule + sourceRun = sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun + } + const resolvedLayout = computeCornerRunLayout({ + module: sourceModule, + run: sourceRun, + nodes: sceneApi.nodes(), + side: endSide, + turnSide, + minConnectedWidth: + sourceModule.width < MIN_CORNER_CONNECTED_WIDTH - CABINET_EDGE_EPSILON + ? MIN_TRIMMED_CORNER_CONNECTED_WIDTH + : MIN_CORNER_CONNECTED_WIDTH, + }) + if (!resolvedLayout) return null + const { + baseRunPosition, + wallRunPosition, + bridgeFillerRunPosition, + legRotation, + connectedWidth, + connectedShelfCount, + bridgeWidth, + } = resolvedLayout + const runWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) + const sourceWallChildId = ensureWallCabinetAbove({ + module: sourceModule, + run: sourceRun, + sceneApi, + shelfCount: connectedShelfCount, + openSide: endSide, + }) + const existingWallTop = sourceWallChildId + ? (sceneApi.get(sourceWallChildId) ?? null) + : wallChildOf(sourceModule, sceneApi.nodes()) + // Legs are siblings of the source module under the SOURCE RUN — the run is + // the modular cabinet group; the clicked module must not become a container. + const baseLocalPosition = worldToCabinetLocalPosition( + sourceRun, + sceneApi.nodes(), + baseRunPosition, + ) + const baseLocalRotation = worldToCabinetLocalRotation(sourceRun, sceneApi.nodes(), legRotation) + const baseModules = + endSide === 'right' + ? [ + { + name: 'Corner Filler', + width: sourceRun.depth, + moduleKind: 'corner-filler' as const, + openSide: 'right' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Base Cabinet', + width: connectedWidth, + openSide: 'left' as const, + stack: doorStack(connectedShelfCount), + }, + ] + : [ + { + name: 'Base Cabinet', + width: connectedWidth, + openSide: 'right' as const, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Corner Filler', + width: sourceRun.depth, + moduleKind: 'corner-filler' as const, + openSide: 'left' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ] + + const baseLeg = upsertCabinetRunWithModules({ + depth: sourceRun.depth, + modulePatches: baseModules, + name: 'Corner Base Run', + parentId: sourceRun.id as AnyNodeId, + position: baseLocalPosition, + rotation: baseLocalRotation, + runTier: 'base', + sceneApi, + sourceRun, + }) + const selectionRootId = cornerSelectionRootId(sourceRun, baseLeg.runId) + const linkedRunIds: AnyNodeId[] = [baseLeg.runId] + const baseLegLiveMetadata = sceneApi.get(baseLeg.runId)?.metadata ?? null + const baseLegMetadata = cabinetMetadataRecord(baseLegLiveMetadata) + sceneApi.update(baseLeg.runId, { + metadata: { + ...(selectionRootId === baseLeg.runId + ? baseLegMetadata + : withSelectionProxyMetadata(baseLegLiveMetadata, selectionRootId)), + cabinetCornerDerivedRun: { + role: 'base-leg', + side: endSide, + turnSide, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + for (const moduleId of baseLeg.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + const baseLegRunNode = sceneApi.get(baseLeg.runId) ?? sourceRun + const cornerFillerModule = + childModuleByName(baseLegRunNode, 'Corner Filler', sceneApi.nodes()) ?? + sceneApi.get(baseLeg.moduleIds[0]!) + const connectedBaseModule = + childModuleByName(baseLegRunNode, 'Base Cabinet', sceneApi.nodes()) ?? + sceneApi.get(baseLeg.moduleIds[1]!) + + if (cornerFillerModule) { + const bridgeRunWorldPosition = anchoredBridgeRunWorldPosition({ + sourceWallTop: existingWallTop, + sourceRun, + bridgeWidth, + side: endSide, + fallbackPosition: bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + const bridgeRunLocalPosition = worldToCabinetLocalPosition( + cornerFillerModule, + sceneApi.nodes(), + bridgeRunWorldPosition, + ) + const bridgeRunLocalRotation = worldToCabinetLocalRotation( + cornerFillerModule, + sceneApi.nodes(), + runWorld.rotation, + ) + const bridgeRun = upsertCabinetRunWithModules({ + depth: CABINET_WALL_DEPTH, + modulePatches: [ + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler', + openSide: endSide === 'right' ? 'left' : 'right', + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ], + name: 'Corner Wall Bridge', + parentId: cornerFillerModule.id as AnyNodeId, + position: bridgeRunLocalPosition, + rotation: bridgeRunLocalRotation, + runTier: 'wall', + sceneApi, + sourceRun, + }) + linkedRunIds.push(bridgeRun.runId) + const bridgeRunLiveMetadata = sceneApi.get(bridgeRun.runId)?.metadata ?? null + const bridgeRunMetadata = cabinetMetadataRecord(bridgeRunLiveMetadata) + sceneApi.update(bridgeRun.runId, { + metadata: { + ...(selectionRootId === bridgeRun.runId + ? bridgeRunMetadata + : withSelectionProxyMetadata(bridgeRunLiveMetadata, selectionRootId)), + cabinetCornerDerivedRun: { + role: 'bridge', + side: endSide, + turnSide, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + for (const moduleId of bridgeRun.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + const wallModuleCenters = chainModuleCenters([sourceRun.depth, connectedWidth]) + const cornerWallFillerCenter = + endSide === 'right' ? (wallModuleCenters[0] ?? 0) : (wallModuleCenters[1] ?? 0) + const cornerWallFillerWorldPosition = runLocalToPlan( + { position: wallRunPosition, rotation: legRotation }, + [cornerWallFillerCenter, 0, 0], + ) + const wallFillerRun = upsertCabinetRunWithModules({ + depth: CABINET_WALL_DEPTH, + modulePatches: [ + { + name: 'Corner Wall Filler', + width: sourceRun.depth, + moduleKind: 'corner-filler', + openSide: endSide === 'right' ? 'right' : 'left', + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ], + name: 'Corner Wall Run', + parentId: cornerFillerModule.id as AnyNodeId, + position: worldToCabinetLocalPosition( + cornerFillerModule, + sceneApi.nodes(), + cornerWallFillerWorldPosition, + ), + rotation: worldToCabinetLocalRotation(cornerFillerModule, sceneApi.nodes(), legRotation), + runTier: 'wall', + sceneApi, + sourceRun, + }) + linkedRunIds.push(wallFillerRun.runId) + const wallFillerRunLiveMetadata = + sceneApi.get(wallFillerRun.runId)?.metadata ?? null + const wallFillerRunMetadata = cabinetMetadataRecord(wallFillerRunLiveMetadata) + sceneApi.update(wallFillerRun.runId, { + metadata: { + ...(selectionRootId === wallFillerRun.runId + ? wallFillerRunMetadata + : withSelectionProxyMetadata(wallFillerRunLiveMetadata, selectionRootId)), + cabinetCornerDerivedRun: { + role: 'wall-leg', + side: endSide, + turnSide, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + for (const moduleId of wallFillerRun.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + } + + if (connectedBaseModule) { + const wallChildId = ensureWallCabinetAbove({ + module: connectedBaseModule, + run: sceneApi.get(baseLeg.runId) ?? baseLegRunNode, + sceneApi, + shelfCount: connectedShelfCount, + openSide: connectedBaseModule.openSide, + }) + if (wallChildId) { + setCabinetSelectionProxy(sceneApi, wallChildId, selectionRootId) + } + } + + sceneApi.update( + sourceModule.id as AnyNodeId, + { + metadata: { + ...cabinetMetadataRecord( + sceneApi.get(sourceModule.id as AnyNodeId)?.metadata ?? null, + ), + cabinetCornerSourceLink: { + side: endSide, + linkedRunIds, + }, + }, + } as Partial, + ) + + bumpCabinetRunLayoutRevision(sceneApi, sourceRun) + return baseLeg.moduleIds[1] ?? baseLeg.moduleIds[0] ?? null +} + +/** + * Nest a wall cabinet (or chimney hood) above a base module. Returns the new + * node id, or null when the module already carries one / isn't a base unit. + */ +export function addWallChildAbove({ + kind, + module, + run, + sceneApi, + openSide, +}: { + kind: 'cabinet' | 'hood' + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + openSide?: CabinetModuleNode['openSide'] +}): AnyNodeId | null { + if (resolveCabinetType(module, run) !== 'base') return null + if (wallChildOf(module, sceneApi.nodes())) return null + + const isHood = kind === 'hood' + const carcassHeight = isHood + ? Math.max(0.4, hoodCompartmentHeight('hood-pyramid')) + : CABINET_WALL_CARCASS_HEIGHT + const wall = CabinetModuleNodeSchema.parse({ + name: isHood ? 'Chimney' : 'Wall Cabinet', + parentId: module.id, + // Wall cabinet top aligns with the default tall cabinet top. + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight, + plinthHeight: 0, + toeKickDepth: 0, + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + stack: isHood ? [newCabinetCompartment('hood-pyramid')] : doorStack(1), + frontStyle: module.frontStyle, + frontOverlay: module.frontOverlay, + handleStyle: module.handleStyle, + handlePosition: module.handlePosition, + ...(openSide ? { openSide } : {}), + }) + sceneApi.upsert(wall as AnyNode, module.id as AnyNodeId) + sceneApi.markDirty(module.id as AnyNodeId) + return wall.id +} + +function ensureWallCabinetAbove({ + module, + run, + sceneApi, + shelfCount, + openSide, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + shelfCount: number + openSide?: CabinetModuleNode['openSide'] +}): AnyNodeId | null { + const existingWall = wallChildOf(module, sceneApi.nodes()) + if (existingWall) { + sceneApi.update( + existingWall.id as AnyNodeId, + { + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight: CABINET_WALL_CARCASS_HEIGHT, + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + frontStyle: module.frontStyle, + frontOverlay: module.frontOverlay, + handleStyle: module.handleStyle, + handlePosition: module.handlePosition, + stack: cloneWallCabinetStack(existingWall, shelfCount), + ...(openSide ? { openSide } : {}), + } as Partial, + ) + return existingWall.id as AnyNodeId + } + + const wallChildId = addWallChildAbove({ + kind: 'cabinet', + module, + run, + sceneApi, + openSide, + }) + if (!wallChildId) return null + + sceneApi.update(wallChildId, { + stack: doorStack(shelfCount), + } as Partial) + return wallChildId +} + +/** Convert a base module to a tall unit (deletes any nested wall cabinet). */ +export function switchCabinetToTall({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'base') return false + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) sceneApi.delete(wallChild.id as AnyNodeId) + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Tall Cabinet', + cabinetType: 'tall', + depth: CABINET_TALL_DEPTH, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, CABINET_TALL_DEPTH), + ], + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + toeKickDepth: 0.075, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: doorStack(3), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} + +/** Convert a tall module back to a base unit matching the run's dimensions. */ +export function switchCabinetToBase({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'tall') return false + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Base Cabinet', + cabinetType: 'base', + depth: run.depth, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, run.depth), + ], + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: doorStack(1), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx new file mode 100644 index 00000000..800e310c --- /dev/null +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -0,0 +1,555 @@ +'use client' + +import type { + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { createSceneApi, useScene } from '@pascal-app/core' +import { + ActionButton, + PanelSection, + PanelWrapper, + SegmentedControl, + SliderControl, + ToggleControl, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Plus, Trash } from 'lucide-react' +import { useCallback, useMemo } from 'react' +import { + addCabinetModuleSide, + backAlignZ, + bumpCabinetRunLayoutRevision, + cornerLinkedSourceModuleForRun, + runModuleBaseY, + syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, + wallChildOf, +} from './run-ops' +import { + backAnchoredModuleZ, + minCabinetCarcassHeightForStack, + reflowCabinetRunModules, + stackForCabinet, +} from './stack' + +export type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) +const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ + 'frontStyle', + 'frontOverlay', + 'handleStyle', + 'handlePosition', +]) +const RUN_DEPTH_PATCH_KEY = 'depth' + +const FRONT_STYLE_OPTIONS = [ + { value: 'slab', label: 'Slab' }, + { value: 'shaker', label: 'Shaker' }, + { value: 'raised-arch', label: 'Raised Arch' }, +] as const + +const FRONT_OVERLAY_OPTIONS = [ + { value: 'full', label: 'Overlay' }, + { value: 'inset', label: 'Inset' }, +] as const + +const HANDLE_STYLE_OPTIONS = [ + { value: 'bar', label: 'Bar' }, + { value: 'knob', label: 'Knob' }, + { value: 'cutout', label: 'Cutout' }, + { value: 'hole', label: 'Hole' }, + { value: 'none', label: 'None' }, +] as const + +const HANDLE_POSITION_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Center' }, +] as const + +function moduleSummary(module: CabinetModuleNodeType) { + if ((module.cabinetType ?? 'base') === 'tall') return 'Tall cabinet' + const stack = stackForCabinet(module) + if (stack.length === 0) return 'Empty' + if (stack.length === 1) return stack[0]!.type + return `${stack.length} compartments` +} + +export function bumpRunLayoutRevisionViaStore( + scene: ReturnType, + run: CabinetNodeType, +) { + bumpCabinetRunLayoutRevision(createSceneApi(useScene), run) + scene.markDirty(run.id as AnyNodeId) +} + +export function reflowRunModules({ + modules, + parentRun, + patch, + scene, + selected, +}: { + modules: CabinetModuleNodeType[] + parentRun: CabinetNodeType + patch: Partial + scene: ReturnType + selected: CabinetModuleNodeType +}) { + const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width) + if (reflowed.length === 0) return + + const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) + for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) { + const reflow = reflowById.get(module.id) + if (!reflow) continue + const isSelected = module.id === selected.id + const nextPatch: Partial = isSelected ? { ...patch } : {} + const nextPosition: CabinetModuleNodeType['position'] = [ + reflow.position[0], + isSelected && patch.position ? patch.position[1] : reflow.position[1], + isSelected && typeof patch.depth === 'number' + ? backAnchoredModuleZ(module.position[2], module.depth, patch.depth) + : reflow.position[2], + ] + + if (isSelected) { + const cabinetType = patch.cabinetType ?? module.cabinetType + if (cabinetType === 'base') { + nextPatch.depth = patch.depth ?? parentRun.depth + nextPatch.carcassHeight = patch.carcassHeight ?? parentRun.carcassHeight + nextPatch.plinthHeight = patch.plinthHeight ?? parentRun.plinthHeight + nextPatch.toeKickDepth = patch.toeKickDepth ?? parentRun.toeKickDepth + nextPatch.countertopThickness = patch.countertopThickness ?? 0 + nextPatch.countertopOverhang = patch.countertopOverhang ?? parentRun.countertopOverhang + } + } + + nextPatch.position = nextPosition + scene.updateNode(module.id as AnyNodeId, nextPatch) + + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id as AnyNodeId, { + position: [ + 0, + wallChild.position[1], + backAlignZ(nextPatch.depth ?? module.depth, wallChild.depth), + ], + width: reflow.width, + }) + scene.markDirty(module.id as AnyNodeId) + } + } + + bumpRunLayoutRevisionViaStore(scene, parentRun) +} + +export function CabinetRunPanel({ + node, + modules, + onClose, +}: { + node: CabinetNodeType + modules: CabinetModuleNodeType[] + onClose: () => void +}) { + const setSelection = useViewer((s) => s.setSelection) + const sortedModules = useMemo( + () => [...modules].sort((a, b) => a.position[0] - b.position[0]), + [modules], + ) + + const updateRun = useCallback( + (patch: Partial) => { + const scene = useScene.getState() + const sceneApi = createSceneApi(useScene) + const nextPatch = { ...patch } + if (typeof nextPatch.carcassHeight === 'number') { + const minModuleHeight = Math.max( + 0.4, + ...modules.map((module) => minCabinetCarcassHeightForStack(module)), + ) + nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight) + } + const nextNode = { ...node, ...nextPatch } + scene.updateNode(node.id, nextPatch) + + const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch + const shouldSyncHeight = 'carcassHeight' in nextPatch + const shouldSyncPosition = Object.keys(nextPatch).some((key) => + RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType), + ) + const shouldSyncModules = Object.keys(nextPatch).some((key) => + RUN_MODULE_SYNC_PATCH_KEYS.has(key as keyof CabinetNodeType), + ) + if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition && !shouldSyncModules) return + + const stylePatch: Partial = {} + if ('frontStyle' in nextPatch) stylePatch.frontStyle = nextNode.frontStyle + if ('frontOverlay' in nextPatch) stylePatch.frontOverlay = nextNode.frontOverlay + if ('handleStyle' in nextPatch) stylePatch.handleStyle = nextNode.handleStyle + if ('handlePosition' in nextPatch) stylePatch.handlePosition = nextNode.handlePosition + + for (const module of modules) { + const modulePatch: Partial = {} + if (shouldSyncDepth) { + modulePatch.depth = nextNode.depth + } + if (shouldSyncHeight) { + modulePatch.carcassHeight = Math.max( + nextNode.carcassHeight, + minCabinetCarcassHeightForStack(module), + ) + } + if (shouldSyncPosition) { + modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]] + } + if (shouldSyncModules) { + if ('frontStyle' in nextPatch) modulePatch.frontStyle = nextNode.frontStyle + if ('frontOverlay' in nextPatch) modulePatch.frontOverlay = nextNode.frontOverlay + if ('handleStyle' in nextPatch) modulePatch.handleStyle = nextNode.handleStyle + if ('handlePosition' in nextPatch) modulePatch.handlePosition = nextNode.handlePosition + } + scene.updateNode(module.id, modulePatch) + + if (shouldSyncModules) { + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id, { + frontStyle: nextNode.frontStyle, + frontOverlay: nextNode.frontOverlay, + handleStyle: nextNode.handleStyle, + handlePosition: nextNode.handlePosition, + }) + } + } + } + + const cornerSource = cornerLinkedSourceModuleForRun(nextNode, scene.nodes) + if (shouldSyncModules) { + syncCornerStyleGroupFromRun({ + run: nextNode, + patch: stylePatch, + sceneApi, + }) + } else if (cornerSource) { + syncCornerRunsFromSourceModule({ + module: cornerSource, + run: nextNode, + sceneApi, + }) + } + }, + [modules, node], + ) + + const addModule = useCallback( + (side: 'left' | 'right') => { + const id = addCabinetModuleSide({ + anchorModule: null, + run: node, + sceneApi: createSceneApi(useScene), + side, + }) + if (id) setSelection({ selectedIds: [id] }) + }, + [node, setSelection], + ) + + const deleteModule = useCallback( + (module: CabinetModuleNodeType) => { + useScene.getState().deleteNode(module.id as AnyNodeId) + // Deleting the last module cascades the empty run away too — only + // keep it selected/dirty if it survived. + if (useScene.getState().nodes[node.id as AnyNodeId]) { + useScene.getState().markDirty(node.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } else { + setSelection({ selectedIds: [] }) + } + }, + [node.id, setSelection], + ) + + return ( + + +
+ {sortedModules.map((module, index) => ( +
+ + +
+ ))} +
+
+
+ } + label="Add left" + onClick={() => addModule('left')} + /> + } + label="Add right" + onClick={() => addModule('right')} + /> +
+
+
+ + +
+ updateRun({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + minCabinetCarcassHeightForStack(module)))} + onChange={(value) => updateRun({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + updateRun({ showPlinth: checked })} + /> + {node.showPlinth && ( + updateRun({ plinthHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.plinthHeight} + /> + )} + updateRun({ withCountertop: checked })} + /> + {node.withCountertop && ( + <> + updateRun({ countertopThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.countertopThickness} + /> + updateRun({ countertopOverhang: value })} + precision={2} + step={0.005} + unit="m" + value={node.countertopOverhang} + /> + + )} +
+
+ + +
+ {node.withCountertop && node.barLedge?.edge !== 'back' && ( + updateRun({ countertopBackOverhang: value })} + precision={2} + step={0.05} + unit="m" + value={node.countertopBackOverhang} + /> + )} + updateRun({ withFinishedBack: checked })} + /> + {node.withCountertop && ( + updateRun({ withWaterfall: checked })} + /> + )} + + updateRun({ + barLedge: checked ? { edge: 'back', height: 1.06, depth: 0.35 } : undefined, + }) + } + /> + {node.barLedge && ( + <> + + updateRun({ + barLedge: { ...node.barLedge!, edge: value as 'back' | 'left' | 'right' }, + }) + } + options={[ + { value: 'back', label: 'Back' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + ]} + value={node.barLedge.edge} + /> + updateRun({ barLedge: { ...node.barLedge!, height: value } })} + precision={2} + step={0.01} + unit="m" + value={node.barLedge.height} + /> + updateRun({ barLedge: { ...node.barLedge!, depth: value } })} + precision={2} + step={0.01} + unit="m" + value={node.barLedge.depth} + /> + + )} +
+
+ + +
+
+
+ Style +
+ + updateRun({ frontStyle: value as CabinetNodeType['frontStyle'] }) + } + options={FRONT_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontStyle ?? 'slab'} + /> +
+
+
+ Mounting +
+ + updateRun({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) + } + options={FRONT_OVERLAY_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontOverlay ?? 'full'} + /> +
+
+
+ + +
+
+
+ Style +
+ + updateRun({ handleStyle: value as CabinetNodeType['handleStyle'] }) + } + options={HANDLE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handleStyle} + /> +
+ {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( +
+
+ Position +
+ + updateRun({ handlePosition: value as CabinetNodeType['handlePosition'] }) + } + options={HANDLE_POSITION_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handlePosition ?? 'auto'} + /> +
+ )} +
+
+
+ ) +} diff --git a/packages/nodes/src/cabinet/scene-action.ts b/packages/nodes/src/cabinet/scene-action.ts new file mode 100644 index 00000000..c4f8e2ba --- /dev/null +++ b/packages/nodes/src/cabinet/scene-action.ts @@ -0,0 +1,180 @@ +import type { AnyNode, AnyNodeId, SceneActionCapability, SceneApi } from '@pascal-app/core' +import { getEffectiveNode, useLiveNodeOverrides } from '@pascal-app/core' +import { + type CabinetCompartment, + compartmentCooktopActiveBurners, + compartmentCooktopElementCount, + compartmentCooktopKnobProgress, + patchCompartment, +} from './stack' + +export type CabinetCooktopKnobTarget = { + type: 'gas' + compartmentIndex: number + burnerIndex: number +} + +const KNOB_TURN_DURATION_MS = 180 +let knobAnimationId = 0 +const activeKnobAnimations = new Map>() + +function knobTargetFromUserData( + userData: Record, +): CabinetCooktopKnobTarget | null { + const target = userData.cabinetCooktopKnob as CabinetCooktopKnobTarget | undefined + if ( + target?.type === 'gas' && + Number.isInteger(target.compartmentIndex) && + Number.isInteger(target.burnerIndex) && + target.compartmentIndex >= 0 && + target.burnerIndex >= 0 + ) { + return target + } + return null +} + +function gasCompartmentAt( + node: AnyNode, + compartmentIndex: number, +): { stack: CabinetCompartment[]; compartment: CabinetCompartment } | null { + const stack = (node as { stack?: unknown }).stack + if (!Array.isArray(stack)) return null + const compartment = stack[compartmentIndex] as CabinetCompartment | undefined + if (!compartment || typeof compartment !== 'object') return null + if (compartment.type !== 'cooktop-gas') return null + return { stack: stack as CabinetCompartment[], compartment } +} + +function withCooktopBurnerProgress( + node: AnyNode, + target: CabinetCooktopKnobTarget, + progress: number, + nextActiveBurners: readonly number[], +): Partial | null { + const resolved = gasCompartmentAt(node, target.compartmentIndex) + if (!resolved) return null + const { stack, compartment } = resolved + + const nextProgress = compartmentCooktopKnobProgress( + patchCompartment(compartment, { cooktopActiveBurners: [...nextActiveBurners] }), + 'cooktop-gas', + ) + nextProgress[target.burnerIndex] = Math.max(0, Math.min(1, progress)) + + return { + stack: stack.map((entry, index) => + index === target.compartmentIndex + ? patchCompartment(entry, { + cooktopBurnersOn: nextActiveBurners.length > 0, + cooktopActiveBurners: [...nextActiveBurners], + cooktopKnobProgress: nextProgress, + }) + : entry, + ), + } as Partial +} + +function activeBurnersWithTarget( + compartment: CabinetCompartment, + target: CabinetCooktopKnobTarget, + active: boolean, +): number[] { + const current = compartmentCooktopActiveBurners(compartment, 'cooktop-gas') + const withoutTarget = current.filter((index) => index !== target.burnerIndex) + return active ? [...withoutTarget, target.burnerIndex].sort((a, b) => a - b) : withoutTarget +} + +function startKnobAnimation(nodeId: AnyNodeId): number { + const key = nodeId as string + const id = ++knobAnimationId + const active = activeKnobAnimations.get(key) ?? new Set() + active.add(id) + activeKnobAnimations.set(key, active) + return id +} + +function finishKnobAnimation(nodeId: AnyNodeId, id: number): boolean { + const key = nodeId as string + const active = activeKnobAnimations.get(key) + if (!active) return true + active.delete(id) + if (active.size > 0) return false + activeKnobAnimations.delete(key) + return true +} + +/** + * Toggle one gas burner. The knob eases over ~180ms by publishing transient + * stack patches through `useLiveNodeOverrides` (+ dirty marks so the geometry + * rebuilds each frame), then commits the final state once — a single undo step. + */ +function toggleCabinetCooktopKnob( + node: AnyNode, + target: CabinetCooktopKnobTarget, + sceneApi: SceneApi, +): boolean { + const resolved = gasCompartmentAt(node, target.compartmentIndex) + if (!resolved) return false + const { compartment } = resolved + + const count = compartmentCooktopElementCount(compartment, 'cooktop-gas') + if (target.burnerIndex >= count) return false + + const activeBurners = compartmentCooktopActiveBurners(compartment, 'cooktop-gas') + const wasActive = activeBurners.includes(target.burnerIndex) + const from = compartmentCooktopKnobProgress(compartment, 'cooktop-gas')[target.burnerIndex] ?? 0 + const to = wasActive ? 0 : 1 + const nodeId = node.id as AnyNodeId + const startedAt = performance.now() + const animationId = startKnobAnimation(nodeId) + + const buildPatch = (progress: number): Partial | null => { + const latest = sceneApi.get(nodeId) + const effectiveNode = getEffectiveNode(latest ?? node) + const latestCompartment = gasCompartmentAt(effectiveNode, target.compartmentIndex)?.compartment + if (!latestCompartment) return null + return withCooktopBurnerProgress( + effectiveNode, + target, + progress, + activeBurnersWithTarget(latestCompartment, target, !wasActive), + ) + } + + const tick = (time: number) => { + const elapsed = Math.max(0, time - startedAt) + const t = Math.min(1, elapsed / KNOB_TURN_DURATION_MS) + const eased = 1 - (1 - t) ** 3 + const progress = from + (to - from) * eased + const patch = buildPatch(progress) + if (patch) { + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + sceneApi.markDirty(nodeId) + } + + if (t < 1) { + requestAnimationFrame(tick) + return + } + + const finalPatch = buildPatch(to) + const shouldClearOverride = finishKnobAnimation(nodeId, animationId) + if (finalPatch) { + sceneApi.update(nodeId, finalPatch) + } else { + sceneApi.markDirty(nodeId) + } + if (shouldClearOverride) useLiveNodeOverrides.getState().clear(nodeId) + } + requestAnimationFrame(tick) + return true +} + +// Exposed with the default `unknown` target: `activate` only ever receives +// what this capability's own `resolveTarget` returned, so the narrow is safe. +export const cabinetSceneAction: SceneActionCapability = { + resolveTarget: (object) => knobTargetFromUserData(object.userData), + activate: (node, target, sceneApi) => + toggleCabinetCooktopKnob(node, target as CabinetCooktopKnobTarget, sceneApi), +} diff --git a/packages/nodes/src/cabinet/schema.ts b/packages/nodes/src/cabinet/schema.ts new file mode 100644 index 00000000..30cd3bcf --- /dev/null +++ b/packages/nodes/src/cabinet/schema.ts @@ -0,0 +1 @@ +export { CabinetModuleNode, CabinetNode } from '@pascal-app/core' diff --git a/packages/nodes/src/cabinet/slots.ts b/packages/nodes/src/cabinet/slots.ts new file mode 100644 index 00000000..1e6e2630 --- /dev/null +++ b/packages/nodes/src/cabinet/slots.ts @@ -0,0 +1,37 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type CabinetSlotId = + | 'front' + | 'carcass' + | 'countertop' + | 'plinth' + | 'hardware' + | 'glass' + | 'appliance' + | 'applianceInterior' + +const FRONT_DEFAULT = 'library:preset-softwhite' +const CARCASS_DEFAULT = 'library:preset-softwhite' +const COUNTERTOP_DEFAULT = 'library:wood-finewood27' +const PLINTH_DEFAULT = 'library:preset-softwhite' +const HARDWARE_DEFAULT = 'library:metal-chrome' +const GLASS_DEFAULT = 'library:preset-glass' +const APPLIANCE_DEFAULT = 'library:metal-steel' +const APPLIANCE_INTERIOR_DEFAULT = 'library:preset-charcoal' + +export function cabinetSlots(): SlotDeclaration[] { + return [ + { slotId: 'front', label: 'Front', default: FRONT_DEFAULT }, + { slotId: 'carcass', label: 'Carcass', default: CARCASS_DEFAULT }, + { slotId: 'countertop', label: 'Countertop', default: COUNTERTOP_DEFAULT }, + { slotId: 'plinth', label: 'Plinth', default: PLINTH_DEFAULT }, + { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + { slotId: 'appliance', label: 'Appliance', default: APPLIANCE_DEFAULT }, + { + slotId: 'applianceInterior', + label: 'Appliance Interior', + default: APPLIANCE_INTERIOR_DEFAULT, + }, + ] +} diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts new file mode 100644 index 00000000..1ebe79a5 --- /dev/null +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -0,0 +1,163 @@ +import type { + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { resolveCabinetType } from './run-ops' +import { + type CabinetCompartment, + type CabinetCooktopCompartmentType, + type CabinetFridgeCompartmentType, + type CabinetHoodCompartmentType, + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_WIDTH, + FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + hoodCompartmentHeight, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + MICROWAVE_STANDARD_WIDTH, + PULL_OUT_PANTRY_STANDARD_WIDTH, + replaceCabinetCompartmentStack, + SINK_STANDARD_WIDTH, + sinkCabinetStack, + stackForCabinet, + TALL_CABINET_CARCASS_HEIGHT, +} from './stack' + +const BASE_MODULE_WIDTH = 0.6 +const BASE_CARCASS_HEIGHT = 0.72 +const WALL_CARCASS_HEIGHT = 0.72 +const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT + +export function resolveCompartmentTransition({ + node, + parentRun, + index, + next, +}: { + node: CabinetNodeType | CabinetModuleNodeType + parentRun: CabinetNodeType | undefined + index: number + next: CabinetCompartment +}): { stack: CabinetCompartment[]; modulePatch: Partial } { + const stack = stackForCabinet(node) + const current = stack[index] + const leavingFridge = current ? isFridgeCompartmentType(current.type) : false + const enteringFridge = isFridgeCompartmentType(next.type) + const enteringCooktop = isCooktopCompartmentType(next.type) + const enteringSink = next.type === 'sink' + const leavingPullOutPantry = current?.type === 'pull-out-pantry' + const enteringPullOutPantry = next.type === 'pull-out-pantry' + const leavingHood = current ? isHoodCompartmentType(current.type) : false + const enteringHood = isHoodCompartmentType(next.type) + const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 + const hoodModulePatch: Partial = enteringHood + ? { + carcassHeight: Math.max( + 0.4, + hoodCompartmentHeight(next.type as CabinetHoodCompartmentType), + ), + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + } + : leavingHood + ? { carcassHeight: WALL_CARCASS_HEIGHT } + : {} + const tallApplianceModulePatch: Partial = + enteringFridge || enteringPullOutPantry + ? { + cabinetType: 'tall', + width: enteringPullOutPantry + ? PULL_OUT_PANTRY_STANDARD_WIDTH + : next.type === 'fridge-double' + ? FRIDGE_WIDE_WIDTH + : FRIDGE_COLUMN_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: TALL_CARCASS_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + const standardModulePatch: Partial = + (leavingFridge || leavingPullOutPantry) && !enteringFridge && !enteringPullOutPantry + ? { + cabinetType: 'base', + width: + next.type === 'microwave' + ? MICROWAVE_STANDARD_WIDTH + : next.type === 'dishwasher' + ? DISHWASHER_STANDARD_WIDTH + : enteringCooktop + ? COOKTOP_STANDARD_WIDTH + : BASE_MODULE_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + const dishwasherModulePatch: Partial = enteringSingleDishwasher + ? { + cabinetType: 'base', + width: DISHWASHER_STANDARD_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + + return { + stack: enteringFridge + ? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType) + : enteringCooktop && stack.length === 1 + ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) + : enteringSink && stack.length === 1 + ? sinkCabinetStack() + : enteringPullOutPantry + ? [{ ...next, height: TALL_CARCASS_HEIGHT }] + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), + modulePatch: { + ...tallApplianceModulePatch, + ...standardModulePatch, + ...dishwasherModulePatch, + ...hoodModulePatch, + ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), + ...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}), + ...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}), + ...(enteringSink ? { width: SINK_STANDARD_WIDTH } : {}), + ...(enteringPullOutPantry ? { width: PULL_OUT_PANTRY_STANDARD_WIDTH } : {}), + ...(isFridgeCompartmentType(next.type) && next.type !== 'fridge-double' + ? { width: FRIDGE_COLUMN_WIDTH } + : {}), + ...(next.type === 'fridge-double' ? { width: FRIDGE_WIDE_WIDTH } : {}), + }, + } +} diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts new file mode 100644 index 00000000..978a78d2 --- /dev/null +++ b/packages/nodes/src/cabinet/stack.ts @@ -0,0 +1,545 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' + +type CabinetStackOwner = CabinetNode | CabinetModuleNode + +export const CABINET_COMPARTMENT_TYPES = [ + 'shelf', + 'drawer', + 'door', + 'oven', + 'microwave', + 'dishwasher', + 'sink', + 'cooktop-gas', + 'cooktop-induction', + 'pull-out-pantry', + 'fridge-single', + 'fridge-double', + 'fridge-top-freezer', + 'fridge-bottom-freezer', + 'hood-pyramid', + 'hood-curved-glass', +] as const +export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] +export type CabinetFridgeCompartmentType = Extract< + CabinetCompartmentType, + 'fridge-single' | 'fridge-double' | 'fridge-top-freezer' | 'fridge-bottom-freezer' +> +export type CabinetHoodCompartmentType = Extract< + CabinetCompartmentType, + 'hood-pyramid' | 'hood-curved-glass' +> +export type CabinetCooktopCompartmentType = Extract< + CabinetCompartmentType, + 'cooktop-gas' | 'cooktop-induction' +> +export const SINK_LAYOUTS = ['single', 'double', 'double-offset'] as const +export type SinkLayout = (typeof SINK_LAYOUTS)[number] +export const COOKTOP_LAYOUTS = [ + 'gas-2burner', + 'gas-4burner', + 'gas-5burner-wok', + 'gas-6burner', + 'induction-2zone', + 'induction-4zone', +] as const +export type CooktopLayout = (typeof COOKTOP_LAYOUTS)[number] +export const PULL_OUT_PANTRY_RACK_STYLES = ['wire', 'tray', 'glass'] as const +export type PullOutPantryRackStyle = (typeof PULL_OUT_PANTRY_RACK_STYLES)[number] + +export const CABINET_DOOR_TYPES = ['single-left', 'single-right', 'double', 'glass'] as const +export type CabinetDoorType = (typeof CABINET_DOOR_TYPES)[number] + +export type CabinetCompartment = NonNullable[number] + +let compartmentIdCounter = 0 +const DEFAULT_SHELF_COUNT = 2 +const DEFAULT_MIN_COMPARTMENT_HEIGHT = 0.1 + +export const OVEN_DEFAULT_HEIGHT = 0.595 +export const MICROWAVE_STANDARD_WIDTH = 0.61 +export const MICROWAVE_STANDARD_HEIGHT = 0.39 +export const MICROWAVE_DEFAULT_HEIGHT = MICROWAVE_STANDARD_HEIGHT +export const DISHWASHER_STANDARD_WIDTH = 0.6 +export const DISHWASHER_STANDARD_HEIGHT = 0.72 +export const COOKTOP_STANDARD_WIDTH = 0.75 +export const SINK_STANDARD_WIDTH = 0.8 +export const SINK_DEFAULT_LAYOUT: SinkLayout = 'single' +export const COOKTOP_DEFAULT_HEIGHT = 0.08 +export const COOKTOP_DEFAULT_GAS_LAYOUT: CooktopLayout = 'gas-5burner-wok' +export const COOKTOP_DEFAULT_INDUCTION_LAYOUT: CooktopLayout = 'induction-4zone' +export const PULL_OUT_PANTRY_STANDARD_WIDTH = 0.3 +export const PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT = 5 +export const PULL_OUT_PANTRY_DEFAULT_RACK_STYLE: PullOutPantryRackStyle = 'wire' +export const FRIDGE_COLUMN_WIDTH = 0.76 +export const FRIDGE_WIDE_WIDTH = 0.91 +export const FRIDGE_STANDARD_DEPTH = 0.76 +export const FRIDGE_COLUMN_HEIGHT = 1.78 +export const TALL_CABINET_CARCASS_HEIGHT = 2.07 +export const HOOD_CANOPY_DEPTH = 0.5 +export const HOOD_PYRAMID_CANOPY_HEIGHT = 0.38 +export const HOOD_CURVED_BODY_HEIGHT = 0.16 +export const HOOD_CURVED_TOTAL_HEIGHT = 0.44 +export const HOOD_DUCT_SIZE = 0.28 +export const DEFAULT_CEILING_HEIGHT = 2.5 + +export function hoodCompartmentHeight(type: CabinetHoodCompartmentType): number { + if (type === 'hood-pyramid') return HOOD_PYRAMID_CANOPY_HEIGHT + return HOOD_CURVED_TOTAL_HEIGHT +} + +export function isFridgeCompartmentType( + type: CabinetCompartmentType, +): type is CabinetFridgeCompartmentType { + return ( + type === 'fridge-single' || + type === 'fridge-double' || + type === 'fridge-top-freezer' || + type === 'fridge-bottom-freezer' + ) +} + +export function isHoodCompartmentType( + type: CabinetCompartmentType, +): type is CabinetHoodCompartmentType { + return type === 'hood-pyramid' || type === 'hood-curved-glass' +} + +export function isCooktopCompartmentType( + type: CabinetCompartmentType, +): type is CabinetCooktopCompartmentType { + return type === 'cooktop-gas' || type === 'cooktop-induction' +} + +function makeId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `cc_${crypto.randomUUID().slice(0, 8)}` + } + return `cc_${(compartmentIdCounter++).toString(36)}` +} + +export function defaultDoorType(width: number): CabinetDoorType { + return width > 0.5 ? 'double' : 'single-left' +} + +export function newCabinetCompartment( + type: T, +): Extract { + const build = (): CabinetCompartment => { + if (type === 'drawer') return { id: makeId(), type: 'drawer', drawerCount: 1 } + if (type === 'shelf') return { id: makeId(), type: 'shelf', shelfCount: 1 } + if (type === 'oven') return { id: makeId(), type: 'oven', height: OVEN_DEFAULT_HEIGHT } + if (type === 'microwave') + return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } + if (type === 'dishwasher') + return { id: makeId(), type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT } + if (type === 'sink') return { id: makeId(), type: 'sink', sinkLayout: SINK_DEFAULT_LAYOUT } + if (type === 'cooktop-gas') + return { + id: makeId(), + type: 'cooktop-gas', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopLayout: COOKTOP_DEFAULT_GAS_LAYOUT as Extract, + cooktopBurnersOn: false, + cooktopActiveBurners: [], + cooktopKnobProgress: [], + cooktopShowGrate: true, + } + if (type === 'cooktop-induction') + return { + id: makeId(), + type: 'cooktop-induction', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopLayout: COOKTOP_DEFAULT_INDUCTION_LAYOUT as Extract< + CooktopLayout, + `induction-${string}` + >, + cooktopBurnersOn: false, + cooktopActiveBurners: [], + cooktopKnobProgress: [], + cooktopShowGrate: true, + } + if (type === 'pull-out-pantry') + return { + id: makeId(), + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + pantryRackStyle: PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, + } + if (isFridgeCompartmentType(type as CabinetCompartmentType)) + return { + id: makeId(), + type: type as CabinetFridgeCompartmentType, + height: FRIDGE_COLUMN_HEIGHT, + } + if (isHoodCompartmentType(type as CabinetCompartmentType)) { + const hood = type as CabinetHoodCompartmentType + return { id: makeId(), type: hood, height: hoodCompartmentHeight(hood) } + } + return { id: makeId(), type: 'door' } + } + return build() as Extract +} + +export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] { + return [newCabinetCompartment(type), { ...newCabinetCompartment('drawer'), drawerCount: 1 }] +} + +export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): CabinetCompartment[] { + return [{ ...newCabinetCompartment('drawer'), drawerCount: 2 }, newCabinetCompartment(type)] +} + +export function sinkCabinetStack(): CabinetCompartment[] { + return [{ ...newCabinetCompartment('door'), doorType: 'double' }, newCabinetCompartment('sink')] +} + +/** + * Read an optional field off the compartment union without narrowing. The + * `compartment*` accessors below are deliberately defensive — they accept any + * compartment (saved scenes may carry stale fields from before the + * discriminated union) and validate what they find. + */ +function loose(compartment: CabinetCompartment, key: string): T | undefined { + return (compartment as Record)[key] as T | undefined +} + +/** Union of every field any compartment variant can carry. */ +type AnyCompartmentFields = Partial<{ + type: CabinetCompartmentType + height: number + doorType: CabinetDoorType + drawerCount: number + shelfCount: number + pantryRackStyle: PullOutPantryRackStyle + cooktopLayout: CooktopLayout + sinkLayout: SinkLayout + cooktopBurnersOn: boolean + cooktopShowGrate: boolean + cooktopActiveBurners: number[] + cooktopKnobProgress: number[] +}> + +/** + * Spread-with-override for the compartment union. Callers patch fields that + * are valid for the compartment's actual variant (the UI only offers + * variant-appropriate controls); the cast is contained here so every call + * site stays clean under the discriminated union. + */ +export function patchCompartment( + compartment: CabinetCompartment, + patch: AnyCompartmentFields, +): CabinetCompartment { + return { ...compartment, ...patch } as CabinetCompartment +} + +export function defaultCabinetStack(node: Pick): CabinetCompartment[] { + return [ + { + id: makeId(), + type: 'door', + doorType: defaultDoorType(node.width), + shelfCount: 1, + }, + ] +} + +export function stackForCabinet( + node: Pick, +): CabinetCompartment[] { + if (Array.isArray(node.stack) && node.stack.length > 0) return node.stack + return defaultCabinetStack(node) +} + +export function compartmentDrawerCount(compartment: CabinetCompartment): number { + const drawerCount = loose(compartment, 'drawerCount') + return typeof drawerCount === 'number' && drawerCount > 0 + ? Math.max(1, Math.min(6, Math.floor(drawerCount))) + : 2 +} + +export function compartmentShelfCount(compartment: CabinetCompartment): number { + const shelfCount = loose(compartment, 'shelfCount') + return typeof shelfCount === 'number' + ? Math.max(0, Math.min(8, Math.floor(shelfCount))) + : DEFAULT_SHELF_COUNT +} + +export function compartmentPullOutPantryRackStyle( + compartment: CabinetCompartment, +): PullOutPantryRackStyle { + const style = loose(compartment, 'pantryRackStyle') + return style && PULL_OUT_PANTRY_RACK_STYLES.includes(style) + ? style + : PULL_OUT_PANTRY_DEFAULT_RACK_STYLE +} + +export function compartmentCooktopLayout( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): CooktopLayout { + const layout = loose(compartment, 'cooktopLayout') as CooktopLayout + const allowedPrefix = type === 'cooktop-gas' ? 'gas-' : 'induction-' + return COOKTOP_LAYOUTS.includes(layout) && layout.startsWith(allowedPrefix) + ? layout + : type === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT +} + +export function cooktopLayoutElementCount(layout: CooktopLayout): number { + switch (layout) { + case 'gas-2burner': + case 'induction-2zone': + return 2 + case 'gas-6burner': + return 6 + case 'gas-5burner-wok': + return 5 + default: + return 4 + } +} + +export function compartmentCooktopElementCount( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number { + return cooktopLayoutElementCount(compartmentCooktopLayout(compartment, type)) +} + +export function compartmentCooktopBurnersOn(compartment: CabinetCompartment): boolean { + const activeBurners = loose(compartment, 'cooktopActiveBurners') + if (Array.isArray(activeBurners)) { + return activeBurners.length > 0 + } + return loose(compartment, 'cooktopBurnersOn') === true +} + +export function compartmentCooktopActiveBurners( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number[] { + const count = compartmentCooktopElementCount(compartment, type) + const activeBurners = loose(compartment, 'cooktopActiveBurners') + if (Array.isArray(activeBurners)) { + return [ + ...new Set( + activeBurners.filter((index) => Number.isInteger(index) && index >= 0 && index < count), + ), + ].sort((a, b) => a - b) + } + return loose(compartment, 'cooktopBurnersOn') === true + ? Array.from({ length: count }, (_, index) => index) + : [] +} + +export function compartmentCooktopKnobProgress( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number[] { + const count = compartmentCooktopElementCount(compartment, type) + const active = new Set(compartmentCooktopActiveBurners(compartment, type)) + const knobProgress = loose(compartment, 'cooktopKnobProgress') + return Array.from({ length: count }, (_, index) => { + const value = knobProgress?.[index] + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(1, value)) + : active.has(index) + ? 1 + : 0 + }) +} + +export function compartmentCooktopShowGrate(compartment: CabinetCompartment): boolean { + return loose(compartment, 'cooktopShowGrate') !== false +} + +export function compartmentSinkLayout(compartment: CabinetCompartment): SinkLayout { + const layout = loose(compartment, 'sinkLayout') + return layout && SINK_LAYOUTS.includes(layout) ? layout : SINK_DEFAULT_LAYOUT +} + +export function compartmentDoorType( + compartment: CabinetCompartment, + width: number, +): CabinetDoorType { + return loose(compartment, 'doorType') ?? defaultDoorType(width) +} + +function explicitCompartmentHeight(compartment: CabinetCompartment): number | null { + // Cooktops and sinks live in/on the countertop plane — zero stack height. + if (isCooktopCompartmentType(compartment.type) || compartment.type === 'sink') return 0 + return typeof compartment.height === 'number' && compartment.height > 0 + ? compartment.height + : null +} + +function lockedApplianceHeight(compartment: CabinetCompartment): number | null { + if ( + compartment.type !== 'oven' && + compartment.type !== 'microwave' && + compartment.type !== 'dishwasher' && + compartment.type !== 'sink' && + !isCooktopCompartmentType(compartment.type) && + compartment.type !== 'pull-out-pantry' && + !isFridgeCompartmentType(compartment.type) && + !isHoodCompartmentType(compartment.type) + ) + return null + return explicitCompartmentHeight(compartment) +} + +export function minCabinetCarcassHeightForStack( + node: Pick, + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): number { + const stack = stackForCabinet(node) + return stack.reduce( + (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? minHeight), + 0, + ) +} + +export function replaceCabinetCompartmentStack( + node: Pick, + index: number, + next: CabinetCompartment, + fillerType: Extract = 'drawer', + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): CabinetCompartment[] { + const stack = stackForCabinet(node) + if (index < 0 || index >= stack.length) return stack + + const replaced = stack.map((compartment, compartmentIndex) => + compartmentIndex === index ? next : compartment, + ) + if (lockedApplianceHeight(next) == null) return replaced + if (isHoodCompartmentType(next.type)) return replaced + if (next.type === 'dishwasher') return replaced + if (next.type === 'pull-out-pantry') return replaced + + const hasFlexibleSibling = replaced.some( + (compartment, compartmentIndex) => + compartmentIndex !== index && lockedApplianceHeight(compartment) == null, + ) + if (hasFlexibleSibling) return replaced + + const lockedHeight = replaced.reduce( + (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? 0), + 0, + ) + if (node.carcassHeight - lockedHeight < minHeight) return replaced + + const filler = newCabinetCompartment(fillerType) + if (isFridgeCompartmentType(next.type)) { + return [...replaced.slice(0, index + 1), filler, ...replaced.slice(index + 1)] + } + return [...replaced.slice(0, index), filler, ...replaced.slice(index)] +} + +export function normalizeCabinetStack( + node: Pick, +): Array<{ + compartment: CabinetCompartment + index: number + height: number + y0: number + y1: number +}> { + const stack = stackForCabinet(node) + if (stack.length === 0) return [] + const fixed = stack.map(explicitCompartmentHeight) + const fixedSum = fixed.reduce((sum, height) => sum + (height ?? 0), 0) + const freeCount = fixed.filter((height) => height == null).length + const remainder = Math.max(0, node.carcassHeight - fixedSum) + const freeHeight = freeCount > 0 ? remainder / freeCount : 0 + let y0 = 0 + return stack.map((compartment, index) => { + const height = fixed[index] ?? freeHeight + const y1 = y0 + height + const row = { compartment, index, height, y0, y1 } + y0 = y1 + return row + }) +} + +export function resizeCabinetCompartmentStack( + node: Pick, + index: number, + targetHeight: number, + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): CabinetCompartment[] { + const stack = stackForCabinet(node) + if (stack.length === 0 || index < 0 || index >= stack.length) return stack + if (stack.length === 1) { + const compartment = stack[0]! + return [ + { + ...compartment, + height: + lockedApplianceHeight(compartment) != null + ? Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) + : node.carcassHeight, + }, + ] + } + + const normalized = normalizeCabinetStack({ ...node, stack }) + const otherRows = normalized.filter((row) => row.index !== index) + const lockedOtherRows = otherRows.filter((row) => lockedApplianceHeight(row.compartment) != null) + const flexibleOtherRows = otherRows.filter( + (row) => lockedApplianceHeight(row.compartment) == null, + ) + const lockedOtherHeight = lockedOtherRows.reduce( + (sum, row) => sum + (lockedApplianceHeight(row.compartment) ?? 0), + 0, + ) + const availableForEditedAndFlexibleRows = Math.max( + minHeight, + node.carcassHeight - lockedOtherHeight, + ) + const maxTargetHeight = Math.max( + minHeight, + availableForEditedAndFlexibleRows - flexibleOtherRows.length * minHeight, + ) + const resizedHeight = Math.min(Math.max(targetHeight, minHeight), maxTargetHeight) + const remainingFreeHeight = Math.max( + minHeight * flexibleOtherRows.length, + availableForEditedAndFlexibleRows - resizedHeight, + ) + const distributableHeight = Math.max( + 0, + remainingFreeHeight - flexibleOtherRows.length * minHeight, + ) + const weights = flexibleOtherRows.map((row) => Math.max(0, row.height - minHeight)) + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) + + const otherHeights = new Map() + let assignedOtherHeight = 0 + lockedOtherRows.forEach((row) => { + otherHeights.set(row.index, lockedApplianceHeight(row.compartment) ?? minHeight) + }) + flexibleOtherRows.forEach((row, rowIndex) => { + const isLastOther = rowIndex === flexibleOtherRows.length - 1 + const height = isLastOther + ? Math.max(minHeight, remainingFreeHeight - assignedOtherHeight) + : minHeight + + (totalWeight > 0 + ? distributableHeight * (weights[rowIndex]! / totalWeight) + : distributableHeight / Math.max(1, flexibleOtherRows.length)) + assignedOtherHeight += height + otherHeights.set(row.index, height) + }) + + return stack.map((compartment, compartmentIndex) => ({ + ...compartment, + height: compartmentIndex === index ? resizedHeight : otherHeights.get(compartmentIndex), + })) +} + +export { reflowRunModules as reflowCabinetRunModules } from './run-layout' + +export function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { + return currentZ + (nextDepth - currentDepth) / 2 +} diff --git a/packages/nodes/src/cabinet/system.tsx b/packages/nodes/src/cabinet/system.tsx new file mode 100644 index 00000000..83d82cc9 --- /dev/null +++ b/packages/nodes/src/cabinet/system.tsx @@ -0,0 +1,129 @@ +'use client' + +import { type AnyNodeId, getEffectiveNode, type SceneApi, sceneRegistry } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useRef } from 'react' +import type { BufferAttribute, Material, Mesh, Object3D } from 'three' +import { poseCabinetMovingParts } from './animation' +import { type CooktopFlameSeed, updateCooktopFlameTube } from './cooktop-flame' +import { + bumpCabinetRunsNear, + type CabinetRunFootprint, + cabinetRunFootprint, + cabinetRunNeighborSignature, +} from './definition' + +function materialWithOpacity(material: Material | Material[] | undefined): Material | null { + if (!material) return null + return Array.isArray(material) ? (material[0] ?? null) : material +} + +function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes: boolean) { + root.traverse((obj) => { + const jet = obj.userData.cabinetFlameJet as + | { seed: CooktopFlameSeed; burnerR: number } + | undefined + if (jet) { + if (!updateTubes) return + const mesh = obj as Mesh + const position = mesh.geometry.getAttribute('position') as BufferAttribute + updateCooktopFlameTube(position.array as Float32Array, elapsedTime, jet.seed, jet.burnerR) + position.needsUpdate = true + return + } + + const pulse = obj.userData.cabinetFlamePulse as + | { phase: number; amplitude: number; base: number } + | undefined + if (pulse) { + obj.scale.setScalar(pulse.base + pulse.amplitude * Math.sin(elapsedTime * 2 + pulse.phase)) + } + + const materialPulse = obj.userData.cabinetFlameMaterialPulse as + | { phase: number; amplitude: number; base: number } + | undefined + if (!materialPulse) return + const material = materialWithOpacity((obj as { material?: Material | Material[] }).material) + if (!material || !('opacity' in material)) return + material.opacity = + materialPulse.base + + materialPulse.amplitude * Math.sin(elapsedTime * 2.3 + materialPulse.phase) + }) +} + +/** + * Poses door hinges / drawer slides stamped with `userData.cabinetPose` + * directly, so `operationState` changes never trigger a geometry rebuild + * (it is deliberately absent from the cabinet `geometryKey`s). Builders + * still bake the current pose at build time; this system only acts when + * the value drifts from what the mounted group last showed. + */ +const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { + const appliedRef = useRef(new Map()) + const lastTubeUpdateRef = useRef(0) + // Last-seen neighbor-affecting signature per run. A run whose countertop + // overhang trims against sibling runs never sees a neighbor's move in its + // own geometryKey, so when a run's signature changes here we bump the + // adjacency revision on nearby siblings to re-key them. + const neighborSignaturesRef = useRef( + new Map(), + ) + + useFrame(({ clock }) => { + const applied = appliedRef.current + const nodes = sceneApi.nodes() + + const signatures = neighborSignaturesRef.current + const changedFootprints: CabinetRunFootprint[] = [] + const changedIds = new Set() + const seenRunIds = new Set() + for (const id of sceneRegistry.byType.cabinet ?? []) { + const run = nodes[id as AnyNodeId] + if (run?.type !== 'cabinet') continue + seenRunIds.add(id) + const signature = cabinetRunNeighborSignature(run) + const previous = signatures.get(id) + if (previous?.signature === signature) continue + const footprint = cabinetRunFootprint(run, nodes) + signatures.set(id, { signature, footprint }) + // First sighting is initial mount/load — every run rebuilds then anyway. + if (!previous) continue + changedIds.add(id) + changedFootprints.push(previous.footprint, footprint) + } + for (const id of signatures.keys()) { + if (seenRunIds.has(id)) continue + const removed = signatures.get(id)! + signatures.delete(id) + changedFootprints.push(removed.footprint) + } + if (changedFootprints.length > 0) { + bumpCabinetRunsNear(sceneApi, changedFootprints, changedIds) + } + // Throttle the heavy JS flame-tube vertex rebuild to ~30fps; the cheap + // ring/core/halo pulses stay at the render frame rate. + const updateTubes = clock.elapsedTime - lastTubeUpdateRef.current >= 1 / 30 + if (updateTubes) lastTubeUpdateRef.current = clock.elapsedTime + for (const kind of ['cabinet', 'cabinet-module'] as const) { + for (const id of sceneRegistry.byType[kind]!) { + const node = nodes[id as AnyNodeId] + if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) continue + // Resolve live overrides so panel-driven open/close animations (which + // publish transient frames without committing to the store) pose in + // real time. + const value = getEffectiveNode(node).operationState ?? 0 + const obj = sceneRegistry.nodes.get(id) + if (!obj) continue + if (applied.get(id) !== value) { + poseCabinetMovingParts(obj, value) + applied.set(id, value) + } + animateCabinetFlames(obj, clock.elapsedTime, updateTubes) + } + } + }, 2) + + return null +} + +export default CabinetAnimationSystem diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx new file mode 100644 index 00000000..120dba10 --- /dev/null +++ b/packages/nodes/src/cabinet/tool.tsx @@ -0,0 +1,1121 @@ +'use client' + +import { + type AnyNodeId, + CabinetModuleNode, + CabinetNode, + createSceneApi, + emitter, + type GridEvent, + getFloorPlacedFootprints, + getWallThickness, + isCurvedWall, + nodeRegistry, + spatialGridManager, + useScene, + type WallEvent, + type WallNode, +} from '@pascal-app/core' +import { + clearPlacementSurface, + getFloorStackPreviewPosition, + getSideFromNormal, + isGridSnapActive, + isMagneticSnapActive, + isValidWallSideFace, + markToolCancelConsumed, + movementSfxStepKey, + PlacementBox, + publishPlacementSurface, + triggerSFX, + useEditor, + useFacingPose, + usePlacementPreview, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useFrame } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type Group, Mesh, Quaternion, Vector3 } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, + subscribeFloorPlacementDoubleClicks, +} from '../shared/floor-placement' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' +import { + type CabinetStretchPreview, + cabinetStretchExitSide, + chooseCabinetContinuousAnchor, + createCabinetContinuousContinuation, + isCabinetContinuousFollowUpClick, + isForcePlacementEvent, + planCabinetContinuousStretch, + resolveCabinetContinuousValidity, + type StretchAnchor, + type StretchContinuation, +} from './continuous-placement' +import { + bumpCabinetRunsNear, + cabinetDefinition, + cabinetModuleDefinition, + cabinetRunFootprint, +} from './definition' +import { buildCabinetGeometry } from './geometry' +import useCabinetPlacementStatus from './placement-status' +import useCabinetPlacementType from './placement-type' +import { cabinetPresetById } from './presets' +import { runLocalToPlan } from './run-layout' +import { addCabinetModuleSide, addCornerRun, previewCornerAdditionLayout } from './run-ops' +import { + type CabinetWallSnapPlacement, + collectCabinetWallSnapNeighbors, + resolveCabinetWallFaceOffset, + resolveCabinetWallSnapPlacement, +} from './wall-snap' + +const PREVIEW_OPACITY = 0.55 +const ROTATE_STEP_RAD = Math.PI / 4 +const DEFAULT_PLACEMENT_PRESET = cabinetPresetById('base-door') +const ISLAND_SEATING_OVERHANG = 0.3 + +type CabinetPlacement = { + position: [number, number, number] + yaw: number + snappedToWall: boolean + valid: boolean + conflictIds: string[] + wallLocalX?: number + guide?: CabinetWallSnapPlacement['guide'] + snapReason?: CabinetWallSnapPlacement['snapReason'] + wallSurfaceNormal?: [number, number, number] + // Rubber-band state: anchor is `position`/`yaw`; modules are run-local + // center offsets filling the anchor→cursor span. + stretch?: CabinetStretchPreview + stretchAnchor?: StretchAnchor +} + +type DraftSegment = { + anchor: StretchAnchor + stretch: CabinetStretchPreview +} + +type DraftAnchorState = StretchAnchor | StretchContinuation + +function isStretchContinuation(anchor: DraftAnchorState): anchor is StretchContinuation { + return 'straightAnchor' in anchor +} + +function stretchWithAdjustedConnectedWidth( + stretch: CabinetStretchPreview, + connectedWidth: number, +): CabinetStretchPreview { + if (stretch.modules.length < 2) return stretch + const widths = [ + stretch.modules[0]!.width, + connectedWidth, + ...stretch.modules.slice(2).map((module) => module.width), + ] + const halfFirst = widths[0]! / 2 + let cum = 0 + const modules = widths.map((width) => { + const x = stretch.direction * (cum + width / 2 - halfFirst) + cum += width + return { x, width } + }) + const total = widths.reduce((sum, width) => sum + width, 0) + return { + modules, + length: total, + centerLocalX: stretch.direction * (total / 2 - halfFirst), + direction: stretch.direction, + } +} + +function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { + return showPlinth ? plinthHeight : 0 +} + +function buildCabinetPlacementPreviewNode({ + island, + position, + previewModule, + yaw, +}: { + island: boolean + position: [number, number, number] + previewModule: Pick< + ReturnType, + 'width' | 'depth' | 'carcassHeight' + > + yaw: number +}) { + const defaults = cabinetDefinition.defaults() + return CabinetNode.parse({ + ...defaults, + name: island ? 'Kitchen Island Preview' : 'Modular Cabinet Preview', + position, + rotation: yaw, + width: previewModule.width, + depth: previewModule.depth, + carcassHeight: previewModule.carcassHeight, + ...(island && { + countertopBackOverhang: ISLAND_SEATING_OVERHANG, + withFinishedBack: true, + }), + }) +} + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +// Cabinet wall attachment is a placement affordance, separate from floor-grid +// quantization. Keep the long-standing behavior in grid and magnetic modes; +// Off remains the explicit way to place without wall attachment. +function isWallSnapEligible(): boolean { + return isGridSnapActive() || isMagneticSnapActive() +} + +function WallSnapGuide({ + blocked, + guide, +}: { + blocked: boolean + guide: NonNullable +}) { + const dx = guide.end[0] - guide.start[0] + const dz = guide.end[2] - guide.start[2] + const length = Math.hypot(dx, dz) + if (length <= 1e-4) return null + return ( + + + + + + + ) +} + +// Re-key only the sibling runs whose countertop join the new run can affect. +// The adjacency watcher in system.tsx skips a run's first sighting, so it +// never re-keys neighbors when a run APPEARS — this covers that gap. History +// stays paused inside `bumpCabinetRunsNear`, keeping placement one undo step. +function bumpCabinetRunsNearNewRun(runId: AnyNodeId) { + const scene = useScene.getState() + const run = scene.nodes[runId] + if (run?.type !== 'cabinet') return + bumpCabinetRunsNear( + createSceneApi(useScene), + [cabinetRunFootprint(run, scene.nodes)], + new Set([runId]), + ) +} + +function wallHitFromWallEvent(event: WallEvent): WallHit | null { + if (!event.normal || !isValidWallSideFace(event.normal) || isCurvedWall(event.node)) return null + const wall = event.node as WallNode + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dy) + if (wallLength <= 1e-6) return null + const side = getSideFromNormal(event.normal) + + return { + wall, + localX: event.localPosition[0], + perpDistance: (side === 'front' ? 1 : -1) * (getWallThickness(wall) / 2), + side, + dirX: dx / wallLength, + dirY: dy / wallLength, + wallLength, + itemRotation: side === 'front' ? 0 : Math.PI, + } +} + +const CabinetTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const [placement, setPlacement] = useState(null) + const [draftSegments, setDraftSegments] = useState([]) + const [yaw, setYaw] = useState(0) + const placementType = useCabinetPlacementType((s) => s.type) + const islandMode = placementType === 'island' + const yawRef = useRef(0) + const islandModeRef = useRef(useCabinetPlacementType.getState().type === 'island') + const placementRef = useRef(null) + const draftSegmentsRef = useRef([]) + const chainRootRunRef = useRef(null) + const chainRunRef = useRef(null) + const chainEndModuleRef = useRef | null>(null) + const chainCornerSideRef = useRef<'left' | 'right' | null>(null) + const previousSnapRef = useRef(null) + const previousWasWallSnapRef = useRef(false) + const previousTickFrameRef = useRef(-1) + const draftAnchorRef = useRef(null) + const activeGhostRef = useRef(null) + const surfacePointRef = useRef(new Vector3()) + const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) + const surfaceQuatRef = useRef(new Quaternion()) + const surfaceForwardRef = useRef(new Vector3(0, 0, 1)) + const facingPointRef = useRef(new Vector3()) + + const previewNode = useMemo(() => { + const runDefaults = cabinetDefinition.defaults() + return CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + ...DEFAULT_PLACEMENT_PRESET.createPatch(), + showPlinth: runDefaults.showPlinth, + plinthHeight: runDefaults.plinthHeight, + toeKickDepth: runDefaults.toeKickDepth, + withCountertop: runDefaults.withCountertop, + countertopThickness: runDefaults.countertopThickness, + countertopOverhang: runDefaults.countertopOverhang, + countertopBackOverhang: runDefaults.countertopBackOverhang, + }) + }, []) + const placementDimensions = useMemo(() => { + const defaults = cabinetDefinition.defaults() + return [ + previewNode.width, + (defaults.showPlinth ? defaults.plinthHeight : 0) + + previewNode.carcassHeight + + (defaults.withCountertop ? defaults.countertopThickness : 0), + previewNode.depth + (islandMode ? ISLAND_SEATING_OVERHANG : 0), + ] as [number, number, number] + }, [previewNode, islandMode]) + const ghost = useMemo(() => { + const group = buildCabinetGeometry(previewNode) + group.traverse((child) => { + if (child instanceof Mesh) { + child.material = child.material.clone() + child.material.transparent = true + child.material.opacity = PREVIEW_OPACITY + child.raycast = () => {} + } + }) + return group + }, [previewNode]) + // The stretched span renders one ghost per module — the same Object3D can't + // appear twice in the scene, so extra modules reuse pooled clones (geometry + // and materials stay shared) instead of cloning on every pointer move. + const ghostPoolRef = useRef([]) + const ghostForIndex = useCallback( + (index: number): Group => { + if (index === 0) return ghost + const pool = ghostPoolRef.current + while (pool.length < index) pool.push(ghost.clone()) + return pool[index - 1] as Group + }, + [ghost], + ) + + const publishFloorplanPreview = useCallback( + (next: CabinetPlacement, island = islandModeRef.current) => { + const stretch = next.stretch + const node = buildCabinetPlacementPreviewNode({ + island, + position: stretch + ? runLocalToPlan({ position: next.position, rotation: next.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : next.position, + previewModule: previewNode, + yaw: next.yaw, + }) + // A stretched span can exceed the schema's width cap — override post-parse. + usePlacementPreview.getState().set(stretch ? { ...node, width: stretch.length } : node) + }, + [previewNode], + ) + + useFrame(() => { + const ghostGroup = activeGhostRef.current + const current = placementRef.current + if (!ghostGroup || !current) { + clearPlacementSurface() + useFacingPose.getState().clear() + return + } + + ghostGroup.getWorldPosition(surfacePointRef.current) + const facingPoint = current.stretch + ? ghostGroup.localToWorld(facingPointRef.current.set(current.stretch.centerLocalX, 0, 0)) + : facingPointRef.current.copy(surfacePointRef.current) + if (current.snappedToWall) { + ghostGroup.getWorldQuaternion(surfaceQuatRef.current) + const forward = surfaceForwardRef.current.set(0, 0, 1).applyQuaternion(surfaceQuatRef.current) + forward.y = 0 + if (forward.lengthSq() > 1e-6) { + surfaceNormalRef.current.copy(forward.normalize()) + } else if (current.wallSurfaceNormal) { + surfaceNormalRef.current.set(...current.wallSurfaceNormal) + } + surfacePointRef.current.addScaledVector(surfaceNormalRef.current, -previewNode.depth / 2) + } else { + surfaceNormalRef.current.set(0, 1, 0) + } + publishPlacementSurface(surfacePointRef.current, surfaceNormalRef.current) + useFacingPose.getState().set({ + position: [facingPoint.x, facingPoint.y, facingPoint.z], + rotationY: current.snappedToWall || current.stretch ? current.yaw : yawRef.current, + depth: current.stretch ? placementDimensions[2] : previewNode.depth, + }) + }) + + useEffect(() => { + if (!activeLevelId) return + placementRef.current = null + draftSegmentsRef.current = [] + chainRootRunRef.current = null + chainRunRef.current = null + chainEndModuleRef.current = null + chainCornerSideRef.current = null + setDraftSegments([]) + previousSnapRef.current = null + previousWasWallSnapRef.current = false + previousTickFrameRef.current = -1 + draftAnchorRef.current = null + let lastWallEventTime = -1 + let wallOwnedPointerAt = Number.NEGATIVE_INFINITY + const WALL_OWNS_POINTER_MS = 64 + const markWallOwnedPointer = () => { + wallOwnedPointerAt = performance.now() + } + const wallOwnsPointer = () => performance.now() - wallOwnedPointerAt < WALL_OWNS_POINTER_MS + + const clearDraft = () => { + draftSegmentsRef.current = [] + chainRootRunRef.current = null + chainRunRef.current = null + chainEndModuleRef.current = null + chainCornerSideRef.current = null + setDraftSegments([]) + draftAnchorRef.current = null + placementRef.current = null + setPlacement(null) + usePlacementPreview.getState().clear() + previousSnapRef.current = null + previousTickFrameRef.current = -1 + clearPlacementSurface() + useFacingPose.getState().clear() + useCabinetPlacementStatus.getState().setBlocked(false) + } + + const applyPlacementType = (type: 'cabinet' | 'island') => { + const nextIslandMode = type === 'island' + if (nextIslandMode === islandModeRef.current) return + const currentPlacement = placementRef.current + const hasContinuousDraft = + draftAnchorRef.current !== null || draftSegmentsRef.current.length > 0 + if (hasContinuousDraft) clearDraft() + islandModeRef.current = nextIslandMode + if (hasContinuousDraft) return + // Drop a stale wall-snapped preview so the next move re-resolves free. + if (nextIslandMode && currentPlacement?.snappedToWall) { + placementRef.current = null + setPlacement(null) + usePlacementPreview.getState().clear() + } else if (currentPlacement) { + publishFloorplanPreview(currentPlacement, nextIslandMode) + } + } + + // The segmented draft survives only while continuous mode is on. + const resolveDraftAnchor = (): DraftAnchorState | null => { + const anchor = draftAnchorRef.current + if (!anchor) return null + if (useEditor.getState().getContinuation('cabinet') !== 'continuous') { + clearDraft() + return null + } + return anchor + } + + const resolveRawPosition = ( + event: FloorPlacementClickTriggerEvent, + ): [number, number, number] => { + return getLevelLocalSnappedPosition(activeLevelId, event, 0, true) + } + + const resolveGridPosition = ( + raw: [number, number, number], + bypassGrid = false, + ): [number, number, number] => { + const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + return [snap(raw[0], step), 0, snap(raw[2], step)] + } + + const withPlacementValidity = ( + next: Omit, + bypassCollision: boolean, + ): CabinetPlacement => { + if (bypassCollision) return { ...next, conflictIds: [], valid: true } + const floorPlaced = nodeRegistry.get(previewNode.type)?.capabilities?.floorPlaced + const effectiveNode = { + ...previewNode, + position: next.position, + rotation: next.yaw, + } + const footprints = floorPlaced + ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { + nodes: useScene.getState().nodes, + }).filter( + ( + footprint, + ): footprint is { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + } => footprint.position != null, + ) + : [] + const result = + footprints.length > 0 + ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints) + : spatialGridManager.canPlaceOnFloor(activeLevelId, next.position, placementDimensions, [ + 0, + next.yaw, + 0, + ]) + return { ...next, conflictIds: result.conflictIds, valid: result.valid } + } + + const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => { + if (!isWallSnapEligible()) return null + const nodes = useScene.getState().nodes + const neighbors = collectCabinetWallSnapNeighbors({ + hit, + nodes, + parentLevelId: activeLevelId as AnyNodeId, + width: previewNode.width, + }) + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId: activeLevelId as AnyNodeId, + }) + + const wallPlacement = resolveCabinetWallSnapPlacement({ + depth: previewNode.depth, + faceOffset, + gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + hit, + neighbors, + width: previewNode.width, + }) + if (!wallPlacement) return null + const wallSurfaceNormal = [Math.sin(wallPlacement.yaw), 0, Math.cos(wallPlacement.yaw)] as [ + number, + number, + number, + ] + + return { + conflictIds: [], + guide: wallPlacement.guide, + position: wallPlacement.position, + snapReason: wallPlacement.snapReason, + valid: true, + wallLocalX: wallPlacement.localX, + wallSurfaceNormal, + yaw: wallPlacement.yaw, + snappedToWall: true, + } + } + + const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => { + if (!isWallSnapEligible()) return null + const nodes = useScene.getState().nodes + const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId) + if (!hit) return null + return resolveWallHitPlacement(hit) + } + + const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { + const raw = resolveRawPosition(event) + const forcePlacement = isForcePlacementEvent(event) + const wallPlacement = islandModeRef.current ? null : resolveWallPlacement(raw) + if (wallPlacement) return withPlacementValidity(wallPlacement, forcePlacement) + return withPlacementValidity( + { + position: resolveGridPosition(raw), + yaw: yawRef.current, + snappedToWall: false, + }, + forcePlacement, + ) + } + + // While stretching, the run is pinned at the anchored first module and + // grows toward the cursor — the far end tracks the pointer smoothly. + const resolveStretchedPlacement = ( + anchor: StretchAnchor, + event: FloorPlacementClickTriggerEvent, + ): CabinetPlacement => { + const raw = resolveRawPosition(event) + let stretch = planCabinetContinuousStretch({ + anchor, + previewWidth: previewNode.width, + rawPlanPosition: raw, + }) + if ( + anchor.leadingWidth != null && + chainRunRef.current && + chainEndModuleRef.current && + chainCornerSideRef.current + ) { + const preview = previewCornerAdditionLayout({ + module: chainEndModuleRef.current, + run: chainRunRef.current, + nodes: useScene.getState().nodes, + side: chainCornerSideRef.current, + }) + if (!preview) { + return { + position: anchor.position, + yaw: anchor.yaw, + snappedToWall: anchor.snappedToWall, + wallSurfaceNormal: anchor.wallSurfaceNormal, + valid: false, + conflictIds: [], + stretch, + stretchAnchor: anchor, + } + } + stretch = stretchWithAdjustedConnectedWidth(stretch, preview.connectedWidth) + } + const spanCenter = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + const ignoreIds = chainRootRunRef.current + ? [chainRootRunRef.current.id as AnyNodeId] + : undefined + const result = resolveCabinetContinuousValidity( + spatialGridManager.canPlaceOnFloor( + activeLevelId, + spanCenter, + [stretch.length, placementDimensions[1], placementDimensions[2]], + [0, anchor.yaw, 0], + ignoreIds, + ), + isForcePlacementEvent(event), + ) + return { + position: anchor.position, + yaw: anchor.yaw, + snappedToWall: anchor.snappedToWall, + wallSurfaceNormal: anchor.wallSurfaceNormal, + valid: result.valid, + conflictIds: result.conflictIds, + stretch, + stretchAnchor: anchor, + } + } + + const resolveActiveStretchPlacement = ( + anchor: DraftAnchorState, + event: FloorPlacementClickTriggerEvent, + ): CabinetPlacement => { + if (isStretchContinuation(anchor)) { + return resolveStretchedPlacement( + chooseCabinetContinuousAnchor(anchor, resolveRawPosition(event)), + event, + ) + } + return resolveStretchedPlacement(anchor, event) + } + + const publishPlacement = (next: CabinetPlacement, frame = -1) => { + placementRef.current = next + setPlacement(next) + useCabinetPlacementStatus.getState().setBlocked(!next.valid) + publishFloorplanPreview(next) + const nextSnapKey = movementSfxStepKey({ + coords: + next.snappedToWall && typeof next.wallLocalX === 'number' + ? [next.wallLocalX] + : [next.position[0], next.position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) + const prev = previousSnapRef.current + const wasWallSnap = previousWasWallSnapRef.current + if (frame !== previousTickFrameRef.current && prev !== nextSnapKey) { + if (next.snappedToWall && !wasWallSnap) { + triggerSFX('sfx:item-pick') + } else { + triggerSFX('sfx:grid-snap') + } + previousSnapRef.current = nextSnapKey + previousTickFrameRef.current = frame + } + previousWasWallSnapRef.current = next.snappedToWall + } + + const onGridMove = (event: GridEvent) => { + const ts = event.nativeEvent?.timeStamp ?? -1 + if (ts === lastWallEventTime || wallOwnsPointer()) return + const anchor = resolveDraftAnchor() + if (anchor) { + publishPlacement(resolveActiveStretchPlacement(anchor, event), ts) + return + } + publishPlacement(resolvePlacement(event), ts) + } + + const onWallMove = (event: WallEvent) => { + lastWallEventTime = event.nativeEvent?.timeStamp ?? -1 + if (event.node.parentId !== activeLevelId) return + const anchor = resolveDraftAnchor() + if (anchor) { + markWallOwnedPointer() + publishPlacement(resolveActiveStretchPlacement(anchor, event), lastWallEventTime) + event.stopPropagation() + return + } + const hit = islandModeRef.current ? null : wallHitFromWallEvent(event) + const next = hit ? resolveWallHitPlacement(hit) : null + if (next) { + markWallOwnedPointer() + publishPlacement( + withPlacementValidity(next, isForcePlacementEvent(event)), + lastWallEventTime, + ) + event.stopPropagation() + return + } + publishPlacement(resolvePlacement(event), lastWallEventTime) + } + + const buildRunNodes = (position: [number, number, number], yaw: number) => { + const patch = DEFAULT_PLACEMENT_PRESET.createPatch() + const island = islandModeRef.current + const cabinet = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + name: island ? 'Kitchen Island' : 'Modular Cabinet', + position, + rotation: yaw, + depth: patch.depth ?? cabinetDefinition.defaults().depth, + carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, + ...(island && { + countertopBackOverhang: ISLAND_SEATING_OVERHANG, + withFinishedBack: true, + }), + }) + const buildModule = (localX: number, width: number, index: number) => + CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + ...patch, + name: index === 0 ? (patch.name ?? 'Base Cabinet') : `Base Cabinet ${index + 1}`, + parentId: cabinet.id, + position: [localX, runModuleBaseY(cabinet.plinthHeight, cabinet.showPlinth), 0], + width, + depth: cabinet.depth, + carcassHeight: cabinet.carcassHeight, + plinthHeight: cabinet.plinthHeight, + toeKickDepth: cabinet.toeKickDepth, + countertopThickness: cabinet.countertopThickness, + countertopOverhang: cabinet.countertopOverhang, + }) + return { cabinet, buildModule } + } + + const commitDraftSegment = ( + segment: DraftSegment, + ): { + endModule: ReturnType + run: CabinetNode + } | null => { + const sceneApi = createSceneApi(useScene) + sceneApi.pauseHistory() + try { + if (!chainRunRef.current || !chainEndModuleRef.current || !chainCornerSideRef.current) { + const { cabinet, buildModule } = buildRunNodes( + segment.anchor.position, + segment.anchor.yaw, + ) + sceneApi.upsert(cabinet, activeLevelId) + const modules = segment.stretch.modules.map((m, index) => + buildModule(m.x, m.width, index), + ) + for (const module of modules) sceneApi.upsert(module, cabinet.id as AnyNodeId) + bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + sceneApi.resumeHistory() + return { + endModule: modules[modules.length - 1]!, + run: sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet, + } + } + + const connectedId = addCornerRun({ + module: chainEndModuleRef.current, + run: chainRunRef.current, + sceneApi, + side: chainCornerSideRef.current, + }) + if (!connectedId) throw new Error('Unable to create cabinet corner') + const connectedModule = + sceneApi.get>(connectedId) + const nextRun = connectedModule?.parentId + ? sceneApi.get(connectedModule.parentId as AnyNodeId) + : null + if (!connectedModule || !nextRun) throw new Error('Unable to resolve connected corner run') + + const plannedConnectedWidths = segment.stretch.modules + .slice(1) + .map((module) => module.width) + let anchorModule = connectedModule + for (const expectedWidth of plannedConnectedWidths.slice(1)) { + const addedId = addCabinetModuleSide({ + anchorModule, + run: nextRun, + sceneApi, + side: 'right', + }) + if (!addedId) break + let added = sceneApi.get>(addedId) + if (!added) break + if (expectedWidth < added.width - 1e-4) { + const leftEdge = added.position[0] - added.width / 2 + sceneApi.update(added.id as AnyNodeId, { + width: expectedWidth, + position: [leftEdge + expectedWidth / 2, added.position[1], added.position[2]], + }) + added = sceneApi.get>(addedId) ?? added + } + anchorModule = added + } + + bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) + sceneApi.resumeHistory() + return { + endModule: anchorModule, + run: sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun, + } + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return null + } + } + + const resolveCurrentDraftSegment = ( + anchor: DraftAnchorState, + event: FloorPlacementClickTriggerEvent, + ): DraftSegment | null => { + const currentPlacement = + placementRef.current?.stretch && placementRef.current.stretchAnchor + ? placementRef.current + : resolveActiveStretchPlacement(anchor, event) + if (!currentPlacement.valid || !currentPlacement.stretch || !currentPlacement.stretchAnchor) { + return null + } + return { anchor: currentPlacement.stretchAnchor, stretch: currentPlacement.stretch } + } + + const onDoubleClick = (event: FloorPlacementClickTriggerEvent) => { + const anchor = resolveDraftAnchor() + if (!anchor) return + const segment = resolveCurrentDraftSegment(anchor, event) + if (segment) { + const committed = commitDraftSegment(segment) + if (committed) { + chainRunRef.current = committed.run + chainEndModuleRef.current = committed.endModule + chainCornerSideRef.current = cabinetStretchExitSide(segment.stretch) + triggerSFX('sfx:item-place') + } + } + clearDraft() + stopPlacementCommitPropagation(event) + } + + const onClick = (event: FloorPlacementClickTriggerEvent) => { + const anchor = resolveDraftAnchor() + if (anchor) { + const detail = + ((event as { nativeEvent?: { detail?: number } }).nativeEvent?.detail as + | number + | undefined) ?? 1 + if (isCabinetContinuousFollowUpClick(detail)) { + clearDraft() + stopPlacementCommitPropagation(event) + return + } + const segment = resolveCurrentDraftSegment(anchor, event) + if (!segment) { + stopPlacementCommitPropagation(event) + return + } + const committed = commitDraftSegment(segment) + if (!committed) { + stopPlacementCommitPropagation(event) + return + } + chainRootRunRef.current ??= committed.run + chainRunRef.current = committed.run + chainEndModuleRef.current = committed.endModule + chainCornerSideRef.current = cabinetStretchExitSide(segment.stretch) + draftAnchorRef.current = createCabinetContinuousContinuation({ + anchor: segment.anchor, + previewDepth: previewNode.depth, + previewWidth: previewNode.width, + stretch: segment.stretch, + }) + publishPlacement(resolveActiveStretchPlacement(draftAnchorRef.current, event)) + triggerSFX('sfx:item-place') + stopPlacementCommitPropagation(event) + return + } + const next = isForcePlacementEvent(event) + ? resolvePlacement(event) + : (placementRef.current ?? resolvePlacement(event)) + if (!next.valid) { + stopPlacementCommitPropagation(event) + return + } + if (useEditor.getState().getContinuation('cabinet') === 'continuous') { + draftSegmentsRef.current = [] + setDraftSegments([]) + chainRootRunRef.current = null + chainRunRef.current = null + chainEndModuleRef.current = null + chainCornerSideRef.current = null + draftAnchorRef.current = { + position: next.position, + yaw: next.yaw, + snappedToWall: next.snappedToWall, + wallSurfaceNormal: next.wallSurfaceNormal, + } + publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) + triggerSFX('sfx:item-pick') + stopPlacementCommitPropagation(event) + return + } + const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) + const module = buildModule(0, previewNode.width, 0) + useScene.getState().createNodes([ + { node: cabinet, parentId: activeLevelId }, + { node: module, parentId: cabinet.id }, + ]) + bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [module.id] }) + useEditor.getState().setMode('select') + triggerSFX('sfx:item-place') + usePlacementPreview.getState().clear() + clearPlacementSurface() + useFacingPose.getState().clear() + stopPlacementCommitPropagation(event) + } + + const onKeyDown = (event: KeyboardEvent) => { + const tag = (event.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (event.key === 'i' || event.key === 'I') { + event.preventDefault() + event.stopPropagation() + useCabinetPlacementType.getState().cycleType() + triggerSFX('sfx:item-rotate') + return + } + if (event.key !== 'r' && event.key !== 'R' && event.key !== 't' && event.key !== 'T') return + event.preventDefault() + event.stopPropagation() + const steps = event.key === 't' || event.key === 'T' ? -1 : 1 + yawRef.current += steps * ROTATE_STEP_RAD + setYaw(yawRef.current) + if ( + placementRef.current && + !placementRef.current.snappedToWall && + !placementRef.current.stretch + ) { + const next = { ...placementRef.current, yaw: yawRef.current } + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + } + triggerSFX('sfx:item-rotate') + } + + const onCancel = () => { + if (!draftAnchorRef.current) return + markToolCancelConsumed() + clearDraft() + } + + emitter.on('grid:move', onGridMove) + emitter.on('wall:move', onWallMove) + emitter.on('tool:cancel', onCancel) + const unsubscribeCabinetPlacementType = useCabinetPlacementType.subscribe((state) => { + applyPlacementType(state.type) + }) + const unsubscribePlacementClicks = subscribeFloorPlacementClicks(onClick) + const unsubscribePlacementDoubleClicks = subscribeFloorPlacementDoubleClicks(onDoubleClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('wall:move', onWallMove) + emitter.off('tool:cancel', onCancel) + unsubscribeCabinetPlacementType() + unsubscribePlacementClicks() + unsubscribePlacementDoubleClicks() + window.removeEventListener('keydown', onKeyDown, true) + draftAnchorRef.current = null + usePlacementPreview.getState().clear() + clearPlacementSurface() + useFacingPose.getState().clear() + useCabinetPlacementStatus.getState().setBlocked(false) + } + }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) + + if (!activeLevelId || !placement) return null + const stretch = placement.stretch + const draftModuleOffsets = draftSegments.map((segment, segmentIndex) => + draftSegments + .slice(0, segmentIndex) + .reduce((sum, previous) => sum + previous.stretch.modules.length, 0), + ) + const activeStretchGhostOffset = draftSegments.reduce( + (sum, segment) => sum + segment.stretch.modules.length, + 0, + ) + const placementLabel = stretch + ? placement.valid + ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` + : null + : !placement.valid + ? null + : placement.snappedToWall + ? placement.snapReason === 'cabinet-edge' + ? 'Edge snap' + : placement.snapReason === 'corner' + ? 'Corner snap' + : 'Wall snap' + : null + const labelPosition = stretch + ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : placement.position + const visualPosition = getFloorStackPreviewPosition({ + node: buildCabinetPlacementPreviewNode({ + island: islandMode, + position: placement.position, + previewModule: previewNode, + yaw: placement.yaw, + }), + position: placement.position, + rotation: placement.yaw, + levelId: activeLevelId, + }) + const placementRotationY = placement.snappedToWall || stretch ? placement.yaw : yaw + const placementBoxDimensions: [number, number, number] = [ + stretch ? stretch.length : placementDimensions[0], + placementDimensions[1], + placementDimensions[2], + ] + const placementBoxPlanPosition = stretch + ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : placement.position + const placementBoxPosition: [number, number, number] = [ + placementBoxPlanPosition[0], + visualPosition[1], + placementBoxPlanPosition[2], + ] + + return ( + + {placement.guide && } + + {draftSegments.map((segment, segmentIndex) => ( + + {segment.stretch.modules.map((module, index) => ( + + + + ))} + + ))} + + {stretch ? ( + stretch.modules.map((module, index) => ( + + + + )) + ) : ( + + )} + + {placementLabel ? ( + +
+ {placementLabel} +
+ + ) : null} +
+ ) +} + +export default CabinetTool diff --git a/packages/nodes/src/cabinet/tree-structure.ts b/packages/nodes/src/cabinet/tree-structure.ts new file mode 100644 index 00000000..c28827b1 --- /dev/null +++ b/packages/nodes/src/cabinet/tree-structure.ts @@ -0,0 +1,189 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' + +/** + * Sidebar-tree shaping for cabinet runs (`def.tree` on both cabinet + * definitions). L-corner legs are real cabinet runs parented inside the + * source run (see run-ops' `addCornerRun`), but the sidebar should read as + * the user's mental model: one run whose base cabinets carry their corner + * modules inline. So corner-derived runs are hidden and their modules are + * flattened into the surrounding hierarchy. + */ + +type CabinetCornerDerivedRunLink = { + role: 'base-leg' | 'wall-leg' | 'bridge' + side: 'left' | 'right' + sourceModuleId: AnyNodeId + sourceRunId: AnyNodeId +} + +function cornerDerivedRunLink( + metadata: CabinetNodeType['metadata'] | CabinetModuleNodeType['metadata'], +): CabinetCornerDerivedRunLink | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + const sourceModuleId = (value as { sourceModuleId?: unknown }).sourceModuleId + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') || + typeof sourceModuleId !== 'string' || + typeof sourceRunId !== 'string' + ) { + return null + } + return { + role, + side, + sourceModuleId: sourceModuleId as AnyNodeId, + sourceRunId: sourceRunId as AnyNodeId, + } +} + +function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { + return node?.type === 'cabinet' +} + +function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { + return node?.type === 'cabinet-module' +} + +function cabinetModuleChildren( + run: CabinetNodeType, + nodes: Readonly>>, +): CabinetModuleNodeType[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module') +} + +function childIdsOf(node: AnyNode | undefined): AnyNodeId[] { + if (!node || typeof node !== 'object' || !('children' in node) || !Array.isArray(node.children)) { + return [] + } + return node.children as AnyNodeId[] +} + +function resolveCabinetRunChildIds( + run: CabinetNodeType, + nodes: Readonly>>, +): AnyNodeId[] { + const resolved: AnyNodeId[] = [] + for (const childId of run.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) { + resolved.push(child.id as AnyNodeId) + continue + } + if (!isCabinetRun(child)) continue + const link = cornerDerivedRunLink(child.metadata) + if (link?.role === 'base-leg') { + resolved.push(...resolveCabinetRunChildIds(child, nodes)) + continue + } + if (link) continue + resolved.push(child.id as AnyNodeId) + } + return resolved +} + +/** `def.tree.hidden` for cabinet runs: corner-derived legs disappear as rows + * (their modules resurface through `childIds` flattening). */ +export function cabinetTreeHidden( + node: AnyNode, + _nodes: Readonly>>, +): boolean { + return isCabinetRun(node) && cornerDerivedRunLink(node.metadata) != null +} + +export function cabinetTreeLabel( + node: AnyNode, + nodes: Readonly>>, +): string { + if (node.name) return node.name + if (isCabinetRun(node)) { + const moduleCount = resolveCabinetRunChildIds(node, nodes).filter((childId) => { + const child = nodes[childId] + return child?.type === 'cabinet-module' + }).length + return `Modular Cabinet (${moduleCount} module${moduleCount === 1 ? '' : 's'})` + } + if (isCabinetModule(node)) return 'Cabinet Module' + return 'Node' +} + +/** `def.tree.childIds` for both cabinet kinds. */ +export function cabinetTreeChildIds( + node: AnyNode, + nodes: Readonly>>, +): AnyNodeId[] { + if (isCabinetRun(node)) { + return resolveCabinetRunChildIds(node, nodes) + } + + if (!isCabinetModule(node)) return [] + + const resolved: AnyNodeId[] = [] + for (const childId of childIdsOf(node)) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) { + resolved.push(child.id as AnyNodeId) + continue + } + if (!isCabinetRun(child)) continue + const link = cornerDerivedRunLink(child.metadata) + if (link) { + resolved.push(...cabinetModuleChildren(child, nodes).map((module) => module.id as AnyNodeId)) + continue + } + resolved.push(child.id as AnyNodeId) + } + return resolved +} + +export function cabinetFloorplanAffectedIds(args: { + node: AnyNode + nodes: Record + liveOverrides: Map> +}): readonly AnyNodeId[] { + const { node, nodes, liveOverrides } = args + const affected = new Set() + const visited = new Set() + const queue: AnyNodeId[] = [node.id as AnyNodeId] + + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const current = nodes[id] + if (!isCabinetRun(current) && !isCabinetModule(current)) continue + affected.add(id) + + const parentIds = [ + current.parentId as AnyNodeId | undefined, + (liveOverrides.get(id) as { parentId?: AnyNodeId } | undefined)?.parentId, + ] + for (const parentId of parentIds) { + const parent = parentId ? nodes[parentId] : undefined + if (parentId && (isCabinetRun(parent) || isCabinetModule(parent))) { + queue.push(parentId) + } + } + + for (const childId of childIdsOf(current)) { + const child = nodes[childId] + if (isCabinetRun(child) || isCabinetModule(child)) { + queue.push(childId as AnyNodeId) + } + } + } + + return Array.from(affected) +} diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts new file mode 100644 index 00000000..af19b45e --- /dev/null +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -0,0 +1,384 @@ +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + calculateLevelMiters, + getWallPlanFootprint, + getWallThickness, + type WallNode, +} from '@pascal-app/core' +import type { WallHit } from '../shared/wall-attach-target' +import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target' +import { planToRunLocal, runLocalToPlan } from './run-layout' + +const EDGE_SNAP_THRESHOLD = 0.08 +const FACE_MATCH_THRESHOLD = 0.12 +const YAW_MATCH_THRESHOLD = 0.08 +const WALL_FACE_EPSILON = 1e-5 + +export type CabinetWallSnapNeighbor = { + minX: number + maxX: number +} + +export type CabinetWallSnapPlacement = { + position: [number, number, number] + yaw: number + localX: number + side: WallHit['side'] + snapReason: 'grid' | 'corner' | 'cabinet-edge' + guide: { + start: [number, number, number] + end: [number, number, number] + } +} + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function snapLocalXToStops({ + localX, + neighbors, + wallLength, + width, +}: { + localX: number + neighbors: CabinetWallSnapNeighbor[] + wallLength: number + width: number +}): { localX: number; reason: CabinetWallSnapPlacement['snapReason'] } { + if (wallLength <= width) return { localX: wallLength / 2, reason: 'corner' } + + const halfWidth = width / 2 + const stops: Array<{ value: number; reason: CabinetWallSnapPlacement['snapReason'] }> = [ + { value: 0, reason: 'corner' }, + { value: wallLength, reason: 'corner' }, + ] + for (const neighbor of neighbors) { + stops.push( + { value: neighbor.minX, reason: 'cabinet-edge' }, + { value: neighbor.maxX, reason: 'cabinet-edge' }, + ) + } + + let best: { + localX: number + distance: number + reason: CabinetWallSnapPlacement['snapReason'] + } | null = null + for (const movingStop of [localX - halfWidth, localX + halfWidth]) { + for (const stop of stops) { + const delta = stop.value - movingStop + const candidateLocalX = localX + delta + if (candidateLocalX < halfWidth || candidateLocalX > wallLength - halfWidth) continue + const distance = Math.abs(delta) + if (distance > EDGE_SNAP_THRESHOLD) continue + if (!best || distance < best.distance) { + best = { localX: candidateLocalX, distance, reason: stop.reason } + } + } + } + + return best ? { localX: best.localX, reason: best.reason } : { localX, reason: 'grid' } +} + +function cabinetRunWidthAndCenterOffset( + cabinet: Extract, + nodes: Record, +): { width: number; centerOffset: number } { + const modules = (cabinet.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((node): node is CabinetModuleNode => node?.type === 'cabinet-module') + if (modules.length === 0) return { width: cabinet.width, centerOffset: 0 } + + const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + return { width: Math.max(0.01, maxX - minX), centerOffset: (minX + maxX) / 2 } +} + +export function resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId, +}: { + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId +}): number { + const walls = Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === parentLevelId, + ) + if (walls.length === 0) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + + const miterData = calculateLevelMiters(walls) + const footprint = getWallPlanFootprint(hit.wall, miterData) + if (footprint.length < 3) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + + const frontNormal = [-hit.dirY, hit.dirX] as const + const localPoints = footprint.map((point) => { + const dx = point.x - hit.wall.start[0] + const dz = point.y - hit.wall.start[1] + return { + x: dx * hit.dirX + dz * hit.dirY, + z: dx * frontNormal[0] + dz * frontNormal[1], + } + }) + + const zIntersections: number[] = [] + for (let i = 0; i < localPoints.length; i += 1) { + const a = localPoints[i]! + const b = localPoints[(i + 1) % localPoints.length]! + const minX = Math.min(a.x, b.x) + const maxX = Math.max(a.x, b.x) + if (hit.localX < minX - WALL_FACE_EPSILON || hit.localX > maxX + WALL_FACE_EPSILON) { + continue + } + const dx = b.x - a.x + if (Math.abs(dx) <= WALL_FACE_EPSILON) { + if (Math.abs(hit.localX - a.x) <= WALL_FACE_EPSILON) { + zIntersections.push(a.z, b.z) + } + continue + } + const t = (hit.localX - a.x) / dx + if (t < -WALL_FACE_EPSILON || t > 1 + WALL_FACE_EPSILON) continue + zIntersections.push(a.z + (b.z - a.z) * t) + } + + if (zIntersections.length === 0) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + return hit.side === 'front' ? Math.max(...zIntersections) : Math.min(...zIntersections) +} + +export function collectCabinetWallSnapNeighbors({ + hit, + nodes, + excludeIds = [], + parentLevelId, + width, +}: { + excludeIds?: readonly AnyNodeId[] + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId + width: number +}): CabinetWallSnapNeighbor[] { + const frontNormal = [-hit.dirY, hit.dirX] as const + const normalScale = hit.side === 'front' ? 1 : -1 + const yaw = Math.atan2(frontNormal[0] * normalScale, frontNormal[1] * normalScale) + const wallFaceOffset = getWallThickness(hit.wall) / 2 + const neighbors: CabinetWallSnapNeighbor[] = [] + const excluded = new Set(excludeIds) + + for (const node of Object.values(nodes)) { + if (node?.type !== 'cabinet') continue + if (excluded.has(node.id as AnyNodeId)) continue + if (node.parentId !== parentLevelId) continue + if (Math.abs(angleDelta(node.rotation, yaw)) > YAW_MATCH_THRESHOLD) continue + + const run = cabinetRunWidthAndCenterOffset(node, nodes) + const localXAxis = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const centerX = node.position[0] + localXAxis[0] * run.centerOffset + const centerZ = node.position[2] + localXAxis[1] * run.centerOffset + const fromStartX = centerX - hit.wall.start[0] + const fromStartZ = centerZ - hit.wall.start[1] + const localX = fromStartX * hit.dirX + fromStartZ * hit.dirY + const perp = fromStartX * frontNormal[0] + fromStartZ * frontNormal[1] + const expectedPerp = normalScale * (wallFaceOffset + node.depth / 2) + if (Math.abs(perp - expectedPerp) > FACE_MATCH_THRESHOLD) continue + + const minX = localX - run.width / 2 + const maxX = localX + run.width / 2 + if (maxX < width / 2 || minX > hit.wallLength - width / 2) continue + neighbors.push({ minX, maxX }) + } + + return neighbors +} + +export function resolveCabinetWallSnapPlacement({ + depth, + gridStep = 0, + faceOffset, + hit, + neighbors = [], + width, +}: { + depth: number + faceOffset?: number + gridStep?: number + hit: WallHit + neighbors?: CabinetWallSnapNeighbor[] + width: number +}): CabinetWallSnapPlacement | null { + if (hit.wallLength <= 1e-6) return null + + const halfWidth = width / 2 + const snappedLocalX = snap(hit.localX, gridStep) + const clampedLocalX = + hit.wallLength > width + ? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX)) + : hit.wallLength / 2 + const snapped = snapLocalXToStops({ + localX: clampedLocalX, + neighbors, + wallLength: hit.wallLength, + width, + }) + const localX = snapped.localX + const centerline = projectWallLocalPointToPlan(hit.wall, localX) + const frontNormal = [-hit.dirY, hit.dirX] as const + const normalScale = hit.side === 'front' ? 1 : -1 + const normal = [frontNormal[0] * normalScale, frontNormal[1] * normalScale] as const + const resolvedFaceOffset = faceOffset ?? (normalScale * getWallThickness(hit.wall)) / 2 + const cabinetCenterOffset = resolvedFaceOffset + normalScale * (depth / 2) + const guideOffset = resolvedFaceOffset + const guideStart = projectWallLocalPointToPlan( + hit.wall, + Math.max(0, localX - halfWidth), + guideOffset, + ) + const guideEnd = projectWallLocalPointToPlan( + hit.wall, + Math.min(hit.wallLength, localX + halfWidth), + guideOffset, + ) + + return { + position: [ + centerline[0] + frontNormal[0] * cabinetCenterOffset, + 0, + centerline[1] + frontNormal[1] * cabinetCenterOffset, + ], + yaw: Math.atan2(normal[0], normal[1]), + localX, + side: hit.side, + snapReason: snapped.reason, + guide: { + start: [guideStart[0], 0.025, guideStart[1]], + end: [guideEnd[0], 0.025, guideEnd[1]], + }, + } +} + +/** + * Wall snap for a single dragged module, in its run's LOCAL frame — the + * frame `movable.parentFrame` kinds store `position` in. Converts the + * candidate to plan space, resolves the same flush-to-wall placement a run + * drag gets, and converts back. Snaps only when the module's world yaw + * already faces the wall (a module drag cannot rotate its run). + */ +export function resolveCabinetModuleWallSnapLocal({ + candidateLocal, + excludeIds = [], + gridStep = 0, + module, + nodes, + parentLevelId, + run, +}: { + candidateLocal: [number, number, number] + excludeIds?: readonly AnyNodeId[] + gridStep?: number + module: CabinetModuleNode + nodes: Record + parentLevelId: AnyNodeId + run: Extract +}): [number, number, number] | null { + const planCenter = runLocalToPlan(run, candidateLocal) + const hit = findClosestWallInPlan([planCenter[0], planCenter[2]], nodes, parentLevelId) + if (!hit) return null + if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null + + const faceOffset = resolveCabinetWallFaceOffset({ hit, nodes, parentLevelId }) + const placement = resolveCabinetWallSnapPlacement({ + depth: module.depth, + faceOffset, + gridStep, + hit, + neighbors: collectCabinetWallSnapNeighbors({ + hit, + nodes, + // The moving module's own run must not offer edge stops — its span + // still includes the module's pre-drag position. + excludeIds: [...excludeIds, run.id as AnyNodeId], + parentLevelId, + width: module.width, + }), + width: module.width, + }) + if (!placement) return null + + const worldYaw = run.rotation + module.rotation + if (Math.abs(angleDelta(worldYaw, placement.yaw)) > YAW_MATCH_THRESHOLD) return null + + return planToRunLocal(run, placement.position[0], candidateLocal[1], placement.position[2]) +} + +export function resolveCabinetRunWallSnap({ + cabinet, + candidatePosition, + excludeIds = [], + gridStep = 0, + nodes, + parentLevelId, +}: { + cabinet: Extract + candidatePosition: [number, number, number] + excludeIds?: readonly AnyNodeId[] + gridStep?: number + nodes: Record + parentLevelId: AnyNodeId +}): [number, number, number] | null { + const run = cabinetRunWidthAndCenterOffset(cabinet, nodes) + const axisX = Math.cos(cabinet.rotation) + const axisZ = -Math.sin(cabinet.rotation) + const footprintCenter: [number, number] = [ + candidatePosition[0] + axisX * run.centerOffset, + candidatePosition[2] + axisZ * run.centerOffset, + ] + const hit = findClosestWallInPlan(footprintCenter, nodes, parentLevelId) + if (!hit) return null + // A wall moving with the same group (whole-room drag) still sits at its + // pre-drag position in `nodes` — snapping to it would tear the group apart. + if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null + + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId, + }) + const placement = resolveCabinetWallSnapPlacement({ + depth: cabinet.depth, + faceOffset, + gridStep, + hit, + neighbors: collectCabinetWallSnapNeighbors({ + hit, + nodes, + excludeIds, + parentLevelId, + width: run.width, + }), + width: run.width, + }) + if (!placement) return null + if (Math.abs(angleDelta(cabinet.rotation, placement.yaw)) > YAW_MATCH_THRESHOLD) return null + + return [ + placement.position[0] - Math.cos(placement.yaw) * run.centerOffset, + candidatePosition[1], + placement.position[2] + Math.sin(placement.yaw) * run.centerOffset, + ] +} diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 1db4bde6..f0e96317 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -133,7 +133,6 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = fallbackPoint: context.gridPoint, levelId: ceilingLevelId, excludeId: ceilingId, - altKey: context.nativeEvent?.altKey === true, }).point, [ceilingId, ceilingLevelId], ) diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts index 693f6a55..9c7d3637 100644 --- a/packages/nodes/src/ceiling/floorplan-affordances.ts +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -19,7 +19,6 @@ const ceilingSnapOptions = { nodes, rawPoint, fallbackPoint, - modifiers, }: PolygonAffordanceSnapContext) { const sceneNodes = nodes as Record return resolveCeilingPlanPointSnap({ @@ -29,8 +28,7 @@ const ceilingSnapOptions = { excludeId: node.id, nodes: sceneNodes, // Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the - // `lines` mode), so no Shift bypass — Alt still force-skips alignment. - altKey: modifiers.altKey, + // `lines` mode), so no Shift or Alt snap bypass. }).point }, } diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index e3b24505..af8b9c5a 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -13,7 +13,6 @@ import { CursorSphere, clearCeilingSnapFeedback, EDITOR_LAYER, - isAlignmentGuideActive, isAngleSnapActive, isGridSnapActive, markToolCancelConsumed, @@ -117,7 +116,6 @@ export const CeilingTool: React.FC = () => { rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, - altKey: !isAlignmentGuideActive(), }).point setSnappedCursorPosition(displayPoint) if ( diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 8a285f03..31befe79 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -14,6 +14,7 @@ import { isAlignmentGuideActive, isGridSnapActive, isMagneticSnapActive, + movementSfxStepKey, triggerSFX, useAlignmentGuides, useEditor, @@ -58,7 +59,7 @@ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, num const ColumnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) const cursorVisibleRef = useRef(false) const [cursorVisible, setCursorVisible] = useState(false) @@ -119,10 +120,15 @@ const ColumnTool = () => { // aligned cursor so users see the pillar before they click. usePlacementPreview.getState().set({ ...previewNode, position }) + const nextSnapKey = movementSfxStepKey({ + coords: [position[0], position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if (prev !== nextSnapKey) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [position[0], position[2]] + previousSnapRef.current = nextSnapKey } } diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index ee5a13b3..9e0005ab 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,6 +1,7 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { boxVentDefinition } from './box-vent' import { buildingDefinition } from './building' +import { cabinetDefinition, cabinetModuleDefinition } from './cabinet' import { ceilingDefinition } from './ceiling' import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' @@ -70,6 +71,8 @@ export const builtinPlugin: Plugin = { ceilingDefinition as unknown as AnyNodeDefinition, doorDefinition as unknown as AnyNodeDefinition, windowDefinition as unknown as AnyNodeDefinition, + cabinetDefinition as unknown as AnyNodeDefinition, + cabinetModuleDefinition as unknown as AnyNodeDefinition, itemDefinition as unknown as AnyNodeDefinition, // Stage A — wrap-exports the legacy renderer + system. Legacy // panels / move tools / floorplan branches still serve these. @@ -113,6 +116,15 @@ export const builtinPlugin: Plugin = { export { boxVentDefinition } from './box-vent' export { buildingDefinition } from './building' +export { + bakeCabinetAnimationClip, + type CabinetPlacementType, + cabinetDefinition, + cabinetModuleDefinition, + poseCabinetMovingParts, + useCabinetPlacementStatus, + useCabinetPlacementType, +} from './cabinet' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx index 1ef946f4..60f8d93f 100644 --- a/packages/nodes/src/item/panel.tsx +++ b/packages/nodes/src/item/panel.tsx @@ -100,7 +100,7 @@ export default function ItemPanel() { return ( { + e.stopPropagation() + e.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) +} + export function stopPlacementCommitPropagation(event: FloorPlacementClickTriggerEvent) { const native = (event as { nativeEvent?: unknown }).nativeEvent const nativeStopPropagation = (native as { stopPropagation?: () => void } | undefined) @@ -111,6 +125,7 @@ export function stopPlacementCommitPropagation(event: FloorPlacementClickTrigger } const direct = (event as { stopPropagation?: () => void }).stopPropagation if (typeof direct === 'function') direct.call(event) + if ('node' in event) swallowFollowUpBrowserClick() } export function subscribeFloorPlacementClicks( @@ -132,3 +147,23 @@ export function subscribeFloorPlacementClicks( } } } + +export function subscribeFloorPlacementDoubleClicks( + onDoubleClick: (event: FloorPlacementClickTriggerEvent) => void, +) { + emitter.on('grid:double-click', onDoubleClick) + type SuffixedKey = `${K}:${EventSuffix}` + type DoubleClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]> + for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { + const key = `${kind}:double-click` as DoubleClickKey + emitter.on(key, onDoubleClick as never) + } + + return () => { + emitter.off('grid:double-click', onDoubleClick) + for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { + const key = `${kind}:double-click` as DoubleClickKey + emitter.off(key, onDoubleClick as never) + } + } +} diff --git a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts index 04311429..c79b70f8 100644 --- a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts +++ b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts @@ -2,37 +2,13 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' import { type AnyNode, type RoofSegmentNode, useScene } from '@pascal-app/core' import { getRoofSurfaceFaceBoundsAt } from './roof-surface' -mock.module('@pascal-app/editor', () => ({ - useOpeningGuides: { - getState: () => ({ - clear: () => undefined, - set: () => undefined, - }), - }, -})) - -mock.module('@pascal-app/viewer', () => ({ - Brush: class {}, - SUBTRACTION: 0, - csgEvaluator: { - evaluate: () => ({ geometry: { dispose: () => undefined } }), - }, - csgGeometry: () => ({ - clone: () => ({ - addGroup: () => undefined, - clearGroups: () => undefined, - getIndex: () => null, - translate: () => undefined, - }), - }), - prepareBrushForCSG: () => undefined, - useViewer: { - getState: () => ({ - selection: {}, - }), - }, -})) - +// bun's mock.module is process-global: it replaces the mocked module for +// every test file that runs after this one in the same `bun test` invocation. +// Do NOT stub the '@pascal-app/editor' / '@pascal-app/viewer' stores here — +// the real ones work for this test (guides publish into the real +// useOpeningGuides store; `useViewer.getState().selection.buildingId` reads +// the store default), while a stubbed `getState` blinds every later suite +// (select-candidates, wall-drafting) to its own `setState` calls. mock.module('../skylight/frame-csg', () => ({ buildFrameGeometry: () => null, })) diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index f26f742c..35c7ce95 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -3,6 +3,7 @@ import { type AnyNodeId, generateSceneMaterialId, type MaterialSchema, + type MaterialTarget, type PaintCapability, type PaintPreviewArgs, type PaintResolveArgs, @@ -230,6 +231,7 @@ export function resolveSlotByReRaycast(args: PaintResolveArgs): string | null { } export type SlotPaintConfig = { + materialTarget?: MaterialTarget /** Resolve the slot id for a pointer hit (`null` = not paintable here). */ resolveRole: (args: PaintResolveArgs) => string | null /** Apply a preview to the registered mesh subtree for `role`. */ @@ -249,6 +251,7 @@ export type SlotPaintConfig = { export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability { return { + materialTarget: config.materialTarget, roomScope: config.roomScope, resolveRole: config.resolveRole, buildPatch: ({ node, role, materialPreset }) => { diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index 27e3e4df..de4dc94d 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -12,6 +12,7 @@ import { isAlignmentGuideActive, isGridSnapActive, isMagneticSnapActive, + movementSfxStepKey, triggerSFX, useAlignmentGuides, useEditor, @@ -32,7 +33,7 @@ import ShelfPreview from './preview' const ShelfTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) const cursorVisibleRef = useRef(false) const [cursorVisible, setCursorVisible] = useState(false) @@ -101,10 +102,15 @@ const ShelfTool = () => { cursorRef.current?.position.set(...visualPosition) lastCursorRef.current = position + const nextSnapKey = movementSfxStepKey({ + coords: [position[0], position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if (prev !== nextSnapKey) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [position[0], position[2]] + previousSnapRef.current = nextSnapKey } } diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 87d66dc1..514c744a 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -38,15 +38,19 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom if (points.length < 2) return geometry const positions: number[] = [] + const uvs: number[] = [] // Create a simple line loop at ground level - for (const [x, z] of points) { + points.forEach(([x, z], index) => { positions.push(x ?? 0, Y_OFFSET, z ?? 0) - } + uvs.push(points.length > 1 ? index / (points.length - 1) : 0, 0) + }) // Close the loop positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0) + uvs.push(1, 0) geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) return geometry } diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index 47a5071b..0d0a1748 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -85,7 +85,6 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI fallbackPoint: context.gridPoint, levelId: slabLevelId, excludeId: slabId, - altKey: context.nativeEvent?.altKey === true, }).point, [slabId, slabLevelId], ) diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index 5c403334..5054294a 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -27,7 +27,6 @@ const slabSnapOptions = { nodes, rawPoint, fallbackPoint, - modifiers, }: PolygonAffordanceSnapContext) { const sceneNodes = nodes as Record return resolveSlabPlanPointSnap({ @@ -37,8 +36,7 @@ const slabSnapOptions = { excludeId: node.id, nodes: sceneNodes, // Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the - // `lines` mode), so no Shift bypass — Alt still force-skips alignment. - altKey: modifiers.altKey, + // `lines` mode), so no Shift or Alt snap bypass. }).point }, } diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 12f10a55..cf67ef36 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -100,7 +100,6 @@ export const SlabTool: React.FC = () => { rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, - altKey: event.nativeEvent?.altKey === true, }).point setSnappedCursorPosition(displayPoint) if ( diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 4f926562..c68a5dbc 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -13,6 +13,7 @@ import { isAlignmentGuideActive, isGridSnapActive, isMagneticSnapActive, + movementSfxStepKey, triggerSFX, useAlignmentGuides, useEditor, @@ -43,7 +44,7 @@ function getExistingSpawnIds() { const SpawnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) // Default spawn for the footprint anchors the alignment solver reads. const previewNode = useMemo( @@ -79,10 +80,15 @@ const SpawnTool = () => { cursorRef.current?.position.set(...visualPosition) lastCursorRef.current = position + const nextSnapKey = movementSfxStepKey({ + coords: [position[0], position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if (prev !== nextSnapKey) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [position[0], position[2]] + previousSnapRef.current = nextSnapKey } } diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index f6b1ceb8..a41adfd4 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -26,7 +26,7 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 1, + schemaVersion: 5, schema: WallNode, category: 'structure', surfaceRole: 'wall', @@ -59,10 +59,10 @@ export const wallDefinition: NodeDefinition = { // preview through this entry rather than carrying a kind-name // arm. paint: wallPaint, - // Declared paintable slots (interior / exterior) with their default - // appearance — the same `{ slotId, label, default }` contract every other - // paintable kind exposes. Paint still writes the legacy inline fields via - // `wallPaint`; migrating those into `node.slots` is a later step. + // Declared paintable slots with their default appearance — the same + // `{ slotId, label, default }` contract every other paintable kind exposes. + // Paint still writes the legacy inline fields for base faces via + // `wallPaint`; migrating those fully into `node.slots` is a later step. slots: () => wallSlots(), }, diff --git a/packages/nodes/src/wall/paint.test.ts b/packages/nodes/src/wall/paint.test.ts new file mode 100644 index 00000000..386200f4 --- /dev/null +++ b/packages/nodes/src/wall/paint.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from 'bun:test' +import { sceneRegistry, type WallNode } from '@pascal-app/core' +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Ray, Vector3 } from 'three' +import { resolveWallRole, wallPaint } from './paint' + +const baseWall: WallNode = { + object: 'node', + id: 'wall_test', + type: 'wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + height: 2.5, + thickness: 0.1, + faceBands: { enabled: true, count: 3, lowerHeight: 0.9, middleHeight: 0.12, upperHeight: 0.61 }, + frontSide: 'interior', + backSide: 'exterior', +} + +describe('resolveWallRole', () => { + test('maps one wall face to lower, middle, and upper band slots by hit height', () => { + expect( + resolveWallRole({ + node: baseWall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('lowerInterior') + + expect( + resolveWallRole({ + node: baseWall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.95, 0.05], + }), + ).toBe('middleInterior') + + expect( + resolveWallRole({ + node: baseWall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 1.3, 0.05], + }), + ).toBe('upperInterior') + }) + + test('uses adjusted band heights from the wall config', () => { + const wall = { + ...baseWall, + faceBands: { + enabled: true, + count: 3, + lowerHeight: 1.2, + middleHeight: 0.2, + upperHeight: 0.61, + }, + } + + expect( + resolveWallRole({ + node: wall, + materialIndex: 2, + normal: [0, 0, -1], + localPosition: [1, 1.1, -0.05], + }), + ).toBe('lowerExterior') + + expect( + resolveWallRole({ + node: wall, + materialIndex: 2, + normal: [0, 0, -1], + localPosition: [1, 1.3, -0.05], + }), + ).toBe('middleExterior') + }) + + test('falls back to whole-side roles when bands are disabled', () => { + const wall = { + ...baseWall, + faceBands: { + enabled: false, + count: 1, + lowerHeight: 0.9, + middleHeight: 0.12, + upperHeight: 0.61, + }, + } + + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('interior') + }) + + test('maps four wall bands to lower, middle, upper, and top slots', () => { + const wall = { + ...baseWall, + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.5, + middleHeight: 0.5, + upperHeight: 0.5, + }, + } + + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('lowerInterior') + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.7, 0.05], + }), + ).toBe('middleInterior') + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 1.2, 0.05], + }), + ).toBe('upperInterior') + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 1.8, 0.05], + }), + ).toBe('topInterior') + }) + + test('falls back to whole-side roles by default', () => { + const { faceBands, ...wall } = baseWall + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('interior') + }) + + test('uses direct trim slot hits and exposes trim default materials', () => { + expect( + resolveWallRole({ + node: baseWall, + hitObject: { userData: { slotId: 'crownInterior' } }, + materialIndex: null, + normal: undefined, + localPosition: undefined, + }), + ).toBe('crownInterior') + + expect( + wallPaint.getEffectiveMaterial?.({ + node: baseWall, + role: 'crownInterior', + nodes: { [baseWall.id]: baseWall }, + }), + ).toEqual({ material: undefined, materialPreset: 'library:preset-white' }) + }) + + test('prefers trim child ray hits over the broad wall face role', () => { + const root = new Object3D() + const trim = new Mesh(new BoxGeometry(1, 1, 0.08), new MeshBasicMaterial()) + trim.userData.slotId = 'crownInterior' + root.add(trim) + root.updateMatrixWorld(true) + sceneRegistry.nodes.set(baseWall.id, root) + + try { + expect( + resolveWallRole({ + node: baseWall, + hitObject: { userData: {} }, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [0, 1, 0.05], + ray: new Ray(new Vector3(0, 0, 1), new Vector3(0, 0, -1)), + }), + ).toBe('crownInterior') + } finally { + sceneRegistry.nodes.delete(baseWall.id) + trim.geometry.dispose() + ;(trim.material as MeshBasicMaterial).dispose() + } + }) +}) diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index 45eaefe1..b914561f 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -2,20 +2,75 @@ import { type AnyNode, type AnyNodeId, getEffectiveWallSurfaceMaterial, + getWallBandSlotId, + getWallFaceBandConfig, + getWallFaceBandForHeight, + getWallSurfaceSideFromBandSlot, type PaintCapability, type PaintPreviewArgs, + parseMaterialRef, + type SceneMaterialId, sceneRegistry, + useScene, + WALL_SURFACE_SLOT_DEFAULTS, type WallNode, type WallSurfaceSide, + type WallSurfaceSlotId, } from '@pascal-app/core' -import type { Material, Mesh } from 'three' -import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' +import { type Material, type Mesh, type Object3D, type Ray, Raycaster } from 'three' +import { + buildSlotPreviewMaterial, + createSlotPaintCapability, + previewSlotByUserData, +} from '../shared/slot-paint' + +const WALL_SLOT_IDS = new Set(Object.keys(WALL_SURFACE_SLOT_DEFAULTS)) +const WALL_ARRAY_SLOT_INDEX: Partial> = { + interior: 1, + exterior: 2, + lowerInterior: 3, + middleInterior: 4, + upperInterior: 5, + topInterior: 6, + lowerExterior: 7, + middleExterior: 8, + upperExterior: 9, + topExterior: 10, +} +const WALL_INDEX_SLOT = new Map( + Object.entries(WALL_ARRAY_SLOT_INDEX).map(([slotId, index]) => [ + index, + slotId as WallSurfaceSlotId, + ]), +) +const wallSlotRaycaster = new Raycaster() + +function resolveSideFromMaterialIndex(materialIndex: number | null): WallSurfaceSide | null { + const slotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex) + if (slotId) return getWallSurfaceSideFromBandSlot(slotId) + return null +} + +function resolveWallSlotByRay(node: WallNode, ray: Ray | undefined): WallSurfaceSlotId | null { + if (!ray) return null + const root = sceneRegistry.nodes.get(node.id as AnyNodeId) + if (!root) return null + + wallSlotRaycaster.ray.copy(ray) + const hits = wallSlotRaycaster.intersectObject(root, true) + for (const hit of hits) { + const slotId = (hit.object as Object3D).userData?.slotId + if (typeof slotId === 'string' && WALL_SLOT_IDS.has(slotId)) { + return slotId as WallSurfaceSlotId + } + } + + return null +} /** - * Resolve which side of a wall the user clicked. Walls expose two - * paintable surfaces — interior + exterior — split by: - * 1. Material-slot index from the renderer's groups (1 = interior, - * 2 = exterior). Cheap reference-equality path. + * Resolve which wall face band the user clicked. The side comes from: + * 1. Material-slot index from the renderer's groups. Cheap reference path. * 2. Falls back to the hit-surface normal + local-Z when the * groups aren't conclusive. Front/back of the wall maps to the * node's `frontSide` / `backSide` semantic; absent that, front @@ -26,13 +81,34 @@ import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/s */ export function resolveWallRole(args: { node: WallNode + hitObject?: { userData?: { slotId?: unknown } } materialIndex: number | null normal: readonly [number, number, number] | undefined localPosition: readonly [number, number, number] | undefined -}): WallSurfaceSide | null { - const { node, materialIndex, normal, localPosition } = args - if (materialIndex === 1) return 'interior' - if (materialIndex === 2) return 'exterior' + ray?: Ray +}): string | null { + const { node, hitObject, materialIndex, normal, localPosition, ray } = args + const directSlotId = hitObject?.userData?.slotId + if (typeof directSlotId === 'string' && WALL_SLOT_IDS.has(directSlotId)) { + return directSlotId + } + + const raySlotId = resolveWallSlotByRay(node, ray) + if (raySlotId) return raySlotId + + const indexedSlotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex) + const indexedSide = resolveSideFromMaterialIndex(materialIndex) + const sideFromIndex = indexedSide ?? null + if (indexedSlotId && indexedSlotId !== 'interior' && indexedSlotId !== 'exterior') { + return indexedSlotId + } + + if (sideFromIndex && localPosition) { + const bands = getWallFaceBandConfig(node) + if (!bands.enabled) return sideFromIndex + return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1])) + } + if (sideFromIndex) return sideFromIndex const normalZ = normal?.[2] const localZ = localPosition?.[2] @@ -41,6 +117,7 @@ export function resolveWallRole(args: { if ( normalZ === undefined || localZ === undefined || + localPosition === undefined || Math.abs(normalZ) < 0.65 || Math.abs(localZ) < Math.max(thickness * 0.2, 0.01) ) { @@ -51,17 +128,15 @@ export function resolveWallRole(args: { const semantic = hitFace === 'front' ? node.frontSide : node.backSide if (semantic === 'interior' || semantic === 'exterior') { - return semantic + const bands = getWallFaceBandConfig(node) + if (!bands.enabled) return semantic + return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1])) } - return hitFace === 'front' ? 'interior' : 'exterior' -} - -// The wall's 3-material array maps side → group index (see -// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior. -const WALL_SIDE_MATERIAL_INDEX: Record = { - interior: 1, - exterior: 2, + const side = hitFace === 'front' ? 'interior' : 'exterior' + const bands = getWallFaceBandConfig(node) + if (!bands.enabled) return side + return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1])) } /** @@ -72,9 +147,12 @@ const WALL_SIDE_MATERIAL_INDEX: Record = { */ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { const { role, material, materialPreset } = args - const side = role as WallSurfaceSide - const index = WALL_SIDE_MATERIAL_INDEX[side] - if (!index) return null + if (!(role in WALL_ARRAY_SLOT_INDEX)) { + return previewSlotByUserData(args) + } + + const index = WALL_ARRAY_SLOT_INDEX[role as WallSurfaceSlotId] + if (!index) return previewSlotByUserData(args) const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId) if (!(mesh && (mesh as Mesh).isMesh)) return null @@ -98,18 +176,43 @@ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { /** * Capability binding for the wall kind on the unified slot model. Painting - * writes `node.slots[interior|exterior]` (a `library:` ref or a minted - * `scene:` material) exactly like every other kind; `legacyEffective` reads - * the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the - * picker still shows the current value on a pre-migration scene. + * writes `node.slots[bandSide]` (a `library:` ref or a minted `scene:` + * material) exactly like every other kind; `legacyEffective` reads the + * whole-side fallback so old scenes still show the current value. */ export const wallPaint: PaintCapability = createSlotPaintCapability({ roomScope: true, - resolveRole: ({ node, materialIndex, normal, localPosition }) => - resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }), + resolveRole: ({ node, hitObject, materialIndex, normal, localPosition, ray }) => + resolveWallRole({ + node: node as WallNode, + hitObject: hitObject as { userData?: { slotId?: unknown } } | undefined, + materialIndex, + normal, + localPosition, + ray, + }), applyPreview: applyWallPreview, legacyEffective: (node: AnyNode, role: string) => { - const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide) + const side = getWallSurfaceSideFromBandSlot(role) + if (!side && role in WALL_SURFACE_SLOT_DEFAULTS) { + return { + material: undefined, + materialPreset: WALL_SURFACE_SLOT_DEFAULTS[role as WallSurfaceSlotId], + } + } + if (!side) return null + + const sideRef = (node as WallNode).slots?.[side] + const parsed = parseMaterialRef(sideRef) + if (parsed?.kind === 'library') { + return { material: undefined, materialPreset: sideRef } + } + if (parsed?.kind === 'scene') { + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined } + } + + const spec = getEffectiveWallSurfaceMaterial(node as WallNode, side) if (spec.material === undefined && spec.materialPreset === undefined) return null return { material: spec.material, materialPreset: spec.materialPreset } }, diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 809a9018..72342657 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -3,13 +3,20 @@ import { type AnyNode, type AnyNodeId, + buildWallFaceBandCountPatch, getClampedWallCurveOffset, getMaxWallCurveOffset, getWallCurveLength, + getWallFaceBandConfig, normalizeWallCurveOffset, useLiveNodeOverrides, useScene, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_FACE_BAND_DEFAULT, + WALL_SKIRTING_DEFAULT, type WallNode, + type WallTrimProfile, } from '@pascal-app/core' import { ActionButton, @@ -20,6 +27,7 @@ import { metersToLinearUnit, PanelSection, PanelWrapper, + SegmentedControl, SliderControl, triggerSFX, useInteractionScope, @@ -28,6 +36,35 @@ import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' +type WallTrimKey = 'skirting' | 'crown' | 'chairRail' + +const WALL_TRIM_PROFILE_OPTIONS: Record< + WallTrimKey, + Array<{ label: string; value: WallTrimProfile }> +> = { + skirting: [ + { label: 'Flat', value: 'flat' }, + { label: 'Modern', value: 'base-modern' }, + { label: 'Colonial', value: 'base-colonial' }, + { label: 'Shoe', value: 'base-shoe' }, + { label: 'Ogee', value: 'base-ogee' }, + ], + crown: [ + { label: 'Flat', value: 'flat' }, + { label: 'Cove', value: 'crown-cove' }, + { label: 'Ogee', value: 'crown-ogee' }, + { label: 'Craft', value: 'crown-craftsman' }, + { label: 'Layered', value: 'crown-layered' }, + ], + chairRail: [ + { label: 'Flat', value: 'flat' }, + { label: 'Round', value: 'rail-rounded' }, + { label: 'Ogee', value: 'rail-ogee' }, + { label: 'Picture', value: 'rail-picture' }, + { label: 'Step', value: 'rail-stepped' }, + ], +} + export default function WallPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const unit = useViewer((s) => s.unit) @@ -134,6 +171,11 @@ export default function WallPanel() { const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) + const wallHeightMeters = node.height ?? 2.5 + + const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } + const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } + const chairRail = { ...WALL_CHAIR_RAIL_DEFAULT, ...(node.chairRail ?? {}) } return ( + + + + + + {!hasWallChildrenBlockingCurve && ( @@ -223,3 +304,218 @@ export default function WallPanel() { ) } + +function WallFaceBandSection({ + node, + onUpdate, + unit, + unitLabel, + wallHeightMeters, +}: { + node: WallNode + onUpdate: (updates: Partial) => void + unit: 'metric' | 'imperial' + unitLabel: string + wallHeightMeters: number +}) { + const bandConfig = getWallFaceBandConfig(node) + const bandCount = bandConfig.count + const lowerHeight = bandConfig.lowerHeight + const middleHeight = bandConfig.middleHeight + const upperHeight = bandConfig.upperHeight + const updateBands = (patch: Partial>) => + onUpdate({ + faceBands: { + ...WALL_FACE_BAND_DEFAULT, + ...(node.faceBands ?? {}), + enabled: bandCount > 1, + count: bandCount, + ...patch, + }, + }) + + return ( + + onUpdate(buildWallFaceBandCountPatch(node, Math.round(value)))} + precision={0} + step={1} + value={bandCount} + /> + {bandCount >= 2 && ( + + updateBands({ + lowerHeight: linearControlValueToMeters(value, unit, { + maxMeters: wallHeightMeters, + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(lowerHeight, unit)} + /> + )} + {bandCount >= 3 && ( + + updateBands({ + middleHeight: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0, wallHeightMeters - lowerHeight), + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(middleHeight, unit)} + /> + )} + {bandCount >= 4 && ( + + updateBands({ + upperHeight: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0, wallHeightMeters - lowerHeight - middleHeight), + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(upperHeight, unit)} + /> + )} + + ) +} + +function WallTrimSection({ + node, + onUpdate, + title, + trimKey, + trimValue, + unit, + unitLabel, + wallHeightMeters, +}: { + node: WallNode + onUpdate: (updates: Partial) => void + title: string + trimKey: WallTrimKey + trimValue: NonNullable + unit: 'metric' | 'imperial' + unitLabel: string + wallHeightMeters: number +}) { + const updateTrim = (patch: Partial>) => + onUpdate({ + [trimKey]: { + ...trimValue, + ...patch, + }, + } as Partial) + const profileOptions = WALL_TRIM_PROFILE_OPTIONS[trimKey] + const selectedProfile = profileOptions.some((option) => option.value === trimValue.profile) + ? trimValue.profile + : profileOptions[0]!.value + + return ( + + + updateTrim({ enabled: !trimValue.enabled })} + /> + + {trimValue.enabled && ( + <> + updateTrim({ sides: next as any })} + options={[ + { label: 'Interior', value: 'interior' }, + { label: 'Exterior', value: 'exterior' }, + { label: 'Both', value: 'both' }, + ]} + value={trimValue.sides} + /> + updateTrim({ profile: next })} + options={profileOptions} + value={selectedProfile} + /> + + updateTrim({ + height: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0.05, wallHeightMeters), + minMeters: 0.01, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(trimValue.height, unit)} + /> + + updateTrim({ + proud: linearControlValueToMeters(value, unit, { + maxMeters: 0.2, + minMeters: 0.001, + }), + }) + } + precision={3} + step={0.005} + unit={unitLabel} + value={metersToLinearUnit(trimValue.proud, unit)} + /> + {trimKey === 'chairRail' && ( + + updateTrim({ + offsetY: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0.05, wallHeightMeters - trimValue.height), + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(trimValue.offsetY ?? 0, unit)} + /> + )} + + )} + + ) +} diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 856f0609..41a760b6 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -1,10 +1,17 @@ 'use client' -import { useRegistry, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + useRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { createWallExtraSlotMaterials, WallTreatments } from './treatments' /** * Thin wall renderer. @@ -27,7 +34,6 @@ import { createPlaceholderGeometry } from '../shared/placeholder-geometry' */ const WallRenderer = ({ node }: { node: WallNode }) => { const ref = useRef(null!) - // 3 groups map 1:1 to the wall's 3-material array (see getVisibleWallMaterials). const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), []) const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), []) @@ -49,12 +55,20 @@ const WallRenderer = ({ node }: { node: WallNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneNodes = useScene((state) => state.nodes) + const childNodes = useMemo( + () => + (node.children ?? []) + .map((childId) => sceneNodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => child !== undefined), + [node.children, sceneNodes], + ) // Subscribe to the scene-material palette so editing a `scene:` material a // wall slot references re-renders the wall live (the wall-system geometry // dirty loop never fires for a material-only edit). `getMaterialsForWall`'s // content hash keeps unaffected walls on their cached materials. const sceneMaterials = useScene((s) => s.materials) - const material = getVisibleWallMaterials( + const baseMaterials = getVisibleWallMaterials( node, shading, textures, @@ -62,12 +76,24 @@ const WallRenderer = ({ node }: { node: WallNode }) => { sceneTheme, sceneMaterials, ) + const extraMaterials = useMemo( + () => createWallExtraSlotMaterials(node, shading, sceneMaterials), + [node, sceneMaterials, shading], + ) + useEffect( + () => () => { + const baseSet = new Set(baseMaterials) + const owned = new Set(Object.values(extraMaterials).filter((entry) => !baseSet.has(entry))) + for (const entry of owned) entry.dispose() + }, + [baseMaterials, extraMaterials], + ) return ( { {...handlers} /> + + {(node.children ?? []).map((childId) => ( ))} diff --git a/packages/nodes/src/wall/slots.ts b/packages/nodes/src/wall/slots.ts index 2a9dc481..2cb285c3 100644 --- a/packages/nodes/src/wall/slots.ts +++ b/packages/nodes/src/wall/slots.ts @@ -1,4 +1,4 @@ -import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core' +import { type SlotDeclaration, WALL_SURFACE_SLOT_DEFAULTS } from '@pascal-app/core' /** * A wall exposes two paintable faces — interior + exterior. Painting writes @@ -9,7 +9,77 @@ import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core' */ export function wallSlots(): SlotDeclaration[] { return [ - { slotId: 'interior', label: 'Interior', default: WALL_SLOT_DEFAULT.interior }, - { slotId: 'exterior', label: 'Exterior', default: WALL_SLOT_DEFAULT.exterior }, + { slotId: 'interior', label: 'Interior', default: WALL_SURFACE_SLOT_DEFAULTS.interior }, + { slotId: 'exterior', label: 'Exterior', default: WALL_SURFACE_SLOT_DEFAULTS.exterior }, + { + slotId: 'lowerInterior', + label: 'Lower band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.lowerInterior, + }, + { + slotId: 'middleInterior', + label: 'Middle band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.middleInterior, + }, + { + slotId: 'upperInterior', + label: 'Upper band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.upperInterior, + }, + { + slotId: 'topInterior', + label: 'Top band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.topInterior, + }, + { + slotId: 'lowerExterior', + label: 'Lower band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.lowerExterior, + }, + { + slotId: 'middleExterior', + label: 'Middle band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.middleExterior, + }, + { + slotId: 'upperExterior', + label: 'Upper band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.upperExterior, + }, + { + slotId: 'topExterior', + label: 'Top band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.topExterior, + }, + { + slotId: 'skirtingInterior', + label: 'Skirting (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.skirtingInterior, + }, + { + slotId: 'skirtingExterior', + label: 'Skirting (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.skirtingExterior, + }, + { + slotId: 'crownInterior', + label: 'Crown (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.crownInterior, + }, + { + slotId: 'crownExterior', + label: 'Crown (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.crownExterior, + }, + { + slotId: 'chairRailInterior', + label: 'Chair rail (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.chairRailInterior, + }, + { + slotId: 'chairRailExterior', + label: 'Chair rail (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior, + }, ] } diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx new file mode 100644 index 00000000..2a2a6715 --- /dev/null +++ b/packages/nodes/src/wall/treatments.tsx @@ -0,0 +1,613 @@ +'use client' + +import { + getWallCurveFrameAt, + getWallThickness, + isCurvedWall, + type SceneMaterial, + type SceneMaterialId, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_SKIRTING_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, + type WallNode, + type WallSurfaceSlotId, + type WallTrimConfig, + type WallTrimProfile, +} from '@pascal-app/core' +import { + baseMaterial, + createMaterialFromPresetRef, + type RenderShading, + resolveMaterialRef, +} from '@pascal-app/viewer' +import { memo, useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +const CURVE_SEGMENTS = 24 +const MIN_SLICE_PROUD = 0.0005 +const EPS = 1e-6 + +type OpeningLike = { + type: string + width?: number + height?: number + position?: [number, number, number] +} + +type TrimKind = 'skirting' | 'crown' | 'chairRail' +type WallSide = 'interior' | 'exterior' +type SceneMaterials = Record +type Point2 = { x: number; z: number } +type WallTreatmentSlotId = + | 'skirtingInterior' + | 'skirtingExterior' + | 'crownInterior' + | 'crownExterior' + | 'chairRailInterior' + | 'chairRailExterior' +type TrimProfileDefinition = { + samples: number + proudAt: (t: number) => number +} + +function clamp01(value: number) { + return Math.max(0, Math.min(1, value)) +} + +function eased(value: number) { + const t = clamp01(value) + return t * t * (3 - 2 * t) +} + +const TRIM_PROFILES: Record>> = { + skirting: { + flat: { + samples: 8, + proudAt: (t) => (t < 0.72 ? 0.62 : 0.9), + }, + bevel: { + samples: 10, + proudAt: (t) => 0.48 + 0.42 * eased(t), + }, + triangle: { + samples: 10, + proudAt: (t) => 0.32 + 0.68 * (1 - t), + }, + cove: { + samples: 12, + proudAt: (t) => 0.45 + 0.42 * Math.sin(t * Math.PI * 0.5), + }, + bullnose: { + samples: 12, + proudAt: (t) => 0.35 + 0.65 * Math.sin(Math.PI * t), + }, + 'base-modern': { + samples: 10, + proudAt: (t) => { + if (t < 0.16) return 0.85 + if (t < 0.72) return 0.58 + if (t < 0.9) return 1 + return 0.62 + }, + }, + 'base-colonial': { + samples: 14, + proudAt: (t) => { + if (t < 0.16) return 0.82 + if (t < 0.55) return 0.52 + const capT = (t - 0.55) / 0.45 + return 0.58 + 0.4 * Math.sin(capT * Math.PI) + }, + }, + 'base-shoe': { + samples: 14, + proudAt: (t) => { + const quarterRound = Math.sqrt(Math.max(0, 1 - t * t)) + return 0.28 + 0.72 * quarterRound + }, + }, + 'base-ogee': { + samples: 16, + proudAt: (t) => { + const ogee = 0.5 - 0.5 * Math.cos(Math.PI * t) + const bead = 0.16 * Math.sin(2 * Math.PI * t) + return clamp01(0.42 + 0.5 * ogee + bead) + }, + }, + }, + crown: { + flat: { + samples: 8, + proudAt: (t) => (t < 0.2 ? 0.52 : t < 0.82 ? 0.78 : 1), + }, + bevel: { + samples: 10, + proudAt: (t) => 0.45 + 0.55 * eased(t), + }, + triangle: { + samples: 10, + proudAt: (t) => 0.35 + 0.65 * t, + }, + cove: { + samples: 14, + proudAt: (t) => 0.46 + 0.45 * Math.sin(t * Math.PI * 0.5), + }, + bullnose: { + samples: 14, + proudAt: (t) => 0.38 + 0.62 * Math.sin(Math.PI * t * 0.5), + }, + 'crown-cove': { + samples: 16, + proudAt: (t) => { + if (t < 0.12) return 0.48 + if (t > 0.9) return 1 + return 0.46 + 0.48 * Math.sin(((t - 0.12) / 0.78) * Math.PI * 0.5) + }, + }, + 'crown-ogee': { + samples: 18, + proudAt: (t) => { + const sCurve = 0.5 - 0.5 * Math.cos(Math.PI * t) + const reverse = 0.18 * Math.sin(2 * Math.PI * (t - 0.12)) + return clamp01(0.42 + 0.55 * sCurve + reverse) + }, + }, + 'crown-craftsman': { + samples: 10, + proudAt: (t) => { + if (t < 0.16) return 0.48 + if (t < 0.4) return 0.88 + if (t < 0.78) return 0.64 + return 1 + }, + }, + 'crown-layered': { + samples: 14, + proudAt: (t) => { + if (t < 0.12) return 0.46 + if (t < 0.28) return 0.82 + if (t < 0.48) return 0.56 + if (t < 0.72) return 0.9 + return 1 + }, + }, + }, + chairRail: { + flat: { + samples: 8, + proudAt: (t) => (t < 0.18 || t > 0.82 ? 0.58 : 0.95), + }, + bevel: { + samples: 10, + proudAt: (t) => 0.45 + 0.45 * Math.sin(Math.PI * t), + }, + triangle: { + samples: 10, + proudAt: (t) => 0.35 + 0.65 * (1 - Math.abs(2 * t - 1)), + }, + cove: { + samples: 12, + proudAt: (t) => 0.42 + 0.42 * Math.sin(Math.PI * t), + }, + bullnose: { + samples: 14, + proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t), + }, + 'rail-rounded': { + samples: 14, + proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t), + }, + 'rail-ogee': { + samples: 16, + proudAt: (t) => { + const center = Math.sin(Math.PI * t) + const twist = 0.14 * Math.sin(2 * Math.PI * t) + return clamp01(0.36 + 0.58 * center + twist) + }, + }, + 'rail-picture': { + samples: 12, + proudAt: (t) => { + if (t < 0.18) return 0.48 + if (t < 0.4) return 0.92 + if (t < 0.74) return 0.58 + return 1 + }, + }, + 'rail-stepped': { + samples: 10, + proudAt: (t) => { + if (t < 0.22) return 0.55 + if (t < 0.78) return 1 + return 0.62 + }, + }, + }, +} + +const TRIM_KIND_CONFIG: Record< + TrimKind, + { + defaultConfig: WallTrimConfig + slots: Record + } +> = { + skirting: { + defaultConfig: WALL_SKIRTING_DEFAULT, + slots: { interior: 'skirtingInterior', exterior: 'skirtingExterior' }, + }, + crown: { + defaultConfig: WALL_CROWN_DEFAULT, + slots: { interior: 'crownInterior', exterior: 'crownExterior' }, + }, + chairRail: { + defaultConfig: WALL_CHAIR_RAIL_DEFAULT, + slots: { interior: 'chairRailInterior', exterior: 'chairRailExterior' }, + }, +} + +function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) { + const defaultProfile = TRIM_KIND_CONFIG[kind].defaultConfig.profile + return ( + TRIM_PROFILES[kind][trim.profile] ?? + TRIM_PROFILES[kind][defaultProfile] ?? + TRIM_PROFILES[kind].flat + ) +} + +function resolveTreatmentSideSign(node: WallNode, side: WallSide) { + if (side === 'interior') { + if (node.frontSide === 'interior') return 1 + if (node.backSide === 'interior') return -1 + return 1 + } + if (node.frontSide === 'exterior') return 1 + if (node.backSide === 'exterior') return -1 + return -1 +} + +function wallToLocalTransform(node: WallNode) { + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const angle = Math.atan2(dz, dx) + const cosA = Math.cos(-angle) + const sinA = Math.sin(-angle) + return (worldX: number, worldZ: number): Point2 => { + const px = worldX - node.start[0] + const pz = worldZ - node.start[1] + return { + x: px * cosA - pz * sinA, + z: px * sinA + pz * cosA, + } + } +} + +function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Point2[] { + const sideSign = resolveTreatmentSideSign(node, side) + const toLocal = wallToLocalTransform(node) + const sampleCount = isCurvedWall(node) ? CURVE_SEGMENTS : 1 + const points: Point2[] = [] + + for (let index = 0; index <= sampleCount; index += 1) { + const frame = getWallCurveFrameAt(node, index / sampleCount) + const worldX = frame.point.x + frame.normal.x * offset * sideSign + const worldZ = frame.point.y + frame.normal.y * offset * sideSign + points.push(toLocal(worldX, worldZ)) + } + + return points +} + +function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] { + if (points.length < 2 || x1 - x0 <= EPS) return [] + const out: Point2[] = [] + for (let index = 0; index < points.length - 1; index += 1) { + const a = points[index] + const b = points[index + 1] + if (!(a && b)) continue + const minX = Math.min(a.x, b.x) + const maxX = Math.max(a.x, b.x) + if (maxX < x0 - EPS || minX > x1 + EPS) continue + + const pushPointAt = (x: number) => { + if (Math.abs(b.x - a.x) <= EPS) { + return { x, z: a.z } + } + const t = (x - a.x) / (b.x - a.x) + return { + x, + z: a.z + (b.z - a.z) * t, + } + } + + const start = minX < x0 ? pushPointAt(x0) : a + const end = maxX > x1 ? pushPointAt(x1) : b + if ( + out.length === 0 || + Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS + ) { + out.push(start) + } + out.push(end) + } + return out +} + +function subtractOpeningRanges(ranges: Array<[number, number]>, openings: Array<[number, number]>) { + let next = ranges.slice() + for (const [gap0, gap1] of openings) { + const updated: Array<[number, number]> = [] + for (const [a, b] of next) { + const start = Math.max(a, gap0) + const end = Math.min(b, gap1) + if (end - start <= EPS) { + updated.push([a, b]) + continue + } + if (start - a > EPS) updated.push([a, start]) + if (b - end > EPS) updated.push([end, b]) + } + next = updated + } + return next +} + +function trimOpeningRanges( + node: WallNode, + childrenNodes: OpeningLike[], + yBottom: number, + height: number, +) { + const yTop = yBottom + height + return childrenNodes + .filter((child) => child.type === 'door' || child.type === 'window') + .flatMap((child) => { + const width = child.width ?? 0 + const childHeight = child.height ?? 0 + const position = child.position ?? [0, 0, 0] + const childBottom = position[1] - childHeight / 2 + const childTop = childBottom + childHeight + if (childTop <= yBottom + EPS || childBottom >= yTop - EPS) return [] + return [[position[0] - width / 2, position[0] + width / 2] as [number, number]] + }) +} + +function buildPlanPolygon(outer: Point2[], inner: Point2[]) { + if (outer.length < 2 || inner.length < 2) return null + const shape = new THREE.Shape() + shape.moveTo(outer[0]!.x, -outer[0]!.z) + for (let index = 1; index < outer.length; index += 1) { + shape.lineTo(outer[index]!.x, -outer[index]!.z) + } + for (let index = inner.length - 1; index >= 0; index -= 1) { + shape.lineTo(inner[index]!.x, -inner[index]!.z) + } + shape.closePath() + return shape +} + +function applyWorldScaleUvs(geometry: THREE.BufferGeometry) { + const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined + if (!position) return + const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined + const uv = new Float32Array(position.count * 2) + + for (let index = 0; index < position.count; index += 1) { + const x = position.getX(index) + const y = position.getY(index) + const z = position.getZ(index) + const nx = Math.abs(normal?.getX(index) ?? 0) + const ny = Math.abs(normal?.getY(index) ?? 0) + const nz = Math.abs(normal?.getZ(index) ?? 0) + const offset = index * 2 + + if (ny >= nx && ny >= nz) { + uv[offset] = x + uv[offset + 1] = z + } else if (nz >= nx) { + uv[offset] = x + uv[offset + 1] = y + } else { + uv[offset] = z + uv[offset + 1] = y + } + } + + geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2)) +} + +function buildTrimSliceGeometry( + outer: Point2[], + inner: Point2[], + extrudeHeight: number, + translateY: number, +) { + const shape = buildPlanPolygon(outer, inner) + if (!shape) return null + const geometry = new THREE.ExtrudeGeometry(shape, { + depth: extrudeHeight, + bevelEnabled: false, + steps: 1, + curveSegments: 1, + }) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, translateY, 0) + geometry.computeVertexNormals() + applyWorldScaleUvs(geometry) + return geometry +} + +function mergeGeometries(geometries: THREE.BufferGeometry[]) { + if (geometries.length === 0) return null + const merged = mergeBufferGeometries(geometries, false) + if (merged) return merged + return null +} + +function buildTrimGeometry( + node: WallNode, + side: WallSide, + trim: WallTrimConfig, + kind: TrimKind, + childrenNodes: OpeningLike[], +) { + const wallHeight = node.height ?? 2.5 + const height = trim.height + const yBottom = + kind === 'crown' + ? Math.max(0, wallHeight - height) + : kind === 'chairRail' + ? Math.max( + 0, + Math.min(wallHeight - height, trim.offsetY ?? WALL_CHAIR_RAIL_DEFAULT.offsetY ?? 0.9), + ) + : 0 + + const thickness = getWallThickness(node) + const inner = buildSidePolyline(node, side, thickness / 2) + if (inner.length < 2) return null + + const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height) + const fullRanges: Array<[number, number]> = [[inner[0]!.x, inner[inner.length - 1]!.x]] + const runs = subtractOpeningRanges(fullRanges, openingRanges) + if (runs.length === 0) return null + + const slices: THREE.BufferGeometry[] = [] + const profile = resolveTrimProfile(kind, trim) + if (!profile) return null + const sliceHeight = height / profile.samples + + for (const [runStart, runEnd] of runs) { + const innerRun = clipPolyline(inner, runStart, runEnd) + if (innerRun.length < 2) continue + for (let index = 0; index < profile.samples; index += 1) { + const t = (index + 0.5) / profile.samples + const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t)) + const outerRun = buildSidePolyline(node, side, thickness / 2 + proud) + const outerClipped = clipPolyline(outerRun, runStart, runEnd) + if (outerClipped.length < 2) continue + const slice = buildTrimSliceGeometry( + outerClipped, + innerRun, + sliceHeight, + yBottom + index * sliceHeight, + ) + if (slice) slices.push(slice) + } + } + + if (slices.length === 0) return null + const merged = mergeGeometries(slices) + for (const slice of slices) slice.dispose() + return merged +} + +function resolveWallSlotMaterial( + node: WallNode, + slotId: WallSurfaceSlotId, + shading: RenderShading, + sceneMaterials: SceneMaterials, +) { + const ref = node.slots?.[slotId] + if (ref) { + return resolveMaterialRef(ref, sceneMaterials, shading) ?? baseMaterial(shading) + } + return ( + createMaterialFromPresetRef(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) ?? + baseMaterial(shading) + ) +} + +export function createWallExtraSlotMaterials( + node: WallNode, + shading: RenderShading, + sceneMaterials: SceneMaterials, +) { + return { + skirtingInterior: resolveWallSlotMaterial(node, 'skirtingInterior', shading, sceneMaterials), + skirtingExterior: resolveWallSlotMaterial(node, 'skirtingExterior', shading, sceneMaterials), + crownInterior: resolveWallSlotMaterial(node, 'crownInterior', shading, sceneMaterials), + crownExterior: resolveWallSlotMaterial(node, 'crownExterior', shading, sceneMaterials), + chairRailInterior: resolveWallSlotMaterial(node, 'chairRailInterior', shading, sceneMaterials), + chairRailExterior: resolveWallSlotMaterial(node, 'chairRailExterior', shading, sceneMaterials), + } satisfies Record +} + +export const WallTreatments = memo(function WallTreatments({ + node, + childrenNodes, + materials, +}: { + node: WallNode + childrenNodes: OpeningLike[] + materials: Record +}) { + const fallbackMaterial = + materials.skirtingInterior ?? + materials.skirtingExterior ?? + materials.crownInterior ?? + materials.crownExterior ?? + materials.chairRailInterior ?? + materials.chairRailExterior + + const trimEntries = useMemo(() => { + const out: Array<{ + key: string + slotId: WallTreatmentSlotId + geometry: THREE.BufferGeometry + material: THREE.Material + }> = [] + + const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [ + ['skirting', node.skirting], + ['crown', node.crown], + ['chairRail', node.chairRail], + ] + + for (const [kind, rawConfig] of configs) { + const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) } + if (!trim.enabled) continue + const sides = + trim.sides === 'both' + ? (['interior', 'exterior'] as WallSide[]) + : ([trim.sides] as WallSide[]) + for (const side of sides) { + const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes) + if (!geometry) continue + const slotId = TRIM_KIND_CONFIG[kind].slots[side] + out.push({ + key: `${kind}-${side}`, + slotId, + geometry, + material: materials[slotId] ?? fallbackMaterial, + }) + } + } + + return out + }, [childrenNodes, fallbackMaterial, materials, node]) + + useEffect( + () => () => { + for (const entry of trimEntries) entry.geometry.dispose() + }, + [trimEntries], + ) + + return ( + <> + {trimEntries.map((entry) => ( + + ))} + + ) +}) diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index 100fea15..7bb86ae1 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -1,8 +1,9 @@ # @pascal-app/plugin-trees -The first-party **example plugin** for the Pascal editor. It contributes a -procedural _trees_ node and a left-rail _presets_ panel, and exists to prove — -and document — the minimal host surface every future plugin reuses. +The first-party **example plugin** for the Pascal editor. It contributes +procedural plant nodes plus a separately exported host-side Nature panel, and +exists to prove — and document — the minimal node-plugin surface every future +plugin reuses. It is structurally identical to a third-party plugin: it peer-depends on `@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/ @@ -11,10 +12,11 @@ nothing private. Copy this folder as the starting point for a new plugin. ## What it demonstrates -The contribution paths a plugin has: +The contribution paths this package demonstrates: -1. **Left-rail panel** — `panels` in the manifest. `presets-panel.tsx` is a - plain React component the host mounts behind an error boundary. +1. **Host-side panel extension** — the standalone editor registers + `treesHostPanel` separately from the core plugin manifest. `presets-panel.tsx` + is a plain React component the host mounts behind an error boundary. 2. **Right inspector for free** — `def.parametrics` (`parametrics.ts`). The host renders the preset/height/seed controls + the Randomize action with zero tree-specific code. @@ -70,11 +72,16 @@ setPluginDiscovery(async () => [treesPlugin]) ``` `treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`, -`trees:grass`) and one panel (`Trees`), loaded through the same `loadPlugin` -path as the built-ins. +`trees:grass`) for the core `loadPlugin` path. The editor app separately imports +`treesHostPanel` to surface the Nature rail entry; panels are not part of the v1 +core plugin manifest. ## Notes / known gaps +- `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`), + which works here only because the host app's bundler transpiles workspace + packages. A real third-party plugin must ship built JS (with `.d.ts` types) or + otherwise ensure the consuming host transpiles the package. - `createNode` and the `floorPlaced.footprint` callback are typed against the host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` / `as TreeNode`). The registry derives `AnyNode` post-migration. diff --git a/packages/plugin-trees/src/find-sync.ts b/packages/plugin-trees/src/find-sync.ts index 2b867052..2d749c4a 100644 --- a/packages/plugin-trees/src/find-sync.ts +++ b/packages/plugin-trees/src/find-sync.ts @@ -6,8 +6,8 @@ import { type TreesPanelMode, useTreesStore } from './store' /** * "Find in catalog" sync. The editor's node action menu emits - * `selection:find-node`; the host opens the panel that owns the kind (via - * `panelRegistry.panelForKind`) — but which *section* of the Nature panel to + * `selection:find-node`; the host opens the panel that owns the kind — but + * which *section* of the Nature panel to * show is plugin knowledge, so the plugin listens too and points its own store * at the found node's section + preset. Module-level (imported by the plugin * manifest) so the listener is live from plugin load, even while the panel has @@ -20,21 +20,18 @@ const MODE_BY_KIND: Record = { 'trees:grass': 'grass', } -emitter.on( - 'selection:find-node' as never, - ((node: AnyNode) => { - const mode = MODE_BY_KIND[node.type as string] - if (!mode) return - const store = useTreesStore.getState() - store.setMode(mode) - if (mode === 'trees') { - const tree = node as unknown as TreeNode - store.setPreset(tree.preset ?? 'oak') - store.setSize(tree.size ?? 'medium') - } else if (mode === 'flowers') { - store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy') - } else { - store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow') - } - }) as never, -) +emitter.on('selection:find-node', (node: AnyNode) => { + const mode = MODE_BY_KIND[node.type as string] + if (!mode) return + const store = useTreesStore.getState() + store.setMode(mode) + if (mode === 'trees') { + const tree = node as unknown as TreeNode + store.setPreset(tree.preset ?? 'oak') + store.setSize(tree.size ?? 'medium') + } else if (mode === 'flowers') { + store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy') + } else { + store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow') + } +}) diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts index 07a6f3a9..f160a451 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -1,4 +1,5 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +import type { EditorHostPanel } from '@pascal-app/editor' // Side-effect: subscribes the panel store to `selection:find-node` so the // host's "find in catalog" lands on the right Nature section (see find-sync.ts). import './find-sync' @@ -22,14 +23,13 @@ export const treesPlugin: Plugin = { flowerDefinition as unknown as AnyNodeDefinition, grassDefinition as unknown as AnyNodeDefinition, ], - panels: [ - { - id: 'trees', - label: 'Nature', - icon: { kind: 'url', src: NATURE_ICON }, - component: () => import('./presets-panel'), - }, - ], +} + +export const treesHostPanel: EditorHostPanel = { + id: 'pascal:trees:trees', + label: 'Nature', + icon: { kind: 'url', src: NATURE_ICON }, + component: () => import('./presets-panel'), } // NOTE: no re-export from './geometry' — it imports ez-tree, which touches diff --git a/packages/plugin-trees/src/instanced.tsx b/packages/plugin-trees/src/instanced.tsx index 19b6e029..a74f8fa9 100644 --- a/packages/plugin-trees/src/instanced.tsx +++ b/packages/plugin-trees/src/instanced.tsx @@ -10,7 +10,7 @@ import { } from '@pascal-app/core' import { useNodeEvents, useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' -import { useLayoutEffect, useMemo, useRef } from 'react' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three' import { toStaticMaterial } from './wind-node' @@ -192,9 +192,15 @@ function InstancedSubMesh({ // (cached) geometry/material alive across any recreation. const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32) - useLayoutEffect(() => { + // Snapshot of each referenced parent level's matrixWorld at the last matrix + // write — the per-frame staleness check below compares against it so a level + // move (explode, elevation edit) refreshes instances without a node change. + const parentWorlds = useRef(new Map()) + + const writeMatrices = useCallback(() => { const mesh = ref.current if (!mesh) return + parentWorlds.current.clear() for (let i = 0; i < nodes.length; i += 1) { const node = nodes[i] if (!node) continue @@ -208,8 +214,11 @@ function InstancedSubMesh({ // scene root, so fold in the parent level's world matrix. const parent = !localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined - if (parent) { + if (parent && node.parentId) { parent.updateWorldMatrix(true, false) + if (!parentWorlds.current.has(node.parentId)) { + parentWorlds.current.set(node.parentId, parent.matrixWorld.toArray()) + } INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix) mesh.setMatrixAt(i, INSTANCE_MATRIX) } else { @@ -221,6 +230,30 @@ function InstancedSubMesh({ mesh.computeBoundingSphere() }, [nodes, naturalHeight, localSpace]) + useLayoutEffect(() => { + writeMatrices() + }, [writeMatrices]) + + // A parent level can move without any node of this kind changing (level + // explode, elevation edits), which would leave the baked-in world transform + // stale. Compare each referenced level's matrixWorld against the snapshot — + // a handful of levels × 16 floats per frame — and rewrite only on change. + useFrame(() => { + if (localSpace || !ref.current) return + for (const [id, cached] of parentWorlds.current) { + const parent = sceneRegistry.nodes.get(id) + if (!parent) continue + parent.updateWorldMatrix(true, false) + const elements = parent.matrixWorld.elements + for (let i = 0; i < 16; i += 1) { + if (elements[i] !== cached[i]) { + writeMatrices() + return + } + } + } + }) + return ( { const tree = generateTree(treeSpecOf(node)) tree.scale.setScalar(node.height / naturalHeight(tree)) + // Overlay layer keeps the ghost out of export/snapshot passes. Layers + // don't inherit, so every object in the built tree needs it. + tree.traverse((obj) => obj.layers.set(EDITOR_LAYER)) return tree }, [node]) diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx index 02089da4..305155f2 100644 --- a/packages/plugin-trees/src/tool.tsx +++ b/packages/plugin-trees/src/tool.tsx @@ -1,7 +1,7 @@ 'use client' import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { usePlacement } from './placement' @@ -17,29 +17,27 @@ import { useTreesStore } from './store' */ export default function TreeTool() { const activeLevelId = useViewer((s) => s.selection.levelId) - const brush = useTreesStore() + const preset = useTreesStore((s) => s.preset) + const size = useTreesStore((s) => s.size) + const height = useTreesStore((s) => s.height) + const foliageDensity = useTreesStore((s) => s.foliageDensity) + const trunkThickness = useTreesStore((s) => s.trunkThickness) + const leafless = useTreesStore((s) => s.leafless) const previewNode = useMemo( () => TreeNode.parse({ - preset: brush.preset, - size: brush.size, - height: brush.height, - foliageDensity: brush.foliageDensity, - trunkThickness: brush.trunkThickness, - leafless: brush.leafless, + preset, + size, + height, + foliageDensity, + trunkThickness, + leafless, // seed/treeType left unset → the ghost shows the pure preset, as placed. position: [0, 0, 0], rotation: [0, 0, 0], }), - [ - brush.preset, - brush.size, - brush.height, - brush.foliageDensity, - brush.trunkThickness, - brush.leafless, - ], + [preset, size, height, foliageDensity, trunkThickness, leafless], ) const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => { @@ -67,7 +65,7 @@ export default function TreeTool() { if (!activeLevelId) return null return ( - + ) diff --git a/packages/viewer/src/components/viewer/registered-systems.tsx b/packages/viewer/src/components/viewer/registered-systems.tsx index 8d16666d..074288ce 100644 --- a/packages/viewer/src/components/viewer/registered-systems.tsx +++ b/packages/viewer/src/components/viewer/registered-systems.tsx @@ -1,19 +1,23 @@ 'use client' -import { type AnyNodeDefinition, nodeRegistry } from '@pascal-app/core' +import { type AnyNodeDefinition, createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' import { type ComponentType, lazy, Suspense, useMemo } from 'react' const DEFAULT_PRIORITY = 5 // Cache lazy components keyed by the module-loader function so React.lazy // isn't re-invoked across renders. -const lazyCache = new WeakMap<() => Promise, ComponentType>() +type RegisteredSystemProps = { + sceneApi: ReturnType +} -function loadSystem(def: AnyNodeDefinition): ComponentType | null { +const lazyCache = new WeakMap<() => Promise, ComponentType>() + +function loadSystem(def: AnyNodeDefinition): ComponentType | null { if (!def.system) return null const cached = lazyCache.get(def.system.module) if (cached) return cached - const Comp = lazy(def.system.module as () => Promise<{ default: ComponentType }>) + const Comp = lazy(def.system.module) lazyCache.set(def.system.module, Comp) return Comp } @@ -29,6 +33,7 @@ function loadSystem(def: AnyNodeDefinition): ComponentType | null { * guard added to each legacy system. */ export function RegisteredSystems() { + const sceneApi = useMemo(() => createSceneApi(useScene), []) const entries = useMemo(() => { return Array.from(nodeRegistry.entries()) .filter(([, def]) => def.system != null) @@ -46,7 +51,7 @@ export function RegisteredSystems() { {entries.map(([kind, def]) => { const Comp = loadSystem(def) if (!Comp) return null - return + return })} ) diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 2a813af7..d7cb7348 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -11,6 +11,7 @@ import { type LevelNode, type NodeEvent, pointInPolygon, + resolveSelectionProxyId, sceneRegistry, useScene, type WallNode, @@ -184,20 +185,20 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = const getStrategy = (): SelectionStrategy | null => { const { buildingId, levelId, zoneId } = useViewer.getState().selection - const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => { + const computeNextIds = (nodeId: string, selectedIds: string[], event?: any): string[] => { const isMeta = event?.metaKey || event?.nativeEvent?.metaKey const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey - // Shift toggles like Cmd/Ctrl — same convention as the 2D floorplan layer. + // Shift toggles membership like Cmd/Ctrl. const isShift = event?.shiftKey || event?.nativeEvent?.shiftKey if (isMeta || isCtrl || isShift) { - if (selectedIds.includes(node.id)) { - return selectedIds.filter((id) => id !== node.id) + if (selectedIds.includes(nodeId)) { + return selectedIds.filter((id) => id !== nodeId) } - return [...selectedIds, node.id] + return [...selectedIds, nodeId] } - return [node.id] + return [nodeId] } // No building selected -> can select buildings @@ -266,9 +267,13 @@ const getStrategy = (): SelectionStrategy | null => { } const { selectedIds } = useViewer.getState().selection + const proxyId = resolveSelectionProxyId( + nodeToSelect, + useScene.getState().nodes as Record, + ) useViewer .getState() - .setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) }) + .setSelection({ selectedIds: computeNextIds(proxyId, selectedIds, nativeEvent) }) }, handleDeselect: () => { const { selectedIds } = useViewer.getState().selection @@ -317,7 +322,12 @@ export const SelectionManager = () => { useViewer.setState({ hoveredId: null }) return } - useViewer.setState({ hoveredId: event.node.id }) + useViewer.setState({ + hoveredId: resolveSelectionProxyId( + event.node, + useScene.getState().nodes as Record, + ), + }) } } @@ -327,7 +337,13 @@ export const SelectionManager = () => { if (event.node.type === 'ceiling') return if (strategy.isValid(event.node)) { event.stopPropagation() - useViewer.setState({ hoveredId: null }) + const targetId = resolveSelectionProxyId( + event.node, + useScene.getState().nodes as Record, + ) + if (useViewer.getState().hoveredId === targetId) { + useViewer.setState({ hoveredId: null }) + } } } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index fa786bb9..2dd8780e 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -46,6 +46,7 @@ export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' +export { applyWorldScaleBoxUVs } from './lib/box-uv' // CSG primitives — used by chimney's roof-trim and other kinds whose // geometry subtracts pieces against their host. Lives in viewer // because three-bvh-csg / three-mesh-bvh are viewer-only deps. diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 677b1d13..160dce8a 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -89,6 +89,7 @@ export const glassMaterial = new MeshLambertNodeMaterial({ opacity: 0.35, side: THREE.FrontSide, }) +glassMaterial.userData.__pascalCachedMaterial = true function resolveNodeMaterialSide(side: THREE.Side): THREE.Side { return side === THREE.DoubleSide ? THREE.FrontSide : side @@ -445,6 +446,7 @@ export function createMaterialFromPreset( const material = shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() applyMaterialPresetToMaterials(material, preset) + material.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, material) return material } @@ -494,6 +496,7 @@ export function createMaterial( metalness: props.metalness, }) + threeMaterial.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, threeMaterial) return threeMaterial } @@ -570,6 +573,7 @@ function cachedDefaultMaterial( if (cached) return cached const material = createDefaultMaterial(color, roughness, shading, side) + material.userData.__pascalCachedMaterial = true defaultMaterialCache.set(cacheKey, material) return material } diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 6b27d59b..13fab094 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -21,6 +21,8 @@ type ViewerState = { setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' setCameraMode: (mode: 'perspective' | 'orthographic') => void + isExporting: boolean + setExporting: (value: boolean) => void levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void wallMode: 'up' | 'cutaway' | 'down' @@ -32,6 +34,8 @@ type ViewerState = { setSelection: (updates: Partial) => void resetSelection: () => void outliner: Outliner + geometryRevision: number + bumpGeometryRevision: () => void exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null) => void } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 176f5520..60907a4b 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -122,6 +122,10 @@ type ViewerState = { resetSelection: () => void outliner: Outliner // No setter as we will manipulate directly the arrays + /** Bumped by GeometrySystem after each rebuild pass so selection/outline + * effects can re-apply to the freshly swapped meshes. */ + geometryRevision: number + bumpGeometryRevision: () => void // Export functionality exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null @@ -395,6 +399,9 @@ const useViewer = create()( }), outliner: { selectedObjects: [], hoveredObjects: [] }, + geometryRevision: 0, + bumpGeometryRevision: () => + set((state) => ({ geometryRevision: state.geometryRevision + 1 })), exportScene: null, setExportScene: (fn) => set({ exportScene: fn }), diff --git a/packages/viewer/src/systems/geometry/geometry-system.test.ts b/packages/viewer/src/systems/geometry/geometry-system.test.ts index 13c559ba..76ef4c33 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.test.ts +++ b/packages/viewer/src/systems/geometry/geometry-system.test.ts @@ -1,8 +1,30 @@ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' -import { Group } from 'three' -import { type GeometryBuildCacheEntry, shouldReuseGeometryBuild } from './geometry-system' +import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three' +import { + type GeometryBuildCacheEntry, + markGeometryBuildOutput, + shouldReuseGeometryBuild, +} from './geometry-system' + +describe('markGeometryBuildOutput', () => { + test('marks nested builder meshes for slot paint preview', () => { + const root = new Group() + const nested = new Group() + const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()) + mesh.userData.slotId = 'appliance' + nested.add(mesh) + root.add(nested) + + markGeometryBuildOutput(root) + + expect(root.userData.__fromGeometry).toBe(true) + expect(nested.userData.__fromGeometry).toBe(true) + expect(mesh.userData.__fromGeometry).toBe(true) + expect(mesh.userData.slotId).toBe('appliance') + }) +}) describe('shouldReuseGeometryBuild', () => { test('rebuilds when the same node id remounts into a new group with the same key', () => { diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 8eb06079..0b9f3b7f 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -8,11 +8,12 @@ import { nodeRegistry, type SurfaceRole, sceneRegistry, + useLiveNodeOverrides, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' -import { FrontSide, type Group, type Material, type Mesh } from 'three' +import { FrontSide, type Group, type Material, type Mesh, type Object3D } from 'three' import { type ColorPreset, createSurfaceRoleMaterial, @@ -58,6 +59,7 @@ export const GeometrySystem = () => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const bumpGeometryRevision = useViewer((s) => s.bumpGeometryRevision) // The shared scene-material library, threaded into each builder's ctx so // pure geometry builders can resolve `scene:` slot refs without // importing `useScene`. @@ -107,6 +109,7 @@ export const GeometrySystem = () => { useFrame(() => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes + let rebuiltGeometry = false // Phase 1 — group dirty nodes by (kind, parentId). Kinds that // declare `def.computeLevelData` get one batch precompute per @@ -187,7 +190,8 @@ export const GeometrySystem = () => { // never skipped. This kills the board remount + pointer enter/leave // churn when an item reparents onto a shelf. if (def.geometryKey) { - const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}` + const childLiveOverrideKey = liveChildOverrideKey(node) + const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}|${childLiveOverrideKey}` if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) { clearDirty(id as AnyNodeId) continue @@ -219,21 +223,10 @@ export const GeometrySystem = () => { disposeChildren(group) for (const child of [...built.children]) { - // Tag every child the builder produced so a subsequent rebuild - // can dispose only THIS rebuild's outputs and leave React- - // mounted siblings (hosted items inside a shelf / slab / etc.) - // alone. Without this, a parent rebuild triggered by a child - // event (e.g. an item reparenting onto a shelf calls - // `dirtyNodes.add(parent)` in `ItemRenderer`'s effect) would - // wipe ALL of the parent group's children — including the - // freshly-mounted item — leaving the item in scene state but - // invisible. - ;(child as { userData?: Record }).userData = { - ...(child as { userData?: Record }).userData, - __fromGeometry: true, - } + markGeometryBuildOutput(child) group.add(child) } + rebuiltGeometry = true // NOTE: we intentionally do NOT reset `group.position` / `group.rotation` // here. The `ParametricNodeRenderer` binds them via JSX (`position={...}` // / `rotation={...}`) driven by `useLiveTransforms` during drag and @@ -245,11 +238,25 @@ export const GeometrySystem = () => { clearDirty(id as AnyNodeId) } + if (rebuiltGeometry) bumpGeometryRevision() }, 2) return null } +function liveChildOverrideKey(node: AnyNode): string { + const childIds = (node as unknown as { children?: AnyNodeId[] }).children + if (!Array.isArray(childIds) || childIds.length === 0) return '' + + const overrides = useLiveNodeOverrides.getState().overrides + const entries: Array<[AnyNodeId, unknown]> = [] + for (const childId of childIds) { + const override = overrides.get(childId) + if (override) entries.push([childId, override]) + } + return entries.length === 0 ? '' : JSON.stringify(entries) +} + function nodeReferencesSceneMaterial(node: AnyNode): boolean { const slots = (node as { slots?: Record }).slots if (!slots) return false @@ -265,15 +272,18 @@ function buildGeometryContext( levelData: unknown, materials: GeometryContext['materials'], ): GeometryContext { - const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined + const resolve = (id: AnyNodeId): N | undefined => { + const resolved = nodes[id] + return resolved ? (getEffectiveNode(resolved) as N) : undefined + } const childIds = (node as unknown as { children?: AnyNodeId[] }).children const children: AnyNode[] = Array.isArray(childIds) - ? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined) + ? childIds.map((cid) => resolve(cid)).filter((n): n is AnyNode => n !== undefined) : [] const parentId = node.parentId as AnyNodeId | null - const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null + const parent: AnyNode | null = parentId ? (resolve(parentId) ?? null) : null // Siblings = same kind, same parent, excluding self. Walks the parent's // children array; falls back to scanning the whole scene if the parent @@ -284,13 +294,13 @@ function buildGeometryContext( if (Array.isArray(parentChildIds)) { for (const sid of parentChildIds) { if (sid === node.id) continue - const s = nodes[sid] + const s = resolve(sid) if (s && s.type === node.type) siblings.push(s) } } else { - siblings = Object.values(nodes).filter( - (n) => n !== node && n.type === node.type && n.parentId === parentId, - ) + siblings = Object.values(nodes) + .filter((n) => n !== node && n.type === node.type && n.parentId === parentId) + .map((n) => getEffectiveNode(n)) } } @@ -386,6 +396,18 @@ export type GeometryBuildCacheEntry = { key: string } +export function markGeometryBuildOutput(child: Object3D): void { + // Tag the builder subtree so paint previews can reach nested meshes (for + // example a cabinet sink faucet handle), while disposal still removes only + // top-level builder children from the registered group. + child.traverse((object) => { + object.userData = { + ...object.userData, + __fromGeometry: true, + } + }) +} + export function shouldReuseGeometryBuild( cache: Map, id: string, diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index eeb47b46..85014710 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -24,6 +24,7 @@ import * as THREE from 'three' import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' function csgGeometry(brush: Brush): THREE.BufferGeometry { @@ -905,6 +906,23 @@ function hasSegmentTrim(node: RoofSegmentNode): boolean { ) } +// Material slot the freshly exposed trim plane is assigned to. Slot 0 is the +// wall/trim band — the same finish the gable walls render with (concrete-drywall +// by default, continuous with the walls below). `remapRoofShellFaces` does not +// reclassify slot 0, so any new interior face CSG carves out of a BoxGeometry +// cutter lands on the wall band regardless of which box face it came from. +// Without this the box's default 6 groups (materialIndex 0..5) collapse via +// mod-4 and `remapRoofShellFaces` would reshuffle the vertical ones across +// slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry` +// when they expose fewer material slots. +const TRIM_CUT_MATERIAL_SLOT = 0 + +function assignTrimCutterSlot(geometry: THREE.BufferGeometry): void { + geometry.clearGroups() + const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count + geometry.addGroup(0, count, TRIM_CUT_MATERIAL_SLOT) +} + function buildTrimCutBrush( minX: number, maxX: number, @@ -920,6 +938,11 @@ function buildTrimCutBrush( const geometry = new THREE.BoxGeometry(width, height, depth) geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) + // World-metre UVs on the cutter so the newly exposed interior faces produced + // by CSG inherit tiled UVs (1 uv unit per metre) instead of the box's default + // 0→1-per-face, which would stretch the finish to fit each cut face. + applyWorldScaleBoxUVs(geometry, width, height, depth) + assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) @@ -974,6 +997,9 @@ function buildDiagonalTrimCutBrush( const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth) geometry.rotateY(yaw) geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ) + // World-metre UVs so the diagonal cut's interior face inherits tiled UVs. + applyWorldScaleBoxUVs(geometry, cutterLength, height, cutterDepth) + assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 52d23d47..ebee5f3b 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,10 +1,21 @@ -import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + getWallFaceBandConfig, + sceneRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import type { Material } from 'three' import { type Mesh, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' -import { getMaterialsForWall, getSelectionHighlightMaterials } from './wall-materials' +import { + getMaterialsForWall, + getSelectionHighlightMaterials, + getWallMaterialHash, +} from './wall-materials' const tmpVec = new Vector3() const u = new Vector3() @@ -48,6 +59,7 @@ export const WallCutout = () => { const lastShading = useRef(useViewer.getState().shading) const lastNumberOfWalls = useRef(0) const lastHighlightKey = useRef('') + const lastWallAppearanceKey = useRef('') const lastTextures = useRef(useViewer.getState().textures) const lastColorPreset = useRef(useViewer.getState().colorPreset) const lastSceneTheme = useRef(useViewer.getState().sceneTheme) @@ -62,22 +74,31 @@ export const WallCutout = () => { const previewSelectedIds = useViewer.getState().previewSelectedIds const hoveredId = useViewer.getState().hoveredId const hoverHighlightMode = useViewer.getState().hoverHighlightMode + const sceneState = useScene.getState() const currentTime = clock.elapsedTime const currentCameraPosition = camera.position camera.getWorldDirection(tmpVec) tmpVec.add(currentCameraPosition) const highlightedWallIds = new Set( [...selectedIds, ...previewSelectedIds].filter( - (id) => useScene.getState().nodes[id as AnyNodeId]?.type === 'wall', + (id) => sceneState.nodes[id as AnyNodeId]?.type === 'wall', ), ) const deleteHoveredWallId = hoverHighlightMode === 'delete' && hoveredId && - useScene.getState().nodes[hoveredId as AnyNodeId]?.type === 'wall' + sceneState.nodes[hoveredId as AnyNodeId]?.type === 'wall' ? hoveredId : null const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}` + const wallAppearanceKey = Array.from(sceneRegistry.byType.wall!) + .sort() + .map((wallId) => { + const wallNode = sceneState.nodes[wallId as WallNode['id']] + if (wallNode?.type !== 'wall') return `${wallId}:missing` + return `${wallId}:${getWallMaterialHash(wallNode, shading, sceneState.materials)}:${JSON.stringify(wallNode.faceBands ?? null)}` + }) + .join('|') const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current) const directionChanged = tmpVec.distanceTo(lastCameraTarget.current) @@ -91,7 +112,8 @@ export const WallCutout = () => { lastColorPreset.current !== colorPreset || lastSceneTheme.current !== sceneTheme || sceneRegistry.byType.wall!.size !== lastNumberOfWalls.current || - lastHighlightKey.current !== highlightKey + lastHighlightKey.current !== highlightKey || + lastWallAppearanceKey.current !== wallAppearanceKey ) { lastCameraPosition.current.copy(currentCameraPosition) lastCameraTarget.current.copy(tmpVec) @@ -102,37 +124,39 @@ export const WallCutout = () => { walls.forEach((wallId) => { const wallMesh = sceneRegistry.nodes.get(wallId) if (!wallMesh) return - const wallNode = useScene.getState().nodes[wallId as WallNode['id']] + const wallNode = sceneState.nodes[wallId as WallNode['id']] if (wallNode?.type !== 'wall') return const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) + const shouldSelectionHighlight = + isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled const materials = getMaterialsForWall( wallNode, shading, textures, colorPreset, sceneTheme, - useScene.getState().materials, + sceneState.materials, ) if (wallMode === 'translucent') { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteTranslucent - : isSelectionHighlighted + : shouldSelectionHighlight ? getSelectionHighlightMaterials(materials.translucent) : materials.translucent } else if (hideWall) { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteInvisible - : isSelectionHighlighted + : shouldSelectionHighlight ? getSelectionHighlightMaterials(materials.invisible) : materials.invisible } else { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteVisible - : isSelectionHighlighted + : shouldSelectionHighlight ? getSelectionHighlightMaterials(materials.visible) : materials.visible } @@ -144,6 +168,7 @@ export const WallCutout = () => { lastSceneTheme.current = sceneTheme lastNumberOfWalls.current = sceneRegistry.byType.wall!.size lastHighlightKey.current = highlightKey + lastWallAppearanceKey.current = wallAppearanceKey } }) diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index f8d2da25..03f081f1 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -2,14 +2,17 @@ import { getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, + getWallSurfaceSideFromBandSlot, parseMaterialRef, resolveMaterial, type SceneMaterial, type SceneMaterialId, WALL_SLOT_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, type WallNode, type WallSurfaceMaterialSpec, type WallSurfaceSide, + type WallSurfaceSlotId, } from '@pascal-app/core' import { Color, type Material } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' @@ -41,7 +44,7 @@ const WALL_HIGHLIGHT_PROFILES = { type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES -export type WallMaterialArray = [Material, Material, Material] +export type WallMaterialArray = Material[] export interface WallMaterials { visible: WallMaterialArray @@ -126,6 +129,28 @@ function resolveWallFaceMaterial( return resolveWallSlotDefault(WALL_SLOT_DEFAULT[side], shading) } +function resolveWallSlotMaterial( + wallNode: WallNode, + slotId: WallSurfaceSlotId, + shading: RenderShading, + sceneMaterials: SceneMaterials, +): Material { + const ref = wallNode.slots?.[slotId] + if (ref) { + return ( + resolveMaterialRef(ref, sceneMaterials, shading) ?? + resolveWallSlotDefault(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) + ) + } + + const side = getWallSurfaceSideFromBandSlot(slotId) + if (side) { + return resolveWallFaceMaterial(wallNode, side, shading, sceneMaterials) + } + + return resolveWallSlotDefault(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) +} + // Cache-key fragment for one face: the slot ref plus, for a `scene:` ref, the // referenced material's *content* — so editing a scene material assigned to a // wall invalidates the cache (a `library:` ref is static catalog content, so @@ -149,6 +174,28 @@ function wallFaceMaterialSignature( return getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(wallNode, side)) } +function wallSlotMaterialSignature( + wallNode: WallNode, + slotId: WallSurfaceSlotId, + sceneMaterials: SceneMaterials, +): string { + const ref = wallNode.slots?.[slotId] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'scene') { + return JSON.stringify({ + ref, + material: sceneMaterials?.[parsed.id as SceneMaterialId]?.material ?? null, + }) + } + return JSON.stringify({ ref }) + } + + const side = getWallSurfaceSideFromBandSlot(slotId) + if (side) return wallFaceMaterialSignature(wallNode, side, sceneMaterials) + return JSON.stringify({ default: WALL_SURFACE_SLOT_DEFAULTS[slotId] }) +} + // Slot-first tint for the cutaway/invisible wall variant. function resolveWallFaceColor( wallNode: WallNode, @@ -171,6 +218,28 @@ function resolveWallFaceColor( return getSurfaceColor(getEffectiveWallSurfaceMaterial(wallNode, side), fallback) } +function resolveWallSlotColor( + wallNode: WallNode, + slotId: WallSurfaceSlotId, + sceneMaterials: SceneMaterials, + fallback: string, +): string { + const ref = wallNode.slots?.[slotId] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties?.color ?? fallback + } + if (parsed?.kind === 'scene') { + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : fallback + } + } + + const side = getWallSurfaceSideFromBandSlot(slotId) + return side ? resolveWallFaceColor(wallNode, side, sceneMaterials, fallback) : fallback +} + function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { const preset = getMaterialPresetByRef(spec.materialPreset) if (preset?.mapProperties?.color) { @@ -346,6 +415,14 @@ export function getWallMaterialHash( shading, interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials), exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials), + lowerInterior: wallSlotMaterialSignature(wallNode, 'lowerInterior', sceneMaterials), + middleInterior: wallSlotMaterialSignature(wallNode, 'middleInterior', sceneMaterials), + upperInterior: wallSlotMaterialSignature(wallNode, 'upperInterior', sceneMaterials), + topInterior: wallSlotMaterialSignature(wallNode, 'topInterior', sceneMaterials), + lowerExterior: wallSlotMaterialSignature(wallNode, 'lowerExterior', sceneMaterials), + middleExterior: wallSlotMaterialSignature(wallNode, 'middleExterior', sceneMaterials), + upperExterior: wallSlotMaterialSignature(wallNode, 'upperExterior', sceneMaterials), + topExterior: wallSlotMaterialSignature(wallNode, 'topExterior', sceneMaterials), }) } @@ -388,39 +465,77 @@ export function getMaterialsForWall( wallRoleMaterial, resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topExterior', shading, sceneMaterials), ] - : [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial] + : Array.from({ length: 11 }, () => wallRoleMaterial) const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme) const invisible: WallMaterialArray = [ createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'), - createInvisibleWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...(['interior', 'exterior'] as WallSurfaceSide[]).map((side) => + createInvisibleWallMaterial( + textures + ? resolveWallFaceColor(wallNode, side, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), - createInvisibleWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...( + [ + 'lowerInterior', + 'middleInterior', + 'upperInterior', + 'topInterior', + 'lowerExterior', + 'middleExterior', + 'upperExterior', + 'topExterior', + ] as WallSurfaceSlotId[] + ).map((slotId) => + createInvisibleWallMaterial( + textures + ? resolveWallSlotColor(wallNode, slotId, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), ] const translucent: WallMaterialArray = [ createTranslucentWallMaterial(wallRoleColor, textures ? shading : 'solid'), - createTranslucentWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...(['interior', 'exterior'] as WallSurfaceSide[]).map((side) => + createTranslucentWallMaterial( + textures + ? resolveWallFaceColor(wallNode, side, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), - createTranslucentWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...( + [ + 'lowerInterior', + 'middleInterior', + 'upperInterior', + 'topInterior', + 'lowerExterior', + 'middleExterior', + 'upperExterior', + 'topExterior', + ] as WallSurfaceSlotId[] + ).map((slotId) => + createTranslucentWallMaterial( + textures + ? resolveWallSlotColor(wallNode, slotId, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), ] diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 824dc21b..a0263448 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -6,7 +6,10 @@ import { type DoorNode, getAdjacentWallIds, getEffectiveNode, + getWallBandSlotId, getWallCurveFrameAt, + getWallFaceBandConfig, + getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, @@ -22,6 +25,8 @@ import { useScene, type WallMiterData, type WallNode, + type WallSurfaceSide, + type WallSurfaceSlotId, type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' @@ -37,6 +42,25 @@ csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const WALL_FACE_NORMAL_Y_EPSILON = 0.6 const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 +const WALL_BAND_SPLIT_EPSILON = 1e-5 +const WALL_BAND_SLOT_MATERIAL_INDEX: Record = { + interior: 1, + exterior: 2, + lowerInterior: 3, + middleInterior: 4, + upperInterior: 5, + topInterior: 6, + lowerExterior: 7, + middleExterior: 8, + upperExterior: 9, + topExterior: 10, + skirtingInterior: 0, + skirtingExterior: 0, + crownInterior: 0, + crownExterior: 0, + chairRailInterior: 0, + chairRailExterior: 0, +} function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { ;(geometry as any).computeBoundsTree = computeBoundsTree @@ -191,15 +215,19 @@ function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBounda } function getWallFaceMaterialIndex( - wall: Pick, + wall: Pick, face: 'front' | 'back', -): 0 | 1 | 2 { + y: number, +): number { const semantic = face === 'front' ? wall.frontSide : wall.backSide - const fallback = face === 'front' ? 1 : 2 + const fallback: WallSurfaceSide = face === 'front' ? 'interior' : 'exterior' + const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback - if (semantic === 'interior') return 1 - if (semantic === 'exterior') return 2 - return fallback + const bands = getWallFaceBandConfig(wall) + if (!bands.enabled) return WALL_BAND_SLOT_MATERIAL_INDEX[side] + + const band = getWallFaceBandForHeight(wall, y) + return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)] } function assignWallMaterialGroups( @@ -285,7 +313,7 @@ function assignWallMaterialGroups( continue } - triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag) + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y) } geometry.clearGroups() @@ -305,6 +333,126 @@ function assignWallMaterialGroups( geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) } +type SplitVertex = { + x: number + y: number + z: number +} + +function interpolateSplitVertex(a: SplitVertex, b: SplitVertex, t: number): SplitVertex { + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + } +} + +function clipPolygonByY(polygon: SplitVertex[], planeY: number, keepBelow: boolean): SplitVertex[] { + const out: SplitVertex[] = [] + if (polygon.length === 0) return out + + const isInside = (vertex: SplitVertex) => + keepBelow + ? vertex.y <= planeY + WALL_BAND_SPLIT_EPSILON + : vertex.y >= planeY - WALL_BAND_SPLIT_EPSILON + + for (let index = 0; index < polygon.length; index += 1) { + const current = polygon[index]! + const previous = polygon[(index + polygon.length - 1) % polygon.length]! + const currentInside = isInside(current) + const previousInside = isInside(previous) + + if (currentInside !== previousInside) { + const denom = current.y - previous.y + if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) { + out.push(interpolateSplitVertex(previous, current, (planeY - previous.y) / denom)) + } + } + if (currentInside) out.push(current) + } + + return out +} + +function triangulateSplitPolygon(polygon: SplitVertex[], positions: number[]) { + if (polygon.length < 3) return + const first = polygon[0]! + for (let index = 1; index < polygon.length - 1; index += 1) { + const b = polygon[index]! + const c = polygon[index + 1]! + positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z) + } +} + +function splitGeometryAtHorizontalPlanes( + geometry: THREE.BufferGeometry, + planes: number[], +): THREE.BufferGeometry { + const splitPlanes = Array.from( + new Set( + planes + .filter((plane) => Number.isFinite(plane) && plane > WALL_BAND_SPLIT_EPSILON) + .map((plane) => Math.round(plane / WALL_BAND_SPLIT_EPSILON) * WALL_BAND_SPLIT_EPSILON), + ), + ).sort((a, b) => a - b) + if (splitPlanes.length === 0) return geometry + + const source = geometry.index ? geometry.toNonIndexed() : geometry + const position = source.getAttribute('position') + if (!position || position.count === 0) return source + + const positions: number[] = [] + for (let index = 0; index < position.count; index += 3) { + let polygons: SplitVertex[][] = [ + [ + { x: position.getX(index), y: position.getY(index), z: position.getZ(index) }, + { x: position.getX(index + 1), y: position.getY(index + 1), z: position.getZ(index + 1) }, + { x: position.getX(index + 2), y: position.getY(index + 2), z: position.getZ(index + 2) }, + ], + ] + + for (const plane of splitPlanes) { + const next: SplitVertex[][] = [] + for (const polygon of polygons) { + const minY = Math.min(...polygon.map((vertex) => vertex.y)) + const maxY = Math.max(...polygon.map((vertex) => vertex.y)) + if (plane <= minY + WALL_BAND_SPLIT_EPSILON || plane >= maxY - WALL_BAND_SPLIT_EPSILON) { + next.push(polygon) + continue + } + + const below = clipPolygonByY(polygon, plane, true) + const above = clipPolygonByY(polygon, plane, false) + if (below.length >= 3) next.push(below) + if (above.length >= 3) next.push(above) + } + polygons = next + } + + for (const polygon of polygons) triangulateSplitPolygon(polygon, positions) + } + + if (source !== geometry) geometry.dispose() + source.dispose() + + const split = new THREE.BufferGeometry() + split.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + split.computeVertexNormals() + return split +} + +function getWallBandSplitPlanes(wall: WallNode): number[] { + const bands = getWallFaceBandConfig(wall) + if (!bands.enabled) return [] + const planes = [bands.lowerTop] + if (bands.count >= 3) planes.push(bands.middleTop) + if (bands.count >= 4) planes.push(bands.upperTop) + return planes.filter( + (plane) => + plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON, + ) +} + // ============================================================================ // WALL SYSTEM // ============================================================================ @@ -747,7 +895,14 @@ export function generateExtrudedWall( // Apply CSG subtraction for cutouts (doors/windows) const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) if (cutoutBrushes.length === 0) { - return geometry + const splitGeometry = splitGeometryAtHorizontalPlanes( + geometry, + getWallBandSplitPlanes(wallNode), + ) + splitGeometry.computeVertexNormals() + assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges) + ensureRenderableGeometryAttributes(splitGeometry) + return splitGeometry } // Create wall brush from geometry @@ -777,11 +932,15 @@ export function generateExtrudedWall( } const resultGeometry = csgGeometry(resultBrush) - resultGeometry.computeVertexNormals() - assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges) - ensureRenderableGeometryAttributes(resultGeometry) + const splitResultGeometry = splitGeometryAtHorizontalPlanes( + resultGeometry, + getWallBandSplitPlanes(wallNode), + ) + splitResultGeometry.computeVertexNormals() + assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges) + ensureRenderableGeometryAttributes(splitResultGeometry) - return resultGeometry + return splitResultGeometry } /** diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 79518bb1..9f6d7e14 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -21,10 +21,6 @@ export const myPlugin: Plugin = { armchairDefinition, // ... ], - panels: [ - { id: 'catalog', label: 'Catalog', icon: { kind: 'iconify', name: 'lucide:sofa' }, - component: () => import('./catalog-panel') }, - ], } ``` @@ -32,10 +28,9 @@ export const myPlugin: Plugin = { |---|---|---| | `id` | yes | Globally unique. Use `vendor:pack-name` to avoid collisions. The host treats it as opaque. | | `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. | -| `nodes` | optional | Array of `AnyNodeDefinition`. May be empty for a pure-panel plugin. | -| `panels` | optional | Array of `PluginPanel` — left-rail panels (see [Panel contributions](#panel-contributions)). | +| `nodes` | optional | Array of `AnyNodeDefinition`. | -The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example: one node kind + one panel. Copy it as a starting point. +The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example. Copy it as a starting point. The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` — there's no "internal" plugin format. Whatever works for built-ins works for third parties. @@ -58,39 +53,6 @@ A plugin's `nodes` array is the only meaningful contribution point in v1. Each e See [`node-definitions.md`](node-definitions.md) for the three-checkbox composition model that ties these together. -## Panel contributions - -A plugin can add its own panel to the sidebar **icon rail** via `plugin.panels`. Each entry: - -```ts -export type PluginPanel = { - id: string // unique within the plugin; host namespaces it as `${plugin.id}:${id}` - label: string // rail tooltip + panel header - icon: IconRef // same icon union node presentation uses (iconify / url / svg / component) - component: LazyComponent // () => import('./panel') — a default-exported React component -} -``` - -Behaviour: - -- **In-process React, no iframe.** The component mounts directly in the host React tree, lazily on first open. It can use the host stores (`useScene`, `useEditor`, `useViewer`) and its own Zustand stores freely. -- **Error-isolated.** Every plugin panel is wrapped in an error boundary with a "this plugin crashed" fallback. A throwing panel degrades to that fallback for the session instead of taking down the sidebar — other panels keep working. -- **Namespaced.** `loadPlugin` rewrites the panel id to `${plugin.id}:${panel.id}`, so two plugins can both ship `id: 'main'`. Host-provided panels (the app's own `extraSidebarPanels`) keep precedence on an id collision. -- **Styling.** Scope your CSS — no globals. Use the host sidebar CSS variables (`--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-border`, `--sidebar-ring`) so the panel reads as native in light/dark. - -The registry that backs this (`panelRegistry` in `@pascal-app/core`) is observable — discovery is async, so the sidebar subscribes and the rail icon appears as soon as the plugin loads. - -### Driving a tool from a panel - -A panel typically writes a choice into the plugin's own store, then arms placement: - -```ts -useEditor.getState().setTool('trees:tree') // a plugin tool id -useEditor.getState().setMode('build') -``` - -`Tool` is `KnownTool | (string & {})`, so a plugin's namespaced tool id (the node `kind`) typechecks without the host enumerating it. Dispatch is registry-first — `ToolManager` resolves `nodeRegistry.get(tool)?.tool` — so an unknown-to-the-host tool string flows straight through to the plugin's `def.tool` component. The placement tool reads the plugin store for *what* to place. That round-trip — panel → store → `def.tool` → `SceneApi` → scene → reactive `useScene` read-back in the panel — is the full plugin communication path; `plugin-trees` demonstrates it end to end. - ## Importing host packages A plugin imports from the published `@pascal-app/*` packages — same surface the built-ins use, peer-dependency-style: @@ -158,6 +120,7 @@ A plugin's own data versioning is `schemaVersion` on each `NodeDefinition`. The - **Materials** — there's no `plugin.materials` slot. Use `createMaterial` from `@pascal-app/viewer` inside your `def.renderer` / `def.system`. - **Floor-plan primitives** — the `FloorplanGeometry` union is host-owned. To draw something the union can't express, fall back to `def.renderer` and render through a different 2D mount (or open an issue). +- **Panels / sidebar UI** — host-specific. A host may layer its own extension surface on top of the core plugin manifest, but `@pascal-app/core` does not own that contract. - **Stores** — plugins create their own Zustand stores; they don't extend `useScene`, `useEditor`, or `useViewer`. Host stores are not part of the v1 plugin surface. - **Routes / pages** — plugins are visualisation + interaction code, not full app surfaces. Hosting a settings page belongs to the app. diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index 7958d13b..5a328753 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -61,7 +61,7 @@ type SelectionPath = { `setSelection` has a hierarchy guard: setting `levelId` without `buildingId` resets children. Use `resetSelection()` to clear everything. -Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`. Regular click replaces it. +Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`; `Shift + click` toggles the same way. Regular click replaces it. --- diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 9426b38c..883c5b46 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -88,6 +88,14 @@ export function MyTool() { `continuationContext`), not on the presence of hand-written `def.toolHints`, so a snappable draft tool with no bespoke hints (e.g. `zone`) still advertises the Shift = cycle control it already honors. See [interaction-scope](interaction-scope.md) § "Snapping mode & modifiers" and `lib/snapping-mode.ts`. + - **Sanctioned exception — wall connect snap.** Wall drafting keeps a tight, mode-independent + "connect" snap so a room can still close in the non-magnetic modes (`grid` / `angles` / `off`): + within `WALL_CONNECT_SNAP_RADIUS` (0.05 m, `components/tools/wall/wall-snap-geometry.ts`) of an + existing wall's endpoint / midpoint / crossing / body, the drafted point sticks onto it (and the + beacon shows). This is *connectivity*, not alignment — the snap runs from the already + mode-positioned point, so grid quantise / angle lock / free placement are respected right up to + the wall and only the last few cm stick. It is **not** a Shift bypass and must not be gated on + modifiers. See `snapWallDraftPointDetailed` in `components/tools/wall/wall-drafting.ts`. - **Constraints and guides can be decoupled.** When a stronger constraint owns the proposal — a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained