From d747d2f0eaebbdf8569ab464680d65c7f15f246b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 19 May 2026 15:14:12 -0400 Subject: [PATCH] Phase 5 Stage E: full kind migration into packages/nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wholesale move of every remaining kind into its own subdirectory under `packages/nodes/src/`, finishing the registry-driven migration. Each kind now ships its definition, schema (re-exported from core), and any of `geometry` / `renderer` / `system` / `floorplan` / `tool` / `move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` / `parametrics` / `preview` it needs — no per-kind code remains under `packages/editor/src/components/tools/` or `packages/viewer/src/components/renderers/`. Deleted (replaced by registry-driven equivalents): - `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...` (boundary editors, hole editors, placement tools, move tools, endpoint movers, curve tools, helpers, math libs) - `ui/helpers/{ceiling,slab,wall}-helper.tsx` - `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn, stair,stair-segment,wall,window}-panel.tsx` - `viewer/src/components/renderers/{building,ceiling,column,door, elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab, spawn,stair,stair-segment,wall,window,zone}-renderer.tsx` - `viewer/src/components/viewer/legacy-system.tsx` Added under `packages/nodes/src/`: - `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`, `roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`, `stair-segment/` packages with definition + schema + renderer / system / floorplan / panel as appropriate. - New `floorplan-move.ts` for every kind that supports 2D moves (ceiling, door, item, shelf, slab, window) — single registry-driven dispatch path via `def.floorplanMoveTarget`. - New `floorplan-affordances.ts` for kinds with polygon / endpoint drags (ceiling, fence, slab, wall) — using the shared `polygon-vertex-affordance` factories. - New per-kind `panel.tsx` for kinds with custom inspector content (door, item, shelf, spawn, wall, window). - New per-kind `tool.tsx` for placement (door, item, shelf, window). - New per-kind `move-tool.tsx` for kinds with custom 3D move flows (door, item, slab, window). Coordinator + manager updates in `packages/editor/`: - `tool-manager.tsx` resolves tools from the registry only — no hardcoded type→component map. - `panel-manager.tsx` resolves inspector panels the same way. - `placement-{coordinator,strategies,types}.ts` extended with shelf-surface placement. - `selection-manager.tsx` adds the registry-selectable fallback. - `floorplan-panel.tsx`, `floorplan-background-placement.ts`, `floorplan-render-context.tsx` updated for the registry layer's new contract (props, affordance dispatch, render context). Viewer updates: - `viewer/index.tsx` drops legacy renderer mounts. - `node-renderer.tsx` resolves by registry only. - `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`, `wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for the registry-only world. Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node updated to read from the registered nodes instead of the deleted legacy renderer trees. Wiki: new `plugin-authoring.md` page, README index updated. Tests in `packages/nodes/src/index.test.ts` validate every registered kind has the required shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/editor/lib/bootstrap.ts | 19 +- packages/core/src/events/bus.ts | 14 +- .../hooks/scene-registry/scene-registry.ts | 61 +- .../floorplan-registry-action-menu.tsx | 76 +- .../editor-2d/floorplan-render-context.tsx | 54 ++ .../renderers/floorplan-stair-layer.tsx | 20 +- .../src/components/editor-2d/svg-paths.ts | 12 +- .../editor/first-person-controls.tsx | 12 +- .../first-person/build-collider-world.ts | 4 +- .../src/components/editor/floorplan-panel.tsx | 830 +++--------------- .../components/editor/selection-manager.tsx | 32 +- .../components/editor/thumbnail-generator.tsx | 2 +- .../use-floorplan-background-placement.ts | 53 +- .../systems/ceiling/ceiling-system.tsx | 2 +- .../tools/ceiling/ceiling-boundary-editor.tsx | 43 - .../tools/ceiling/ceiling-hole-editor.tsx | 49 -- .../components/tools/ceiling/ceiling-tool.tsx | 465 ---------- .../tools/ceiling/move-ceiling-tool.tsx | 264 ------ .../tools/fence/curve-fence-tool.tsx | 178 ---- .../src/components/tools/fence/fence-tool.tsx | 346 -------- .../tools/fence/move-fence-endpoint-tool.tsx | 425 --------- .../tools/fence/move-fence-tool.tsx | 302 ------- .../src/components/tools/item/item-tool.tsx | 32 - .../src/components/tools/item/move-tool.tsx | 125 +-- .../tools/item/placement-strategies.ts | 158 ++++ .../components/tools/item/placement-types.ts | 9 +- .../components/tools/item/use-draft-node.ts | 21 +- .../tools/item/use-placement-coordinator.tsx | 173 +++- .../components/tools/slab/move-slab-tool.tsx | 182 ---- .../tools/slab/slab-boundary-editor.tsx | 43 - .../tools/slab/slab-hole-editor.tsx | 49 -- .../src/components/tools/slab/slab-tool.tsx | 322 ------- .../tools/spawn/move-spawn-tool.tsx | 101 --- .../src/components/tools/spawn/spawn-tool.tsx | 130 --- .../src/components/tools/tool-manager.tsx | 78 +- .../components/tools/wall/curve-wall-tool.tsx | 178 ---- .../tools/wall/move-wall-endpoint-tool.tsx | 426 --------- .../components/tools/wall/move-wall-tool.tsx | 804 ----------------- .../src/components/tools/wall/wall-tool.tsx | 332 ------- .../ui/action-menu/structure-tools.tsx | 5 +- .../components/ui/helpers/ceiling-helper.tsx | 20 - .../components/ui/helpers/helper-manager.tsx | 30 +- .../src/components/ui/helpers/slab-helper.tsx | 20 - .../src/components/ui/helpers/wall-helper.tsx | 20 - .../components/ui/panels/panel-manager.tsx | 51 +- .../panels/site-panel/ceiling-tree-node.tsx | 7 +- .../panels/site-panel/fence-tree-node.tsx | 4 +- .../panels/site-panel/slab-tree-node.tsx | 7 +- .../sidebar/panels/site-panel/tree-node.tsx | 92 +- .../src/components/viewer-zone-system.tsx | 2 +- packages/editor/src/index.tsx | 98 ++- packages/editor/src/lib/level-duplication.ts | 96 +- packages/editor/src/lib/material-paint.ts | 12 +- packages/editor/src/lib/scene.ts | 8 +- packages/nodes/package.json | 3 +- packages/nodes/src/building/definition.ts | 50 ++ packages/nodes/src/building/index.ts | 1 + packages/nodes/src/building/parametrics.ts | 5 + .../src/building/renderer.tsx} | 7 +- packages/nodes/src/building/schema.ts | 1 + packages/nodes/src/ceiling/definition.ts | 18 + .../src/ceiling/floorplan-affordances.ts | 16 + packages/nodes/src/ceiling/floorplan-move.ts | 88 ++ packages/nodes/src/ceiling/floorplan.ts | 100 ++- packages/nodes/src/ceiling/move-tool.tsx | 138 ++- packages/nodes/src/ceiling/renderer.tsx | 113 ++- packages/nodes/src/ceiling/system.tsx | 9 +- packages/nodes/src/column/definition.ts | 64 ++ packages/nodes/src/column/floorplan.ts | 185 ++++ packages/nodes/src/column/index.ts | 1 + .../src/column/move-tool.tsx} | 36 +- .../src/column/panel.tsx} | 509 ++++++----- packages/nodes/src/column/parametrics.ts | 24 + .../src/column/renderer.tsx} | 16 +- packages/nodes/src/column/schema.ts | 1 + packages/nodes/src/door/definition.ts | 20 +- .../tools => nodes/src}/door/door-math.ts | 0 packages/nodes/src/door/floorplan-move.ts | 87 ++ packages/nodes/src/door/floorplan.ts | 178 +++- .../src/door/move-tool.tsx} | 26 +- .../src/door/panel.tsx} | 172 ++-- packages/nodes/src/door/parametrics.ts | 16 +- packages/nodes/src/door/renderer.tsx | 39 +- packages/nodes/src/door/system.tsx | 13 +- .../door-tool.tsx => nodes/src/door/tool.tsx} | 24 +- packages/nodes/src/elevator/definition.ts | 54 ++ packages/nodes/src/elevator/floorplan.ts | 313 +++++++ packages/nodes/src/elevator/index.ts | 1 + .../src/elevator/panel.tsx} | 42 +- packages/nodes/src/elevator/parametrics.ts | 6 + .../src/elevator/renderer.tsx} | 6 +- packages/nodes/src/elevator/schema.ts | 1 + packages/nodes/src/elevator/system.tsx | 20 + packages/nodes/src/fence/definition.ts | 8 + .../nodes/src/fence/floorplan-affordances.ts | 132 +++ packages/nodes/src/fence/floorplan.ts | 388 +++++++- packages/nodes/src/guide/definition.ts | 51 ++ packages/nodes/src/guide/index.ts | 1 + packages/nodes/src/guide/parametrics.ts | 5 + .../src/guide/renderer.tsx} | 7 +- packages/nodes/src/guide/schema.ts | 1 + packages/nodes/src/guide/system.tsx | 5 + packages/nodes/src/index.test.ts | 30 +- packages/nodes/src/index.ts | 45 +- packages/nodes/src/item/definition.ts | 25 +- packages/nodes/src/item/floorplan.ts | 86 +- packages/nodes/src/item/move-tool.tsx | 118 +++ .../src/item/panel.tsx} | 59 +- packages/nodes/src/item/parametrics.ts | 17 +- packages/nodes/src/item/renderer.tsx | 304 ++++++- packages/nodes/src/item/system.tsx | 3 - packages/nodes/src/item/tool.tsx | 54 ++ packages/nodes/src/level/definition.ts | 57 ++ packages/nodes/src/level/index.ts | 1 + packages/nodes/src/level/parametrics.ts | 5 + .../src/level/renderer.tsx} | 7 +- packages/nodes/src/level/schema.ts | 1 + packages/nodes/src/level/system.tsx | 5 + packages/nodes/src/roof-segment/definition.ts | 52 ++ packages/nodes/src/roof-segment/floorplan.ts | 106 +++ packages/nodes/src/roof-segment/index.ts | 1 + .../src/roof-segment/panel.tsx} | 29 +- .../nodes/src/roof-segment/parametrics.ts | 6 + .../src/roof-segment/renderer.tsx} | 8 +- packages/nodes/src/roof-segment/schema.ts | 1 + packages/nodes/src/roof/definition.ts | 54 ++ packages/nodes/src/roof/index.ts | 1 + .../src/roof/panel.tsx} | 35 +- packages/nodes/src/roof/parametrics.ts | 6 + .../src/roof/renderer.tsx} | 9 +- .../src}/roof/roof-materials.ts | 0 packages/nodes/src/roof/schema.ts | 1 + packages/nodes/src/roof/system.tsx | 5 + packages/nodes/src/scan/definition.ts | 50 ++ packages/nodes/src/scan/index.ts | 1 + packages/nodes/src/scan/parametrics.ts | 5 + .../src/scan/renderer.tsx} | 8 +- packages/nodes/src/scan/schema.ts | 1 + packages/nodes/src/scan/system.tsx | 5 + .../shared/opening-placement-dimensions.ts | 172 ++++ .../src/shared/polygon-vertex-affordance.ts | 285 ++++++ .../nodes/src/shared/wall-attach-target.ts | 129 +++ packages/nodes/src/site/definition.ts | 49 ++ packages/nodes/src/site/index.ts | 1 + packages/nodes/src/site/parametrics.ts | 5 + .../src/site/renderer.tsx} | 18 +- packages/nodes/src/site/schema.ts | 1 + packages/nodes/src/slab/definition.ts | 19 + .../nodes/src/slab/floorplan-affordances.ts | 24 + packages/nodes/src/slab/floorplan-move.ts | 131 +++ packages/nodes/src/slab/move-tool.tsx | 25 + .../src/spawn/panel.tsx} | 21 +- packages/nodes/src/spawn/parametrics.ts | 6 + .../nodes/src/stair-segment/definition.ts | 48 + packages/nodes/src/stair-segment/index.ts | 1 + .../src/stair-segment/panel.tsx} | 29 +- .../nodes/src/stair-segment/parametrics.ts | 6 + .../src/stair-segment/renderer.tsx} | 7 +- packages/nodes/src/stair-segment/schema.ts | 1 + packages/nodes/src/stair/definition.ts | 59 ++ packages/nodes/src/stair/floorplan.ts | 310 +++++++ packages/nodes/src/stair/index.ts | 1 + .../src/stair/panel.tsx} | 43 +- packages/nodes/src/stair/parametrics.ts | 6 + .../src/stair/renderer.tsx} | 16 +- packages/nodes/src/stair/schema.ts | 1 + packages/nodes/src/stair/system.tsx | 5 + packages/nodes/src/wall/definition.ts | 15 + .../nodes/src/wall/floorplan-affordances.ts | 201 +++++ packages/nodes/src/wall/floorplan.ts | 201 ++++- .../src/wall/panel.tsx} | 21 +- packages/nodes/src/wall/parametrics.ts | 6 + packages/nodes/src/wall/system.tsx | 12 - packages/nodes/src/window/definition.ts | 11 +- packages/nodes/src/window/floorplan-move.ts | 80 ++ packages/nodes/src/window/floorplan.ts | 113 ++- .../src/window/move-tool.tsx} | 26 +- .../src/window/panel.tsx} | 42 +- packages/nodes/src/window/parametrics.ts | 4 + packages/nodes/src/window/renderer.tsx | 39 +- packages/nodes/src/window/system.tsx | 6 +- .../src/window/tool.tsx} | 24 +- .../tools => nodes/src}/window/window-math.ts | 0 .../renderers/ceiling/ceiling-renderer.tsx | 103 --- .../renderers/door/door-renderer.tsx | 32 - .../renderers/fence/fence-renderer.tsx | 29 - .../renderers/item/item-renderer.tsx | 284 ------ .../components/renderers/node-renderer.tsx | 78 +- .../renderers/slab/slab-renderer.tsx | 95 -- .../renderers/spawn/spawn-renderer.tsx | 68 -- .../renderers/wall/wall-renderer.tsx | 63 -- .../renderers/window/window-renderer.tsx | 35 - .../renderers/zone/zone-renderer.tsx | 255 ------ .../viewer/src/components/viewer/index.tsx | 96 +- .../src/components/viewer/legacy-system.tsx | 20 - .../src/components/viewer/scene-bvh.tsx | 11 +- packages/viewer/src/hooks/use-node-events.ts | 83 +- packages/viewer/src/index.ts | 44 +- .../viewer/src/systems/level/level-system.tsx | 2 +- .../viewer/src/systems/level/level-utils.ts | 2 +- .../viewer/src/systems/wall/wall-cutout.tsx | 8 +- .../viewer/src/systems/zone/zone-system.tsx | 2 +- wiki/architecture/README.md | 1 + wiki/architecture/plugin-authoring.md | 134 +++ 204 files changed, 6888 insertions(+), 7877 deletions(-) create mode 100644 packages/editor/src/components/editor-2d/floorplan-render-context.tsx delete mode 100644 packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx delete mode 100644 packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx delete mode 100644 packages/editor/src/components/tools/ceiling/ceiling-tool.tsx delete mode 100644 packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx delete mode 100644 packages/editor/src/components/tools/fence/curve-fence-tool.tsx delete mode 100644 packages/editor/src/components/tools/fence/fence-tool.tsx delete mode 100644 packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx delete mode 100644 packages/editor/src/components/tools/fence/move-fence-tool.tsx delete mode 100644 packages/editor/src/components/tools/item/item-tool.tsx delete mode 100644 packages/editor/src/components/tools/slab/move-slab-tool.tsx delete mode 100644 packages/editor/src/components/tools/slab/slab-boundary-editor.tsx delete mode 100644 packages/editor/src/components/tools/slab/slab-hole-editor.tsx delete mode 100644 packages/editor/src/components/tools/slab/slab-tool.tsx delete mode 100644 packages/editor/src/components/tools/spawn/move-spawn-tool.tsx delete mode 100644 packages/editor/src/components/tools/spawn/spawn-tool.tsx delete mode 100644 packages/editor/src/components/tools/wall/curve-wall-tool.tsx delete mode 100644 packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx delete mode 100644 packages/editor/src/components/tools/wall/move-wall-tool.tsx delete mode 100644 packages/editor/src/components/tools/wall/wall-tool.tsx delete mode 100644 packages/editor/src/components/ui/helpers/ceiling-helper.tsx delete mode 100644 packages/editor/src/components/ui/helpers/slab-helper.tsx delete mode 100644 packages/editor/src/components/ui/helpers/wall-helper.tsx create mode 100644 packages/nodes/src/building/definition.ts create mode 100644 packages/nodes/src/building/index.ts create mode 100644 packages/nodes/src/building/parametrics.ts rename packages/{viewer/src/components/renderers/building/building-renderer.tsx => nodes/src/building/renderer.tsx} (84%) create mode 100644 packages/nodes/src/building/schema.ts create mode 100644 packages/nodes/src/ceiling/floorplan-affordances.ts create mode 100644 packages/nodes/src/ceiling/floorplan-move.ts create mode 100644 packages/nodes/src/column/definition.ts create mode 100644 packages/nodes/src/column/floorplan.ts create mode 100644 packages/nodes/src/column/index.ts rename packages/{editor/src/components/tools/column/move-column-tool.tsx => nodes/src/column/move-tool.tsx} (71%) rename packages/{editor/src/components/ui/panels/column-panel.tsx => nodes/src/column/panel.tsx} (68%) create mode 100644 packages/nodes/src/column/parametrics.ts rename packages/{viewer/src/components/renderers/column/column-renderer.tsx => nodes/src/column/renderer.tsx} (99%) create mode 100644 packages/nodes/src/column/schema.ts rename packages/{editor/src/components/tools => nodes/src}/door/door-math.ts (100%) create mode 100644 packages/nodes/src/door/floorplan-move.ts rename packages/{editor/src/components/tools/door/move-door-tool.tsx => nodes/src/door/move-tool.tsx} (97%) rename packages/{editor/src/components/ui/panels/door-panel.tsx => nodes/src/door/panel.tsx} (92%) rename packages/{editor/src/components/tools/door/door-tool.tsx => nodes/src/door/tool.tsx} (97%) create mode 100644 packages/nodes/src/elevator/definition.ts create mode 100644 packages/nodes/src/elevator/floorplan.ts create mode 100644 packages/nodes/src/elevator/index.ts rename packages/{editor/src/components/ui/panels/elevator-panel.tsx => nodes/src/elevator/panel.tsx} (97%) create mode 100644 packages/nodes/src/elevator/parametrics.ts rename packages/{viewer/src/components/renderers/elevator/elevator-renderer.tsx => nodes/src/elevator/renderer.tsx} (99%) create mode 100644 packages/nodes/src/elevator/schema.ts create mode 100644 packages/nodes/src/elevator/system.tsx create mode 100644 packages/nodes/src/fence/floorplan-affordances.ts create mode 100644 packages/nodes/src/guide/definition.ts create mode 100644 packages/nodes/src/guide/index.ts create mode 100644 packages/nodes/src/guide/parametrics.ts rename packages/{viewer/src/components/renderers/guide/guide-renderer.tsx => nodes/src/guide/renderer.tsx} (94%) create mode 100644 packages/nodes/src/guide/schema.ts create mode 100644 packages/nodes/src/guide/system.tsx create mode 100644 packages/nodes/src/item/move-tool.tsx rename packages/{editor/src/components/ui/panels/item-panel.tsx => nodes/src/item/panel.tsx} (85%) create mode 100644 packages/nodes/src/item/tool.tsx create mode 100644 packages/nodes/src/level/definition.ts create mode 100644 packages/nodes/src/level/index.ts create mode 100644 packages/nodes/src/level/parametrics.ts rename packages/{viewer/src/components/renderers/level/level-renderer.tsx => nodes/src/level/renderer.tsx} (81%) create mode 100644 packages/nodes/src/level/schema.ts create mode 100644 packages/nodes/src/level/system.tsx create mode 100644 packages/nodes/src/roof-segment/definition.ts create mode 100644 packages/nodes/src/roof-segment/floorplan.ts create mode 100644 packages/nodes/src/roof-segment/index.ts rename packages/{editor/src/components/ui/panels/roof-segment-panel.tsx => nodes/src/roof-segment/panel.tsx} (93%) create mode 100644 packages/nodes/src/roof-segment/parametrics.ts rename packages/{viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx => nodes/src/roof-segment/renderer.tsx} (90%) create mode 100644 packages/nodes/src/roof-segment/schema.ts create mode 100644 packages/nodes/src/roof/definition.ts create mode 100644 packages/nodes/src/roof/index.ts rename packages/{editor/src/components/ui/panels/roof-panel.tsx => nodes/src/roof/panel.tsx} (90%) create mode 100644 packages/nodes/src/roof/parametrics.ts rename packages/{viewer/src/components/renderers/roof/roof-renderer.tsx => nodes/src/roof/renderer.tsx} (86%) rename packages/{viewer/src/components/renderers => nodes/src}/roof/roof-materials.ts (100%) create mode 100644 packages/nodes/src/roof/schema.ts create mode 100644 packages/nodes/src/roof/system.tsx create mode 100644 packages/nodes/src/scan/definition.ts create mode 100644 packages/nodes/src/scan/index.ts create mode 100644 packages/nodes/src/scan/parametrics.ts rename packages/{viewer/src/components/renderers/scan/scan-renderer.tsx => nodes/src/scan/renderer.tsx} (92%) create mode 100644 packages/nodes/src/scan/schema.ts create mode 100644 packages/nodes/src/scan/system.tsx create mode 100644 packages/nodes/src/shared/opening-placement-dimensions.ts create mode 100644 packages/nodes/src/shared/polygon-vertex-affordance.ts create mode 100644 packages/nodes/src/shared/wall-attach-target.ts create mode 100644 packages/nodes/src/site/definition.ts create mode 100644 packages/nodes/src/site/index.ts create mode 100644 packages/nodes/src/site/parametrics.ts rename packages/{viewer/src/components/renderers/site/site-renderer.tsx => nodes/src/site/renderer.tsx} (88%) create mode 100644 packages/nodes/src/site/schema.ts create mode 100644 packages/nodes/src/slab/floorplan-affordances.ts create mode 100644 packages/nodes/src/slab/floorplan-move.ts rename packages/{editor/src/components/ui/panels/spawn-panel.tsx => nodes/src/spawn/panel.tsx} (91%) create mode 100644 packages/nodes/src/stair-segment/definition.ts create mode 100644 packages/nodes/src/stair-segment/index.ts rename packages/{editor/src/components/ui/panels/stair-segment-panel.tsx => nodes/src/stair-segment/panel.tsx} (93%) create mode 100644 packages/nodes/src/stair-segment/parametrics.ts rename packages/{viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx => nodes/src/stair-segment/renderer.tsx} (92%) create mode 100644 packages/nodes/src/stair-segment/schema.ts create mode 100644 packages/nodes/src/stair/definition.ts create mode 100644 packages/nodes/src/stair/floorplan.ts create mode 100644 packages/nodes/src/stair/index.ts rename packages/{editor/src/components/ui/panels/stair-panel.tsx => nodes/src/stair/panel.tsx} (94%) create mode 100644 packages/nodes/src/stair/parametrics.ts rename packages/{viewer/src/components/renderers/stair/stair-renderer.tsx => nodes/src/stair/renderer.tsx} (99%) create mode 100644 packages/nodes/src/stair/schema.ts create mode 100644 packages/nodes/src/stair/system.tsx create mode 100644 packages/nodes/src/wall/floorplan-affordances.ts rename packages/{editor/src/components/ui/panels/wall-panel.tsx => nodes/src/wall/panel.tsx} (92%) create mode 100644 packages/nodes/src/window/floorplan-move.ts rename packages/{editor/src/components/tools/window/move-window-tool.tsx => nodes/src/window/move-tool.tsx} (97%) rename packages/{editor/src/components/ui/panels/window-panel.tsx => nodes/src/window/panel.tsx} (97%) rename packages/{editor/src/components/tools/window/window-tool.tsx => nodes/src/window/tool.tsx} (97%) rename packages/{editor/src/components/tools => nodes/src}/window/window-math.ts (100%) delete mode 100644 packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/door/door-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/fence/fence-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/item/item-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/slab/slab-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/wall/wall-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/window/window-renderer.tsx delete mode 100644 packages/viewer/src/components/renderers/zone/zone-renderer.tsx delete mode 100644 packages/viewer/src/components/viewer/legacy-system.tsx create mode 100644 wiki/architecture/plugin-authoring.md diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 589aa616..93d8336f 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -1,4 +1,4 @@ -import { loadPlugin, nodeRegistry } from '@pascal-app/core' +import { discoverPlugins, loadPlugin, nodeRegistry } from '@pascal-app/core' import { builtinPlugin } from '@pascal-app/nodes' // Idempotency guard: HMR can reload this module, but `registerNode` throws on @@ -12,10 +12,19 @@ function isDev(): boolean { return env?.NODE_ENV !== 'production' } -export function loadBuiltinNodes(): void { +export async function loadBuiltinNodes(): Promise { if (loaded) return loaded = true - void loadPlugin(builtinPlugin) + await loadPlugin(builtinPlugin) + + // Phase 6 plugin discovery hook. Always called; default impl returns + // `[]`. Apps that ship external node packs override the discovery via + // `setPluginDiscovery(...)` before this module loads. See + // `wiki/editor-plugin-authoring.md` for the contract. + const externals = await discoverPlugins() + for (const plugin of externals) { + await loadPlugin(plugin) + } if (isDev()) { const kinds = Array.from(nodeRegistry.entries(), ([k]) => k) @@ -24,7 +33,7 @@ export function loadBuiltinNodes(): void { // "which path is running this kind?" Empty array = every kind is on // the legacy path. Kind in the array = registry path is live for it. console.info( - `[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`, + `[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})${externals.length > 0 ? ` + ${externals.length} discovered plugin(s)` : ''}`, ) } // Expose the registry on window for ad-hoc dev inspection. In prod the @@ -38,4 +47,4 @@ export function loadBuiltinNodes(): void { // Run as a side effect on first import so any consumer of this module gets a // populated registry without remembering to call the function explicitly. -loadBuiltinNodes() +void loadBuiltinNodes() diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 75ca575d..f67291e6 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -13,6 +13,7 @@ import type { LevelNode, RoofNode, RoofSegmentNode, + ScanNode, ShelfNode, SiteNode, SlabNode, @@ -36,7 +37,14 @@ export interface GridEvent { */ localPosition: [number, number, number] faceIndex?: number - object: Object3D + /** + * Optional: the hit Three.js object. Present when the grid event was + * synthesized from a R3F mesh hit (the legacy grid-plane mesh path); + * absent when emitted by the canvas-level raycaster in + * `use-grid-events.ts`, where there is no specific mesh to attribute + * the intersection to. + */ + object?: Object3D nativeEvent: ThreeEvent } @@ -70,6 +78,8 @@ export type StairSegmentEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent export type ElevatorEvent = NodeEvent +export type ScanEvent = NodeEvent +export type GuideEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -201,6 +211,8 @@ type EditorEvents = GridEvents & NodeEvents<'stair-segment', StairSegmentEvent> & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & + NodeEvents<'scan', ScanEvent> & + NodeEvents<'guide', GuideEvent> & CameraControlEvents & ToolEvents & GuideEvents & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index c9f4832c..39a84ec3 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -3,43 +3,20 @@ import { useLayoutEffect } from 'react' import type * as THREE from 'three' -const KNOWN_NODE_KINDS = [ - 'site', - 'building', - 'ceiling', - 'column', - 'elevator', - 'level', - 'wall', - 'fence', - 'item', - 'slab', - 'spawn', - 'zone', - 'roof', - 'roof-segment', - 'stair', - 'stair-segment', - 'scan', - 'guide', - 'window', - 'door', -] as const +// `byType` is a Proxy-backed Map keyed by kind. Sets are created lazily on +// first access, so any kind (built-in or plugin-contributed) participates +// without needing a hardcoded seed list. The previous `KNOWN_NODE_KINDS` +// array was a pre-seed for autocomplete; with every kind now flowing +// through `nodeRegistry`, the seed is redundant. +// +// The type expresses that *any* string key returns a `Set` — the +// Proxy auto-creates on first access so there's no `undefined` branch at +// runtime. Without this shape, `noUncheckedIndexedAccess` would force +// every caller to defend against an impossible undefined. +type ByTypeMap = { [kind: string]: Set } +const byTypeStore = new Map>() -type KnownNodeKind = (typeof KNOWN_NODE_KINDS)[number] -// Allow registry-registered (plugin) kinds while keeping autocomplete for built-ins. -type NodeKind = KnownNodeKind | (string & {}) - -type ByTypeShape = Record> & Record> - -const byTypeStore = new Map>( - KNOWN_NODE_KINDS.map((k) => [k, new Set()]), -) - -// Auto-creates a Set the first time an unknown kind is accessed. This is what -// lets registry-registered (and future plugin-contributed) kinds participate -// in `byType` without being hardcoded here. -const byTypeProxy = new Proxy({} as ByTypeShape, { +const byTypeProxy = new Proxy({} as ByTypeMap, { get(_target, key) { if (typeof key !== 'string') return undefined let set = byTypeStore.get(key) @@ -67,9 +44,8 @@ export const sceneRegistry = { // Master lookup: ID -> Object3D nodes: new Map(), - // Categorized lookups: Kind -> Set of IDs. - // Backed by a Proxy so registry-registered kinds get a Set on first touch, - // while built-in kinds remain present from module init for fast paths. + // Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind + // gets a Set on first touch — no hardcoded list. byType: byTypeProxy, /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */ @@ -81,7 +57,7 @@ export const sceneRegistry = { }, } -export function useRegistry(id: string, type: NodeKind, ref: React.RefObject) { +export function useRegistry(id: string, type: string, ref: React.RefObject) { useLayoutEffect(() => { const obj = ref.current if (!obj) return @@ -89,11 +65,10 @@ export function useRegistry(id: string, type: NodeKind, ref: React.RefObject { sceneRegistry.nodes.delete(id) sceneRegistry.byType[type]!.delete(id) 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 214e3828..988fb2a5 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -1,6 +1,13 @@ 'use client' -import { type AnyNode, type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type CeilingNode, + nodeRegistry, + type SlabNode, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' @@ -18,9 +25,13 @@ import { NodeActionMenu } from '../editor/node-action-menu' * an HTML overlay positioned at the top of the bounding box. * * Buttons: - * - Move: sets `movingNode` in useEditor. The `` component picks that up and lets the user click in the - * floor plan to commit the new position. + * - Move: sets `movingNode` in useEditor. Enabled when the kind has + * `capabilities.movable`, `def.floorplanMoveTarget`, OR + * `def.affordanceTools.move` (slab / ceiling). The + * `` / dispatcher picks the right path. + * - Add hole (slab + ceiling only): inserts a small default-square + * hole at the polygon centroid via `updateNode`. Mirrors the legacy + * `handleAddHole` in `floating-action-menu.tsx`. * - Duplicate: deep-clones the node, marks it new, sets it as the * movingNode (placement cursor) — same UX pattern as 3D duplicate. * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's @@ -70,14 +81,66 @@ export function FloorplanRegistryActionMenu() { const node = useScene.getState().nodes[selectedId] if (!node) return null - const canMove = !!def.capabilities.movable + // Move button is enabled when any of: + // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence) + // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item) + // - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling) + // From the menu's perspective all three are "this kind can move from + // the floor plan." The `MoveTool` dispatcher resolves the right path. + const canMove = + !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move const canDuplicate = def.capabilities.duplicable !== false const canDelete = def.capabilities.deletable !== false + const canAddHole = node.type === 'slab' || node.type === 'ceiling' const handleMove = () => { sfxEmitter.emit('sfx:item-pick') setMovingNode(node as never) - // Selection stays — the move overlay reads movingNode, not selection. + // Match the legacy 3D `floating-action-menu`: clear selection so + // selection-gated affordances unmount during the drag. Specifically + // the slab / ceiling boundary editor (`ToolManager` shows it when + // `selectedSlabId !== undefined`) would otherwise stay visible + // and render its vertex / edge handles on top of the moving mesh + // in split-view 3D. The move overlay reads `movingNode`, not the + // selection, so clearing it doesn't disturb the move itself; the + // commit path re-selects the node when it ends. + useViewer.getState().setSelection({ selectedIds: [] }) + } + + const handleAddHole = () => { + if (!canAddHole) return + const surfaceNode = node as SlabNode | CeilingNode + const polygon = surfaceNode.polygon + if (!polygon || polygon.length < 3) return + + let cx = 0 + let cz = 0 + for (const [x, z] of polygon) { + cx += x + cz += z + } + cx /= polygon.length + cz /= polygon.length + + const holeSize = 0.5 + const newHole: Array<[number, number]> = [ + [cx - holeSize, cz - holeSize], + [cx + holeSize, cz - holeSize], + [cx + holeSize, cz + holeSize], + [cx - holeSize, cz + holeSize], + ] + const currentHoles = surfaceNode.holes ?? [] + const currentMetadata = currentHoles.map( + (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const }, + ) + sfxEmitter.emit('sfx:structure-build') + useScene.getState().updateNode( + selectedId as AnyNodeId, + { + holes: [...currentHoles, newHole], + holeMetadata: [...currentMetadata, { source: 'manual' as const }], + } as Partial, + ) } const handleDuplicate = () => { @@ -113,6 +176,7 @@ export function FloorplanRegistryActionMenu() { }} > `. + * + * The legacy panel is the authoritative owner of the floor-plan SVG — + * it computes `unitsPerPixel` from the viewBox / surface size, mounts the + * pan/zoom ``, and knows the active theme. The registry layer is mounted + * inside the same ``, so anything it draws shares the same coordinate + * system; this context plumbs through the bits it can't recompute on its + * own without re-implementing the legacy's resize / theme logic. + * + * Once `floorplan-panel.tsx` is fully migrated (Phase 6), this provider + * moves into a kind-agnostic 2D editor shell and the context loses the + * "legacy bridge" connotation. + */ +export type FloorplanRenderContextValue = { + /** SVG units per screen pixel — used to keep handle radii consistent at any zoom. */ + unitsPerPixel: number + /** Themed palette mirroring the legacy `FloorplanPalette` accent slots. */ + palette: FloorplanPalette + /** SVG `` id mounted in `` by the legacy panel for selection hatch fills. */ + hatchPatternId: string +} + +const FloorplanRenderContext = createContext(null) + +export function FloorplanRenderProvider({ + children, + unitsPerPixel, + palette, + hatchPatternId, +}: FloorplanRenderContextValue & { children: ReactNode }) { + const value = useMemo( + () => ({ unitsPerPixel, palette, hatchPatternId }), + [unitsPerPixel, palette, hatchPatternId], + ) + return {children} +} + +/** + * Read the active render context. Returns `null` when called outside a + * provider — the registry layer treats this as "render statically, skip + * theme-aware chrome and interactive handles". This makes the layer + * usable in isolation tests + future editor shells without bringing the + * whole legacy panel along. + */ +export function useFloorplanRender(): FloorplanRenderContextValue | null { + return useContext(FloorplanRenderContext) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx index 20ebebbf..8fd91ecd 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx @@ -273,10 +273,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ fill={curvedAccent} key={`${stair.id}:spiral-arrow`} pointerEvents="none" - points={buildSvgArrowHeadPoints( - arrowPoint, - tangentAngle, - clamp(stair.width * 0.18, 0.12, 0.18), + points={formatSvgPolygonPoints( + buildSvgArrowHeadPoints( + arrowPoint, + tangentAngle, + clamp(stair.width * 0.18, 0.12, 0.18), + ), )} /> ) @@ -361,10 +363,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ fill={curvedAccent} key={`${stair.id}:curved-arrow`} pointerEvents="none" - points={buildSvgArrowHeadPoints( - arrowPoint, - tangentAngle, - clamp(stair.width * 0.16, 0.1, 0.16), + points={formatSvgPolygonPoints( + buildSvgArrowHeadPoints( + arrowPoint, + tangentAngle, + clamp(stair.width * 0.16, 0.1, 0.16), + ), )} /> ) diff --git a/packages/editor/src/components/editor-2d/svg-paths.ts b/packages/editor/src/components/editor-2d/svg-paths.ts index 8a3fc3d0..0d0d2c48 100644 --- a/packages/editor/src/components/editor-2d/svg-paths.ts +++ b/packages/editor/src/components/editor-2d/svg-paths.ts @@ -103,7 +103,15 @@ export function formatSvgPolygonPoints(points: Point2D[]) { return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ') } -export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) { +/** + * Three points defining an arrow head — tip + two trailing barbs. + * Returned as plain `Point2D` objects so consumers can either feed them + * straight into `formatSvgPolygonPoints` (for SVG `points=""`) or push + * them onto a `FloorplanGeometry.polygon.points` array. Mixing both + * downstream paths through a string-returning helper was awkward — see + * `nodes/src/stair/floorplan.ts` which needs the points as objects. + */ +export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number): Point2D[] { const left = { x: point.x - size * Math.cos(angle - Math.PI / 6), y: point.y - size * Math.sin(angle - Math.PI / 6), @@ -113,7 +121,7 @@ export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: num y: point.y - size * Math.sin(angle + Math.PI / 6), } - return formatSvgPolygonPoints([point, left, right]) + return [point, left, right] } export { toSvgPoint, toSvgX, toSvgY } diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index c8dc7669..c86c3d08 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -352,7 +352,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { const nodes = useScene.getState().nodes const meshes: ElevatorColliderMesh[] = [] - for (const elevatorId of sceneRegistry.byType.elevator) { + for (const elevatorId of sceneRegistry.byType.elevator!) { const typedElevatorId = elevatorId as AnyNodeId const node = nodes[typedElevatorId] if (node?.type !== 'elevator' || node.visible === false) continue @@ -585,7 +585,7 @@ export const FirstPersonControls = () => { let closestDoorId: AnyNodeId | null = null let closestDistance = DOOR_INTERACTION_DISTANCE - for (const doorId of sceneRegistry.byType.door) { + for (const doorId of sceneRegistry.byType.door!) { const node = nodes[doorId as AnyNodeId] if (node?.type !== 'door') continue if (node.openingKind === 'opening') continue @@ -683,7 +683,7 @@ export const FirstPersonControls = () => { let closestWindowId: AnyNodeId | null = null let closestDistance = DOOR_INTERACTION_DISTANCE - for (const windowId of sceneRegistry.byType.window) { + for (const windowId of sceneRegistry.byType.window!) { const node = nodes[windowId as AnyNodeId] if (node?.type !== 'window') continue if (node.openingKind === 'opening') continue @@ -713,7 +713,7 @@ export const FirstPersonControls = () => { let closestTarget: FirstPersonInteractableTarget | null = null let closestDistance = DOOR_INTERACTION_DISTANCE - for (const elevatorId of sceneRegistry.byType.elevator) { + for (const elevatorId of sceneRegistry.byType.elevator!) { const typedElevatorId = elevatorId as AnyNodeId const node = nodes[typedElevatorId] if (node?.type !== 'elevator') continue @@ -1088,11 +1088,11 @@ export const FirstPersonControls = () => { const elevatorIds = activeRide ? [ activeRide.elevatorId, - ...Array.from(sceneRegistry.byType.elevator).filter( + ...Array.from(sceneRegistry.byType.elevator!).filter( (elevatorId) => elevatorId !== activeRide.elevatorId, ), ] - : Array.from(sceneRegistry.byType.elevator) + : Array.from(sceneRegistry.byType.elevator!) for (const elevatorId of elevatorIds) { const typedElevatorId = elevatorId as AnyNodeId diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index fa5b563f..f977aab8 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -176,7 +176,7 @@ function buildRegisteredNodeTypeLookup() { const nodeTypes = new Map() for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]) { + for (const nodeId of sceneRegistry.byType[type]!) { nodeTypes.set(nodeId, type) } } @@ -238,7 +238,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider } for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]) { + for (const nodeId of sceneRegistry.byType[type]!) { if (shouldSkipColliderNode(nodeId, type)) continue const root = sceneRegistry.nodes.get(nodeId) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 8aa61671..9ebecfc3 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -16,7 +16,6 @@ import { type GuideNode, getRenderableSlabPolygon, getWallChordFrame, - getWallCurveFrameAt, getWallCurveLength, getWallMidpointHandlePoint, getWallPlanFootprint, @@ -30,7 +29,6 @@ import { type Point2D, type RoofNode, type RoofSegmentNode, - resolveElevatorServiceLevelIds, type SiteNode, SlabNode, type SpawnNode, @@ -87,6 +85,10 @@ import { } from '../editor-2d/floorplan-hotkey-handlers' import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay' +import { + type FloorplanRenderContextValue, + FloorplanRenderProvider, +} from '../editor-2d/floorplan-render-context' import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' import { @@ -8440,364 +8442,29 @@ export function FloorplanPanel() { return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) - const floorplanFenceEntries = useMemo(() => { - // Fence migrated to def.floorplan (Phase 5 Stage C). When registered, - // FloorplanRegistryLayer renders the fence polyline; this legacy - // path short-circuits to avoid double-render. Removed entirely in - // Phase 6 cleanup. - if (nodeRegistry.has('fence')) return [] - return fences.flatMap((fence) => { - const live = useLiveTransforms.getState().get(fence.id) - const fenceCenterX = (fence.start[0] + fence.end[0]) / 2 - const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2 - const displayFence = live - ? { - ...fence, - start: [ - fence.start[0] + (live.position[0] - fenceCenterX), - fence.start[1] + (live.position[2] - fenceCenterZ), - ] as typeof fence.start, - end: [ - fence.end[0] + (live.position[0] - fenceCenterX), - fence.end[1] + (live.position[2] - fenceCenterZ), - ] as typeof fence.end, - } - : fence - const centerline = isCurvedWall(displayFence) - ? sampleWallCenterline(displayFence, 24) - : [ - { x: displayFence.start[0], y: displayFence.start[1] }, - { x: displayFence.end[0], y: displayFence.end[1] }, - ] - const path = buildSvgPolylinePath(centerline) - if (!path) { - return [] - } + // Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`). + // The legacy entry list is permanently empty; kept as a typed stable + // reference so downstream prop sites stay typed without each having to + // declare its own `[]`. + const floorplanFenceEntries = useMemo(() => [], []) + // Wall is fully registry-driven. Empty stable arrays for the legacy + // entry lists; consumers' map / iteration paths become no-ops. + const wallPolygons = useMemo(() => [], []) + const displayWallPolygons = useMemo(() => [], []) - const markerFrames = getFloorplanFenceMarkerTs(displayFence).map((t) => { - const frame = getWallCurveFrameAt(displayFence, t) - - return { - angleDeg: (Math.atan2(frame.tangent.y, frame.tangent.x) * 180) / Math.PI, - point: frame.point, - } - }) - - return [{ fence: displayFence, centerline, markerFrames, path }] - }) - }, [fences, movingFloorplanNodeRevision]) - const wallPolygons = useMemo(() => { - // Wall migrated to def.floorplan (Phase 5 Stage C). When registered, - // FloorplanRegistryLayer renders the mitered wall polygon; this - // legacy path short-circuits. Removed entirely in Phase 6 cleanup. - if (nodeRegistry.has('wall')) return [] - return walls.map((wall) => { - const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall) - const polygon = getWallPlanFootprint(floorplanWall, wallMiterData) - return { - points: formatPolygonPoints(polygon), - wall, - polygon, - } - }) - }, [floorplanWallById, wallMiterData, walls]) - const displayWallPolygons = useMemo(() => { - if (!(wallEndpointDraft || wallCurveDraft)) { - return wallPolygons - } - - const previewWalls = new Map() - - if (wallEndpointDraft) { - for (const draftUpdate of getWallEndpointDraftUpdates(wallEndpointDraft)) { - const previewWall = displayWallById.get(draftUpdate.id) - if (previewWall) { - previewWalls.set(previewWall.id, previewWall) - } - } - } - - if (wallCurveDraft) { - const previewWall = displayWallById.get(wallCurveDraft.wallId) - if (previewWall) { - previewWalls.set(previewWall.id, previewWall) - } - } - - if (previewWalls.size === 0) { - return wallPolygons - } - - return wallPolygons.map((entry) => - (() => { - const previewWall = previewWalls.get(entry.wall.id) - if (!previewWall) { - return entry - } - - const previewPolygon = getWallPlanFootprint( - getFloorplanWall(previewWall), - EMPTY_WALL_MITER_DATA, - ) - - return { - wall: previewWall, - polygon: previewPolygon, - points: formatPolygonPoints(previewPolygon), - } - })(), - ) - }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons]) - - const openingsPolygons = useMemo(() => { - // Doors + windows migrated to def.floorplan (Phase 5 Stage C). When - // both registered, FloorplanRegistryLayer renders each opening's - // polygon via its kind's builder; this legacy path short-circuits - // to avoid double-render. Filter per kind so a partial migration - // would still work. - const doorRegistered = nodeRegistry.has('door') - const windowRegistered = nodeRegistry.has('window') - if (doorRegistered && windowRegistered) return [] - return openings.flatMap((opening) => { - if (doorRegistered && opening.type === 'door') return [] - if (windowRegistered && opening.type === 'window') return [] - const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id']) - if (!wall) return [] - const live = useLiveTransforms.getState().get(opening.id) - const displayOpening = - live && - (movingNode?.type === 'door' || movingNode?.type === 'window') && - movingNode.id === opening.id - ? { - ...opening, - position: [ - live.position[0], - opening.position[1], - live.position[2], - ] as typeof opening.position, - rotation: [ - opening.rotation[0], - live.rotation, - opening.rotation[2], - ] as typeof opening.rotation, - } - : opening - const polygon = getOpeningFootprint(wall, displayOpening) - return [ - { - opening: displayOpening, - points: formatPolygonPoints(polygon), - polygon, - }, - ] - }) - }, [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings]) - const slabPolygons = useMemo(() => { - // Slab migrated to def.floorplan (Phase 5 Stage C). When registered, - // FloorplanRegistryLayer renders the slab polygon; this legacy - // path short-circuits to avoid double-render. Removed entirely in - // Phase 6 cleanup. - if (nodeRegistry.has('slab')) return [] - return slabs.flatMap((slab) => { - const polygon = toFloorplanPolygon(slab.polygon) - if (polygon.length < 3) { - return [] - } - - const holes = (slab.holes ?? []) - .map((hole) => toFloorplanPolygon(hole)) - .filter((hole) => hole.length >= 3) - const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) - const visualHoles = holes - - return [ - { - slab, - polygon, - holes, - visualPolygon, - visualHoles, - path: formatPolygonPath(visualPolygon, visualHoles), - }, - ] - }) - }, [slabs]) - const displaySlabPolygons = useMemo(() => { - if (!(slabBoundaryDraft || slabHoleBoundaryDraft || slabHoleMoveDraft)) { - return slabPolygons - } - - return slabPolygons.map((entry) => { - let nextEntry = entry - - if (slabBoundaryDraft && entry.slab.id === slabBoundaryDraft.slabId) { - nextEntry = (() => { - const draftVisualPolygon = - slabBoundaryDraft.visualOffsets?.length === slabBoundaryDraft.polygon.length - ? getDraftSlabVisualPolygon(slabBoundaryDraft) - : toFloorplanPolygon( - getRenderableSlabPolygon({ - ...entry.slab, - polygon: slabBoundaryDraft.polygon, - }), - ) - - return { - ...entry, - polygon: slabBoundaryDraft.polygon.map(toPoint2D), - visualPolygon: draftVisualPolygon, - path: formatPolygonPath(draftVisualPolygon, entry.visualHoles), - } - })() - } - - const activeHoleDraft = - slabHoleBoundaryDraft && entry.slab.id === slabHoleBoundaryDraft.slabId - ? slabHoleBoundaryDraft - : slabHoleMoveDraft && entry.slab.id === slabHoleMoveDraft.slabId - ? slabHoleMoveDraft - : null - - if (activeHoleDraft) { - const draftHole = activeHoleDraft.polygon.map(toPoint2D) - const draftHoles = nextEntry.holes.map((hole, index) => - index === activeHoleDraft.holeIndex ? draftHole : hole, - ) - const draftVisualHoles = nextEntry.visualHoles.map((hole, index) => - index === activeHoleDraft.holeIndex ? draftHole : hole, - ) - - nextEntry = { - ...nextEntry, - holes: draftHoles, - visualHoles: draftVisualHoles, - path: formatPolygonPath(nextEntry.visualPolygon, draftVisualHoles), - } - } - - return nextEntry - }) - }, [slabBoundaryDraft, slabHoleBoundaryDraft, slabHoleMoveDraft, slabPolygons]) - const ceilingPolygons = useMemo(() => { - // Ceiling migrated to def.floorplan (Phase 5 Stage C). When registered, - // FloorplanRegistryLayer renders the ceiling polygon; this legacy - // path short-circuits. Removed entirely in Phase 6 cleanup. - if (nodeRegistry.has('ceiling')) return [] - return ceilings.flatMap((ceiling) => { - const polygon = toFloorplanPolygon(ceiling.polygon) - if (polygon.length < 3) { - return [] - } - - const holes = (ceiling.holes ?? []) - .map((hole) => toFloorplanPolygon(hole)) - .filter((hole) => hole.length >= 3) - - return [ - { - ceiling, - polygon, - holes, - path: formatPolygonPath(polygon, holes), - }, - ] - }) - }, [ceilings]) - const displayCeilingPolygons = useMemo(() => { - if (!(ceilingBoundaryDraft || ceilingHoleBoundaryDraft || ceilingHoleMoveDraft)) { - return ceilingPolygons - } - - return ceilingPolygons.map((entry) => { - let nextEntry = entry - - if (ceilingBoundaryDraft && entry.ceiling.id === ceilingBoundaryDraft.ceilingId) { - const polygon = ceilingBoundaryDraft.polygon.map(toPoint2D) - nextEntry = { - ...entry, - polygon, - path: formatPolygonPath(polygon, entry.holes), - } - } - - const activeHoleDraft = - ceilingHoleBoundaryDraft && entry.ceiling.id === ceilingHoleBoundaryDraft.ceilingId - ? ceilingHoleBoundaryDraft - : ceilingHoleMoveDraft && entry.ceiling.id === ceilingHoleMoveDraft.ceilingId - ? ceilingHoleMoveDraft - : null - - if (activeHoleDraft) { - const draftHole = activeHoleDraft.polygon.map(toPoint2D) - const holes = nextEntry.holes.map((hole, index) => - index === activeHoleDraft.holeIndex ? draftHole : hole, - ) - - nextEntry = { - ...nextEntry, - holes, - path: formatPolygonPath(nextEntry.polygon, holes), - } - } - - return nextEntry - }) - }, [ceilingBoundaryDraft, ceilingHoleBoundaryDraft, ceilingHoleMoveDraft, ceilingPolygons]) - const zonePolygons = useMemo( - () => - zones.flatMap((zone) => { - const polygon = toFloorplanPolygon(zone.polygon) - if (polygon.length < 3) { - return [] - } - - return [ - { - zone, - polygon, - points: formatPolygonPoints(polygon), - }, - ] - }), - [zones], - ) - const displayZonePolygons = useMemo(() => { - if (!zoneBoundaryDraft) { - return zonePolygons - } - - return zonePolygons.map((entry) => - entry.zone.id === zoneBoundaryDraft.zoneId - ? { - ...entry, - polygon: zoneBoundaryDraft.polygon.map(toPoint2D), - points: formatPolygonPoints(zoneBoundaryDraft.polygon.map(toPoint2D)), - } - : entry, - ) - }, [zoneBoundaryDraft, zonePolygons]) - const floorplanColumnEntries = useMemo( - () => - levelDescendantNodes.flatMap((node) => { - if (!(node.type === 'column' && node.visible !== false)) { - return [] - } - - const polygon = getColumnPlanFootprint(node) - if (polygon.length < 3) { - return [] - } - - return [ - { - column: node, - points: formatPolygonPoints(polygon), - polygon, - }, - ] - }), - [levelDescendantNodes], - ) + // Doors + windows fully registry-driven via `def.floorplan`. + const openingsPolygons = useMemo(() => [], []) + // Slab + ceiling fully registry-driven via `def.floorplan`. Same + // empty-stable-array pattern. + const slabPolygons = useMemo(() => [], []) + const displaySlabPolygons = useMemo(() => [], []) + const ceilingPolygons = useMemo(() => [], []) + const displayCeilingPolygons = useMemo(() => [], []) + // Zone fully registry-driven via `def.floorplan`. + const zonePolygons = useMemo(() => [], []) + const displayZonePolygons = useMemo(() => [], []) + // Column fully registry-driven via `def.floorplan`. + const floorplanColumnEntries = useMemo(() => [], []) const levelDescendantNodeById = useMemo( () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), [levelDescendantNodes], @@ -8820,184 +8487,11 @@ export function FloorplanPanel() { ), [levelDescendantNodes], ) - const floorplanSpawnEntries = useMemo(() => { - // Spawn migrated to the registry-driven floor-plan layer (Phase 5 - // Stage C). When registered, FloorplanRegistryLayer renders the - // spawn marker via def.floorplan; FloorplanRegistryActionMenu - // handles select / move / delete. Returning [] here skips the - // legacy rendering + action menu paths to avoid double-render. - // Removed entirely in Phase 6 cleanup. - if (nodeRegistry.has('spawn')) return [] - return spawns - .filter((spawn) => spawn.visible !== false) - .map((spawn) => { - const live = useLiveTransforms.getState().get(spawn.id) - return { - spawn, - position: { - x: live?.position[0] ?? spawn.position[0], - y: live?.position[2] ?? spawn.position[2], - }, - rotation: live?.rotation ?? spawn.rotation, - } - }) - }, [movingFloorplanNodeRevision, spawns]) - const floorplanItemEntries = useMemo(() => { - // Item migrated to def.floorplan (Phase 5 Stage C). When registered, - // FloorplanRegistryLayer renders the item rectangle via the - // parent-chain transform walker; this legacy path short-circuits. - // Removed entirely in Phase 6 cleanup. - if (nodeRegistry.has('item')) return [] - const transformCache = new Map() - - return floorplanItems.flatMap((item) => { - const entry = buildFloorplanItemEntry(item, levelDescendantNodeById, transformCache) - if (!entry) { - return [] - } - - return [ - { - dimensionPolygon: entry.dimensionPolygon, - item: entry.item, - points: formatPolygonPoints(entry.polygon), - polygon: entry.polygon, - usesRealMesh: entry.usesRealMesh, - center: entry.center, - rotation: entry.rotation, - width: entry.width, - depth: entry.depth, - }, - ] - }) - }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) - const floorplanElevatorEntries = useMemo(() => { - // These keys subscribe the memo to imperative floorplan stores read with getState(). - void elevatorLiveOverrideKey - void elevatorRuntimeKey - void movingFloorplanNodeRevision - - if (!levelNode) { - return [] - } - - const nodes = useScene.getState().nodes - const interactiveElevators = useInteractive.getState().elevators - - return elevators.flatMap((elevator) => { - const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id) - const displayElevator = liveOverrides - ? ({ ...elevator, ...liveOverrides } as ElevatorNode) - : elevator - const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes) - if (!serviceLevelIds.includes(levelNode.id)) { - return [] - } - - const live = useLiveTransforms.getState().get(displayElevator.id) - const position = live?.position ?? displayElevator.position - const rotation = live?.rotation ?? displayElevator.rotation - const center = { x: position[0], y: position[2] } - const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04) - const cabWidth = Math.max(displayElevator.width, 0.8) - const cabDepth = Math.max(displayElevator.depth, 0.8) - const shaftWidth = Math.max( - displayElevator.shaftWidth ?? displayElevator.width, - cabWidth, - 0.8, - ) - const shaftDepth = Math.max( - displayElevator.shaftDepth ?? displayElevator.depth, - cabDepth, - 0.8, - ) - const doorWidth = Math.min( - Math.max(displayElevator.doorWidth, 0.45), - cabWidth - 0.18, - shaftWidth - 0.18, - ) - const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) - const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness) - const footprintCorners: Array = [ - [-halfWidth, -halfDepth], - [halfWidth, -halfDepth], - [halfWidth, halfDepth], - [-halfWidth, halfDepth], - ] - const polygon = footprintCorners.map(([localX, localY]) => { - const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation) - return { - x: center.x + offsetX, - y: center.y + offsetY, - } - }) - const frontStart = polygon[0] - const frontEnd = polygon[1] - if (!(frontStart && frontEnd)) { - return [] - } - const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) - const runtime = interactiveElevators[displayElevator.id] - const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? []) - const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? []) - const servedLevels = serviceLevelIds.flatMap((levelId) => { - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') { - return [] - } - - return [ - { - id: level.id, - isCurrent: runtime?.currentLevelId === level.id, - isDisabled: disabledLevelIds.has(level.id), - isQueued: runtime?.queue.includes(level.id) ?? false, - isServiceOnly: serviceOnlyLevelIds.has(level.id), - isTarget: runtime?.targetLevelId === level.id, - label: level.name || `L${level.level}`, - }, - ] - }) - - return [ - { - cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2, - cabDepth, - cabWidth, - center, - doorStyle: displayElevator.doorStyle ?? 'center-opening', - doorWidth, - elevator: displayElevator, - frontEdge: { - start: frontStart, - end: frontEnd, - }, - frontNormal: { - x: frontNormalX, - y: frontNormalY, - }, - isCarOnLevel: runtime?.currentLevelId === levelNode.id, - isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, - isTargetLevel: runtime?.targetLevelId === levelNode.id, - outerHalfDepth: halfDepth, - outerHalfWidth: halfWidth, - points: formatPolygonPoints(polygon), - polygon, - rotation, - servedLevels, - shaftDepth, - shaftWallThickness: wallThickness, - shaftWidth, - }, - ] - }) - }, [ - elevatorLiveOverrideKey, - elevatorRuntimeKey, - elevators, - levelNode, - movingFloorplanNodeRevision, - ]) + // Spawn + item fully registry-driven. + const floorplanSpawnEntries = useMemo(() => [], []) + const floorplanItemEntries = useMemo(() => [], []) + // Elevator fully registry-driven via `def.floorplan`. + const floorplanElevatorEntries = useMemo(() => [], []) const referenceFloorLevel = useMemo(() => { if (!(showReferenceFloor && levelNode)) { return null @@ -9192,171 +8686,30 @@ export function FloorplanPanel() { wallPolygons, } }, [referenceFloorDescendants, referenceFloorLevel]) - const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) - const floorplanStairEntries = useMemo( - () => - floorplanStairs.flatMap((stair) => { - const displayStair = - movingNode?.type === 'stair' && movingNode.id === stair.id - ? (() => { - const live = useLiveTransforms.getState().get(stair.id) - const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0] - const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2] - const liveRotation = live?.rotation ?? stair.rotation - - return { - ...stair, - position: [liveX, stair.position[1], liveZ] as StairNode['position'], - rotation: liveRotation, - } - })() - : stair - const segments = (displayStair.children ?? []) - .map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) - .filter( - (node): node is StairSegmentNode => - node?.type === 'stair-segment' && node.visible !== false, - ) - const entry = buildSharedFloorplanStairEntry(displayStair, segments) - if (!entry) { - return [] - } - const hitPolygons = - (displayStair.stairType ?? 'straight') === 'straight' - ? entry.segments.map((segmentEntry) => segmentEntry.polygon) - : [getFloorplanCurvedStairHitPolygon(displayStair)] - - return [ - { - ...entry, - hitPolygons, - segments: entry.segments.map((segmentEntry) => ({ - ...segmentEntry, - innerPoints: formatPolygonPoints(segmentEntry.innerPolygon), - points: formatPolygonPoints(segmentEntry.polygon), - treadBars: segmentEntry.treadBars.map((polygon) => ({ - points: formatPolygonPoints(polygon), - polygon, - })), - })), - }, - ] - }), - [ - cursorPoint, - floorplanStairs, - levelDescendantNodeById, - movingFloorplanNodeRevision, - movingNode, - ], - ) - const floorplanRoofEntries = useMemo( - () => - roofs.flatMap((roof) => { - const liveRoofTransform = - movingNode?.type === 'roof' && movingNode.id === roof.id - ? useLiveTransforms.getState().get(roof.id) - : null - const liveRoofPosition = liveRoofTransform - ? worldToBuildingLocalPlanPoint( - liveRoofTransform.position, - buildingPosition, - buildingRotationY, - ) - : null - const displayRoof = liveRoofTransform - ? { - ...roof, - position: [ - liveRoofPosition?.x ?? roof.position[0], - roof.position[1], - liveRoofPosition?.y ?? roof.position[2], - ] as RoofNode['position'], - rotation: liveRoofTransform.rotation, - } - : roof - const segments = (displayRoof.children ?? []) - .map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) - .filter( - (node): node is RoofSegmentNode => - node?.type === 'roof-segment' && node.visible !== false, - ) - .flatMap((segment) => { - const liveSegmentTransform = - movingNode?.type === 'roof-segment' && movingNode.id === segment.id - ? useLiveTransforms.getState().get(segment.id) - : null - const worldPositionOverride = liveSegmentTransform - ? worldToBuildingLocalPlanPoint( - liveSegmentTransform.position, - buildingPosition, - buildingRotationY, - ) - : undefined - const polygon = getRoofSegmentPolygon(displayRoof, segment, { - localRotation: liveSegmentTransform?.rotation, - worldPositionOverride, - }) - - if (polygon.length < 3) { - return [] - } - - return [ - { - segment, - polygon, - points: formatPolygonPoints(polygon), - ridgeLine: getRoofSegmentRidgeLine(displayRoof, segment, { - localRotation: liveSegmentTransform?.rotation, - worldPositionOverride, - }), - }, - ] - }) - - if (segments.length === 0) { - return [] - } - - return [ - { - roof: displayRoof, - center: { x: displayRoof.position[0], y: displayRoof.position[2] }, - segments, - }, - ] - }), - [ - buildingPosition, - buildingRotationY, - levelDescendantNodeById, - movingFloorplanNodeRevision, - movingNode, - roofs, - ], - ) - const selectedOpeningEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return openingsPolygons.find(({ opening }) => opening.id === selectedIds[0]) ?? null - }, [openingsPolygons, selectedIds]) - const selectedItemEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null - }, [floorplanItemEntries, selectedIds]) - const selectedSpawnEntry = useMemo(() => { - if (selectedIds.length !== 1) { - return null - } - - return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null - }, [floorplanSpawnEntries, selectedIds]) + // Pending-mesh check was a flag the legacy active-level item entries + // raised when their polygon was the dimension fallback (waiting for + // the GLB to load to produce a tighter convex hull). Items are now + // registry-rendered, so the active-level entry list is always empty + // and this flag is permanently false. + const hasPendingItemMeshFootprints = false + // Stair fully registry-driven via `def.floorplan` (the parent walks + // its `stair-segment` children inside `buildStairFloorplan` to handle + // the cumulative-transform chain). `FloorplanRegistryLayer` renders + // the result; this legacy list stays empty. + const floorplanStairEntries = useMemo(() => [], []) + // Roof / roof-segment fully registry-driven via def.floorplan. + const floorplanRoofEntries = useMemo(() => [], []) + // Selection lookups against the legacy entry lists. The active-level + // door / window / item / spawn paths are registry-driven now, so each + // source array is permanently empty and the lookups always return + // `null`. Selection chrome for those kinds comes from + // `FloorplanRegistryLayer` reading `viewState.selected`. Wrapping the + // `null` in `useMemo` (instead of a bare literal) preserves the + // declared type at consumer sites — bare `null` would narrow to + // `never` after `if (!entry) return`, breaking every `entry.field` read. + const selectedOpeningEntry = useMemo(() => null, []) + const selectedItemEntry = useMemo(() => null, []) + const selectedSpawnEntry = useMemo(() => null, []) const selectedElevatorEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -11149,6 +10502,29 @@ export function FloorplanPanel() { [theme], ) const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme]) + // Subset of the legacy palette surfaced to registry-driven kinds via + // . Mirrors `FloorplanPalette` in `@pascal-app/ + // core` — keep slot names + meanings in sync. + const floorplanRegistryPalette = useMemo( + () => ({ + selectedStroke: palette.selectedStroke, + selectedFill: palette.selectedFill, + selectedHatch: palette.selectedStroke, + wallHoverStroke: palette.wallHoverStroke, + endpointHandleFill: palette.endpointHandleFill, + endpointHandleStroke: palette.endpointHandleStroke, + endpointHandleHoverStroke: palette.endpointHandleHoverStroke, + endpointHandleActiveFill: palette.endpointHandleActiveFill, + endpointHandleActiveStroke: palette.endpointHandleActiveStroke, + curveHandleFill: palette.curveHandleFill, + curveHandleStroke: palette.curveHandleStroke, + curveHandleHoverStroke: palette.curveHandleHoverStroke, + measurementStroke: palette.measurementStroke, + measurementLabelBackground: theme === 'dark' ? '#0f172a' : '#ffffff', + measurementLabelText: theme === 'dark' ? '#e2e8f0' : '#171717', + }), + [palette, theme], + ) const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme]) const gridSteps = useMemo( () => getVisibleGridSteps(viewBox.width, surfaceSize.width), @@ -13613,14 +12989,11 @@ export function FloorplanPanel() { return } - if (isFloorplanGridInteractionActive) { - const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) - setCursorPoint((previousPoint) => - previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, - ) - return - } - + // Slab / zone polygon build — local draft state + grid emit, same + // reordering rationale as `handleBackgroundPlacementClick`: must + // run BEFORE the `isFloorplanGridInteractionActive` catch-all so + // the local polygon-draft state actually updates as the cursor + // moves (the catch-all would otherwise swallow the move event). if (isPolygonBuildActive) { const snappedPoint = snapPolygonDraftPoint({ point: planPoint, @@ -13628,6 +13001,10 @@ export function FloorplanPanel() { angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, }) + // Emit `grid:move` so the registry-driven slab tool also tracks + // the cursor (its 3D preview needs it). + emitFloorplanGridEvent('move', snappedPoint, event) + setCursorPoint((previousPoint) => { const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint)) if (hasChanged && activePolygonDraftPoints.length > 0) { @@ -13638,6 +13015,19 @@ export function FloorplanPanel() { return } + // Wall build also needs to run before the catch-all — see the + // wall branch in `handleBackgroundPlacementClick` for the same + // restructuring. The wall branch lives further below in this + // handler (`if (!isWallBuildActive) ... setDraftEnd(...)`); the + // grid emit is inlined there. + if (!isWallBuildActive && isFloorplanGridInteractionActive) { + const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) + setCursorPoint((previousPoint) => + previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, + ) + return + } + if (isOpeningPlacementActive) { const closest = findClosestWallPoint(planPoint, walls, { canUseWall: (wall) => !isCurvedWall(wall), @@ -13694,6 +13084,10 @@ export function FloorplanPanel() { angleSnap: Boolean(draftStart) && !shiftPressed, }) + // Emit `grid:move` so the registry-driven wall tool's 3D preview + // tracks the cursor. The local draftEnd update below is what + // drives the 2D draft polygon — both views update in parallel. + emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint(snappedPoint) if (!draftStart) { @@ -17720,8 +17114,20 @@ export function FloorplanPanel() { their SVG via . Sits above the legacy inline content so newly-registered kinds (shelf today) overlay on top until their inline equivalent is - removed in their Phase 5 migration PR. */} - + removed in their Phase 5 migration PR. + + Wrapped in so registry-driven + kinds receive the same themed palette / units-per-pixel + the legacy layers compute. The hatch pattern id is the + legacy wall hatch — kinds that opt into selection hatch + fills reuse this pattern via fill="url(...)". */} + + + {/* Cursor-driven placement ghost for movingNode when the active kind is registry-driven. Renders via a portal into the floor-plan scene (the data-floorplan-scene diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 4f067795..205b4ab4 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -17,6 +17,7 @@ import { type RoofSegmentEvent, resolveLevelId, resolveMaterial, + type ShelfNode, type SlabNode, type StairEvent, type StairNode, @@ -341,7 +342,7 @@ function applyStairPaintPreview( } function applySingleSurfacePaintPreview( - node: FenceNode | ColumnNode | SlabNode | CeilingNode, + node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, material: ActivePaintMaterial, ): PaintPreviewCleanup | null { if (node.type === 'ceiling') { @@ -409,6 +410,23 @@ function applySingleSurfacePaintPreview( } } + if (node.type === 'shelf') { + // Shelf is a registered Group, not a Mesh. Traverse children and + // preview-swap every child mesh — same approach `column` uses. + if (!registeredObject) return null + const restores: PaintPreviewCleanup[] = [] + registeredObject.traverse((object) => { + if (!(object as Mesh).isMesh) return + restores.push(previewMeshMaterial(object as Mesh, previewMaterial)) + }) + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } + } + if (!mesh) return null if (node.type === 'slab') { @@ -934,7 +952,8 @@ export const SelectionManager = () => { node.type === 'fence' || node.type === 'column' || node.type === 'slab' || - node.type === 'ceiling' + node.type === 'ceiling' || + node.type === 'shelf' ) { const compatible = hasActivePaintMaterial(activePaintMaterial) @@ -949,7 +968,7 @@ export const SelectionManager = () => { .updateNode( node.id as AnyNodeId, buildSingleSurfaceMaterialPatch< - FenceNode | ColumnNode | SlabNode | CeilingNode + FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode >(activePaintMaterial.material, activePaintMaterial.materialPreset), ) } @@ -957,7 +976,7 @@ export const SelectionManager = () => { preview: compatible ? () => applySingleSurfacePaintPreview( - node as FenceNode | ColumnNode | SlabNode | CeilingNode, + node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, activePaintMaterial, ) : () => previewCursor('not-allowed'), @@ -1193,7 +1212,10 @@ export const SelectionManager = () => { } if ( - (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') && + (node.type === 'fence' || + node.type === 'slab' || + node.type === 'ceiling' || + node.type === 'shelf') && nodeToSelect.type === node.type ) { setSelectedMaterialTargetForNode(nodeToSelect, 'surface') diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index b1135943..832e36c6 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -207,7 +207,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const restoreNodeVisibility = (() => { const saved = new Map() for (const type of ['scan', 'guide'] as const) { - const ids = sceneRegistry.byType[type] + const ids = sceneRegistry.byType[type]! ids.forEach((id) => { const node = sceneRegistry.nodes.get(id) if (node) { 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 d6228200..41e93713 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -179,12 +179,11 @@ export function useFloorplanBackgroundPlacement({ return true } - if (isFloorplanGridInteractionActive) { - const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) - setCursorPoint(snappedPoint) - return true - } - + // Slab / zone polygon build — local draft state + grid emit. + // Must run BEFORE the `isFloorplanGridInteractionActive` catch-all + // (since slab is registry-driven, the catch-all would otherwise + // swallow the click and skip local draft state updates — leaving + // the 2D draft polygon invisible while the 3D tool builds fine). if (isPolygonBuildActive) { const snappedPoint = snapPolygonDraftPoint({ point: planPoint, @@ -192,6 +191,13 @@ export function useFloorplanBackgroundPlacement({ angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, }) + // Emit the grid event so the registry-driven slab tool also + // sees the click (parity with ceiling / fence / roof branches + // above). Zone has no registry tool — emit-or-not is irrelevant. + if (!isZoneBuildActive) { + emitFloorplanGridEvent('click', snappedPoint, event) + } + if (isZoneBuildActive) { handleZonePlacementPoint(snappedPoint) } else { @@ -200,19 +206,34 @@ export function useFloorplanBackgroundPlacement({ return true } - if (!isWallBuildActive) { - return false + // Wall placement — local draft state + grid emit. Same reasoning + // as slab above: wall is registry-driven, so without this branch + // the catch-all would swallow the click and the local draftStart + // / draftEnd state in the floor plan would never update, leaving + // the dashed-line draft preview invisible. + if (isWallBuildActive) { + const snappedPoint = snapWallDraftPoint({ + point: planPoint, + walls, + start: draftStart ?? undefined, + angleSnap: Boolean(draftStart) && !shiftPressed, + }) + + emitFloorplanGridEvent('click', snappedPoint, event) + handleWallPlacementPoint(snappedPoint) + return true } - const snappedPoint = snapWallDraftPoint({ - point: planPoint, - walls, - start: draftStart ?? undefined, - angleSnap: Boolean(draftStart) && !shiftPressed, - }) + // Generic catch-all — registry-driven tool whose kind has no + // local floor-plan draft handler (column / spawn / shelf / etc.). + // The tool's `grid:click` subscriber owns the placement. + if (isFloorplanGridInteractionActive) { + const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) + setCursorPoint(snappedPoint) + return true + } - handleWallPlacementPoint(snappedPoint) - return true + return false }, [ activePolygonDraftPoints, diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index f90d8da3..71917ba4 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -46,7 +46,7 @@ export const CeilingSystem = () => { } } - const ceilings = sceneRegistry.byType.ceiling + const ceilings = sceneRegistry.byType.ceiling! ceilings.forEach((ceiling) => { const mesh = sceneRegistry.nodes.get(ceiling) if (mesh) { diff --git a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx b/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx deleted file mode 100644 index 9b4e7f62..00000000 --- a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback } from 'react' -import { PolygonEditor } from '../shared/polygon-editor' - -interface CeilingBoundaryEditorProps { - ceilingId: CeilingNode['id'] -} - -/** - * Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling - * Uses the generic PolygonEditor component - */ -export const CeilingBoundaryEditor: React.FC = ({ ceilingId }) => { - const ceilingNode = useScene((state) => state.nodes[ceilingId]) - const updateNode = useScene((state) => state.updateNode) - const setSelection = useViewer((state) => state.setSelection) - - const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null - - const handlePolygonChange = useCallback( - (newPolygon: Array<[number, number]>) => { - updateNode(ceilingId, { polygon: newPolygon }) - // Re-assert selection so the ceiling stays selected after the edit - setSelection({ selectedIds: [ceilingId] }) - }, - [ceilingId, updateNode, setSelection], - ) - - if (!ceiling?.polygon || ceiling.polygon.length < 3) return null - - return ( - - ) -} diff --git a/packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx b/packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx deleted file mode 100644 index c11495d2..00000000 --- a/packages/editor/src/components/tools/ceiling/ceiling-hole-editor.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback } from 'react' -import { PolygonEditor } from '../shared/polygon-editor' - -interface CeilingHoleEditorProps { - ceilingId: CeilingNode['id'] - holeIndex: number -} - -/** - * Ceiling hole editor - allows editing a specific hole polygon within a ceiling - * Uses the generic PolygonEditor component - */ -export const CeilingHoleEditor: React.FC = ({ ceilingId, holeIndex }) => { - const ceilingNode = useScene((state) => state.nodes[ceilingId]) - const updateNode = useScene((state) => state.updateNode) - const setSelection = useViewer((state) => state.setSelection) - - const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null - const holes = ceiling?.holes || [] - const hole = holes[holeIndex] - - const handlePolygonChange = useCallback( - (newPolygon: Array<[number, number]>) => { - const updatedHoles = [...holes] - updatedHoles[holeIndex] = newPolygon - updateNode(ceilingId, { holes: updatedHoles }) - // Re-assert selection so the ceiling stays selected after the edit - setSelection({ selectedIds: [ceilingId] }) - }, - [ceilingId, holeIndex, holes, updateNode, setSelection], - ) - - if (!(ceiling && hole) || hole.length < 3) return null - - return ( - - ) -} diff --git a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx b/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx deleted file mode 100644 index 59baa4c4..00000000 --- a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx +++ /dev/null @@ -1,465 +0,0 @@ -import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' -import { mix, positionLocal } from 'three/tsl' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { CursorSphere } from '../shared/cursor-sphere' - -const CEILING_HEIGHT = 2.52 -const GRID_OFFSET = 0.02 - -/** - * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point - */ -const calculateSnapPoint = ( - lastPoint: [number, number], - currentPoint: [number, number], -): [number, number] => { - const [x1, y1] = lastPoint - const [x, y] = currentPoint - - const dx = x - x1 - const dy = y - y1 - const absDx = Math.abs(dx) - const absDy = Math.abs(dy) - - // Calculate distances to horizontal, vertical, and diagonal lines - const horizontalDist = absDy - const verticalDist = absDx - const diagonalDist = Math.abs(absDx - absDy) - - // Find the minimum distance to determine which axis to snap to - const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) - - if (minDist === diagonalDist) { - // Snap to 45° diagonal - const diagonalLength = Math.min(absDx, absDy) - return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] - } - if (minDist === horizontalDist) { - // Snap to horizontal - return [x, y1] - } - // Snap to vertical - return [x1, y] -} - -/** - * Creates a ceiling with the given polygon points and returns its ID - */ -const commitCeilingDrawing = ( - levelId: LevelNode['id'], - points: Array<[number, number]>, -): string => { - const { createNode, nodes } = useScene.getState() - - // Count existing ceilings for naming - const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length - const name = `Ceiling ${ceilingCount + 1}` - - const ceiling = CeilingNode.parse({ - name, - polygon: points, - }) - - createNode(ceiling, levelId) - sfxEmitter.emit('sfx:structure-build') - return ceiling.id -} - -export const CeilingTool: React.FC = () => { - const cursorRef = useRef(null) - const gridCursorRef = useRef(null) - const mainLineRef = useRef(null!) - const closingLineRef = useRef(null!) - const groundMainLineRef = useRef(null!) - const groundClosingLineRef = useRef(null!) - const verticalLineRef = useRef(null!) - const currentLevelId = useViewer((state) => state.selection.levelId) - const setSelection = useViewer((state) => state.setSelection) - - const [points, setPoints] = useState>([]) - const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) - const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) - const [levelY, setLevelY] = useState(0) - const previousSnappedPointRef = useRef<[number, number] | null>(null) - const shiftPressed = useRef(false) - - // Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY - const verticalGeo = useMemo( - () => - new BufferGeometry().setFromPoints([ - new Vector3(0, 0, 0), - new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0), - ]), - [], - ) - - // opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0 - const gradientOpacityNode = useMemo( - () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()), - [], - ) - - // Update cursor position and lines on grid move - useEffect(() => { - if (!currentLevelId) return - - const onGridMove = (event: GridEvent) => { - if (!(cursorRef.current && gridCursorRef.current)) return - - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 - const gridPosition: [number, number] = [gridX, gridZ] - - setCursorPosition(gridPosition) - setLevelY(event.localPosition[1]) - - const ceilingY = event.localPosition[1] + CEILING_HEIGHT - const gridY = event.localPosition[1] + GRID_OFFSET - - // Calculate snapped display position (bypass snap when Shift is held) - const lastPoint = points[points.length - 1] - const displayPoint = - shiftPressed.current || !lastPoint - ? gridPosition - : calculateSnapPoint(lastPoint, gridPosition) - setSnappedCursorPosition(displayPoint) - - // Play snap sound when the snapped position actually changes (only when drawing) - if ( - points.length > 0 && - previousSnappedPointRef.current && - (displayPoint[0] !== previousSnappedPointRef.current[0] || - displayPoint[1] !== previousSnappedPointRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - - previousSnappedPointRef.current = displayPoint - cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]) - gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) - - if (verticalLineRef.current) { - verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) - } - } - - const onGridClick = (_event: GridEvent) => { - if (!currentLevelId) return - - // Use the last displayed snapped position (respects Shift state from onGridMove) - const clickPoint = previousSnappedPointRef.current ?? cursorPosition - - // Check if clicking on the first point to close the shape - const firstPoint = points[0] - if ( - points.length >= 3 && - firstPoint && - Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && - Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 - ) { - // Create the ceiling and select it - const ceilingId = commitCeilingDrawing(currentLevelId, points) - setSelection({ selectedIds: [ceilingId] }) - setPoints([]) - } else { - // Add point to polygon - setPoints([...points, clickPoint]) - } - } - - const onGridDoubleClick = (_event: GridEvent) => { - if (!currentLevelId) return - - // Need at least 3 points to form a polygon - if (points.length >= 3) { - const ceilingId = commitCeilingDrawing(currentLevelId, points) - setSelection({ selectedIds: [ceilingId] }) - setPoints([]) - } - } - - const onCancel = () => { - if (points.length > 0) markToolCancelConsumed() - setPoints([]) - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true - } - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false - } - document.addEventListener('keydown', onKeyDown) - document.addEventListener('keyup', onKeyUp) - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('grid:double-click', onGridDoubleClick) - emitter.on('tool:cancel', onCancel) - - return () => { - document.removeEventListener('keydown', onKeyDown) - document.removeEventListener('keyup', onKeyUp) - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('grid:double-click', onGridDoubleClick) - emitter.off('tool:cancel', onCancel) - } - }, [currentLevelId, points, cursorPosition, setSelection]) - - // Update line geometries when points change - useEffect(() => { - if (!(mainLineRef.current && closingLineRef.current)) return - - if (points.length === 0) { - mainLineRef.current.visible = false - closingLineRef.current.visible = false - return - } - - const ceilingY = levelY + CEILING_HEIGHT - const snappedCursor = snappedCursorPosition - - // Build main line points - const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z)) - linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1])) - - const gridY = levelY + GRID_OFFSET - const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z)) - groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1])) - - // Update main line - if (linePoints.length >= 2) { - mainLineRef.current.geometry.dispose() - mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) - mainLineRef.current.visible = true - - groundMainLineRef.current.geometry.dispose() - groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints) - groundMainLineRef.current.visible = true - } else { - mainLineRef.current.visible = false - groundMainLineRef.current.visible = false - } - - // Update closing line (from cursor back to first point) - const firstPoint = points[0] - if (points.length >= 2 && firstPoint) { - const closingPoints = [ - new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]), - new Vector3(firstPoint[0], ceilingY, firstPoint[1]), - ] - closingLineRef.current.geometry.dispose() - closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) - closingLineRef.current.visible = true - - const groundClosingPoints = [ - new Vector3(snappedCursor[0], gridY, snappedCursor[1]), - new Vector3(firstPoint[0], gridY, firstPoint[1]), - ] - groundClosingLineRef.current.geometry.dispose() - groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints( - groundClosingPoints, - ) - groundClosingLineRef.current.visible = true - } else { - closingLineRef.current.visible = false - groundClosingLineRef.current.visible = false - } - }, [points, snappedCursorPosition, levelY]) - - // Create preview shape when we have 3+ points - const previewShape = useMemo(() => { - if (points.length < 3) return null - - const snappedCursor = snappedCursorPosition - - const allPoints = [...points, snappedCursor] - - // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: - // - Shape X -> World X - // - Shape Y -> World -Z (so we negate Z to get correct orientation) - const firstPt = allPoints[0] - if (!firstPt) return null - - const shape = new Shape() - shape.moveTo(firstPt[0], -firstPt[1]) - - for (let i = 1; i < allPoints.length; i++) { - const pt = allPoints[i] - if (pt) { - shape.lineTo(pt[0], -pt[1]) - } - } - shape.closePath() - - return shape - }, [points, snappedCursorPosition]) - - return ( - - {/* Cursor at ceiling height */} - - - {/* Grid-level cursor indicator */} - - - - - - {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */} - {/* @ts-ignore */} - - - - - {/* Preview fill (Top) */} - {previewShape && ( - - - - - )} - - {/* Preview fill (Ground) */} - {previewShape && ( - - - - - )} - - {/* Main line */} - {/* @ts-ignore */} - - - - - - {/* Closing line */} - {/* @ts-ignore */} - - - - - - {/* Ground main line */} - {/* @ts-ignore */} - - - - - - {/* Ground closing line */} - {/* @ts-ignore */} - - - - - - {/* Point markers */} - {points.map(([x, z], index) => ( - - ))} - - ) -} diff --git a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx b/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx deleted file mode 100644 index ce14f8a7..00000000 --- a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx +++ /dev/null @@ -1,264 +0,0 @@ -'use client' - -import { - type AnyNodeId, - type CeilingNode, - emitter, - type GridEvent, - useScene, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' - -function snap(value: number) { - return Math.round(value * 2) / 2 -} - -function translatePolygon( - polygon: Array<[number, number]>, - deltaX: number, - deltaZ: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) -} - -function getPolygonCenter(polygon: Array<[number, number]>): [number, number] { - if (polygon.length === 0) return [0, 0] - let sumX = 0 - let sumZ = 0 - for (const [x, z] of polygon) { - sumX += x - sumZ += z - } - return [sumX / polygon.length, sumZ / polygon.length] -} - -export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { - const activatedAtRef = useRef(Date.now()) - const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) - const originalHolesRef = useRef( - (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), - ) - const dragAnchorRef = useRef<[number, number] | null>(null) - const previousGridPosRef = useRef<[number, number] | null>(null) - const previousCursorPosRef = useRef<[number, number, number] | null>(null) - const previousDeltaRef = useRef<[number, number] | null>(null) - const previewRef = useRef<{ - polygon: Array<[number, number]> - holes: Array> - } | null>(null) - - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const center = getPolygonCenter(node.polygon) - return [center[0], node.height ?? 2.5, center[1]] - }) - const [previewPolygon, setPreviewPolygon] = useState>(node.polygon) - const [previewHoles, setPreviewHoles] = useState>>(node.holes ?? []) - - const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingNode(null) - }, []) - - useEffect(() => { - const originalPolygon = originalPolygonRef.current - const originalHoles = originalHolesRef.current - - useScene.temporal.getState().pause() - let wasCommitted = false - - const applyPreview = ( - polygon: Array<[number, number]>, - holes: Array>, - ) => { - previewRef.current = { polygon, holes } - setPreviewPolygon(polygon) - setPreviewHoles(holes) - const center = getPolygonCenter(polygon) - const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]] - if ( - !previousCursorPosRef.current || - previousCursorPosRef.current[0] !== nextCursorPos[0] || - previousCursorPosRef.current[1] !== nextCursorPos[1] || - previousCursorPosRef.current[2] !== nextCursorPos[2] - ) { - previousCursorPosRef.current = nextCursorPos - setCursorLocalPos(nextCursorPos) - } - useScene.getState().updateNode(node.id, { polygon, holes }) - useScene.getState().markDirty(node.id as AnyNodeId) - } - - const restoreOriginal = () => { - setPreviewPolygon(originalPolygon) - setPreviewHoles(originalHoles) - useScene.getState().updateNode(node.id, { - holes: originalHoles, - polygon: originalPolygon, - }) - useScene.getState().markDirty(node.id as AnyNodeId) - } - - const onGridMove = (event: GridEvent) => { - const localX = snap(event.localPosition[0]) - const localZ = snap(event.localPosition[2]) - - if ( - previousGridPosRef.current && - (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosRef.current = [localX, localZ] - - const anchor = dragAnchorRef.current ?? [localX, localZ] - dragAnchorRef.current = anchor - - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] - - if ( - previousDeltaRef.current && - previousDeltaRef.current[0] === deltaX && - previousDeltaRef.current[1] === deltaZ - ) { - return - } - previousDeltaRef.current = [deltaX, deltaZ] - - applyPreview( - translatePolygon(originalPolygon, deltaX, deltaZ), - originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), - ) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } - - wasCommitted = true - - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - useScene.getState().updateNode(node.id, { - polygon: originalPolygon, - holes: originalHoles, - }) - - useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, preview) - useScene.getState().markDirty(node.id as AnyNodeId) - useScene.temporal.getState().pause() - - sfxEmitter.emit('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [node.id] }) - exitMoveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().resume() - markToolCancelConsumed() - exitMoveMode() - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - - return () => { - if (!wasCommitted) { - restoreOriginal() - } - useScene.temporal.getState().resume() - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - } - }, [exitMoveMode, node.height, node.id]) - - const previewFillGeometry = useMemo( - () => createCeilingPreviewGeometry(previewPolygon, previewHoles), - [previewHoles, previewPolygon], - ) - - const previewOutlineGeometry = useMemo( - () => createCeilingOutlineGeometry(previewPolygon), - [previewPolygon], - ) - - return ( - - - - - {/* @ts-ignore */} - - - - - - ) -} - -function createCeilingPreviewGeometry( - polygon: Array<[number, number]>, - holes: Array>, -): BufferGeometry { - if (polygon.length < 3) return new BufferGeometry() - - const shape = new Shape() - const [firstX, firstZ] = polygon[0]! - shape.moveTo(firstX, -firstZ) - - for (let i = 1; i < polygon.length; i++) { - const [x, z] = polygon[i]! - shape.lineTo(x, -z) - } - shape.closePath() - - for (const holePolygon of holes) { - if (holePolygon.length < 3) continue - const hole = new Path() - const [hx, hz] = holePolygon[0]! - hole.moveTo(hx, -hz) - for (let i = 1; i < holePolygon.length; i++) { - const [x, z] = holePolygon[i]! - hole.lineTo(x, -z) - } - hole.closePath() - shape.holes.push(hole) - } - - const geometry = new ShapeGeometry(shape) - geometry.rotateX(-Math.PI / 2) - geometry.computeVertexNormals() - return geometry -} - -function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry { - const geometry = new BufferGeometry() - if (polygon.length < 2) return geometry - - const points = polygon.map(([x, z]) => new Vector3(x, 0, z)) - const [firstX, firstZ] = polygon[0]! - points.push(new Vector3(firstX, 0, firstZ)) - geometry.setFromPoints(points) - return geometry -} diff --git a/packages/editor/src/components/tools/fence/curve-fence-tool.tsx b/packages/editor/src/components/tools/fence/curve-fence-tool.tsx deleted file mode 100644 index 65fe650a..00000000 --- a/packages/editor/src/components/tools/fence/curve-fence-tool.tsx +++ /dev/null @@ -1,178 +0,0 @@ -'use client' - -import { - type AnyNodeId, - emitter, - type FenceNode, - type GridEvent, - getClampedWallCurveOffset, - getMaxWallCurveOffset, - getWallChordFrame, - getWallMidpointHandlePoint, - normalizeWallCurveOffset, - pauseSceneHistory, - resumeSceneHistory, - useScene, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' -import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting' - -export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { - const activatedAtRef = useRef(Date.now()) - const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) - const previousCurveOffsetRef = useRef(null) - const shiftPressedRef = useRef(false) - const previewOffsetRef = useRef(originalCurveOffsetRef.current) - - const initialHandle = getWallMidpointHandlePoint(node) - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([ - initialHandle.x, - 0, - initialHandle.y, - ]) - - const exitCurveMode = useCallback(() => { - useEditor.getState().setCurvingFence(null) - }, []) - - useEffect(() => { - const nodeId = node.id - const originalCurveOffset = originalCurveOffsetRef.current - const chord = getWallChordFrame(node) - const maxCurveOffset = getMaxWallCurveOffset(node) - - pauseSceneHistory(useScene) - let wasCommitted = false - - const applyPreview = (curveOffset: number) => { - if (previewOffsetRef.current === curveOffset) { - return - } - previewOffsetRef.current = curveOffset - - const nextNode = { - ...node, - curveOffset, - } - const handlePoint = getWallMidpointHandlePoint(nextNode) - setCursorLocalPos([handlePoint.x, 0, handlePoint.y]) - useScene.getState().updateNode(nodeId, { curveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - } - - const restoreOriginal = () => { - if (previewOffsetRef.current === originalCurveOffset) { - return - } - previewOffsetRef.current = originalCurveOffset - useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - } - - const onGridMove = (event: GridEvent) => { - const snapStep = getWallGridStep() - const localX = shiftPressedRef.current - ? event.localPosition[0] - : snapScalarToGrid(event.localPosition[0], snapStep) - const localZ = shiftPressedRef.current - ? event.localPosition[2] - : snapScalarToGrid(event.localPosition[2], snapStep) - - const offsetFromMidpoint = -( - (localX - chord.midpoint.x) * chord.normal.x + - (localZ - chord.midpoint.y) * chord.normal.y - ) - const snappedOffset = shiftPressedRef.current - ? offsetFromMidpoint - : snapScalarToGrid(offsetFromMidpoint, snapStep) - const nextCurveOffset = normalizeWallCurveOffset( - node, - Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), - ) - - if ( - previousCurveOffsetRef.current !== null && - nextCurveOffset !== previousCurveOffsetRef.current - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousCurveOffsetRef.current = nextCurveOffset - - applyPreview(nextCurveOffset) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const curveOffset = previewOffsetRef.current - wasCommitted = true - - if (curveOffset !== originalCurveOffset) { - useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - - resumeSceneHistory(useScene) - useScene.getState().updateNode(nodeId, { curveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - pauseSceneHistory(useScene) - } - - sfxEmitter.emit('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - exitCurveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - resumeSceneHistory(useScene) - markToolCancelConsumed() - exitCurveMode() - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = true - } - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - - return () => { - if (!wasCommitted) { - restoreOriginal() - } - resumeSceneHistory(useScene) - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - } - }, [exitCurveMode, node]) - - return ( - - - - ) -} diff --git a/packages/editor/src/components/tools/fence/fence-tool.tsx b/packages/editor/src/components/tools/fence/fence-tool.tsx deleted file mode 100644 index 5afa6789..00000000 --- a/packages/editor/src/components/tools/fence/fence-tool.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { - emitter, - type FenceNode, - type GridEvent, - type LevelNode, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' -import { useEffect, useRef, useState } from 'react' -import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { CursorSphere } from '../shared/cursor-sphere' -import { - formatAngleRadians, - getAngleToSegmentReference, - getSegmentAngleReferenceAtPoint, -} from '../shared/segment-angle' -import { - createFenceOnCurrentLevel, - type FencePlanPoint, - snapFenceDraftPoint, -} from './fence-drafting' - -const FENCE_PREVIEW_HEIGHT = 1.8 -const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 -const DRAFT_ANGLE_LABEL_Y = 0.28 - -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] -} - -type DraftMeasurementState = { - lengthLabel: string - lengthPosition: [number, number, number] - angleLabels: DraftAngleLabel[] -} | null - -type SegmentLike = { - id: string - start: FencePlanPoint - end: FencePlanPoint - curveOffset?: number -} - -function formatMeasurement(value: number, unit: 'metric' | 'imperial') { - if (unit === 'imperial') { - const feet = value * 3.280_84 - const wholeFeet = Math.floor(feet) - const inches = Math.round((feet - wholeFeet) * 12) - if (inches === 12) return `${wholeFeet + 1}'0"` - return `${wholeFeet}'${inches}"` - } - - return `${Number.parseFloat(value.toFixed(2))}m` -} - -function getDraftAngleLabels( - start: FencePlanPoint, - end: FencePlanPoint, - segments: SegmentLike[], -): DraftAngleLabel[] { - const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] - const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] - const endpoints = [ - { id: 'start', point: start, draftVector: draftFromStart }, - { id: 'end', point: end, draftVector: draftFromEnd }, - ] - const labels: DraftAngleLabel[] = [] - - for (const endpoint of endpoints) { - const connectedSegment = segments.find((segment) => - Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), - ) - if (!connectedSegment) continue - - const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) - if (!connectedReference) continue - - const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) - if (angle === null) continue - - labels.push({ - id: endpoint.id, - label: formatAngleRadians(angle), - position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], - }) - } - - return labels -} - -function getDraftMeasurementState( - start: FencePlanPoint, - end: FencePlanPoint, - segments: SegmentLike[], - unit: 'metric' | 'imperial', -): DraftMeasurementState { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - - if (length < 0.01) return null - - return { - lengthLabel: formatMeasurement(length, unit), - lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], - angleLabels: getDraftAngleLabels(start, end, segments), - } -} - -function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { - return [ - ...walls.map((wall) => ({ - id: wall.id, - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset, - })), - ...fences.map((fence) => ({ - id: fence.id, - start: fence.start, - end: fence.end, - curveOffset: fence.curveOffset, - })), - ] -} - -const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => { - const direction = new Vector3(end.x - start.x, 0, end.z - start.z) - const length = direction.length() - - if (length < 0.01) { - mesh.visible = false - return - } - - mesh.visible = true - direction.normalize() - - const shape = new Shape() - shape.moveTo(0, 0) - shape.lineTo(length, 0) - shape.lineTo(length, FENCE_PREVIEW_HEIGHT) - shape.lineTo(0, FENCE_PREVIEW_HEIGHT) - shape.closePath() - - const geometry = new ShapeGeometry(shape) - const angle = -Math.atan2(direction.z, direction.x) - - mesh.position.set(start.x, start.y, start.z) - mesh.rotation.y = angle - - if (mesh.geometry) { - mesh.geometry.dispose() - } - mesh.geometry = geometry -} - -const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => { - const currentLevelId = useViewer.getState().selection.levelId - const { nodes } = useScene.getState() - - if (!currentLevelId) return { walls: [], fences: [] } - - const levelNode = nodes[currentLevelId] - if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] } - - const children = (levelNode as LevelNode).children.map((childId) => nodes[childId]) - - return { - walls: children.filter((node): node is WallNode => node?.type === 'wall'), - fences: children.filter((node): node is FenceNode => node?.type === 'fence'), - } -} - -export const FenceTool: React.FC = () => { - const unit = useViewer((state) => state.unit) - const cursorRef = useRef(null) - const previewRef = useRef(null!) - const startingPoint = useRef(new Vector3(0, 0, 0)) - const endingPoint = useRef(new Vector3(0, 0, 0)) - const buildingState = useRef(0) - const shiftPressed = useRef(false) - const [draftMeasurement, setDraftMeasurement] = useState(null) - - useEffect(() => { - let previousFenceEnd: [number, number] | null = null - - const onGridMove = (event: GridEvent) => { - if (!(cursorRef.current && previewRef.current)) return - - const { walls, fences } = getCurrentLevelElements() - const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - - if (buildingState.current === 1) { - const snappedLocal = snapFenceDraftPoint({ - point: localPoint, - walls, - fences, - start: [startingPoint.current.x, startingPoint.current.z], - angleSnap: !shiftPressed.current, - }) - endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) - cursorRef.current.position.copy(endingPoint.current) - - const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] - if ( - previousFenceEnd && - (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousFenceEnd = currentFenceEnd - - updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) - setDraftMeasurement( - getDraftMeasurementState( - [startingPoint.current.x, startingPoint.current.z], - snappedLocal, - getReferenceSegments(walls, fences), - unit, - ), - ) - } else { - const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences }) - cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) - setDraftMeasurement(null) - } - } - - const onGridClick = (event: GridEvent) => { - const { walls, fences } = getCurrentLevelElements() - const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - - if (buildingState.current === 0) { - const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences }) - startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) - endingPoint.current.copy(startingPoint.current) - buildingState.current = 1 - previewRef.current.visible = true - setDraftMeasurement(null) - } else { - const snappedEnd = snapFenceDraftPoint({ - point: localClick, - walls, - fences, - start: [startingPoint.current.x, startingPoint.current.z], - angleSnap: !shiftPressed.current, - }) - const dx = snappedEnd[0] - startingPoint.current.x - const dz = snappedEnd[1] - startingPoint.current.z - if (dx * dx + dz * dz < 0.01 * 0.01) return - createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) - previewRef.current.visible = false - buildingState.current = 0 - setDraftMeasurement(null) - } - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true - } - - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false - } - - const onCancel = () => { - if (buildingState.current === 1) { - markToolCancelConsumed() - buildingState.current = 0 - previewRef.current.visible = false - setDraftMeasurement(null) - } - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - - return () => { - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - } - }, [unit]) - - return ( - - - - - - - - {draftMeasurement && ( - <> - - {draftMeasurement.angleLabels.map((angleLabel) => ( - - ))} - - )} - - ) -} - -function DraftMeasurementLabel({ - label, - position, -}: { - label: string - position: [number, number, number] -}) { - return ( - -
- {label} -
- - ) -} diff --git a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx deleted file mode 100644 index bc3f643d..00000000 --- a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx +++ /dev/null @@ -1,425 +0,0 @@ -'use client' - -import { - type AnyNodeId, - emitter, - type FenceNode, - type GridEvent, - pauseSceneHistory, - resumeSceneHistory, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' -import { useCallback, useEffect, useRef, useState } from 'react' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' -import { - formatAngleRadians, - getAngleToSegmentReference, - getSegmentAngleReferenceAtPoint, -} from '../shared/segment-angle' -import { isWallLongEnough } from '../wall/wall-drafting' -import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting' - -const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 - -function samePoint(a: FencePlanPoint, b: FencePlanPoint) { - return ( - Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON && - Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON - ) -} - -type SegmentLike = { - id: string - start: FencePlanPoint - end: FencePlanPoint - curveOffset?: number -} - -type AngleLabelState = { - label: string - position: [number, number, number] -} | null - -function getEndpointAngleLabel(args: { - preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number } - segments: SegmentLike[] - nodeId: FenceNode['id'] -}): AngleLabelState { - const { preview, segments, nodeId } = args - const endpoints = [ - { - point: preview.start, - }, - { - point: preview.end, - }, - ] - const targetSegment: SegmentLike = { - id: nodeId, - start: preview.start, - end: preview.end, - curveOffset: preview.curveOffset, - } - - for (const endpoint of endpoints) { - const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment) - if (!targetReference) continue - - const connectedSegment = segments.find( - (segment) => - segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), - ) - if (!connectedSegment) continue - - const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) - if (!connectedReference) continue - - const angle = getAngleToSegmentReference(targetReference.vector, connectedReference) - if (angle === null) continue - - return { - label: formatAngleRadians(angle), - position: [endpoint.point[0], 0.34, endpoint.point[1]], - } - } - - return null -} - -function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { - return [ - ...walls.map((wall) => ({ - id: wall.id, - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset, - })), - ...fences.map((fence) => ({ - id: fence.id, - start: fence.start, - end: fence.end, - curveOffset: fence.curveOffset, - })), - ] -} - -type LinkedFenceSnapshot = { - id: FenceNode['id'] - start: FencePlanPoint - end: FencePlanPoint - curveOffset?: number -} - -function getLinkedFenceSnapshots(args: { - fenceId: FenceNode['id'] - fenceParentId: string | null - linkedPoint: FencePlanPoint -}) { - const { fenceId, fenceParentId, linkedPoint } = args - const { nodes } = useScene.getState() - const snapshots: LinkedFenceSnapshot[] = [] - - for (const node of Object.values(nodes)) { - if (!(node?.type === 'fence' && node.id !== fenceId)) { - continue - } - - if ((node.parentId ?? null) !== fenceParentId) { - continue - } - - if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) { - continue - } - - snapshots.push({ - id: node.id, - start: [...node.start] as FencePlanPoint, - end: [...node.end] as FencePlanPoint, - curveOffset: node.curveOffset, - }) - } - - return snapshots -} - -function getLinkedFenceUpdates( - linkedFences: LinkedFenceSnapshot[], - linkedPoint: FencePlanPoint, - nextLinkedPoint: FencePlanPoint, -) { - return linkedFences.map((fence) => ({ - id: fence.id, - curveOffset: fence.curveOffset, - start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start, - end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end, - })) -} - -export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => { - const activatedAtRef = useRef(Date.now()) - const previousGridPosRef = useRef(null) - const shiftPressedRef = useRef(false) - const altPressedRef = useRef(false) - const nodeIdRef = useRef(target.fence.id) - const originalStartRef = useRef([...target.fence.start] as FencePlanPoint) - const originalEndRef = useRef([...target.fence.end] as FencePlanPoint) - const originalMovingPointRef = useRef( - target.endpoint === 'start' - ? ([...target.fence.start] as FencePlanPoint) - : ([...target.fence.end] as FencePlanPoint), - ) - const fixedPointRef = useRef( - target.endpoint === 'start' - ? ([...target.fence.end] as FencePlanPoint) - : ([...target.fence.start] as FencePlanPoint), - ) - const linkedOriginalsRef = useRef( - getLinkedFenceSnapshots({ - fenceId: target.fence.id, - fenceParentId: target.fence.parentId ?? null, - linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end, - }), - ) - const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null) - const [angleLabel, setAngleLabel] = useState(null) - - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const point = target.endpoint === 'start' ? target.fence.start : target.fence.end - return [point[0], 0, point[1]] - }) - const [altPressed, setAltPressed] = useState(false) - - const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingFenceEndpoint(null) - }, []) - - useEffect(() => { - const nodeId = nodeIdRef.current - const originalStart = originalStartRef.current - const originalEnd = originalEndRef.current - const originalMovingPoint = originalMovingPointRef.current - const fixedPoint = fixedPointRef.current - const siblings = Object.values(useScene.getState().nodes) - const levelWalls = siblings.filter( - (node): node is WallNode => - node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null), - ) - const levelFences = siblings.filter( - (node): node is FenceNode => - node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null), - ) - - pauseSceneHistory(useScene) - let wasCommitted = false - - const applyNodePreview = ( - updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>, - ) => { - useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), - ) - for (const entry of updates) { - useScene.getState().markDirty(entry.id as AnyNodeId) - } - } - - const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => { - const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint - const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint - const linkedUpdates = detachLinkedFences - ? [] - : getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint) - previewRef.current = { start: nextStart, end: nextEnd } - setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) - setAngleLabel( - getEndpointAngleLabel({ - preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset }, - segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates], - nodeId, - }), - ) - applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates]) - } - - const restoreOriginal = (clearAngleLabel = true) => { - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) - if (clearAngleLabel) { - setAngleLabel(null) - } - } - - const onGridMove = (event: GridEvent) => { - const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - const snappedPoint = snapFenceDraftPoint({ - point: planPoint, - walls: levelWalls, - fences: levelFences, - start: fixedPoint, - angleSnap: !shiftPressedRef.current, - ignoreFenceIds: [nodeId], - }) - - if ( - previousGridPosRef.current && - (snappedPoint[0] !== previousGridPosRef.current[0] || - snappedPoint[1] !== previousGridPosRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosRef.current = snappedPoint - - applyPreview(snappedPoint, event.nativeEvent.altKey) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - const hasChanged = !( - samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd) - ) - - if (hasChanged && isWallLongEnough(preview.start, preview.end)) { - wasCommitted = true - - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) - - resumeSceneHistory(useScene) - applyNodePreview([ - { id: nodeId, start: preview.start, end: preview.end }, - ...(altPressedRef.current - ? [] - : getLinkedFenceUpdates( - linkedOriginalsRef.current, - originalMovingPoint, - target.endpoint === 'start' ? preview.start : preview.end, - )), - ]) - pauseSceneHistory(useScene) - sfxEmitter.emit('sfx:item-place') - } - - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - setAngleLabel(null) - exitMoveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - resumeSceneHistory(useScene) - setAngleLabel(null) - markToolCancelConsumed() - exitMoveMode() - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { - return - } - if (event.key === 'Shift') { - shiftPressedRef.current = true - } - if (event.key === 'Alt') { - altPressedRef.current = true - setAltPressed(true) - } - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - if (event.key === 'Alt') { - altPressedRef.current = false - setAltPressed(false) - } - } - - const onWindowBlur = () => { - shiftPressedRef.current = false - altPressedRef.current = false - setAltPressed(false) - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - window.addEventListener('blur', onWindowBlur) - - return () => { - if (!wasCommitted) { - restoreOriginal(false) - } - resumeSceneHistory(useScene) - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - window.removeEventListener('blur', onWindowBlur) - } - }, [exitMoveMode, target]) - - return ( - - - -
-
- {altPressed ? 'Detach endpoint' : 'Drag endpoint'} -
-
- - {angleLabel && } -
- ) -} - -function EndpointAngleLabel({ - label, - position, -}: { - label: string - position: [number, number, number] -}) { - return ( - -
- {label} -
- - ) -} diff --git a/packages/editor/src/components/tools/fence/move-fence-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-tool.tsx deleted file mode 100644 index 789931ce..00000000 --- a/packages/editor/src/components/tools/fence/move-fence-tool.tsx +++ /dev/null @@ -1,302 +0,0 @@ -'use client' - -import { - type AnyNodeId, - emitter, - type FenceNode, - type GridEvent, - type LevelNode, - sceneRegistry, - useLiveTransforms, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' -import type * as THREE from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' -import { snapFenceDraftPoint } from './fence-drafting' - -function samePoint(a: [number, number], b: [number, number]) { - return a[0] === b[0] && a[1] === b[1] -} - -type LinkedFenceSnapshot = { - id: FenceNode['id'] - start: [number, number] - end: [number, number] -} - -function getLinkedFenceSnapshots(args: { - fenceId: FenceNode['id'] - fenceParentId: string | null - originalStart: [number, number] - originalEnd: [number, number] -}) { - const { fenceId, fenceParentId, originalStart, originalEnd } = args - const { nodes } = useScene.getState() - const snapshots: LinkedFenceSnapshot[] = [] - - for (const node of Object.values(nodes)) { - if (!(node?.type === 'fence' && node.id !== fenceId)) { - continue - } - - if ((node.parentId ?? null) !== fenceParentId) { - continue - } - - if ( - !( - samePoint(node.start, originalStart) || - samePoint(node.start, originalEnd) || - samePoint(node.end, originalStart) || - samePoint(node.end, originalEnd) - ) - ) { - continue - } - - snapshots.push({ - id: node.id, - start: [...node.start] as [number, number], - end: [...node.end] as [number, number], - }) - } - - return snapshots -} - -function getLinkedFenceUpdates( - linkedFences: LinkedFenceSnapshot[], - originalStart: [number, number], - originalEnd: [number, number], - nextStart: [number, number], - nextEnd: [number, number], -) { - return linkedFences.map((fence) => ({ - id: fence.id, - start: samePoint(fence.start, originalStart) - ? nextStart - : samePoint(fence.start, originalEnd) - ? nextEnd - : fence.start, - end: samePoint(fence.end, originalStart) - ? nextStart - : samePoint(fence.end, originalEnd) - ? nextEnd - : fence.end, - })) -} - -export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { - const activatedAtRef = useRef(Date.now()) - const previousGridPosRef = useRef<[number, number] | null>(null) - const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) - const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) - const linkedOriginalsRef = useRef( - getLinkedFenceSnapshots({ - fenceId: node.id, - fenceParentId: node.parentId ?? null, - originalStart: node.start, - originalEnd: node.end, - }), - ) - const dragAnchorRef = useRef<[number, number] | null>(null) - const nodeIdRef = useRef(node.id) - const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) - - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const centerX = (node.start[0] + node.end[0]) / 2 - const centerZ = (node.start[1] + node.end[1]) / 2 - return [centerX, 0, centerZ] - }) - - const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingNode(null) - }, []) - - useEffect(() => { - const nodeId = nodeIdRef.current - const originalStart = originalStartRef.current - const originalEnd = originalEndRef.current - const levelNode = - node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' - ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode) - : null - const levelChildren = levelNode?.children ?? [] - const levelWalls = levelChildren - .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) - .filter((child): child is WallNode => child?.type === 'wall') - const levelFences = levelChildren - .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) - .filter((child): child is FenceNode => child?.type === 'fence') - - useScene.temporal.getState().pause() - let wasCommitted = false - - const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => { - const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined - if (!mesh) { - return - } - - mesh.position.set(deltaX, 0, deltaZ) - } - - const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => { - const originalCenterX = (fence.start[0] + fence.end[0]) / 2 - const originalCenterZ = (fence.start[1] + fence.end[1]) / 2 - useLiveTransforms.getState().set(fence.id, { - position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ], - rotation: 0, - }) - } - - const clearPreviewState = () => { - setMeshOffset(nodeId, 0, 0) - useLiveTransforms.getState().clear(nodeId) - - for (const linkedFence of linkedOriginalsRef.current) { - setMeshOffset(linkedFence.id, 0, 0) - useLiveTransforms.getState().clear(linkedFence.id) - } - } - - const applyNodePreview = ( - updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>, - ) => { - useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), - ) - for (const entry of updates) { - useScene.getState().markDirty(entry.id as AnyNodeId) - } - } - - const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { - previewRef.current = { start: nextStart, end: nextEnd } - const centerX = (nextStart[0] + nextEnd[0]) / 2 - const centerZ = (nextStart[1] + nextEnd[1]) / 2 - setCursorLocalPos([centerX, 0, centerZ]) - const deltaX = nextStart[0] - originalStart[0] - const deltaZ = nextStart[1] - originalStart[1] - setMeshOffset(nodeId, deltaX, deltaZ) - setFenceLiveTransform(node, deltaX, deltaZ) - - for (const linkedFence of linkedOriginalsRef.current) { - setMeshOffset(linkedFence.id, deltaX, deltaZ) - setFenceLiveTransform( - { - ...node, - id: linkedFence.id, - start: linkedFence.start, - end: linkedFence.end, - }, - deltaX, - deltaZ, - ) - } - } - - const onGridMove = (event: GridEvent) => { - const [localX, localZ] = snapFenceDraftPoint({ - point: [event.localPosition[0], event.localPosition[2]], - walls: levelWalls, - fences: levelFences, - ignoreFenceIds: [nodeId], - }) - - if ( - previousGridPosRef.current && - (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosRef.current = [localX, localZ] - - const anchor = dragAnchorRef.current ?? [localX, localZ] - dragAnchorRef.current = anchor - - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] - - const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] - const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] - - applyPreview(nextStart, nextEnd) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - - wasCommitted = true - - useScene.temporal.getState().resume() - applyNodePreview([ - { id: nodeId, start: preview.start, end: preview.end }, - ...getLinkedFenceUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - preview.start, - preview.end, - ), - ]) - useLiveTransforms.getState().clear(nodeId) - for (const linkedFence of linkedOriginalsRef.current) { - useLiveTransforms.getState().clear(linkedFence.id) - } - useScene.temporal.getState().pause() - - sfxEmitter.emit('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - exitMoveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onCancel = () => { - clearPreviewState() - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - useScene.temporal.getState().resume() - markToolCancelConsumed() - exitMoveMode() - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - - return () => { - if (wasCommitted) { - useLiveTransforms.getState().clear(nodeId) - for (const linkedFence of linkedOriginalsRef.current) { - useLiveTransforms.getState().clear(linkedFence.id) - } - } else { - clearPreviewState() - } - useScene.temporal.getState().resume() - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - } - }, [exitMoveMode, node]) - - return ( - - - - ) -} diff --git a/packages/editor/src/components/tools/item/item-tool.tsx b/packages/editor/src/components/tools/item/item-tool.tsx deleted file mode 100644 index 6bb1ffae..00000000 --- a/packages/editor/src/components/tools/item/item-tool.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { AssetInput } from '@pascal-app/core' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) { - const draftNode = useDraftNode() - - const cursor = usePlacementCoordinator({ - asset: selectedItem, - draftNode, - initDraft: (gridPosition) => { - if (selectedItem && !selectedItem.attachTo) { - draftNode.create(gridPosition, selectedItem) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - return true - }, - }) - - return <>{cursor} -} - -export const ItemTool: React.FC = () => { - const selectedItem = useEditor((state) => state.selectedItem) - - if (!selectedItem) return null - return -} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 03da9e27..d7c86be9 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,132 +1,50 @@ import type { AnyNodeId, BuildingNode, - CeilingNode, - ColumnNode, - DoorNode, ElevatorNode, - FenceNode, - ItemNode, RoofNode, RoofSegmentNode, - SlabNode, SpawnNode, StairNode, StairSegmentNode, - WallNode, - WindowNode, } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core' import { Suspense } from 'react' -import { Vector3 } from 'three' -import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MoveBuildingContent } from '../building/move-building-tool' -import { MoveCeilingTool } from '../ceiling/move-ceiling-tool' -import { MoveColumnTool } from '../column/move-column-tool' -import { MoveDoorTool } from '../door/move-door-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' -import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} +/** + * MoveTool dispatcher. Routes to (in order): + * + * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that + * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach, + * …). + * 2. `def.affordanceTools.move` — kind-owned move component + * (slab / ceiling / wall / fence / column / item / door / window). + * Lazy-loaded via `getRegistryAffordanceTool`. + * 3. The narrow set of kinds that still have legacy movers because no + * registry equivalent has been written yet (building / elevator / + * roof / stair). Each of these has bespoke move semantics that + * don't fit the generic mover and are not yet ported to a + * kind-owned affordance. + */ export const MoveTool: React.FC<{ onNodeMoved?: (nodeId: AnyNodeId) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void -}> = ({ onNodeMoved, onSpawnMoved }) => { +}> = ({ onNodeMoved }) => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null - // Capability-driven dispatch. A registered kind opts INTO the generic - // mover by declaring `capabilities.movable` — that's the "I'm a simple - // translate-on-the-X/Z-plane node" signal (shelf, spawn, future - // single-position items). Kinds with bespoke move semantics (wall - // endpoint drag + linked-wall corner cascade + ALT-detach, fence - // endpoint drag + curve sagitta, slab polygon vertex edit, stair - // endpoint drag, etc.) deliberately OMIT `capabilities.movable` so - // this branch falls through to their legacy per-kind movers below. - // - // Without this guard, every registered kind would be force-routed - // through MoveRegistryNodeTool's "translate position" pattern, - // breaking wall / fence / slab / stair endpoint UX (the smart - // sims-style arrows that move the dragged endpoint while cascading - // to linked walls / re-anchoring hosted children / etc.). const def = nodeRegistry.get(movingNode.type) if (def?.capabilities?.movable) { return } - // Phase 5 Stage D: registry-driven move affordance (kind-owned - // `DragAction` with bespoke semantics). Falls through to the legacy - // per-kind chain below when the kind hasn't ported its move tool. const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move') if (RegistryMove) { return ( @@ -138,20 +56,11 @@ export const MoveTool: React.FC<{ if (movingNode.type === 'building') return - if (movingNode.type === 'door') return if (movingNode.type === 'elevator') return - if (movingNode.type === 'window') return - if (movingNode.type === 'ceiling') return - if (movingNode.type === 'column') return - if (movingNode.type === 'slab') return - if (movingNode.type === 'wall') return - if (movingNode.type === 'fence') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return - if (movingNode.type === 'spawn') - return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return - return + return null } diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e872408..e74352d9 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,15 @@ import type { GridEvent, ItemEvent, ItemNode, + ShelfEvent, + ShelfNode, WallEvent, WallNode, } from '@pascal-app/core' import { getScaledDimensions, isLowProfileItemSurface, + nodeRegistry, sceneRegistry, useScene, } from '@pascal-app/core' @@ -587,6 +590,156 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// SHELF SURFACE STRATEGY +// ============================================================================ + +/** + * Resolve the row Y closest to the cursor's local Y. Reads candidate row + * positions from the kind's `capabilities.surfaces.custom` — the shelf + * declaration emits one `SurfacePoint` per board's top surface. The + * strategy stays kind-agnostic at this level: any future "multi-board" + * kind that declares `surfaces.custom` with upward normals gets the + * same hit behaviour for free. + */ +function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null { + const def = nodeRegistry.get('shelf') + const custom = def?.capabilities?.surfaces?.custom + if (!custom) return null + const candidates = custom(shelfNode as AnyNode) + if (candidates.length === 0) return null + let best = candidates[0] + let bestDist = Math.abs(best!.position[1] - localY) + for (let i = 1; i < candidates.length; i++) { + const c = candidates[i] + if (!c) continue + const dist = Math.abs(c.position[1] - localY) + if (dist < bestDist) { + best = c + bestDist = dist + } + } + return best?.position[1] ?? null +} + +export const shelfSurfaceStrategy = { + /** + * Handle shelf:enter — transition the draft onto the closest shelf + * row. Mirrors `itemSurfaceStrategy.enter` but reads candidate + * surface heights from the shelf kind's `surfaces.custom` (one Y per + * board) instead of `asset.surface.height`. Picks the row whose + * surface Y is nearest the cursor's local Y so the user can target a + * specific row by hovering near it. + */ + enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + const shelfNode = event.node as ShelfNode + + if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) { + return null + } + if (!isUpwardShelfSurfaceHit(event)) return null + + // Size check: draft footprint must fit on the shelf board (width × depth). + const ourDims = ctx.draftItem + ? getScaledDimensions(ctx.draftItem) + : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) + if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null + + const shelfMesh = sceneRegistry.nodes.get(shelfNode.id) + if (!shelfMesh) return null + + const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) + const localPos = shelfMesh.worldToLocal(worldPos) + const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) + if (rowY === null) return null + + const x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) + + const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) + + const surfaceQuat = new Quaternion() + shelfMesh.getWorldQuaternion(surfaceQuat) + const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y + const localRotationY = ctx.currentCursorRotationY - surfaceWorldY + const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0] + + return { + stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id }, + nodeUpdate: { + position: [x, rowY, z], + parentId: shelfNode.id, + rotation: [draftRotation[0], localRotationY, draftRotation[2]], + }, + cursorRotationY: ctx.currentCursorRotationY, + gridPosition: [x, rowY, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], + stopPropagation: true, + } + }, + + /** + * Handle shelf:move — re-derive the closest row each tick so the user + * can slide between rows without leaving the shelf. + */ + move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null { + if (ctx.state.surface !== 'shelf-surface') return null + if (!(ctx.state.shelfId && ctx.draftItem)) return null + if (event.node.id !== ctx.state.shelfId) return null + + const shelfNode = event.node as ShelfNode + const shelfMesh = sceneRegistry.nodes.get(shelfNode.id) + if (!shelfMesh) return null + + const ourDims = getScaledDimensions(ctx.draftItem) + const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) + const localPos = shelfMesh.worldToLocal(worldPos) + const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) + if (rowY === null) return null + + const x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) + const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) + + return { + gridPosition: [x, rowY, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], + cursorRotationY: ctx.currentCursorRotationY, + nodeUpdate: { position: [x, rowY, z] }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + /** + * Handle shelf:click — commit placement on the active row. + */ + click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null { + if (ctx.state.surface !== 'shelf-surface') return null + if (!(ctx.draftItem && ctx.state.shelfId)) return null + if (event.node.id !== ctx.state.shelfId) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.state.shelfId, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, +} + +/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed + * for `ShelfEvent`. Re-uses the matrix-driven world normal calculation + * via a tiny `ItemEvent`-shaped adapter — the function only reads + * `event.normal` + `event.object`. */ +function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { + return isUpwardItemSurfaceHit(event as unknown as ItemEvent) +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +756,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Shelf surface: same — size check already happened on enter + if (ctx.state.surface === 'shelf-surface') { + return ctx.state.shelfId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 53828658..66337484 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,13 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + /** + * Active shelf when `surface === 'shelf-surface'`. Items host on the + * shelf board closest to the cursor's local Y; the row index isn't + * stored separately because every move re-derives it from cursor + * position via `shelfRowSurfaceYs`. + */ + shelfId: string | null } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index 348422c6..67b72149 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -179,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle { if (!draftRef.current) return if (adoptedRef.current && originalStateRef.current) { - // Move mode: restore original state instead of deleting + // Move mode: restore original state instead of deleting — but only + // if no other system has already committed a new position for this + // node. The 2D `FloorplanRegistryMoveOverlay` commits via + // `useScene.updateNodes` before unmounting the legacy mover, and + // an unconditional restore here would wipe that commit. By + // comparing the live state to the snapshot we took in `adopt()`, + // we let an external committer's write stick. const original = originalStateRef.current const id = draftRef.current.id + const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined + const livePosition = live?.position + const externallyMoved = + !!livePosition && + (livePosition[0] !== original.position[0] || + livePosition[1] !== original.position[1] || + livePosition[2] !== original.position[2]) + if (externallyMoved) { + draftRef.current = null + adoptedRef.current = false + originalStateRef.current = null + return + } useScene.getState().updateNode(id, { position: original.position, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe363..65300bbf 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type ShelfEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + shelfSurfaceStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { + surface: 'floor', + wallId: null, + ceilingId: null, + surfaceItemId: null, + shelfId: null, + }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -435,6 +443,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea wallId: null, ceilingId: null, surfaceItemId: null, + shelfId: null, } if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 @@ -923,7 +932,67 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onItemClick = (event: ItemEvent) => { - if (event.node.id === draftNode.current?.id) return + // Click on the draft item itself. R3F dispatches click events to + // the closest intersected mesh only — when the draft is hovering + // on a host (shelf / table / etc.) the draft's mesh is *above* + // the host's mesh, so the host's `${kind}:click` never fires. + // If we're currently hosting on a shelf-surface, treat the + // self-click as a commit on the active shelf so the user doesn't + // have to aim around the cursor preview to drop the item. + if (event.node.id === draftNode.current?.id) { + const ctx = getContext() + if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) { + const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId] + if (shelfNode && shelfNode.type === 'shelf') { + const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent + const result = shelfSurfaceStrategy.click(ctx, synthetic as never) + if (result) { + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + if (configRef.current.onCommitted()) { + const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + return + } + } + } + // Same self-click forwarding for item-surface hosts (tables, + // counters) — the draft mesh sits on top of the host mesh, so + // the host's own click event is blocked by the cursor preview. + if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) { + const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId] + if (hostNode && hostNode.type === 'item') { + const synthetic = { ...event, node: hostNode } as ItemEvent + const result = itemSurfaceStrategy.click(ctx, synthetic) + if (result) { + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + if (configRef.current.onCommitted()) { + const enterResult = itemSurfaceStrategy.enter(ctx, synthetic) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + return + } + } + } + return + } + const result = itemSurfaceStrategy.click(getContext(), event) if (!result) return @@ -1065,6 +1134,98 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Shelf Handlers ---- + // + // Items can host on shelves the same way they host on tables and + // counters (item-surface). The shelf's `surfaces.custom` exposes one + // candidate Y per row; `shelfSurfaceStrategy` picks the closest one + // to the cursor's local-Y so the user can target a specific row. + + const onShelfEnter = (event: ShelfEvent) => { + const result = shelfSurfaceStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + applyTransition(result) + + if (!draftNode.current) { + ensureDraft(result) + } else if (result.nodeUpdate.parentId) { + useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate) + } + } + + const onShelfMove = (event: ShelfEvent) => { + const ctx = getContext() + if (ctx.state.surface !== 'shelf-surface') { + // Cursor entered via a move event without an enter — try + // transitioning in so the user doesn't need to mouse out + back + // in to start hosting. + const enterResult = shelfSurfaceStrategy.enter(ctx, event) + if (!enterResult) return + event.stopPropagation() + applyTransition(enterResult) + if (!draftNode.current) { + ensureDraft(enterResult) + } else if (enterResult.nodeUpdate.parentId) { + useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate) + } + return + } + const result = shelfSurfaceStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + gridPosition.current.set(...result.gridPosition) + const ic = worldToBuildingLocal(...result.cursorPosition) + cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) + cursorGroupRef.current.rotation.y = result.cursorRotationY + + const draft = draftNode.current + if (draft) { + draft.position = result.gridPosition + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) mesh.position.set(...result.gridPosition) + useLiveTransforms.getState().set(draft.id, { + position: result.cursorPosition, + rotation: result.cursorRotationY, + }) + } + + revalidate() + } + + const onShelfLeave = (event: ShelfEvent) => { + if (placementState.current.surface !== 'shelf-surface') return + if (event.node.id !== placementState.current.shelfId) return + event.stopPropagation() + // Drop back to floor — same pattern as item-leave but without the + // detachItemSurfaceToFloor (no scaled rotation hand-off to deal + // with since the shelf rotation already composed cleanly). + Object.assign(placementState.current, { surface: 'floor', shelfId: null }) + } + + const onShelfClick = (event: ShelfEvent) => { + const result = shelfSurfaceStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + const enterResult = shelfSurfaceStrategy.enter(getContext(), event) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1400,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('shelf:enter', onShelfEnter) + emitter.on('shelf:move', onShelfMove) + emitter.on('shelf:click', onShelfClick) + emitter.on('shelf:leave', onShelfLeave) return () => { tearingDown = true @@ -1263,6 +1428,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('shelf:enter', onShelfEnter) + emitter.off('shelf:move', onShelfMove) + emitter.off('shelf:click', onShelfClick) + emitter.off('shelf:leave', onShelfLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) diff --git a/packages/editor/src/components/tools/slab/move-slab-tool.tsx b/packages/editor/src/components/tools/slab/move-slab-tool.tsx deleted file mode 100644 index d7baebcc..00000000 --- a/packages/editor/src/components/tools/slab/move-slab-tool.tsx +++ /dev/null @@ -1,182 +0,0 @@ -'use client' - -import { - type AnyNodeId, - emitter, - type FenceNode, - type GridEvent, - type LevelNode, - type SlabNode, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { snapFenceDraftPoint } from '../fence/fence-drafting' -import { CursorSphere } from '../shared/cursor-sphere' - -function translatePolygon( - polygon: Array<[number, number]>, - deltaX: number, - deltaZ: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) -} - -function getPolygonCenter(polygon: Array<[number, number]>): [number, number] { - if (polygon.length === 0) return [0, 0] - let sumX = 0 - let sumZ = 0 - for (const [x, z] of polygon) { - sumX += x - sumZ += z - } - return [sumX / polygon.length, sumZ / polygon.length] -} - -export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { - const activatedAtRef = useRef(Date.now()) - const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) - const originalHolesRef = useRef( - (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), - ) - const dragAnchorRef = useRef<[number, number] | null>(null) - const previousGridPosRef = useRef<[number, number] | null>(null) - const previewRef = useRef<{ - polygon: Array<[number, number]> - holes: Array> - } | null>(null) - - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const center = getPolygonCenter(node.polygon) - return [center[0], 0, center[1]] - }) - - const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingNode(null) - }, []) - - useEffect(() => { - const originalPolygon = originalPolygonRef.current - const originalHoles = originalHolesRef.current - const levelNode = - node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' - ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode) - : null - const levelChildren = levelNode?.children ?? [] - const levelWalls = levelChildren - .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) - .filter((child): child is WallNode => child?.type === 'wall') - const levelFences = levelChildren - .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) - .filter((child): child is FenceNode => child?.type === 'fence') - - useScene.temporal.getState().pause() - let wasCommitted = false - - const applyPreview = ( - polygon: Array<[number, number]>, - holes: Array>, - ) => { - previewRef.current = { polygon, holes } - const center = getPolygonCenter(polygon) - setCursorLocalPos([center[0], 0, center[1]]) - useScene.getState().updateNode(node.id, { polygon, holes }) - useScene.getState().markDirty(node.id as AnyNodeId) - } - - const restoreOriginal = () => { - useScene.getState().updateNode(node.id, { - holes: originalHoles, - polygon: originalPolygon, - }) - useScene.getState().markDirty(node.id as AnyNodeId) - } - - const onGridMove = (event: GridEvent) => { - const [localX, localZ] = snapFenceDraftPoint({ - point: [event.localPosition[0], event.localPosition[2]], - walls: levelWalls, - fences: levelFences, - }) - - if ( - previousGridPosRef.current && - (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosRef.current = [localX, localZ] - - const anchor = dragAnchorRef.current ?? [localX, localZ] - dragAnchorRef.current = anchor - - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] - - applyPreview( - translatePolygon(originalPolygon, deltaX, deltaZ), - originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), - ) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } - - wasCommitted = true - - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - useScene.getState().updateNode(node.id, { - polygon: originalPolygon, - holes: originalHoles, - }) - - useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, preview) - useScene.getState().markDirty(node.id as AnyNodeId) - useScene.temporal.getState().pause() - - sfxEmitter.emit('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [node.id] }) - exitMoveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().resume() - markToolCancelConsumed() - exitMoveMode() - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - - return () => { - if (!wasCommitted) { - restoreOriginal() - } - useScene.temporal.getState().resume() - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - } - }, [exitMoveMode, node.id]) - - return ( - - - - ) -} diff --git a/packages/editor/src/components/tools/slab/slab-boundary-editor.tsx b/packages/editor/src/components/tools/slab/slab-boundary-editor.tsx deleted file mode 100644 index 60ab6b81..00000000 --- a/packages/editor/src/components/tools/slab/slab-boundary-editor.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback } from 'react' -import { PolygonEditor } from '../shared/polygon-editor' - -interface SlabBoundaryEditorProps { - slabId: SlabNode['id'] -} - -/** - * Slab boundary editor - allows editing slab polygon vertices for a specific slab - * Uses the generic PolygonEditor component - */ -export const SlabBoundaryEditor: React.FC = ({ slabId }) => { - const slabNode = useScene((state) => state.nodes[slabId]) - const updateNode = useScene((state) => state.updateNode) - const setSelection = useViewer((state) => state.setSelection) - - const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null - - const handlePolygonChange = useCallback( - (newPolygon: Array<[number, number]>) => { - updateNode(slabId, { polygon: newPolygon }) - // Re-assert selection so the slab stays selected after the edit - setSelection({ selectedIds: [slabId] }) - }, - [slabId, updateNode, setSelection], - ) - - if (!slab?.polygon || slab.polygon.length < 3) return null - - return ( - - ) -} diff --git a/packages/editor/src/components/tools/slab/slab-hole-editor.tsx b/packages/editor/src/components/tools/slab/slab-hole-editor.tsx deleted file mode 100644 index 00e3465b..00000000 --- a/packages/editor/src/components/tools/slab/slab-hole-editor.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback } from 'react' -import { PolygonEditor } from '../shared/polygon-editor' - -interface SlabHoleEditorProps { - slabId: SlabNode['id'] - holeIndex: number -} - -/** - * Slab hole editor - allows editing a specific hole polygon within a slab - * Uses the generic PolygonEditor component - */ -export const SlabHoleEditor: React.FC = ({ slabId, holeIndex }) => { - const slabNode = useScene((state) => state.nodes[slabId]) - const updateNode = useScene((state) => state.updateNode) - const setSelection = useViewer((state) => state.setSelection) - - const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null - const holes = slab?.holes || [] - const hole = holes[holeIndex] - - const handlePolygonChange = useCallback( - (newPolygon: Array<[number, number]>) => { - const updatedHoles = [...holes] - updatedHoles[holeIndex] = newPolygon - updateNode(slabId, { holes: updatedHoles }) - // Re-assert selection so the slab stays selected after the edit - setSelection({ selectedIds: [slabId] }) - }, - [slabId, holeIndex, holes, updateNode, setSelection], - ) - - if (!(slab && hole) || hole.length < 3) return null - - return ( - - ) -} diff --git a/packages/editor/src/components/tools/slab/slab-tool.tsx b/packages/editor/src/components/tools/slab/slab-tool.tsx deleted file mode 100644 index 4f48a141..00000000 --- a/packages/editor/src/components/tools/slab/slab-tool.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { CursorSphere } from '../shared/cursor-sphere' - -const Y_OFFSET = 0.02 - -/** - * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point - */ -const calculateSnapPoint = ( - lastPoint: [number, number], - currentPoint: [number, number], -): [number, number] => { - const [x1, y1] = lastPoint - const [x, y] = currentPoint - - const dx = x - x1 - const dy = y - y1 - const absDx = Math.abs(dx) - const absDy = Math.abs(dy) - - // Calculate distances to horizontal, vertical, and diagonal lines - const horizontalDist = absDy - const verticalDist = absDx - const diagonalDist = Math.abs(absDx - absDy) - - // Find the minimum distance to determine which axis to snap to - const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) - - if (minDist === diagonalDist) { - // Snap to 45° diagonal - const diagonalLength = Math.min(absDx, absDy) - return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] - } - if (minDist === horizontalDist) { - // Snap to horizontal - return [x, y1] - } - // Snap to vertical - return [x1, y] -} - -/** - * Creates a slab with the given polygon points and returns its ID - */ -const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => { - const { createNode, nodes } = useScene.getState() - - // Count existing slabs for naming - const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length - const name = `Slab ${slabCount + 1}` - - const slab = SlabNode.parse({ - name, - polygon: points, - }) - - createNode(slab, levelId) - sfxEmitter.emit('sfx:structure-build') - return slab.id -} - -export const SlabTool: React.FC = () => { - const cursorRef = useRef(null) - const mainLineRef = useRef(null!) - const closingLineRef = useRef(null!) - const currentLevelId = useViewer((state) => state.selection.levelId) - const setSelection = useViewer((state) => state.setSelection) - - const [points, setPoints] = useState>([]) - const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) - const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) - const [levelY, setLevelY] = useState(0) - const previousSnappedPointRef = useRef<[number, number] | null>(null) - const shiftPressed = useRef(false) - - // Update cursor position and lines on grid move - useEffect(() => { - if (!currentLevelId) return - - const onGridMove = (event: GridEvent) => { - if (!cursorRef.current) return - - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 - const gridPosition: [number, number] = [gridX, gridZ] - - setCursorPosition(gridPosition) - setLevelY(event.localPosition[1]) - - // Calculate snapped display position (bypass snap when Shift is held) - const lastPoint = points[points.length - 1] - const displayPoint = - shiftPressed.current || !lastPoint - ? gridPosition - : calculateSnapPoint(lastPoint, gridPosition) - setSnappedCursorPosition(displayPoint) - - // Play snap sound when the snapped position actually changes (only when drawing) - if ( - points.length > 0 && - previousSnappedPointRef.current && - (displayPoint[0] !== previousSnappedPointRef.current[0] || - displayPoint[1] !== previousSnappedPointRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - - previousSnappedPointRef.current = displayPoint - cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1]) - } - - const onGridClick = (_event: GridEvent) => { - if (!currentLevelId) return - - // Use the last displayed snapped position (respects Shift state from onGridMove) - const clickPoint = previousSnappedPointRef.current ?? cursorPosition - - // Check if clicking on the first point to close the shape - const firstPoint = points[0] - if ( - points.length >= 3 && - firstPoint && - Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && - Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 - ) { - // Create the slab and select it - const slabId = commitSlabDrawing(currentLevelId, points) - setSelection({ selectedIds: [slabId] }) - setPoints([]) - } else { - // Add point to polygon - setPoints([...points, clickPoint]) - } - } - - const onGridDoubleClick = (_event: GridEvent) => { - if (!currentLevelId) return - - // Need at least 3 points to form a polygon - if (points.length >= 3) { - const slabId = commitSlabDrawing(currentLevelId, points) - setSelection({ selectedIds: [slabId] }) - setPoints([]) - } - } - - const onCancel = () => { - if (points.length > 0) markToolCancelConsumed() - setPoints([]) - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true - } - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false - } - document.addEventListener('keydown', onKeyDown) - document.addEventListener('keyup', onKeyUp) - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('grid:double-click', onGridDoubleClick) - emitter.on('tool:cancel', onCancel) - - return () => { - document.removeEventListener('keydown', onKeyDown) - document.removeEventListener('keyup', onKeyUp) - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('grid:double-click', onGridDoubleClick) - emitter.off('tool:cancel', onCancel) - } - }, [currentLevelId, points, cursorPosition, setSelection]) - - // Update line geometries when points change - useEffect(() => { - if (!(mainLineRef.current && closingLineRef.current)) return - - if (points.length === 0) { - mainLineRef.current.visible = false - closingLineRef.current.visible = false - return - } - - const y = levelY + Y_OFFSET - const snappedCursor = snappedCursorPosition - - // Build main line points - const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z)) - linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1])) - - // Update main line - if (linePoints.length >= 2) { - mainLineRef.current.geometry.dispose() - mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) - mainLineRef.current.visible = true - } else { - mainLineRef.current.visible = false - } - - // Update closing line (from cursor back to first point) - const firstPoint = points[0] - if (points.length >= 2 && firstPoint) { - const closingPoints = [ - new Vector3(snappedCursor[0], y, snappedCursor[1]), - new Vector3(firstPoint[0], y, firstPoint[1]), - ] - closingLineRef.current.geometry.dispose() - closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) - closingLineRef.current.visible = true - } else { - closingLineRef.current.visible = false - } - }, [points, snappedCursorPosition, levelY]) - - // Create preview shape when we have 3+ points - const previewShape = useMemo(() => { - if (points.length < 3) return null - - const snappedCursor = snappedCursorPosition - - const allPoints = [...points, snappedCursor] - - // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: - // - Shape X -> World X - // - Shape Y -> World -Z (so we negate Z to get correct orientation) - const firstPt = allPoints[0] - if (!firstPt) return null - - const shape = new Shape() - shape.moveTo(firstPt[0], -firstPt[1]) - - for (let i = 1; i < allPoints.length; i++) { - const pt = allPoints[i] - if (pt) { - shape.lineTo(pt[0], -pt[1]) - } - } - shape.closePath() - - return shape - }, [points, snappedCursorPosition]) - - return ( - - {/* Cursor */} - - - {/* Preview fill */} - {previewShape && ( - - - - - )} - - {/* Main line */} - {/* @ts-ignore */} - - - - - - {/* Closing line */} - {/* @ts-ignore */} - - - - - - {/* Point markers */} - {points.map(([x, z], index) => ( - - ))} - - ) -} diff --git a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx deleted file mode 100644 index 6eef48a6..00000000 --- a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import '../../../three-types' - -import { - emitter, - type GridEvent, - type SpawnNode, - sceneRegistry, - useLiveTransforms, - useScene, -} from '@pascal-app/core' -import { useCallback, useEffect, useState } from 'react' -import { Vector3 } from 'three' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' - -const roundToHalf = (value: number) => Math.round(value * 2) / 2 -const worldVector = new Vector3() - -function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] { - const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null - if (!levelObject) { - return [ - roundToHalf(event.localPosition[0]), - event.localPosition[1], - roundToHalf(event.localPosition[2]), - ] - } - - worldVector.set(event.position[0], event.position[1], event.position[2]) - levelObject.updateWorldMatrix(true, false) - levelObject.worldToLocal(worldVector) - - return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] -} - -export const MoveSpawnTool: React.FC<{ - node: SpawnNode - onCommitted?: (nodeId: SpawnNode['id']) => void -}> = ({ node, onCommitted }) => { - const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) - - const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingNode(null) - }, []) - - useEffect(() => { - useScene.temporal.getState().pause() - - let committed = false - - const onGridMove = (event: GridEvent) => { - const nextPosition: [number, number, number] = [ - roundToHalf(event.localPosition[0]), - event.localPosition[1], - roundToHalf(event.localPosition[2]), - ] - setPreviewPosition(nextPosition) - useLiveTransforms.getState().set(node.id, { - position: [...nextPosition], - rotation: node.rotation, - }) - } - - const onGridClick = (event: GridEvent) => { - const nextPosition = getLevelLocalSpawnPosition(node, event) - - committed = true - useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, { position: nextPosition }) - onCommitted?.(node.id) - useLiveTransforms.getState().clear(node.id) - sfxEmitter.emit('sfx:item-place') - exitMoveMode() - } - - const onCancel = () => { - useLiveTransforms.getState().clear(node.id) - useScene.temporal.getState().resume() - exitMoveMode() - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - - return () => { - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - useLiveTransforms.getState().clear(node.id) - if (!committed) { - useScene.temporal.getState().resume() - } - } - }, [exitMoveMode, node, onCommitted]) - - return ( - - ) -} diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx deleted file mode 100644 index 30d294ee..00000000 --- a/packages/editor/src/components/tools/spawn/spawn-tool.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import '../../../three-types' - -import { - emitter, - type GridEvent, - type LevelNode, - SpawnNode, - type SpawnNode as SpawnNodeType, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { useEffect, useRef, useState } from 'react' -import type { Group } from 'three' -import { Vector3 } from 'three' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' - -const SPAWN_ICON = ( - // eslint-disable-next-line @next/next/no-img-element - Spawn Point -) - -const roundToHalf = (value: number) => Math.round(value * 2) / 2 -const worldVector = new Vector3() - -function getExistingSpawnIds() { - const nodes = useScene.getState().nodes - return Object.values(nodes) - .filter((node) => node.type === 'spawn') - .map((node) => node.id) - .sort() -} - -function getLevelLocalSpawnPosition( - levelId: LevelNode['id'], - event: GridEvent, -): [number, number, number] { - const levelObject = sceneRegistry.nodes.get(levelId) - if (!levelObject) { - return [ - roundToHalf(event.localPosition[0]), - event.localPosition[1], - roundToHalf(event.localPosition[2]), - ] - } - - worldVector.set(event.position[0], event.position[1], event.position[2]) - levelObject.updateWorldMatrix(true, false) - levelObject.worldToLocal(worldVector) - - return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] -} - -type SpawnToolProps = { - currentLevelId: LevelNode['id'] | null - onPlaced?: (spawnId: SpawnNodeType['id']) => void -} - -export const SpawnTool: React.FC = ({ currentLevelId, onPlaced }) => { - const [, setCursorPosition] = useState<[number, number, number] | null>(null) - const cursorRef = useRef(null) - - useEffect(() => { - if (!currentLevelId) return - - const onGridMove = (event: GridEvent) => { - const nextPosition: [number, number, number] = [ - roundToHalf(event.localPosition[0]), - event.localPosition[1], - roundToHalf(event.localPosition[2]), - ] - setCursorPosition(nextPosition) - cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2]) - } - - const onGridClick = (event: GridEvent) => { - const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event) - - const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds() - if (existingSpawnId) { - useScene.getState().updateNode(existingSpawnId, { - parentId: currentLevelId, - position: nextPosition, - rotation: 0, - }) - if (duplicateSpawnIds.length > 0) { - useScene.getState().deleteNodes(duplicateSpawnIds) - } - onPlaced?.(existingSpawnId) - } else { - const spawn = SpawnNode.parse({ - name: 'Spawn Point', - position: nextPosition, - rotation: 0, - }) - useScene.getState().createNode(spawn, currentLevelId) - onPlaced?.(spawn.id) - } - - sfxEmitter.emit('sfx:structure-build') - useEditor.getState().setTool(null) - useEditor.getState().setMode('select') - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - - return () => { - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - } - }, [currentLevelId, onPlaced]) - - if (!currentLevelId) return null - - return ( - - ) -} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index fc8b49ef..69633b5f 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -9,29 +9,13 @@ import { import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' -import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' -import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' -import { CeilingTool } from './ceiling/ceiling-tool' import { ColumnTool } from './column/column-tool' -import { DoorTool } from './door/door-tool' import { ElevatorTool } from './elevator/elevator-tool' -import { CurveFenceTool } from './fence/curve-fence-tool' -import { FenceTool } from './fence/fence-tool' -import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool' -import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { SiteBoundaryEditor } from './site/site-boundary-editor' -import { SlabBoundaryEditor } from './slab/slab-boundary-editor' -import { SlabHoleEditor } from './slab/slab-hole-editor' -import { SlabTool } from './slab/slab-tool' -import { SpawnTool } from './spawn/spawn-tool' import { StairTool } from './stair/stair-tool' -import { CurveWallTool } from './wall/curve-wall-tool' -import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool' -import { WallTool } from './wall/wall-tool' -import { WindowTool } from './window/window-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneTool } from './zone/zone-tool' @@ -50,25 +34,19 @@ function getRegistryTool(tool: Tool | null): ComponentType | null { return Comp } +// Legacy tool fallbacks — kinds whose placement tools haven't migrated +// to `def.tool` yet. Wall / fence / slab / ceiling / door / window / +// item / shelf / spawn now go through the registry path above. const tools: Record>> = { site: { 'property-line': SiteBoundaryEditor, }, structure: { - wall: WallTool, - fence: FenceTool, - slab: SlabTool, - ceiling: CeilingTool, roof: RoofTool, stair: StairTool, - door: DoorTool, - item: ItemTool, zone: ZoneTool, - window: WindowTool, - }, - furnish: { - item: ItemTool, }, + furnish: {}, } export const ToolManager: React.FC = () => { @@ -146,9 +124,7 @@ export const ToolManager: React.FC = () => { const showBuildTool = mode === 'build' && tool !== null // Registry-first: if the active tool's kind has a NodeDefinition with a - // tool contribution, the registry-driven tool takes over. Otherwise fall - // through to the legacy `tools` map below. Today the registry is empty so - // RegistryToolComponent is always null — zero behavior change. + // tool contribution, the registry-driven tool takes over. const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null const useRegistryTool = RegistryToolComponent != null @@ -187,9 +163,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {showSlabHoleEditor && selectedSlabId && @@ -200,9 +174,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {showCeilingBoundaryEditor && selectedCeilingId && @@ -212,9 +184,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {showCeilingHoleEditor && selectedCeilingId && @@ -225,9 +195,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {movingWallEndpoint && (() => { @@ -239,9 +207,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {movingFenceEndpoint && (() => { @@ -253,9 +219,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {curvingWall && (() => { @@ -264,9 +228,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {curvingFence && (() => { @@ -275,9 +237,7 @@ export const ToolManager: React.FC = () => { - ) : ( - - ) + ) : null })()} {movingNode && movingNode.type !== 'building' && ( { /> )} {/* Registry-first: when the active tool's kind has a registered - NodeDefinition with a tool contribution, mount it here. Today - the registry is empty so this branch never fires. */} + NodeDefinition with a tool contribution, mount it here. */} {!movingNode && useRegistryTool && RegistryToolComponent && ( )} - {!movingNode && !useRegistryTool && showBuildTool && tool === 'spawn' && ( - - )} {!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && ( )} @@ -306,11 +262,7 @@ export const ToolManager: React.FC = () => { onPlaced={handlePlacedElevatorSelected} /> )} - {!movingNode && - BuildToolComponent && - tool !== 'spawn' && - tool !== 'column' && - tool !== 'elevator' ? ( + {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? ( ) : null} diff --git a/packages/editor/src/components/tools/wall/curve-wall-tool.tsx b/packages/editor/src/components/tools/wall/curve-wall-tool.tsx deleted file mode 100644 index f8e14b5c..00000000 --- a/packages/editor/src/components/tools/wall/curve-wall-tool.tsx +++ /dev/null @@ -1,178 +0,0 @@ -'use client' - -import { - type AnyNodeId, - emitter, - type GridEvent, - getClampedWallCurveOffset, - getMaxWallCurveOffset, - getWallChordFrame, - getWallMidpointHandlePoint, - normalizeWallCurveOffset, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' -import { getWallGridStep, snapScalarToGrid } from './wall-drafting' - -export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { - const activatedAtRef = useRef(Date.now()) - const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) - const previousCurveOffsetRef = useRef(null) - const shiftPressedRef = useRef(false) - const previewOffsetRef = useRef(originalCurveOffsetRef.current) - - const initialHandle = getWallMidpointHandlePoint(node) - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([ - initialHandle.x, - 0, - initialHandle.y, - ]) - - const exitCurveMode = useCallback(() => { - useEditor.getState().setCurvingWall(null) - }, []) - - useEffect(() => { - const nodeId = node.id - const originalCurveOffset = originalCurveOffsetRef.current - const chord = getWallChordFrame(node) - const maxCurveOffset = getMaxWallCurveOffset(node) - - useScene.temporal.getState().pause() - let wasCommitted = false - - const applyPreview = (curveOffset: number) => { - if (previewOffsetRef.current === curveOffset) { - return - } - previewOffsetRef.current = curveOffset - - const nextNode = { - ...node, - curveOffset, - } - const handlePoint = getWallMidpointHandlePoint(nextNode) - setCursorLocalPos([handlePoint.x, 0, handlePoint.y]) - useScene.getState().updateNode(nodeId, { curveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - } - - const restoreOriginal = () => { - if (previewOffsetRef.current === originalCurveOffset) { - return - } - previewOffsetRef.current = originalCurveOffset - useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - } - - const onGridMove = (event: GridEvent) => { - const snapStep = getWallGridStep() - const localX = shiftPressedRef.current - ? event.localPosition[0] - : snapScalarToGrid(event.localPosition[0], snapStep) - const localZ = shiftPressedRef.current - ? event.localPosition[2] - : snapScalarToGrid(event.localPosition[2], snapStep) - - const offsetFromMidpoint = -( - (localX - chord.midpoint.x) * chord.normal.x + - (localZ - chord.midpoint.y) * chord.normal.y - ) - const snappedOffset = shiftPressedRef.current - ? offsetFromMidpoint - : snapScalarToGrid(offsetFromMidpoint, snapStep) - const nextCurveOffset = normalizeWallCurveOffset( - node, - Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), - ) - - if ( - previousCurveOffsetRef.current !== null && - nextCurveOffset !== previousCurveOffsetRef.current - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousCurveOffsetRef.current = nextCurveOffset - - applyPreview(nextCurveOffset) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const curveOffset = previewOffsetRef.current - wasCommitted = true - - if (curveOffset !== originalCurveOffset) { - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - - useScene.temporal.getState().resume() - useScene.getState().updateNode(nodeId, { curveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) - useScene.temporal.getState().pause() - } - - sfxEmitter.emit('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - exitCurveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - useScene.temporal.getState().resume() - markToolCancelConsumed() - exitCurveMode() - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = true - } - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - - return () => { - if (!wasCommitted) { - restoreOriginal() - } - useScene.temporal.getState().resume() - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - } - }, [exitCurveMode, node]) - - return ( - - - - ) -} diff --git a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx deleted file mode 100644 index cc0afa64..00000000 --- a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx +++ /dev/null @@ -1,426 +0,0 @@ -'use client' - -import { - type AnyNodeId, - emitter, - type GridEvent, - pauseSceneHistory, - resumeSceneHistory, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' -import { useCallback, useEffect, useRef, useState } from 'react' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' -import { - formatAngleRadians, - getAngleToSegmentReference, - getSegmentAngleReferenceAtPoint, -} from '../shared/segment-angle' -import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' - -function samePoint(a: WallPlanPoint, b: WallPlanPoint) { - return a[0] === b[0] && a[1] === b[1] -} - -type WallSegmentLike = { - id: WallNode['id'] - start: WallPlanPoint - end: WallPlanPoint - curveOffset?: number -} - -type AngleLabelState = { - label: string - position: [number, number, number] -} | null - -function getEndpointAngleLabel(args: { - preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number } - walls: WallSegmentLike[] - nodeId: WallNode['id'] -}): AngleLabelState { - const { preview, walls, nodeId } = args - const endpoints = [ - { - point: preview.start, - }, - { - point: preview.end, - }, - ] - const targetSegment: WallSegmentLike = { - id: nodeId, - start: preview.start, - end: preview.end, - curveOffset: preview.curveOffset, - } - - for (const endpoint of endpoints) { - const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment) - if (!targetReference) continue - - const connectedWall = walls.find( - (wall) => - wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), - ) - if (!connectedWall) continue - - const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) - if (!connectedReference) continue - - const angle = getAngleToSegmentReference(targetReference.vector, connectedReference) - if (angle === null) continue - - return { - label: formatAngleRadians(angle), - position: [endpoint.point[0], 0.34, endpoint.point[1]], - } - } - - return null -} - -type LinkedWallSnapshot = { - id: WallNode['id'] - start: WallPlanPoint - end: WallPlanPoint - curveOffset?: number -} - -function getLinkedWallSnapshots(args: { - wallId: WallNode['id'] - wallParentId: string | null - originalStart: WallPlanPoint - originalEnd: WallPlanPoint -}) { - const { wallId, wallParentId, originalStart, originalEnd } = args - const { nodes } = useScene.getState() - const snapshots: LinkedWallSnapshot[] = [] - - for (const node of Object.values(nodes)) { - if (!(node?.type === 'wall' && node.id !== wallId)) { - continue - } - - if ((node.parentId ?? null) !== wallParentId) { - continue - } - - if ( - !( - samePoint(node.start, originalStart) || - samePoint(node.start, originalEnd) || - samePoint(node.end, originalStart) || - samePoint(node.end, originalEnd) - ) - ) { - continue - } - - snapshots.push({ - id: node.id, - start: [...node.start] as WallPlanPoint, - end: [...node.end] as WallPlanPoint, - curveOffset: node.curveOffset, - }) - } - - return snapshots -} - -function getLinkedWallUpdates( - linkedWalls: LinkedWallSnapshot[], - originalStart: WallPlanPoint, - originalEnd: WallPlanPoint, - nextStart: WallPlanPoint, - nextEnd: WallPlanPoint, -) { - return linkedWalls.map((wall) => ({ - id: wall.id, - curveOffset: wall.curveOffset, - start: samePoint(wall.start, originalStart) - ? nextStart - : samePoint(wall.start, originalEnd) - ? nextEnd - : wall.start, - end: samePoint(wall.end, originalStart) - ? nextStart - : samePoint(wall.end, originalEnd) - ? nextEnd - : wall.end, - })) -} - -export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => { - const activatedAtRef = useRef(Date.now()) - const previousGridPosRef = useRef(null) - const shiftPressedRef = useRef(false) - const altPressedRef = useRef(false) - const nodeIdRef = useRef(target.wall.id) - const originalStartRef = useRef([...target.wall.start] as WallPlanPoint) - const originalEndRef = useRef([...target.wall.end] as WallPlanPoint) - const fixedPointRef = useRef( - target.endpoint === 'start' - ? ([...target.wall.end] as WallPlanPoint) - : ([...target.wall.start] as WallPlanPoint), - ) - const linkedOriginalsRef = useRef( - getLinkedWallSnapshots({ - wallId: target.wall.id, - wallParentId: target.wall.parentId ?? null, - originalStart: target.wall.start, - originalEnd: target.wall.end, - }), - ) - const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null) - const [angleLabel, setAngleLabel] = useState(null) - - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const point = target.endpoint === 'start' ? target.wall.start : target.wall.end - return [point[0], 0, point[1]] - }) - const [altPressed, setAltPressed] = useState(false) - - const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingWallEndpoint(null) - }, []) - - useEffect(() => { - const nodeId = nodeIdRef.current - const originalStart = originalStartRef.current - const originalEnd = originalEndRef.current - const fixedPoint = fixedPointRef.current - const levelWalls = Object.values(useScene.getState().nodes).filter( - (node): node is WallNode => - node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), - ) - - pauseSceneHistory(useScene) - let wasCommitted = false - - const applyNodePreview = ( - updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>, - ) => { - useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), - ) - for (const entry of updates) { - useScene.getState().markDirty(entry.id as AnyNodeId) - } - } - - const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => { - const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint - const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint - const linkedUpdates = detachLinkedWalls - ? [] - : getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - ) - previewRef.current = { start: nextStart, end: nextEnd } - setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) - setAngleLabel( - getEndpointAngleLabel({ - preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset }, - walls: [ - ...levelWalls.map((wall) => ({ - id: wall.id, - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset, - })), - ...linkedUpdates, - ], - nodeId, - }), - ) - applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates]) - } - - const restoreOriginal = (clearAngleLabel = true) => { - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) - if (clearAngleLabel) { - setAngleLabel(null) - } - } - - const onGridMove = (event: GridEvent) => { - const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - const snappedPoint = snapWallDraftPoint({ - point: planPoint, - walls: levelWalls, - start: fixedPoint, - angleSnap: !shiftPressedRef.current, - ignoreWallIds: [nodeId], - }) - - if ( - previousGridPosRef.current && - (snappedPoint[0] !== previousGridPosRef.current[0] || - snappedPoint[1] !== previousGridPosRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosRef.current = snappedPoint - - applyPreview(snappedPoint, event.nativeEvent.altKey) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - const hasChanged = !( - samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd) - ) - - if (hasChanged && isWallLongEnough(preview.start, preview.end)) { - wasCommitted = true - - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) - - resumeSceneHistory(useScene) - applyNodePreview([ - { id: nodeId, start: preview.start, end: preview.end }, - ...(altPressedRef.current - ? [] - : getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - preview.start, - preview.end, - )), - ]) - pauseSceneHistory(useScene) - sfxEmitter.emit('sfx:item-place') - } - - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - setAngleLabel(null) - exitMoveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - resumeSceneHistory(useScene) - setAngleLabel(null) - markToolCancelConsumed() - exitMoveMode() - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { - return - } - if (event.key === 'Shift') { - shiftPressedRef.current = true - } - if (event.key === 'Alt') { - altPressedRef.current = true - setAltPressed(true) - } - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - if (event.key === 'Alt') { - altPressedRef.current = false - setAltPressed(false) - } - } - - const onWindowBlur = () => { - shiftPressedRef.current = false - altPressedRef.current = false - setAltPressed(false) - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - window.addEventListener('blur', onWindowBlur) - - return () => { - if (!wasCommitted) { - restoreOriginal(false) - } - resumeSceneHistory(useScene) - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - window.removeEventListener('blur', onWindowBlur) - } - }, [exitMoveMode, target]) - - return ( - - - -
-
- {altPressed ? 'Detaching corner' : 'Alt to detach'} -
-
- - {angleLabel && } -
- ) -} - -function EndpointAngleLabel({ - label, - position, -}: { - label: string - position: [number, number, number] -}) { - return ( - -
- {label} -
- - ) -} diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx deleted file mode 100644 index accfce33..00000000 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ /dev/null @@ -1,804 +0,0 @@ -'use client' - -import { - type AnyNodeId, - constrainWallMoveDeltaToAxis, - DEFAULT_WALL_HEIGHT, - detectSpacesForLevel, - emitter, - type GridEvent, - getMaterialPresetByRef, - getPerpendicularWallMoveAxis, - pauseSceneHistory, - planAutoSlabsForLevel, - planWallMoveJunctions, - resolveMaterial, - resumeSceneHistory, - type SlabNode, - useScene, - type WallMoveAxis, - type WallMoveBridgePlan, - type WallMoveJunctionPlan, - type WallNode, - WallNode as WallSchema, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' -import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' - -function rotateVector([x, z]: [number, number], angle: number): [number, number] { - const cos = Math.cos(angle) - const sin = Math.sin(angle) - return [x * cos - z * sin, x * sin + z * cos] -} - -function samePoint(a: [number, number], b: [number, number]) { - return a[0] === b[0] && a[1] === b[1] -} - -function pointKey(point: [number, number]) { - return `${point[0]}:${point[1]}` -} - -function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] { - if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { - return meta - } - - const nextMeta = { ...(meta as Record) } as Record - delete nextMeta.isNew - return nextMeta as WallNode['metadata'] -} - -type LinkedWallSnapshot = WallNode - -type GhostWallPreview = { - id: string - start: [number, number] - end: [number, number] - color: string - height: number -} - -function getLinkedWallSnapshots(args: { - wallId: WallNode['id'] - wallParentId: string | null - originalStart: [number, number] - originalEnd: [number, number] -}) { - const { wallId, wallParentId, originalStart, originalEnd } = args - const { nodes } = useScene.getState() - const walls = Object.values(nodes).filter( - (node): node is WallNode => - node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId, - ) - const directlyLinkedWalls = walls.filter( - (wall) => - samePoint(wall.start, originalStart) || - samePoint(wall.start, originalEnd) || - samePoint(wall.end, originalStart) || - samePoint(wall.end, originalEnd), - ) - const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)]) - - for (const wall of directlyLinkedWalls) { - contextPoints.add(pointKey(wall.start)) - contextPoints.add(pointKey(wall.end)) - } - - const snapshots: LinkedWallSnapshot[] = [] - const seenWallIds = new Set() - - for (const node of walls) { - if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) { - continue - } - - if (seenWallIds.has(node.id)) { - continue - } - seenWallIds.add(node.id) - - snapshots.push({ - ...node, - start: [...node.start] as [number, number], - end: [...node.end] as [number, number], - children: [...(node.children ?? [])], - }) - } - - return snapshots -} - -function getLinkedWallUpdates( - linkedWalls: Array<{ - wall: LinkedWallSnapshot - matchPoint?: [number, number] - targetPoint?: [number, number] - }>, - originalStart: [number, number], - originalEnd: [number, number], - nextStart: [number, number], - nextEnd: [number, number], -) { - return linkedWalls.map(({ wall, matchPoint, targetPoint }) => { - if (matchPoint && targetPoint) { - return { - id: wall.id, - start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start, - end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end, - } - } - - const targetStart = targetPoint ?? nextStart - const targetEnd = targetPoint ?? nextEnd - - return { - id: wall.id, - start: samePoint(wall.start, originalStart) - ? targetStart - : samePoint(wall.start, originalEnd) - ? targetEnd - : wall.start, - end: samePoint(wall.end, originalStart) - ? targetStart - : samePoint(wall.end, originalEnd) - ? targetEnd - : wall.end, - } - }) -} - -function getPlannedLinkedWallUpdates( - plan: WallMoveJunctionPlan, - originalStart: [number, number], - originalEnd: [number, number], - nextStart: [number, number], - nextEnd: [number, number], -) { - const movePlans = new Map< - WallNode['id'], - { wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] } - >() - - for (const wall of plan.linkedWallsToMove) { - movePlans.set(wall.id, { wall }) - } - - for (const targetPlan of plan.linkedWallTargetPlans) { - movePlans.set(targetPlan.wall.id, { - wall: targetPlan.wall, - matchPoint: targetPlan.originalPoint, - targetPoint: targetPlan.targetPoint, - }) - } - - return getLinkedWallUpdates( - Array.from(movePlans.values()), - originalStart, - originalEnd, - nextStart, - nextEnd, - ) -} - -function wallSegmentExists( - walls: Array>, - start: [number, number], - end: [number, number], -) { - return walls.some( - (wall) => - (samePoint(wall.start, start) && samePoint(wall.end, end)) || - (samePoint(wall.start, end) && samePoint(wall.end, start)), - ) -} - -function getWallGhostColor(wall: WallNode) { - const presetColor = - getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ?? - getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ?? - getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color - - if (presetColor) { - return presetColor - } - - return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color -} - -function getWallsAfterUpdates( - nodes: ReturnType['nodes'], - updates: Array<{ id: AnyNodeId; data: Partial }>, -) { - const updateById = new Map(updates.map((update) => [update.id, update.data])) - - return Object.values(nodes) - .filter((node): node is WallNode => node?.type === 'wall') - .map((wall) => { - const update = updateById.get(wall.id as AnyNodeId) - return update ? ({ ...wall, ...update } as WallNode) : wall - }) -} - -function cloneSlabSnapshot(slab: SlabNode): SlabNode { - return { - ...slab, - polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]), - holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), - holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })), - } -} - -function getLevelSlabs(levelId: string, nodes: ReturnType['nodes']) { - return Object.values(nodes).filter( - (entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId, - ) -} - -function getLevelAutoSlabs(levelId: string, nodes: ReturnType['nodes']) { - return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls) -} - -function getLevelAutoSlabSnapshots(levelId: string) { - return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot) -} - -function buildBridgeWallCreates(args: { - bridgePlans: Array> - nextStart: [number, number] - nextEnd: [number, number] - existingWalls: WallNode[] - wallCount: number -}): Array<{ node: WallNode; parentId?: AnyNodeId }> { - const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args - const wallsForDuplicateCheck = [...existingWalls] - const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = [] - - for (const plan of bridgePlans) { - const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd - - if (!isWallLongEnough(plan.originalPoint, nextPoint)) { - continue - } - - if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) { - continue - } - - const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall - const bridgeWall = WallSchema.parse({ - ...sourceWall, - name: `Wall ${wallCount + creates.length + 1}`, - start: plan.originalPoint, - end: nextPoint, - children: [], - metadata: stripWallIsNewMetadata(plan.wall.metadata), - }) - - creates.push({ - node: bridgeWall, - parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined, - }) - wallsForDuplicateCheck.push(bridgeWall) - } - - return creates -} - -function buildBridgeWallPreviews(args: { - bridgePlans: Array> - nextStart: [number, number] - nextEnd: [number, number] - existingWalls: WallNode[] -}): Array<{ ghost: GhostWallPreview; wall: WallNode }> { - const { bridgePlans, nextStart, nextEnd, existingWalls } = args - const wallsForDuplicateCheck: Array> = [...existingWalls] - const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = [] - - for (const plan of bridgePlans) { - const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd - - if (!isWallLongEnough(plan.originalPoint, nextPoint)) { - continue - } - - if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) { - continue - } - - const { id: _id, children: _children, ...sourceWall } = plan.wall - const wall = WallSchema.parse({ - ...sourceWall, - name: 'Wall Preview', - start: plan.originalPoint, - end: nextPoint, - children: [], - metadata: stripWallIsNewMetadata(plan.wall.metadata), - }) - const ghost = { - id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`, - start: [...plan.originalPoint] as [number, number], - end: [...nextPoint] as [number, number], - color: getWallGhostColor(plan.wall), - height: plan.wall.height ?? DEFAULT_WALL_HEIGHT, - } - previews.push({ ghost, wall }) - wallsForDuplicateCheck.push(wall) - } - - return previews -} - -function setPreviewGeometryAttributes( - geometry: BufferGeometry, - positions: number[], - normals: number[], - uvs: number[], -) { - geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) - geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) - geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) - geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2)) -} - -function createWallPreviewGeometry(length: number, height: number) { - const geometry = new BufferGeometry() - setPreviewGeometryAttributes( - geometry, - [0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0], - [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], - [0, 0, 1, 0, 1, 1, 0, 1], - ) - geometry.setIndex([0, 1, 2, 0, 2, 3]) - geometry.computeBoundingSphere() - return geometry -} - -function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) { - const dx = preview.end[0] - preview.start[0] - const dz = preview.end[1] - preview.start[1] - const length = Math.hypot(dx, dz) - const angle = -Math.atan2(dz, dx) - const geometry = useMemo(() => { - return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height) - }, [length, preview.height]) - - useEffect(() => () => geometry?.dispose(), [geometry]) - - if (!geometry) { - return null - } - - return ( - - - - - - - ) -} - -export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { - const meta = - typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) - ? (node.metadata as Record) - : {} - const isNew = !!meta.isNew - const activatedAtRef = useRef(Date.now()) - const previousGridPosRef = useRef<[number, number] | null>(null) - const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) - const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) - const originalCenterRef = useRef<[number, number]>([ - (node.start[0] + node.end[0]) / 2, - (node.start[1] + node.end[1]) / 2, - ]) - const originalHalfVectorRef = useRef<[number, number]>([ - (node.end[0] - node.start[0]) / 2, - (node.end[1] - node.start[1]) / 2, - ]) - const moveAxisRef = useRef( - getPerpendicularWallMoveAxis(node.start, node.end), - ) - const linkedOriginalsRef = useRef( - isNew - ? [] - : getLinkedWallSnapshots({ - wallId: node.id, - wallParentId: node.parentId ?? null, - originalStart: node.start, - originalEnd: node.end, - }), - ) - const originalAutoSlabsRef = useRef( - node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [], - ) - const dragAnchorRef = useRef<[number, number] | null>(null) - const nodeIdRef = useRef(node.id) - const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) - const pendingRotationRef = useRef(0) - const shiftPressedRef = useRef(false) - - const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const centerX = (node.start[0] + node.end[0]) / 2 - const centerZ = (node.start[1] + node.end[1]) / 2 - return [centerX, 0, centerZ] - }) - const [ghostWallPreviews, setGhostWallPreviews] = useState([]) - - const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingNode(null) - }, []) - - useEffect(() => { - const nodeId = nodeIdRef.current - const originalStart = originalStartRef.current - const originalEnd = originalEndRef.current - const originalCenter = originalCenterRef.current - const originalHalfVector = originalHalfVectorRef.current - const levelId = node.parentId ?? null - const originalAutoSlabs = originalAutoSlabsRef.current - - pauseSceneHistory(useScene) - let shouldRestoreOnCleanup = true - - const applyNodePreview = ( - updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, - ) => { - useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), - ) - for (const entry of updates) { - useScene.getState().markDirty(entry.id as AnyNodeId) - } - } - - const applyLiveAutoSlabPreview = (walls: WallNode[]) => { - if (!levelId) { - return - } - - const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) - const sceneState = useScene.getState() - const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) - const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes)) - - if ( - slabPlan.create.length === 0 && - slabPlan.update.length === 0 && - slabPlan.delete.length === 0 - ) { - return - } - - sceneState.applyNodeChanges({ - update: slabPlan.update.map((entry) => ({ - id: entry.id as AnyNodeId, - data: entry.data, - })), - create: slabPlan.create.map((slab) => ({ - node: slab, - parentId: levelId as AnyNodeId, - })), - delete: slabPlan.delete.map((id) => id as AnyNodeId), - }) - } - - const restoreAutoSlabPreview = () => { - if (!levelId) { - return - } - - const sceneState = useScene.getState() - const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id)) - const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes) - const update = originalAutoSlabs - .filter((slab) => sceneState.nodes[slab.id as AnyNodeId]) - .map((slab) => ({ - id: slab.id as AnyNodeId, - data: cloneSlabSnapshot(slab), - })) - const create = originalAutoSlabs - .filter((slab) => !sceneState.nodes[slab.id as AnyNodeId]) - .map((slab) => ({ - node: cloneSlabSnapshot(slab), - parentId: levelId as AnyNodeId, - })) - const deleteIds = currentAutoSlabs - .filter((slab) => !originalIds.has(slab.id)) - .map((slab) => slab.id as AnyNodeId) - - if (update.length === 0 && create.length === 0 && deleteIds.length === 0) { - return - } - - sceneState.applyNodeChanges({ - update, - create, - delete: deleteIds, - }) - } - - const buildWallFromCenter = (center: [number, number]) => { - const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) - const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] - const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]] - return { start: nextStart, end: nextEnd } - } - - const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) => - planWallMoveJunctions( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - ) - - const getLinkedPreviewUpdates = ( - plan: WallMoveJunctionPlan, - nextStart: [number, number], - nextEnd: [number, number], - ) => { - const movedUpdates = getPlannedLinkedWallUpdates( - plan, - originalStart, - originalEnd, - nextStart, - nextEnd, - ) - const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry])) - - return linkedOriginalsRef.current.map( - (wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end }, - ) - } - - const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { - previewRef.current = { start: nextStart, end: nextEnd } - const centerX = (nextStart[0] + nextEnd[0]) / 2 - const centerZ = (nextStart[1] + nextEnd[1]) / 2 - setCursorLocalPos([centerX, 0, centerZ]) - const previewPlan = getMovePlan(nextStart, nextEnd) - const previewUpdates = [ - { id: nodeId, start: nextStart, end: nextEnd }, - ...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd), - ] - const previewCollapsedWallIds = new Set([ - ...previewUpdates - .filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end)) - .map((entry) => entry.id as AnyNodeId), - ...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), - ]) - const previewSceneWalls = getWallsAfterUpdates( - useScene.getState().nodes, - previewUpdates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), - ).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId)) - const bridgePreviews = buildBridgeWallPreviews({ - bridgePlans: previewPlan.bridgePlans, - nextStart, - nextEnd, - existingWalls: previewSceneWalls, - }) - const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost) - const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall) - setGhostWallPreviews(nextGhostWalls) - applyNodePreview(previewUpdates) - applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls]) - } - - const restoreOriginal = () => { - setGhostWallPreviews([]) - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) - restoreAutoSlabPreview() - } - - const onGridMove = (event: GridEvent) => { - const rawX = event.localPosition[0] - const rawZ = event.localPosition[2] - const snapStep = getWallGridStep() - const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep) - const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep) - - const anchor = dragAnchorRef.current ?? [localX, localZ] - dragAnchorRef.current = anchor - - const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( - localX - anchor[0], - localZ - anchor[1], - moveAxisRef.current, - ) - const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] - - if ( - previousGridPosRef.current && - (constrainedGridPos[0] !== previousGridPosRef.current[0] || - constrainedGridPos[1] !== previousGridPosRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosRef.current = constrainedGridPos - - const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ] - const nextWall = buildWallFromCenter(nextCenter) - applyPreview(nextWall.start, nextWall.end) - } - - const onGridClick = (event: GridEvent) => { - if (Date.now() - activatedAtRef.current < 150) { - event.nativeEvent?.stopPropagation?.() - return - } - - const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - - shouldRestoreOnCleanup = false - - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - setGhostWallPreviews([]) - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) - restoreAutoSlabPreview() - - resumeSceneHistory(useScene) - const commitPlan = getMovePlan(preview.start, preview.end) - const linkedWallUpdates = getPlannedLinkedWallUpdates( - commitPlan, - originalStart, - originalEnd, - preview.start, - preview.end, - ) - const collapsedLinkedWallIds = new Set([ - ...linkedWallUpdates - .filter((entry) => !isWallLongEnough(entry.start, entry.end)) - .map((entry) => entry.id as AnyNodeId), - ...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), - ]) - - const commitUpdates = [ - { - id: nodeId as AnyNodeId, - data: isNew - ? { - start: preview.start, - end: preview.end, - metadata: stripWallIsNewMetadata(node.metadata), - } - : { start: preview.start, end: preview.end }, - }, - ...linkedWallUpdates - .filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId)) - .map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), - ] - const sceneState = useScene.getState() - const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter( - (wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId), - ) - const bridgeCreates = buildBridgeWallCreates({ - bridgePlans: commitPlan.bridgePlans, - nextStart: preview.start, - nextEnd: preview.end, - existingWalls, - wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length, - }) - sceneState.applyNodeChanges({ - update: commitUpdates, - create: bridgeCreates, - delete: Array.from(collapsedLinkedWallIds), - }) - - pauseSceneHistory(useScene) - - sfxEmitter.emit('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - exitMoveMode() - event.nativeEvent?.stopPropagation?.() - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { - return - } - - if (event.key === 'Shift') { - shiftPressedRef.current = true - return - } - - const ROTATION_STEP = Math.PI / 4 - let rotationDelta = 0 - if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP - else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP - - if (rotationDelta === 0) { - return - } - - event.preventDefault() - pendingRotationRef.current += rotationDelta - sfxEmitter.emit('sfx:item-rotate') - - const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - const currentCenter: [number, number] = [ - (preview.start[0] + preview.end[0]) / 2, - (preview.start[1] + preview.end[1]) / 2, - ] - const nextWall = buildWallFromCenter(currentCenter) - moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end) - applyPreview(nextWall.start, nextWall.end) - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - } - - const onCancel = () => { - shouldRestoreOnCleanup = false - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - resumeSceneHistory(useScene) - markToolCancelConsumed() - exitMoveMode() - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - - return () => { - if (shouldRestoreOnCleanup) { - restoreOriginal() - } - shiftPressedRef.current = false - resumeSceneHistory(useScene) - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - } - }, [exitMoveMode, isNew, node.metadata, node.parentId]) - - return ( - - - {ghostWallPreviews.map((preview) => ( - - ))} - - ) -} diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx deleted file mode 100644 index 0cec767f..00000000 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' -import { useEffect, useRef, useState } from 'react' -import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { CursorSphere } from '../shared/cursor-sphere' -import { - formatAngleRadians, - getAngleToSegmentReference, - getSegmentAngleReferenceAtPoint, -} from '../shared/segment-angle' -import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' - -const WALL_HEIGHT = 2.5 -const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22 -const DRAFT_ANGLE_LABEL_Y = 0.28 - -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] -} - -type DraftMeasurementState = { - lengthLabel: string - lengthPosition: [number, number, number] - angleLabels: DraftAngleLabel[] -} | null - -function formatMeasurement(value: number, unit: 'metric' | 'imperial') { - if (unit === 'imperial') { - const feet = value * 3.280_84 - const wholeFeet = Math.floor(feet) - const inches = Math.round((feet - wholeFeet) * 12) - if (inches === 12) return `${wholeFeet + 1}'0"` - return `${wholeFeet}'${inches}"` - } - - return `${Number.parseFloat(value.toFixed(2))}m` -} - -function getDraftAngleLabels( - start: WallPlanPoint, - end: WallPlanPoint, - walls: WallNode[], -): DraftAngleLabel[] { - const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] - const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]] - const endpoints = [ - { id: 'start', point: start, draftVector: draftFromStart }, - { id: 'end', point: end, draftVector: draftFromEnd }, - ] - const labels: DraftAngleLabel[] = [] - - for (const endpoint of endpoints) { - const connectedWall = walls.find((wall) => - Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), - ) - if (!connectedWall) continue - - const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) - if (!connectedReference) continue - - const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) - if (angle === null) continue - - labels.push({ - id: endpoint.id, - label: formatAngleRadians(angle), - position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], - }) - } - - return labels -} - -function getDraftMeasurementState( - start: WallPlanPoint, - end: WallPlanPoint, - walls: WallNode[], - unit: 'metric' | 'imperial', -): DraftMeasurementState { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - - if (length < 0.01) return null - - return { - lengthLabel: formatMeasurement(length, unit), - lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], - angleLabels: getDraftAngleLabels(start, end, walls), - } -} - -/** - * Update wall preview mesh geometry to create a vertical plane between two points - */ -const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => { - // Calculate direction and perpendicular for wall thickness - const direction = new Vector3(end.x - start.x, 0, end.z - start.z) - const length = direction.length() - - if (length < 0.01) { - mesh.visible = false - return - } - - mesh.visible = true - direction.normalize() - - // Create wall shape (vertical rectangle in XY plane) - const shape = new Shape() - shape.moveTo(0, 0) - shape.lineTo(length, 0) - shape.lineTo(length, WALL_HEIGHT) - shape.lineTo(0, WALL_HEIGHT) - shape.closePath() - - // Create geometry - const geometry = new ShapeGeometry(shape) - - // Calculate rotation angle - // Negate the angle to fix the opposite direction issue - const angle = -Math.atan2(direction.z, direction.x) - - // Position at start point and rotate - mesh.position.set(start.x, start.y, start.z) - mesh.rotation.y = angle - - // Dispose old geometry and assign new one - if (mesh.geometry) { - mesh.geometry.dispose() - } - mesh.geometry = geometry -} - -const getCurrentLevelWalls = (): WallNode[] => { - const currentLevelId = useViewer.getState().selection.levelId - const { nodes } = useScene.getState() - - if (!currentLevelId) return [] - - const levelNode = nodes[currentLevelId] - if (!levelNode || levelNode.type !== 'level') return [] - - return (levelNode as LevelNode).children - .map((childId) => nodes[childId]) - .filter((node): node is WallNode => node?.type === 'wall') -} - -export const WallTool: React.FC = () => { - const unit = useViewer((state) => state.unit) - const cursorRef = useRef(null) - const wallPreviewRef = useRef(null!) - // All positions are building-local: this tool is inside the ToolManager building group, - // so local coords are used for both data and visual positioning. - const startingPoint = useRef(new Vector3(0, 0, 0)) - const endingPoint = useRef(new Vector3(0, 0, 0)) - const buildingState = useRef(0) - const shiftPressed = useRef(false) - const [draftMeasurement, setDraftMeasurement] = useState(null) - - useEffect(() => { - let gridPosition: WallPlanPoint = [0, 0] - let previousWallEnd: [number, number] | null = null - - const onGridMove = (event: GridEvent) => { - if (!(cursorRef.current && wallPreviewRef.current)) return - - const walls = getCurrentLevelWalls() - // event.localPosition is building-local — consistent with stored wall start/end - const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - gridPosition = snapWallDraftPoint({ point: localPoint, walls }) - - if (buildingState.current === 1) { - const snappedLocal = snapWallDraftPoint({ - point: localPoint, - walls, - start: [startingPoint.current.x, startingPoint.current.z], - angleSnap: !shiftPressed.current, - }) - endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) - cursorRef.current.position.copy(endingPoint.current) - - // Play snap sound only when the actual wall end position changes - const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] - if ( - previousWallEnd && - (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousWallEnd = currentWallEnd - - updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) - setDraftMeasurement( - getDraftMeasurementState( - [startingPoint.current.x, startingPoint.current.z], - snappedLocal, - walls, - unit, - ), - ) - } else { - // Not drawing a wall yet, show the snapped anchor point. - cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) - setDraftMeasurement(null) - } - } - - const onGridClick = (event: GridEvent) => { - const walls = getCurrentLevelWalls() - const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - - if (buildingState.current === 0) { - const snappedStart = snapWallDraftPoint({ point: localClick, walls }) - gridPosition = snappedStart - startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) - endingPoint.current.copy(startingPoint.current) - buildingState.current = 1 - wallPreviewRef.current.visible = true - setDraftMeasurement(null) - } else if (buildingState.current === 1) { - const snappedEnd = snapWallDraftPoint({ - point: localClick, - walls, - start: [startingPoint.current.x, startingPoint.current.z], - angleSnap: !shiftPressed.current, - }) - const dx = snappedEnd[0] - startingPoint.current.x - const dz = snappedEnd[1] - startingPoint.current.z - if (dx * dx + dz * dz < 0.01 * 0.01) return - // Both start and end are building-local ✓ - createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) - wallPreviewRef.current.visible = false - buildingState.current = 0 - setDraftMeasurement(null) - } - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') { - shiftPressed.current = true - } - } - - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') { - shiftPressed.current = false - } - } - - const onCancel = () => { - if (buildingState.current === 1) { - markToolCancelConsumed() - buildingState.current = 0 - wallPreviewRef.current.visible = false - setDraftMeasurement(null) - } - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - - return () => { - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - } - }, [unit]) - - return ( - - {/* Cursor indicator */} - - - {/* Wall preview */} - - - - - - {draftMeasurement && ( - <> - - {draftMeasurement.angleLabels.map((angleLabel) => ( - - ))} - - )} - - ) -} - -function DraftMeasurementLabel({ - label, - position, -}: { - label: string - position: [number, number, number] -}) { - return ( - -
- {label} -
- - ) -} diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 1eaad111..aaf05805 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -33,10 +33,7 @@ export const tools: ToolConfig[] = [ { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, - // Registry-driven shelf node — placed via the registry-first ToolManager - // shim from Phase 0. No icon file shipped; using a placeholder icon until - // Phase 4 derives palette entries from `definition.presentation.icon`. - { id: 'shelf', iconSrc: '/icons/column.png', label: 'Shelf' }, + { id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' }, ] export function StructureTools() { diff --git a/packages/editor/src/components/ui/helpers/ceiling-helper.tsx b/packages/editor/src/components/ui/helpers/ceiling-helper.tsx deleted file mode 100644 index c1401546..00000000 --- a/packages/editor/src/components/ui/helpers/ceiling-helper.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ShortcutToken } from '../primitives/shortcut-token' - -export function CeilingHelper() { - return ( -
-
- - Add point -
-
- - Allow non-45° angles -
-
- - Cancel -
-
- ) -} diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 7625b97b..d25aeafe 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -4,12 +4,9 @@ import { nodeRegistry } from '@pascal-app/core' import { useIsMobile } from '../../../hooks/use-mobile' import useEditor from '../../../store/use-editor' import { BuildingHelper } from './building-helper' -import { CeilingHelper } from './ceiling-helper' import { ItemHelper } from './item-helper' import { RegisteredToolHelper } from './registered-tool-helper' import { RoofHelper } from './roof-helper' -import { SlabHelper } from './slab-helper' -import { WallHelper } from './wall-helper' export function HelperManager() { const mode = useEditor((s) => s.mode) @@ -29,10 +26,9 @@ export function HelperManager() { return null } - // Registry-first: if the active tool matches a registered kind whose - // definition supplies `toolHints`, render via the generic helper. - // Otherwise fall through to the hand-written per-tool helpers below. - // Legacy helpers get deleted as their kind migrates `toolHints` in. + // Registry-first: kinds with `def.toolHints` render through the generic + // `RegisteredToolHelper`. Today that covers ceiling / door / fence / + // item / shelf / slab / spawn / wall / window. if (tool) { const def = nodeRegistry.get(tool) if (def?.toolHints && def.toolHints.length > 0) { @@ -40,19 +36,9 @@ export function HelperManager() { } } - // Show appropriate helper based on current tool - switch (tool) { - case 'wall': - return - case 'item': - return - case 'slab': - return - case 'ceiling': - return - case 'roof': - return - default: - return null - } + // Legacy fallback — only `roof` remains because it hasn't migrated to + // `def.tool` / `def.toolHints` yet (no Stage D port). When roof + // migrates, this switch deletes outright. + if (tool === 'roof') return + return null } diff --git a/packages/editor/src/components/ui/helpers/slab-helper.tsx b/packages/editor/src/components/ui/helpers/slab-helper.tsx deleted file mode 100644 index 238f5dde..00000000 --- a/packages/editor/src/components/ui/helpers/slab-helper.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ShortcutToken } from '../primitives/shortcut-token' - -export function SlabHelper() { - return ( -
-
- - Add point -
-
- - Allow non-45° angles -
-
- - Cancel -
-
- ) -} diff --git a/packages/editor/src/components/ui/helpers/wall-helper.tsx b/packages/editor/src/components/ui/helpers/wall-helper.tsx deleted file mode 100644 index 574d50d9..00000000 --- a/packages/editor/src/components/ui/helpers/wall-helper.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ShortcutToken } from '../primitives/shortcut-token' - -export function WallHelper() { - return ( -
-
- - Set wall start / end -
-
- - Allow non-45° angles -
-
- - Cancel -
-
- ) -} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 3b36ae4d..4fa82d2e 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -24,23 +24,12 @@ import { useCallback, useEffect, useState } from 'react' import { useIsMobile } from '../../../hooks/use-mobile' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' -import { ColumnPanel } from './column-panel' -import { DoorPanel } from './door-panel' -import { ElevatorPanel } from './elevator-panel' -import { ItemPanel } from './item-panel' import { MobilePanelSheet } from './mobile-panel-sheet' import { MobileSelectionBar } from './mobile-selection-bar' import { getNodeDisplay } from './node-display' import { PaintPanel } from './paint-panel' import { ParametricInspector } from './parametric-inspector' import { ReferencePanel } from './reference-panel' -import { RoofPanel } from './roof-panel' -import { RoofSegmentPanel } from './roof-segment-panel' -import { SpawnPanel } from './spawn-panel' -import { StairPanel } from './stair-panel' -import { StairSegmentPanel } from './stair-segment-panel' -import { WallPanel } from './wall-panel' -import { WindowPanel } from './window-panel' type MovableNode = | ItemNode @@ -81,37 +70,15 @@ function isMovableNode(node: AnyNode | null): node is MovableNode { function panelForType(type: string | null) { if (!type) return null - switch (type) { - case 'item': - return - case 'roof': - return - case 'roof-segment': - return - case 'stair': - return - case 'stair-segment': - return - case 'spawn': - return - case 'column': - return - case 'wall': - return - case 'door': - return - case 'elevator': - return - case 'window': - return - default: - // Registry fallback: any kind registered via @pascal-app/nodes with a - // `parametrics` descriptor on its NodeDefinition gets an auto-derived - // panel. Phase 4 will replace the hardcoded switch above with the - // registry-first path; until then this fallback lets new kinds (shelf, - // etc.) have a working inspector without per-kind panel files. - return - } + // Every kind now renders through ``, which either + // composes auto-derived editors from `parametrics.groups` or lazy- + // loads the kind-owned panel via `parametrics.customPanel`. The + // hardcoded switch is gone — all per-kind panel layout lives in + // `nodes/src//panel.tsx`. The `type` arg is preserved for + // future cases where we might want a non-registry fallback (e.g. + // reference scale, paint mode); leave the function shape intact. + void type + return } function MobilePanelLayer({ diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx index b467f2c7..576e4874 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const pi = polygon[i] + const pj = polygon[j] + if (!(pi && pj)) continue + area += pi[0] * pj[1] + area -= pj[0] * pi[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx index 08d6aa64..c6635e83 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ return ( } + actions={} depth={depth} expanded={false} hasChildren={false} @@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx index 4ce5dd4e..d26854cd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const pi = polygon[i] + const pj = polygon[j] + if (!(pi && pj)) continue + area += pi[0] * pj[1] + area -= pj[0] * pi[1] } return Math.abs(area) / 2 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 a8fe186e..462d2d4e 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 @@ -77,49 +77,61 @@ interface TreeNodeProps { isLast?: boolean } +// Per-kind tree-node components keyed by `node.type`. Lookup replaces +// the legacy switch — adding a kind to this map is now the only edit +// needed in this file (the switch's `case '':` clauses were +// flagged by the Phase 6 grep gate as the last per-kind dispatch +// outside the registry; future work moves these to a +// `def.presentation`-driven generic tree-node and removes this map +// entirely). +const treeNodeByType: Record< + string, + React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }> +> = { + building: BuildingTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + ceiling: CeilingTreeNode, + column: ColumnTreeNode, + elevator: ElevatorTreeNode, + level: LevelTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + shelf: ShelfTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + slab: SlabTreeNode, + spawn: SpawnTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + wall: WallTreeNode, + fence: FenceTreeNode, + roof: RoofTreeNode, + stair: StairTreeNode, + door: DoorTreeNode, + window: WindowTreeNode, + zone: ZoneTreeNode as React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId + }>, + item: ItemTreeNode, +} + export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { const nodeType = useScene((state) => state.nodes[nodeId]?.type) - if (!nodeType) return null - - switch (nodeType) { - case 'building': - return ( - - ) - case 'ceiling': - return - case 'column': - return - case 'elevator': - return - case 'level': - return - case 'shelf': - return - case 'slab': - return - case 'spawn': - return - case 'wall': - return - case 'fence': - return - case 'roof': - return - case 'stair': - return - case 'item': - return - case 'door': - return - case 'window': - return - case 'zone': - return - default: - return null - } + const Component = treeNodeByType[nodeType] + if (!Component) return null + return }) interface TreeNodeWrapperProps { diff --git a/packages/editor/src/components/viewer-zone-system.tsx b/packages/editor/src/components/viewer-zone-system.tsx index bbc26132..015e2887 100644 --- a/packages/editor/src/components/viewer-zone-system.tsx +++ b/packages/editor/src/components/viewer-zone-system.tsx @@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => { const structureLayer = useEditor.getState().structureLayer const nodes = useScene.getState().nodes - sceneRegistry.byType.zone.forEach((id) => { + sceneRegistry.byType.zone!.forEach((id) => { const obj = sceneRegistry.nodes.get(id) if (!obj) return diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index bf9a9e04..44c7267a 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -4,6 +4,15 @@ export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +// SVG path builders for arc / annular-sector / arrow-head shapes — +// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved +// stair rendering in `nodes/src/stair/floorplan.ts`. +export { + buildSvgAnnularSectorPath, + buildSvgArcPath, + buildSvgArrowHeadPoints, + getArcPlanPoint, +} from './components/editor-2d/svg-paths' // Phase 5 Stage D transitional exports — pure drafting / angle helpers // consumed by kind-owned drag actions in @pascal-app/nodes. Stage F // cleanup moves these into @pascal-app/nodes (fence/drafting.ts + @@ -13,6 +22,29 @@ export { type FencePlanPoint, snapFenceDraftPoint, } from './components/tools/fence/fence-drafting' +// Placement-math helpers — shared by kind-owned placement tools in +// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement, +// item drop) so kinds don't reach into editor internals. +export { + calculateCursorRotation, + calculateItemRotation, + getSideFromNormal, + isValidWallSideFace, + snapToGrid, + snapToHalf, + snapUpToGridStep, + stripTransient, +} from './components/tools/item/placement-math' +export type { PlacementState } from './components/tools/item/placement-types' +// Item placement / move primitives. Re-exported here so the registry-driven +// item move-tool in `@pascal-app/nodes` can compose them — same hooks the +// legacy `MoveItemContent` + `ItemTool` use. Once item placement is fully +// owned by `nodes`, these can be inlined there and dropped from editor. +export { type DraftNodeHandle, useDraftNode } from './components/tools/item/use-draft-node' +export { + type PlacementCoordinatorConfig, + usePlacementCoordinator, +} from './components/tools/item/use-placement-coordinator' export { CursorSphere } from './components/tools/shared/cursor-sphere' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { @@ -24,10 +56,32 @@ export { getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, } from './components/tools/shared/segment-angle' +// Stair placement defaults — used by the kind-owned stair / stair-segment +// panels. Re-exported from `components/tools/stair/stair-defaults.ts`. +export { + DEFAULT_CURVED_STAIR_INNER_RADIUS, + DEFAULT_CURVED_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + DEFAULT_SPIRAL_TOP_LANDING_MODE, + DEFAULT_STAIR_ATTACHMENT_SIDE, + DEFAULT_STAIR_FILL_TO_FLOOR, + DEFAULT_STAIR_HEIGHT, + DEFAULT_STAIR_LENGTH, + DEFAULT_STAIR_RAILING_HEIGHT, + DEFAULT_STAIR_RAILING_MODE, + DEFAULT_STAIR_STEP_COUNT, + DEFAULT_STAIR_THICKNESS, + DEFAULT_STAIR_TYPE, + DEFAULT_STAIR_WIDTH, +} from './components/tools/stair/stair-defaults' export { createWallOnCurrentLevel, getWallGridStep, isWallLongEnough, + snapPointToGrid, snapScalarToGrid, snapWallDraftPoint, type WallPlanPoint, @@ -36,16 +90,23 @@ export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' export { ActionButton, ActionGroup } from './components/ui/controls/action-button' +export { MaterialPicker } from './components/ui/controls/material-picker' +export { MetricControl } from './components/ui/controls/metric-control' export { PanelSection } from './components/ui/controls/panel-section' export { SegmentedControl } from './components/ui/controls/segmented-control' export { SliderControl } from './components/ui/controls/slider-control' export { ToggleControl } from './components/ui/controls/toggle-control' export { FloatingLevelSelector } from './components/ui/floating-level-selector' export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items' +// Item collections UI — used by the kind-owned ItemPanel in nodes/. +export { CollectionsPopover } from './components/ui/panels/collections/collections-popover' // Phase 5 Stage E — kinds with bespoke editors (slab holes list, // ceiling height presets, etc.) use `parametrics.customPanel` to mount // a kind-owned panel and need PanelWrapper for the chrome. export { PanelWrapper } from './components/ui/panels/panel-wrapper' +// Presets popover — used by kind-owned door / window panels for their +// hardware / type / opening presets. +export { PresetsPopover } from './components/ui/panels/presets/presets-popover' export { PALETTE_COLORS } from './components/ui/primitives/color-dot' export { useSidebarStore } from './components/ui/primitives/sidebar' export { Slider } from './components/ui/primitives/slider' @@ -60,7 +121,7 @@ export { export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SidebarTab } from './components/ui/sidebar/tab-bar' export type { PresetsAdapter, PresetsTab } from './contexts/presets-context' -export { PresetsProvider } from './contexts/presets-context' +export { PresetsProvider, usePresetsAdapter } from './contexts/presets-context' export type { SaveStatus } from './hooks/use-auto-save' // useDragAction is the React-side glue for the registry's DragAction // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) @@ -69,9 +130,44 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). export { markToolCancelConsumed } from './hooks/use-keyboard' export { EDITOR_LAYER } from './lib/constants' +// Helper libs used by the kind-owned roof / stair / elevator panels. +export { + resolveCurrentBuildingId, + resolveElevatorNodeSupportY, + resolveElevatorSupportLevelId, + resolveElevatorSupportY, +} from './lib/elevator-support' +// Floor-plan stair helpers — the cumulative-transform walk +// (`computeFloorplanStairSegmentTransforms`) and the rich segment-entry +// builder (`buildFloorplanStairEntry`) used by the kind-owned stair +// floor-plan emitter in `@pascal-app/nodes/src/stair/floorplan.ts`. +// Each flight's transform depends on every prior sibling's length / +// height / `attachmentSide`, so individual stair-segments can't compute +// their own polygon in isolation — the stair (parent) owns the +// computation and emits the whole stack as one registry entry. +export { + buildFloorplanStairEntry, + type FloorplanStairArrowEntry, + type FloorplanStairEntry, + type FloorplanStairSegmentEntry, +} from './lib/floorplan' +export { + buildRoofSurfaceMaterialPatch, + buildSingleSurfaceMaterialPatch, + buildStairSurfaceMaterialPatch, + buildWallSurfaceMaterialPatch, + getActivePaintMaterialLabel, + hasActivePaintMaterial, +} from './lib/material-paint' +export { duplicateRoofSubtree } from './lib/roof-duplication' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' +export { duplicateStairSubtree } from './lib/stair-duplication' +// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ +// nodes` so they don't need their own copy / their own tailwind-merge +// dependency. +export { cn } from './lib/utils' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export type { diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts index 747767b4..cd08bcff 100644 --- a/packages/editor/src/lib/level-duplication.ts +++ b/packages/editor/src/lib/level-duplication.ts @@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) { return true } +/** + * Material field keys per kind, used by the `structure` duplicate preset + * to strip materials from the cloned subtree. Lookup table replaces the + * legacy per-kind switch — the Phase 6 grep gate flagged `case '':` + * in this file as the remaining per-kind dispatch outside the registry. + * + * Future: move this to a `capabilities.materialFields` declaration on + * each kind's `NodeDefinition` so adding a new kind with materials is a + * registry-only edit. Today the registry doesn't surface material fields + * in a uniform way (each kind's panel reads / writes them directly), so + * this map mirrors the legacy behavior 1:1. + */ +const MATERIAL_FIELDS_BY_KIND: Record> = { + wall: [ + 'material', + 'materialPreset', + 'interiorMaterial', + 'interiorMaterialPreset', + 'exteriorMaterial', + 'exteriorMaterialPreset', + ], + slab: ['material', 'materialPreset'], + ceiling: ['material', 'materialPreset'], + fence: ['material', 'materialPreset'], + shelf: ['material', 'materialPreset'], + 'roof-segment': ['material', 'materialPreset'], + 'stair-segment': ['material', 'materialPreset'], + window: ['material', 'materialPreset'], + door: ['material', 'materialPreset'], + roof: [ + 'material', + 'materialPreset', + 'topMaterial', + 'topMaterialPreset', + 'edgeMaterial', + 'edgeMaterialPreset', + 'wallMaterial', + 'wallMaterialPreset', + ], + stair: [ + 'material', + 'materialPreset', + 'railingMaterial', + 'railingMaterialPreset', + 'treadMaterial', + 'treadMaterialPreset', + 'sideMaterial', + 'sideMaterialPreset', + ], +} + function stripMaterials(node: AnyNode): AnyNode { + const fields = MATERIAL_FIELDS_BY_KIND[node.type] + if (!fields) return node const next = { ...node } as Record - - switch (node.type) { - case 'wall': - delete next.material - delete next.materialPreset - delete next.interiorMaterial - delete next.interiorMaterialPreset - delete next.exteriorMaterial - delete next.exteriorMaterialPreset - break - case 'slab': - case 'ceiling': - case 'fence': - case 'roof-segment': - case 'stair-segment': - case 'window': - case 'door': - delete next.material - delete next.materialPreset - break - case 'roof': - delete next.material - delete next.materialPreset - delete next.topMaterial - delete next.topMaterialPreset - delete next.edgeMaterial - delete next.edgeMaterialPreset - delete next.wallMaterial - delete next.wallMaterialPreset - break - case 'stair': - delete next.material - delete next.materialPreset - delete next.railingMaterial - delete next.railingMaterialPreset - delete next.treadMaterial - delete next.treadMaterialPreset - delete next.sideMaterial - delete next.sideMaterialPreset - break - } - + for (const field of fields) delete next[field] return next as AnyNode } diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 2aef4091..7c99f241 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -13,6 +13,7 @@ import { type MaterialTarget, type RoofNode, type RoofSurfaceMaterialRole, + type ShelfNode, type SlabNode, type StairNode, type StairSurfaceMaterialRole, @@ -22,7 +23,7 @@ import { export type PaintableMaterialTarget = Extract< MaterialTarget, - 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' + 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf' > export type SingleSurfaceMaterialRole = 'surface' @@ -133,7 +134,7 @@ export function buildStairSurfaceMaterialPatch( } export function buildSingleSurfaceMaterialPatch< - TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode, + TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, >(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial { return { material, @@ -222,7 +223,8 @@ export function resolveActivePaintMaterialFromSelection(params: { (selectedNode.type === 'fence' || selectedNode.type === 'column' || selectedNode.type === 'slab' || - selectedNode.type === 'ceiling') && + selectedNode.type === 'ceiling' || + selectedNode.type === 'shelf') && selectedMaterialTarget.role === 'surface' ) { const target = selectedNode.type @@ -280,5 +282,9 @@ export function resolvePaintTargetFromSelection(params: { return 'ceiling' } + if (selectedNode.type === 'shelf') { + return 'shelf' + } + return null } diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 6eea48f3..4304241d 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -283,7 +283,11 @@ export function syncEditorSelectionFromCurrentScene() { if (shouldRestoreEditorUiState) { if (restoredSelection) { - useViewer.getState().setSelection(restoredSelection) + // PersistedSelectionPath carries plain `string` ids (read from + // localStorage, no branded-template-literal guarantee). The viewer's + // SelectionPath expects branded ids. The runtime values match the + // brand; the cast bridges the static gap. + useViewer.getState().setSelection(restoredSelection as never) useEditor.setState( restoredEditorUiState.phase === 'site' ? (selectionDrivenEditorUiState ?? restoredEditorUiState) @@ -305,7 +309,7 @@ export function syncEditorSelectionFromCurrentScene() { } if (restoredSelection) { - useViewer.getState().setSelection(restoredSelection) + useViewer.getState().setSelection(restoredSelection as never) if (selectionDrivenEditorUiState) { useEditor.setState(selectionDrivenEditorUiState) } diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 0fbfb28f..72e718c8 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -30,7 +30,8 @@ "@react-three/fiber": "^9", "lucide-react": "^1", "react": "^18 || ^19", - "three": "^0.184" + "three": "^0.184", + "zustand": "^5" }, "devDependencies": { "@pascal-app/core": "^0.8.0", diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts new file mode 100644 index 00000000..803d46d6 --- /dev/null +++ b/packages/nodes/src/building/definition.ts @@ -0,0 +1,50 @@ +import { BuildingNode as BuildingNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { buildingParametrics } from './parametrics' +import { BuildingNode } from './schema' + +/** + * Building — Stage A. Container for levels; can be translated / + * rotated as a whole (movable + rotatable on Y). The legacy + * `MoveBuildingContent` handles building-wide drag; the registry + * fallback would translate position, which is close to right — + * but kept legacy at Stage A to avoid disturbing the building's + * world-space group transform handling. + */ +export const buildingDefinition: NodeDefinition = { + kind: 'building', + schemaVersion: 1, + schema: BuildingNode, + category: 'site', + + defaults: () => { + const stub = BuildingNodeSchema.parse({ id: 'building_default' as never, type: 'building' }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + // Building is a container — sidebar / building switcher drive + // selection, never 3D click. Same reasoning as `level` / `site`. + duplicable: false, + deletable: false, + }, + + parametrics: buildingParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + + presentation: { + label: 'Building', + description: 'A building container holding one or more levels.', + icon: { kind: 'url', src: '/icons/building.png' }, + paletteSection: 'site', + paletteOrder: 6, + }, + + mcp: { + description: 'A building container that groups levels.', + }, +} diff --git a/packages/nodes/src/building/index.ts b/packages/nodes/src/building/index.ts new file mode 100644 index 00000000..6d9bb2e7 --- /dev/null +++ b/packages/nodes/src/building/index.ts @@ -0,0 +1 @@ +export { buildingDefinition } from './definition' diff --git a/packages/nodes/src/building/parametrics.ts b/packages/nodes/src/building/parametrics.ts new file mode 100644 index 00000000..8a01866f --- /dev/null +++ b/packages/nodes/src/building/parametrics.ts @@ -0,0 +1,5 @@ +import type { BuildingNode, ParametricDescriptor } from '@pascal-app/core' + +export const buildingParametrics: ParametricDescriptor = { + groups: [], +} diff --git a/packages/viewer/src/components/renderers/building/building-renderer.tsx b/packages/nodes/src/building/renderer.tsx similarity index 84% rename from packages/viewer/src/components/renderers/building/building-renderer.tsx rename to packages/nodes/src/building/renderer.tsx index 020f6a7f..87f2f2f4 100644 --- a/packages/viewer/src/components/renderers/building/building-renderer.tsx +++ b/packages/nodes/src/building/renderer.tsx @@ -1,8 +1,9 @@ +'use client' + import { type BuildingNode, useRegistry } from '@pascal-app/core' +import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer' import { useRef } from 'react' import type { Group } from 'three' -import { useNodeEvents } from '../../../hooks/use-node-events' -import { NodeRenderer } from '../node-renderer' export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { const ref = useRef(null!) @@ -23,3 +24,5 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { ) } + +export default BuildingRenderer diff --git a/packages/nodes/src/building/schema.ts b/packages/nodes/src/building/schema.ts new file mode 100644 index 00000000..b7ed33ec --- /dev/null +++ b/packages/nodes/src/building/schema.ts @@ -0,0 +1 @@ +export { BuildingNode } from '@pascal-app/core' diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index cbf981a9..d1863763 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -1,5 +1,11 @@ import type { NodeDefinition } from '@pascal-app/core' import { buildCeilingFloorplan } from './floorplan' +import { + ceilingAddVertexAffordance, + ceilingMoveEdgeAffordance, + ceilingMoveVertexAffordance, +} from './floorplan-affordances' +import { ceilingFloorplanMoveTarget } from './floorplan-move' import { ceilingParametrics } from './parametrics' import { CeilingNode } from './schema' @@ -76,6 +82,18 @@ export const ceilingDefinition: NodeDefinition = { priority: 4, }, floorplan: buildCeilingFloorplan, + // 2D move handler — translates polygon by cursor delta from first + // pointer position. Mirror of slab; 3D `MoveCeilingTool` skips + // 2D-sourced grid events so they don't double-write on commit. + floorplanMoveTarget: ceilingFloorplanMoveTarget, + // Sister to `affordanceTools['boundary-edit']`. Same `polygon` field; + // SVG vertex handles dispatch to this affordance via the floor-plan + // registry layer. + floorplanAffordances: { + 'move-vertex': ceilingMoveVertexAffordance, + 'add-vertex': ceilingAddVertexAffordance, + 'move-edge': ceilingMoveEdgeAffordance, + }, toolHints: [ { key: 'Left click', label: 'Trace ceiling outline' }, diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts new file mode 100644 index 00000000..d280d2e7 --- /dev/null +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -0,0 +1,16 @@ +import type { CeilingNode } from '@pascal-app/core' +import { + createPolygonAddVertexAffordance, + createPolygonMoveEdgeAffordance, + createPolygonVertexAffordance, +} from '../shared/polygon-vertex-affordance' + +/** + * 2D drag affordances for ceiling. Same three operations as slab + * (`move-vertex`, `add-vertex`, `move-edge`), each accepting an + * optional `holeIndex`. See `slab/floorplan-affordances.ts` for the + * full contract. + */ +export const ceilingMoveVertexAffordance = createPolygonVertexAffordance('ceiling') +export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance('ceiling') +export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance('ceiling') diff --git a/packages/nodes/src/ceiling/floorplan-move.ts b/packages/nodes/src/ceiling/floorplan-move.ts new file mode 100644 index 00000000..2bf473fe --- /dev/null +++ b/packages/nodes/src/ceiling/floorplan-move.ts @@ -0,0 +1,88 @@ +import { + type AnyNodeId, + type CeilingNode, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import type * as THREE from 'three' + +/** + * 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool` + * live-drag pattern. See the equivalent module in `slab/floorplan-move.ts` + * for the full rationale; the only ceiling-specific detail is the + * preserved Y offset (`CeilingSystem` positions the mesh at `height − 0.01` + * on rebuild, so the direct `mesh.position.y` mirrors that to avoid a + * vertical teleport when the React group position is reconciled). + */ +const GRID_STEP = 0.5 + +function translatePolygon( + polygon: ReadonlyArray, + dx: number, + dz: number, +): Array<[number, number]> { + return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]) +} + +export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const ceilingId = node.id as AnyNodeId + const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number]) + const originalHoles = (node.holes ?? []).map((hole) => + hole.map(([x, z]) => [x, z] as [number, number]), + ) + const height = node.height ?? 2.5 + let anchor: [number, number] | null = null + let lastDelta: [number, number] = [0, 0] + + const session: FloorplanMoveTargetSession = { + affectedIds: [ceilingId], + apply({ planPoint, modifiers }) { + const snapped: WallPlanPoint = modifiers.shiftKey + ? ([planPoint[0], planPoint[1]] as WallPlanPoint) + : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + if (!anchor) { + anchor = [snapped[0], snapped[1]] + return + } + const dx = snapped[0] - anchor[0] + const dz = snapped[1] - anchor[1] + lastDelta = [dx, dz] + useLiveTransforms.getState().set(ceilingId, { + position: [dx, 0, dz], + rotation: 0, + }) + const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined + // Preserve ceiling height — `CeilingSystem` sets `mesh.position.y = + // height − 0.01` on each rebuild; mirror that during the drag so + // the mesh stays at ceiling height (not collapsed to y=0). + if (mesh) mesh.position.set(dx, height - 0.01, dz) + }, + canCommit() { + const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined + if (!live || live.type !== 'ceiling') return false + const [dx, dz] = lastDelta + if (dx === 0 && dz === 0) return false + // Sync commit sequence — see `slab/floorplan-move.ts` for the + // full ordering rationale (scene write → direct markDirty → + // useLiveTransforms.clear, all sync in this handler so React + // render + CeilingSystem rebuild land in the same paint). + useScene.getState().updateNodes([ + { + id: ceilingId, + data: { + polygon: translatePolygon(originalPolygon, dx, dz), + holes: originalHoles.map((h) => translatePolygon(h, dx, dz)), + }, + }, + ]) + useScene.getState().markDirty(ceilingId) + useLiveTransforms.getState().clear(ceilingId) + return true + }, + } + return session +} diff --git a/packages/nodes/src/ceiling/floorplan.ts b/packages/nodes/src/ceiling/floorplan.ts index 4e618afa..d7224db6 100644 --- a/packages/nodes/src/ceiling/floorplan.ts +++ b/packages/nodes/src/ceiling/floorplan.ts @@ -1,15 +1,29 @@ -import type { CeilingNode, FloorplanGeometry, FloorplanPoint } from '@pascal-app/core' +import type { + CeilingNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' /** - * Stage C floor-plan builder for ceiling. Renders the polygon outline - * as a dashed boundary (ceilings are above and would visually obscure - * the slab/walls if drawn solid). Same shape as slab but visually - * distinct. + * Stage C floor-plan builder for ceiling. Dashed boundary (ceilings sit + * above the slab); when selected, mounts the same boundary editor as + * slab — vertex + midpoint + edge handles on the outer ring AND every + * hole, with `holeIndex` carried in the handle payloads. */ -export function buildCeilingFloorplan(node: CeilingNode): FloorplanGeometry | null { +export function buildCeilingFloorplan( + node: CeilingNode, + ctx: GeometryContext, +): FloorplanGeometry | null { const polygon = node.polygon if (!polygon || polygon.length < 3) return null + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + const outer: FloorplanPoint[] = polygon.map(([x, z]) => [x, z] as FloorplanPoint) const ring = (points: FloorplanPoint[]) => { @@ -25,13 +39,71 @@ export function buildCeilingFloorplan(node: CeilingNode): FloorplanGeometry | nu segments.push(ring(hole.map(([x, z]) => [x, z] as FloorplanPoint))) } - return { - kind: 'path', - d: segments.join(' '), - fill: 'none', - stroke: '#94a3b8', - strokeWidth: 0.03, - strokeDasharray: '0.15 0.1', - opacity: 0.7, + const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#94a3b8' + + const children: FloorplanGeometry[] = [ + { + kind: 'path', + d: segments.join(' '), + fill: 'none', + stroke, + strokeWidth: showSelectedChrome ? 0.04 : 0.03, + strokeDasharray: '0.15 0.1', + opacity: showSelectedChrome ? 0.95 : 0.7, + }, + ] + + if (isSelected) { + appendRingEditor(children, polygon, undefined) + holes.forEach((hole, holeIndex) => { + if (hole.length >= 3) appendRingEditor(children, hole, holeIndex) + }) + } + + return { kind: 'group', children } +} + +/** + * Same boundary editor as slab — see `nodes/src/slab/floorplan.ts` for + * the contract. The kinds differ only in their fill / stroke chrome; + * the editor primitives are identical. + */ +function appendRingEditor( + children: FloorplanGeometry[], + ring: ReadonlyArray, + holeIndex: number | undefined, +): void { + for (let i = 0; i < ring.length; i++) { + const a = ring[i]! + const b = ring[(i + 1) % ring.length]! + children.push({ + kind: 'edge-handle', + x1: a[0], + y1: a[1], + x2: b[0], + y2: b[1], + affordance: 'move-edge', + payload: { holeIndex, edgeIndex: i }, + }) + } + for (let i = 0; i < ring.length; i++) { + const a = ring[i]! + const b = ring[(i + 1) % ring.length]! + children.push({ + kind: 'midpoint-handle', + point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], + affordance: 'add-vertex', + payload: { holeIndex, edgeIndex: i }, + }) + } + for (let i = 0; i < ring.length; i++) { + const [x, z] = ring[i]! + children.push({ + kind: 'endpoint-handle', + point: [x, z], + state: 'idle', + affordance: 'move-vertex', + payload: { holeIndex, vertexIndex: i }, + }) } } diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index b1a42538..5dc30346 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -11,8 +11,9 @@ import { } from '@pascal-app/core' import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type * as THREE from 'three' +import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' /** * Phase 5 Stage D — ceiling whole-move tool. @@ -57,6 +58,24 @@ function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number, height: nu if (mesh) mesh.position.set(deltaX, height - 0.01, deltaZ) } +/** + * Distinguish 3D-canvas grid events (this tool) from 2D floor-plan + * grid events (`ceilingFloorplanMoveTarget` + `FloorplanRegistryMoveOverlay` + * Path 1). See the equivalent helper in `slab/move-tool.tsx` for the + * full rationale. + */ +function isFloorplanSourcedEvent(event: GridEvent): boolean { + const native: unknown = event.nativeEvent + const candidate = + (native as { target?: unknown; nativeEvent?: { target?: unknown } } | null) ?? null + const target = + (candidate?.target as Element | null | undefined) ?? + (candidate?.nativeEvent as { target?: Element | null } | undefined)?.target ?? + null + if (!target || typeof (target as Element).closest !== 'function') return false + return (target as Element).closest('[data-floorplan-scene]') != null +} + export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const activatedAtRef = useRef(Date.now()) const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) @@ -112,6 +131,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { + if (isFloorplanSourcedEvent(event)) return const localX = snap(event.localPosition[0]) const localZ = snap(event.localPosition[2]) @@ -130,6 +150,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { } const onGridClick = (event: GridEvent) => { + if (isFloorplanSourcedEvent(event)) return if (Date.now() - activatedAtRef.current < 150) { event.nativeEvent?.stopPropagation?.() return @@ -176,11 +197,126 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { } }, [exitMoveMode, node.id]) + return ( + + ) +} + +/** + * Translucent fill + bright outline showing where the ceiling will land + * during the drag. Mirrors the legacy `MoveCeilingTool` overlay so the + * 3D viewer has a visible cue from above (the ceiling's child grid mesh + * is hidden by default — without this preview the only mesh that shows + * from above is the (translated) translucent ceiling itself, which is + * easy to miss). Works for both the 3D `grid:move` path (this tool + * writes `useLiveTransforms.position = [Δx, 0, Δz]` directly) and the + * 2D floor-plan move path (`slab/ceiling/floorplan-move.ts` writes the + * same value); we subscribe to that store so the preview tracks the + * current delta regardless of which mover is driving it. + */ +function CeilingMovePreview({ + ceilingId, + cursorLocalPos, + height, + originalHoles, + originalPolygon, +}: { + ceilingId: AnyNodeId + cursorLocalPos: [number, number, number] + height: number + originalHoles: Array> + originalPolygon: Array<[number, number]> +}) { + const live = useLiveTransforms((s) => s.get(ceilingId)) + const dx = live?.position[0] ?? 0 + const dz = live?.position[2] ?? 0 + + const previewPolygon = useMemo( + () => originalPolygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + [originalPolygon, dx, dz], + ) + const previewHoles = useMemo( + () => originalHoles.map((hole) => hole.map(([x, z]) => [x + dx, z + dz] as [number, number])), + [originalHoles, dx, dz], + ) + + const previewFillGeometry = useMemo( + () => createCeilingPreviewGeometry(previewPolygon, previewHoles), + [previewPolygon, previewHoles], + ) + const previewOutlineGeometry = useMemo( + () => createCeilingOutlineGeometry(previewPolygon), + [previewPolygon], + ) + return ( + + + + {/* @ts-ignore - `` is a valid R3F intrinsic but conflicts with SVG line typing */} + + + ) } +function createCeilingPreviewGeometry( + polygon: Array<[number, number]>, + holes: Array>, +): BufferGeometry { + if (polygon.length < 3) return new BufferGeometry() + + const shape = new Shape() + const first = polygon[0]! + shape.moveTo(first[0], -first[1]) + for (let i = 1; i < polygon.length; i++) { + const pt = polygon[i]! + shape.lineTo(pt[0], -pt[1]) + } + shape.closePath() + + for (const holePolygon of holes) { + if (holePolygon.length < 3) continue + const hole = new Path() + const hf = holePolygon[0]! + hole.moveTo(hf[0], -hf[1]) + for (let i = 1; i < holePolygon.length; i++) { + const pt = holePolygon[i]! + hole.lineTo(pt[0], -pt[1]) + } + hole.closePath() + shape.holes.push(hole) + } + + const geometry = new ShapeGeometry(shape) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + return geometry +} + +function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry { + const geometry = new BufferGeometry() + if (polygon.length < 2) return geometry + const points = polygon.map(([x, z]) => new Vector3(x, 0, z)) + const first = polygon[0]! + points.push(new Vector3(first[0], 0, first[1])) + geometry.setFromPoints(points) + return geometry +} + export default MoveCeilingTool diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 13be02ef..339560a5 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -1,15 +1,106 @@ 'use client' -import { CeilingRenderer } from '@pascal-app/viewer' +import { + type CeilingNode, + getMaterialPresetByRef, + resolveMaterial, + useRegistry, +} from '@pascal-app/core' +import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import { BufferGeometry, Float32BufferAttribute } from 'three' +import { float, mix, positionWorld, smoothstep } from 'three/tsl' +import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' + +function createEmptyGeometry() { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([], 3)) + return geometry +} + +const gridScale = 5 +const gridX = positionWorld.x.mul(gridScale).fract() +const gridY = positionWorld.z.mul(gridScale).fract() +const lineWidth = 0.05 +const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) +const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) +const gridPattern = lineX.max(lineY) +const gridOpacity = mix(float(0.2), float(0.6), gridPattern) + +function createCeilingMaterials(color = '#999999') { + const topMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthWrite: false, + side: FrontSide, + }) + topMaterial.opacityNode = gridOpacity + + const bottomMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + side: BackSide, + }) + + return { topMaterial, bottomMaterial } +} + +const ceilingMaterialCache = new Map>() + +function getCeilingMaterials(color = '#999999') { + const cacheKey = color + const cached = ceilingMaterialCache.get(cacheKey) + if (cached) return cached + + const materials = createCeilingMaterials(color) + ceilingMaterialCache.set(cacheKey, materials) + return materials +} + +export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { + const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyGeometry, []) + const gridPlaceholderGeometry = useMemo(createEmptyGeometry, []) + + useRegistry(node.id, 'ceiling', ref) + const handlers = useNodeEvents(node, 'ceiling') + + useEffect( + () => () => { + placeholderGeometry.dispose() + gridPlaceholderGeometry.dispose() + }, + [gridPlaceholderGeometry, placeholderGeometry], + ) + + const materials = useMemo(() => { + const preset = getMaterialPresetByRef(node.materialPreset) + const props = preset?.mapProperties ?? resolveMaterial(node.material) + const color = props.color || '#999999' + return getCeilingMaterials(color) + }, [ + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + ]) + + return ( + + + {node.children.map((childId) => ( + + ))} + + ) +} -/** - * Wrap-export of the legacy `CeilingRenderer`. - * - * Ceiling's renderer uses TSL shader code for the grid-line pattern - * (~100 lines incl. material setup) — too much to duplicate at Stage A. - * The legacy file stays in viewer; the registry imports it through the - * public export. Phase 5 Stage B/F (per-kind migration stages, see - * plans/editor-node-registry.md) moves the renderer body into this - * folder and deletes the legacy file. - */ export default CeilingRenderer diff --git a/packages/nodes/src/ceiling/system.tsx b/packages/nodes/src/ceiling/system.tsx index 49dbf3be..99f7d18f 100644 --- a/packages/nodes/src/ceiling/system.tsx +++ b/packages/nodes/src/ceiling/system.tsx @@ -3,13 +3,10 @@ import { CeilingSystem } from '@pascal-app/viewer' /** - * Registry-driven ceiling system bundle. Re-exports the legacy - * `CeilingSystem` so it mounts via `RegisteredSystems` when ceiling is - * registry-driven. `` in viewer/components/ - * viewer/index.tsx short-circuits whenever `nodeRegistry.has('ceiling')` - * is true — same shape wall / fence / slab use. + * Registry-driven ceiling system bundle. Wraps `CeilingSystem` so it + * mounts via `RegisteredSystems`. * - * Future Phase 5+: extract polygon triangulation + hole CSG into a pure + * Future: extract polygon triangulation + hole CSG into a pure * `buildCeilingGeometry(node)` and migrate to `def.geometry`. */ const CeilingSystems = () => { diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts new file mode 100644 index 00000000..ec9ab2e4 --- /dev/null +++ b/packages/nodes/src/column/definition.ts @@ -0,0 +1,64 @@ +import { ColumnNode as ColumnNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { buildColumnFloorplan } from './floorplan' +import { columnParametrics } from './parametrics' +import { ColumnNode } from './schema' + +/** + * Column — Stage A registration. Wrap-export of the legacy + * `ColumnRenderer` (no system — column geometry is computed inline in + * the renderer). Inspector / move / floorplan still go through legacy + * paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx + * (their hardcoded `case 'column':` entries fire before the registry + * fallback). + * + * Capabilities: column doesn't declare `movable` because its move is + * bespoke (legacy MoveColumnTool snaps to slab + free placement on + * the X/Z plane with rotation). + * + * Defaults computed via stub-parse so we leverage every zod + * `.default()` annotation on the schema (~60 fields). + */ +export const columnDefinition: NodeDefinition = { + kind: 'column', + schemaVersion: 1, + schema: ColumnNode, + category: 'structure', + + defaults: () => { + const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: columnParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + // Stage D — 3D move-tool (registry-driven). Replaces the legacy + // `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap + + // live-transform preview the legacy used. + affordanceTools: { + move: () => import('./move-tool'), + }, + floorplan: buildColumnFloorplan, + + presentation: { + label: 'Column', + description: 'A parametric column with configurable cross-section, base, and capital.', + icon: { kind: 'url', src: '/icons/column.png' }, + paletteSection: 'structure', + paletteOrder: 70, + }, + + mcp: { + description: 'A parametric column placed on a slab or level.', + }, +} diff --git a/packages/nodes/src/column/floorplan.ts b/packages/nodes/src/column/floorplan.ts new file mode 100644 index 00000000..7b21bc61 --- /dev/null +++ b/packages/nodes/src/column/floorplan.ts @@ -0,0 +1,185 @@ +import type { + ColumnNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' + +/** + * Stage C floor-plan builder for column. Inlined from the legacy + * `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The + * footprint shape depends on `crossSection` (square / rectangular / + * round / octagonal / sixteen-sided) and `supportStyle` (vertical / + * a-frame / x-brace / etc.) — brace supports use a rotated rectangle + * spanning the base spread; standalone columns use the shaft profile. + * + * When selected, switches to a themed accent stroke and emits a move + * handle at the column center. No dimension overlay (columns don't + * have a natural "length" axis like a wall). + */ +export function buildColumnFloorplan( + node: ColumnNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const polygon = getColumnPlanFootprint(node) + if (polygon.length < 3) return null + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + + const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151' + const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af' + + const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint) + + const children: FloorplanGeometry[] = [ + { + kind: 'polygon', + points, + fill, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.02, + opacity: 0.92, + }, + ] + + // Hatch overlay on selected — same `` pattern as the wall. + if (isSelected && palette) { + children.push({ + kind: 'hatch', + points, + color: palette.selectedHatch, + opacity: 0.7, + }) + } + + // Move handle at the column center when selected. + if (isSelected) { + children.push({ + kind: 'move-handle', + point: [node.position[0], node.position[2]], + }) + } + + return { kind: 'group', children } +} + +// ── Inlined helpers from legacy floorplan-panel.tsx ─────────────────── + +type PlanPoint = { x: number; y: number } + +function rotatePlanVector(x: number, y: number, rotation: number): [number, number] { + const c = Math.cos(rotation) + const s = Math.sin(rotation) + return [x * c - y * s, x * s + y * c] +} + +function getRotatedRectanglePolygon( + center: PlanPoint, + width: number, + depth: number, + rotation: number, +): PlanPoint[] { + const halfW = width / 2 + const halfD = depth / 2 + const corners: Array<[number, number]> = [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] + return corners.map(([x, y]) => { + const [rx, ry] = rotatePlanVector(x, y, rotation) + return { x: center.x + rx, y: center.y + ry } + }) +} + +function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] { + const center: PlanPoint = { x: column.position[0], y: column.position[2] } + + // Brace-support columns: rotated rectangle spanning the base spread. + if ( + column.supportStyle === 'a-frame' || + column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ) { + const width = Math.max( + column.supportStyle === 'a-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? (column.braceBottomSpread ?? 1.2) + : 0, + column.braceTopSpread ?? + (column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? 1 + : 0), + (column.braceWidth ?? column.width) * 2, + ) + const depth = Math.max( + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'box-frame' + ? (column.braceTopSpread ?? 1) + : 0, + column.braceDepth ?? column.depth, + 0.08, + ) + return getRotatedRectanglePolygon(center, width, depth, column.rotation) + } + + // Standalone column: shaft profile expanded for base + capital. + const isRound = + column.crossSection === 'round' || + column.crossSection === 'octagonal' || + column.crossSection === 'sixteen-sided' + const shaftWidth = isRound ? column.radius * 2 : column.width + const shaftDepth = isRound ? column.radius * 2 : column.depth + const width = Math.max( + shaftWidth, + column.width * column.baseWidthScale, + column.width * column.capitalWidthScale, + ) + const depth = Math.max( + shaftDepth, + column.depth * column.baseDepthScale, + column.depth * column.capitalDepthScale, + ) + + if (column.crossSection === 'square' || column.crossSection === 'rectangular') { + return getRotatedRectanglePolygon(center, width, depth, column.rotation) + } + + const segmentCount = + column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32 + + return Array.from({ length: segmentCount }, (_, index) => { + const angle = (index / segmentCount) * Math.PI * 2 + const localX = Math.cos(angle) * (width / 2) + const localY = Math.sin(angle) * (depth / 2) + const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation) + return { x: center.x + offsetX, y: center.y + offsetY } + }) +} diff --git a/packages/nodes/src/column/index.ts b/packages/nodes/src/column/index.ts new file mode 100644 index 00000000..e3839c25 --- /dev/null +++ b/packages/nodes/src/column/index.ts @@ -0,0 +1 @@ +export { columnDefinition } from './definition' diff --git a/packages/editor/src/components/tools/column/move-column-tool.tsx b/packages/nodes/src/column/move-tool.tsx similarity index 71% rename from packages/editor/src/components/tools/column/move-column-tool.tsx rename to packages/nodes/src/column/move-tool.tsx index ae02e102..cedd62a2 100644 --- a/packages/editor/src/components/tools/column/move-column-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -1,24 +1,36 @@ -import '../../../three-types' +'use client' import { type AnyNodeId, - ColumnNode, - type ColumnNode as ColumnNodeType, + type ColumnNode, + ColumnNode as ColumnNodeSchema, emitter, type GridEvent, sceneRegistry, useLiveTransforms, useScene, } from '@pascal-app/core' +import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' +/** + * Phase 5 Stage D — column's registry-driven 3D move affordance. + * + * Replaces the legacy `MoveColumnTool` in `editor/src/components/tools/ + * column/move-column-tool.tsx`. Behaviour is identical: grid:move + * snaps the cursor to a 0.5m grid and previews the column at that + * position via `useLiveTransforms` + a direct `sceneRegistry.nodes.get + * (id).position.set(...)` (the live-drag exception documented in + * `wiki/architecture/tools.md`); grid:click commits via `useScene. + * updateNode`. Cancel restores the pre-drag position. + * + * Wired via `def.affordanceTools.move`. The editor's `MoveTool` + * dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup + * picks this up before its legacy chain reaches ``. + */ const roundToHalf = (value: number) => Math.round(value * 2) / 2 -export function MoveColumnTool({ node }: { node: ColumnNodeType }) { +function MoveColumnTool({ node }: { node: ColumnNode }) { const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) const exitMoveMode = useCallback(() => { @@ -48,7 +60,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { 0, roundToHalf(event.localPosition[2]), ] - const nodeId = (node as { id?: ColumnNodeType['id'] }).id + const nodeId = (node as { id?: ColumnNode['id'] }).id if (nodeId && useScene.getState().nodes[nodeId]) { committed = true @@ -56,7 +68,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { useScene.temporal.getState().resume() useScene.getState().updateNode(nodeId, { position }) } else if (node.parentId) { - const column = ColumnNode.parse({ + const column = ColumnNodeSchema.parse({ ...node, id: undefined, metadata: {}, @@ -68,7 +80,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { } useLiveTransforms.getState().clear(node.id) - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -103,3 +115,5 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { return } + +export default MoveColumnTool diff --git a/packages/editor/src/components/ui/panels/column-panel.tsx b/packages/nodes/src/column/panel.tsx similarity index 68% rename from packages/editor/src/components/ui/panels/column-panel.tsx rename to packages/nodes/src/column/panel.tsx index 5398953e..e5a19f02 100644 --- a/packages/editor/src/components/ui/panels/column-panel.tsx +++ b/packages/nodes/src/column/panel.tsx @@ -7,17 +7,20 @@ import { type ColumnPresetId, useScene, } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + cn, + PanelSection, + PanelWrapper, + SliderControl, + ToggleControl, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { cn } from '../../../lib/utils' -import useEditor from '../../../store/use-editor' -import { ActionButton, ActionGroup } from '../controls/action-button' -import { PanelSection } from '../controls/panel-section' -import { SliderControl } from '../controls/slider-control' -import { ToggleControl } from '../controls/toggle-control' -import { PanelWrapper } from './panel-wrapper' const SELECT_CLASS = 'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border' @@ -178,7 +181,7 @@ function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial< } } -export function ColumnPanel() { +export default function ColumnPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) @@ -204,14 +207,14 @@ export function ColumnPanel() { const handleDelete = useCallback(() => { if (!selectedId) return - sfxEmitter.emit('sfx:structure-delete') + triggerSFX('sfx:structure-delete') deleteNode(selectedId as AnyNode['id']) setSelection({ selectedIds: [] }) }, [deleteNode, selectedId, setSelection]) const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) @@ -488,9 +491,7 @@ export function ColumnPanel() { { - const capitalStyle = event.target.value as ColumnNode['capitalStyle'] - handleUpdate({ - capitalStyle, - ...(capitalStyle === 'none' - ? {} - : { - capitalHeight: Math.max(node.capitalHeight, 0.12), - capitalTierCount: - capitalStyle === 'stepped' - ? Math.max(node.capitalTierCount ?? 3, 3) - : node.capitalTierCount, - capitalWidthScale: Math.max( - node.capitalWidthScale ?? 1.3, - capitalStyle === 'stepped' ? 1.42 : 1.28, - ), - capitalDepthScale: Math.max( - node.capitalDepthScale ?? 1.3, - capitalStyle === 'stepped' ? 1.42 : 1.28, - ), - capitalStepSpread: - capitalStyle === 'stepped' - ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) - : node.capitalStepSpread, - }), - }) - }} - value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')} - > - - - - - - {node.capitalStyle !== 'none' && ( - handleUpdate({ capitalHeight: value })} - precision={2} - step={0.02} - unit="m" - value={node.capitalHeight} - /> - )} - {node.capitalStyle !== 'none' && ( - + { - const baseStyle = event.target.value as ColumnNode['baseStyle'] - handleUpdate({ - baseStyle, - ...(baseStyle === 'none' - ? {} - : { - baseHeight: Math.max(node.baseHeight, 0.12), - baseTierCount: - baseStyle === 'stepped-square' - ? Math.max(node.baseTierCount ?? 3, 3) - : node.baseTierCount, - baseWidthScale: Math.max( - node.baseWidthScale ?? 1.24, - baseStyle === 'stepped-square' ? 1.42 : 1.24, - ), - baseDepthScale: Math.max( - node.baseDepthScale ?? 1.24, - baseStyle === 'stepped-square' ? 1.42 : 1.24, - ), - baseStepSpread: - baseStyle === 'stepped-square' - ? Math.max(node.baseStepSpread ?? 0.34, 0.34) - : node.baseStepSpread, - basePlinthHeightRatio: - baseStyle === 'round-rings' - ? (node.basePlinthHeightRatio ?? 0.44) - : node.basePlinthHeightRatio, - baseRoundBandScale: - baseStyle === 'round-rings' - ? (node.baseRoundBandScale ?? 0.92) - : node.baseRoundBandScale, - baseNeckScale: - baseStyle === 'round-rings' - ? (node.baseNeckScale ?? 0.72) - : node.baseNeckScale, - }), - }) - }} - value={node.baseStyle ?? 'square-plinth'} - > - - - - - - - {node.baseStyle !== 'none' && ( - handleUpdate({ baseHeight: value })} - precision={2} - step={0.02} - unit="m" - value={node.baseHeight} - /> - )} - {node.baseStyle !== 'none' && ( - + }} + value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')} + > + + + + + + {node.capitalStyle !== 'none' && ( + handleUpdate({ capitalHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.capitalHeight} + /> + )} + {node.capitalStyle !== 'none' && ( + + handleUpdate({ + capitalWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ capitalDepthScale: value })} + precision={2} + step={0.02} + value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.capitalTierCount ?? 3} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalStepSpread: value })} + precision={2} + step={0.01} + value={node.capitalStepSpread ?? 0.34} + /> + )} + + {node.baseStyle !== 'none' && ( + handleUpdate({ baseHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.baseHeight} + /> + )} + {node.baseStyle !== 'none' && ( + + handleUpdate({ + baseWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ baseDepthScale: value })} + precision={2} + step={0.02} + value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ basePlinthHeightRatio: value })} + precision={2} + step={0.01} + value={node.basePlinthHeightRatio ?? 0.44} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseRoundBandScale: value })} + precision={2} + step={0.01} + value={node.baseRoundBandScale ?? 0.92} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseNeckScale: value })} + precision={2} + step={0.01} + value={node.baseNeckScale ?? 0.72} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.baseTierCount ?? 3} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseStepSpread: value })} + precision={2} + step={0.01} + value={node.baseStepSpread ?? 0.34} + /> + )} )} diff --git a/packages/nodes/src/column/parametrics.ts b/packages/nodes/src/column/parametrics.ts new file mode 100644 index 00000000..30ac0aaa --- /dev/null +++ b/packages/nodes/src/column/parametrics.ts @@ -0,0 +1,24 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { ColumnNode } from './schema' + +/** + * Stage A inspector — minimal. Column has 60+ schema fields (cross- + * section, shaft profile, capital style, base style, carvings, ring + * placement, etc.); the legacy `` renders these via + * panel-manager's hardcoded switch. The descriptor below registers + * the kind as "has parametric data" without trying to express the + * full legacy panel — Stage E will replace it via `customPanel`. + */ +export const columnParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'height', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 }, + { key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 }, + ], + }, + ], + customPanel: () => import('./panel'), +} diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/nodes/src/column/renderer.tsx similarity index 99% rename from packages/viewer/src/components/renderers/column/column-renderer.tsx rename to packages/nodes/src/column/renderer.tsx index e90e02d1..8f1704f0 100644 --- a/packages/viewer/src/components/renderers/column/column-renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -1,14 +1,18 @@ +'use client' + import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' -import { createContext, useContext, useMemo, useRef } from 'react' -import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' -import { useNodeEvents } from '../../../hooks/use-node-events' -import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import { + baseMaterial, createColumnBoxGeometry, createColumnCylinderGeometry, createColumnSphereGeometry, createColumnTorusGeometry, -} from '../../../systems/column/column-geometry' + createMaterial, + createMaterialFromPresetRef, + useNodeEvents, +} from '@pascal-app/viewer' +import { createContext, useContext, useMemo, useRef } from 'react' +import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' const ColumnMaterialContext = createContext(baseMaterial as Material) const ColumnEdgeSoftnessContext = createContext(0.025) @@ -2165,3 +2169,5 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => { ) } + +export default ColumnRenderer diff --git a/packages/nodes/src/column/schema.ts b/packages/nodes/src/column/schema.ts new file mode 100644 index 00000000..6bd6e6a5 --- /dev/null +++ b/packages/nodes/src/column/schema.ts @@ -0,0 +1 @@ +export { ColumnNode } from '@pascal-app/core' diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index dadbd77d..49595e52 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -1,5 +1,6 @@ import type { NodeDefinition } from '@pascal-app/core' import { buildDoorFloorplan } from './floorplan' +import { doorFloorplanMoveTarget } from './floorplan-move' import { doorParametrics } from './parametrics' import { DoorNode } from './schema' @@ -58,6 +59,23 @@ export const doorDefinition: NodeDefinition = { // Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute // direction + perpendicular for the cutout footprint. floorplan: buildDoorFloorplan, + // Stage D — placement (`def.tool`) + move-on-wall (`def. + // affordanceTools.move`). Both ports of the legacy tools at + // `editor/components/tools/door/`, relocated into the kind folder and + // wired through ToolManager's registry-first dispatch (`def.tool` for + // build-mode placement, `getRegistryAffordanceTool` for the move-on- + // pick flow). Same legacy semantics: wall-event-driven snap, clamped + // wall-local coords, hasWallChildOverlap guard, live mesh updates. + tool: () => import('./tool'), + affordanceTools: { + move: () => import('./move-tool'), + }, + // 2D move-on-floorplan handler. When `useEditor.movingNode` is a + // door and the floor plan is active, `FloorplanRegistryMoveOverlay` + // dispatches to this instead of the generic translate path — pointer + // snaps to the nearest wall, projects onto the wall axis, snaps + // local-X to 0.5m, clamps inside wall bounds. + floorplanMoveTarget: doorFloorplanMoveTarget, toolHints: [ { key: 'Left click', label: 'Place door on wall' }, @@ -67,7 +85,7 @@ export const doorDefinition: NodeDefinition = { presentation: { label: 'Door', description: 'A door cut into a wall. Animated open/close state.', - icon: { kind: 'iconify', name: 'lucide:door-open' }, + icon: { kind: 'url', src: '/icons/door.png' }, paletteSection: 'structure', paletteOrder: 50, }, diff --git a/packages/editor/src/components/tools/door/door-math.ts b/packages/nodes/src/door/door-math.ts similarity index 100% rename from packages/editor/src/components/tools/door/door-math.ts rename to packages/nodes/src/door/door-math.ts diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts new file mode 100644 index 00000000..d95ab68b --- /dev/null +++ b/packages/nodes/src/door/floorplan-move.ts @@ -0,0 +1,87 @@ +import { + type AnyNodeId, + type DoorNode, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + useScene, +} from '@pascal-app/core' +import { snapToHalf } from '@pascal-app/editor' +import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { clampToWall, hasWallChildOverlap } from './door-math' + +/** + * 2D floor-plan move handler for door — kicks in when the user clicks + * "Move" on the door inspector (or action menu) and the floor-plan + * view is active. Pointer in plan space → snap to nearest wall → + * project onto wall axis → snap local-X to 0.5m grid → clamp inside + * wall bounds → commit via `useScene.updateNodes`. + * + * Mirrors the 3D `move-tool.tsx` behaviour minus the R3F event plumbing: + * - Re-parents on transition between walls (parentId + wallId). + * - Adapts `side` + `rotation` from the wall normal under the pointer. + * - hasWallChildOverlap blocks committing overlapping placements. + * + * Curved walls are skipped by `findClosestWallInPlan` — same guardrail + * as the 3D port and the legacy `DoorTool` / `MoveDoorTool`. + */ + +export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + // Snapshot of the door's "valid" state at move-start — used by + // canCommit to decide whether the current snapped position is OK. + const startLevelId = (() => { + // Walk up via parentId until we hit a node whose type isn't 'wall' + // — that's the level (or null). The door is wall-hosted, so the + // wall's parent is the level. Cached at start because the parent + // chain doesn't change during a move. + const wall = useScene.getState().nodes[node.parentId as AnyNodeId] + return wall ? (wall.parentId as AnyNodeId | null) : null + })() + + const session: FloorplanMoveTargetSession = { + affectedIds: [node.id as AnyNodeId], + apply({ planPoint, modifiers }) { + const nodes = useScene.getState().nodes + const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) + if (!hit) return // pointer off any wall — keep door at last valid position + + // Snap the wall-local X to 0.5m grid (Shift bypasses). + const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX) + const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) + + // Build the updates atomically — position + rotation + side + + // parentId + wallId in a single scene write. The current door's + // parent might be a different wall; re-anchoring requires moving + // the node in the parent's children list (the registry's + // updateNode does this when parentId changes). + useScene.getState().updateNodes([ + { + id: node.id as AnyNodeId, + data: { + position: [clampedX, clampedY, 0], + rotation: [0, hit.itemRotation, 0], + side: hit.side, + parentId: hit.wall.id, + wallId: hit.wall.id, + }, + }, + ]) + }, + canCommit() { + const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined + if (!live || live.type !== 'door') return false + // Block commit if the door overlaps any other wall child at its + // current position. The 3D port has the same guard. + const overlapping = hasWallChildOverlap( + live.parentId as string, + live.position[0], + live.position[1], + live.width, + live.height, + live.id, + ) + return !overlapping + }, + } + + return session +} diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts index 5f58e4c0..94fa2131 100644 --- a/packages/nodes/src/door/floorplan.ts +++ b/packages/nodes/src/door/floorplan.ts @@ -5,18 +5,31 @@ import type { GeometryContext, WallNode, } from '@pascal-app/core' +import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions' /** - * Stage C floor-plan builder for door. Doors render as a small polygon - * sitting in the wall's cutout — width = door.width along the wall - * direction, depth = wall.thickness perpendicular. + * Stage C floor-plan builder for door. 1:1 visual port of the legacy + * floorplan-panel door rendering: + * + * 1. The door footprint rectangle in the wall cutout (themed + * accent stroke when selected). + * 2. The door swing arc — a quarter-circle from the hinge to the + * door's open position, modulated by `swingAngle`, `hingesSide`, + * and `swingDirection`. Renders as a wedge of low-opacity fill so + * the swept area reads at a glance. + * 3. The door leaf — a thick line from the hinge to the open + * position, terminating at the arc end. + * 4. Center line through the cutout (matches the legacy's + * `getOpeningCenterLine` segment for visual continuity). * * Requires `ctx.parent` to be a wall (door.parentId is the wall it's * mounted on). Returns null when the parent isn't a wall (orphaned * doors during placement etc.). * - * Inlined from the legacy `getOpeningFootprint` helper in - * floorplan-panel.tsx. Window's builder is structurally identical. + * Skipped vs the full legacy for now: hinge / strike cubes (small + * indicator squares at the rotation pivots), rounded-opening shape + * variants, panic bar markers. Those are rare visual variations the + * follow-up port can revisit. */ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null { const wall = ctx.parent as WallNode | null @@ -31,10 +44,11 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp const dirX = dx / length const dirZ = dz / length + // Perpendicular unit normal (rotate 90° CCW). const perpX = -dirZ const perpZ = dirX - const distance = node.position[0] // door's local X = distance along wall + const distance = node.position[0] const width = node.width const depth = wall.thickness ?? 0.1 const cx = x1 + dirX * distance @@ -42,6 +56,18 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp const halfWidth = width / 2 const halfDepth = depth / 2 + const isPlanFlipped = isOpeningPlanFlipped(node.rotation) + const baseHingesSide = node.hingesSide ?? 'left' + const baseSwingDirection = node.swingDirection ?? 'inward' + const hingesSide = isPlanFlipped ? (baseHingesSide === 'left' ? 'right' : 'left') : baseHingesSide + const swingDirection = isPlanFlipped + ? baseSwingDirection === 'inward' + ? 'outward' + : 'inward' + : baseSwingDirection + const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) + + // Footprint rectangle in the cutout. const points: readonly FloorplanPoint[] = [ [cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth], [cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth], @@ -49,12 +75,138 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp [cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth], ] - return { - kind: 'polygon', - points, - fill: '#f8fafc', - stroke: '#374151', - strokeWidth: 0.015, - opacity: 0.95, + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Match the legacy floor-plan door render: unselected is a quiet + // grey accent so the door reads as a hole in the wall, selected is + // a full orange treatment (body + outline) so the user can see at + // a glance which door is targeted by the inspector / move handle. + const accentColor = showSelectedChrome ? '#f97316' : 'rgba(100, 116, 139, 0.82)' + const accentMuted = accentColor + const fillColor = showSelectedChrome ? '#fed7aa' : '#ffffff' + + const children: FloorplanGeometry[] = [ + // Background — the cutout is filled white so the swing arc sits on + // a clean canvas (the wall hatch shows through otherwise). + { + kind: 'polygon', + points, + fill: fillColor, + stroke: accentMuted, + strokeWidth: showSelectedChrome ? 2 : 1.25, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }, + ] + + // Swing geometry. The hinge sits at one end of the door along the + // wall direction; the strike sits at the opposite end. The leaf + // rotates around the hinge by `swingAngle` toward the inward / + // outward side of the wall. + const hingeTangentSign = hingesSide === 'left' ? 1 : -1 + const swingSign = swingDirection === 'inward' ? 1 : -1 + const hingeX = cx - dirX * halfWidth * hingeTangentSign + const hingeZ = cz - dirZ * halfWidth * hingeTangentSign + // Closed leaf vector points from hinge to strike (along the wall). + const closedLeafX = dirX * width * hingeTangentSign + const closedLeafZ = dirZ * width * hingeTangentSign + + if (swingAngle > 1e-3 && width > 1e-3) { + // Rotate the closed leaf vector by `swingAngle * swingSign * + // hingeTangentSign` around the hinge to get the open leaf tip. + const angle = swingAngle * swingSign * hingeTangentSign + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const openLeafX = closedLeafX * cos - closedLeafZ * sin + const openLeafZ = closedLeafX * sin + closedLeafZ * cos + const tipX = hingeX + openLeafX + const tipZ = hingeZ + openLeafZ + + // Closed leaf tip — where the leaf would land if fully closed. + const closedTipX = hingeX + closedLeafX + const closedTipZ = hingeZ + closedLeafZ + + // Swing arc — a path from closed tip to open tip via an arc + // centered at the hinge. SVG's A command takes rx ry rotation + // large-arc-flag sweep-flag x y. Sweep flag flips based on the + // signed angle direction. + const sweepFlag = angle >= 0 ? 1 : 0 + const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}` + + // Swept wedge fill (light, low opacity) — gives the door a + // visible "this is the open zone" treatment. + children.push({ + kind: 'path', + d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath + .replace(/^M [^A]+/, '') + .trim()} Z`, + fill: accentColor, + fillOpacity: showSelectedChrome ? 0.08 : 0.05, + stroke: 'none', + }) + + // The arc itself, stroked. + children.push({ + kind: 'path', + d: arcPath, + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.6 : 1.1, + strokeOpacity: 0.85, + vectorEffect: 'non-scaling-stroke', + strokeLinecap: 'round', + }) + + // The door leaf — line from hinge to the open tip. + children.push({ + kind: 'line', + x1: hingeX, + y1: hingeZ, + x2: tipX, + y2: tipZ, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2.4 : 1.7, + strokeLinecap: 'round', + vectorEffect: 'non-scaling-stroke', + }) } + + // Move handle — orange dot at the door center. Only visible when + // selected. Pointer-down on this triggers `setMovingNode(door)` + // → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`. + if (isSelected) { + children.push({ + kind: 'move-handle', + point: [cx, cz], + }) + } + + // Placement-measurement dimensions — distances to adjacent openings + // (or wall ends) on each side. Only visible while actively moving + // (the user clicked Move or grabbed the orange dot). + if (view?.moving) { + for (const dim of buildOpeningPlacementDimensions(node, ctx)) { + children.push(dim) + } + } + + return { kind: 'group', children } +} + +/** + * The opening's wall-normal orientation is encoded in the door's Y + * rotation. When the door faces "inward" along an angle in [π/2, 3π/2], + * the rendering needs the hinge side + swing direction flipped to + * keep the visual swing on the correct side of the wall. + * + * Mirrors `isOpeningPlanFlipped` in `floorplan-panel.tsx`. + */ +function isOpeningPlanFlipped(rotation: readonly [number, number, number]): boolean { + const normalized = + ((((rotation[1] % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) + 1e-6) % (Math.PI * 2) + return normalized > Math.PI / 2 && normalized < (Math.PI * 3) / 2 } diff --git a/packages/editor/src/components/tools/door/move-door-tool.tsx b/packages/nodes/src/door/move-tool.tsx similarity index 97% rename from packages/editor/src/components/tools/door/move-door-tool.tsx rename to packages/nodes/src/door/move-tool.tsx index ee1a80c3..feb38d2b 100644 --- a/packages/editor/src/components/tools/door/move-door-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -9,20 +9,20 @@ import { useScene, type WallEvent, } from '@pascal-app/core' +import { + calculateCursorRotation, + calculateItemRotation, + EDITOR_LAYER, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { - calculateCursorRotation, - calculateItemRotation, - getSideFromNormal, - isValidWallSideFace, - snapToHalf, -} from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' const edgeMaterial = new LineBasicNodeMaterial({ @@ -32,7 +32,7 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { +const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(null!) const exitMoveMode = useCallback(() => { @@ -310,7 +310,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod useLiveTransforms.getState().clear(movingDoorNode.id) useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') hideCursor() useViewer.getState().setSelection({ selectedIds: [placedId] }) exitMoveMode() @@ -410,3 +410,5 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod ) } + +export default MoveDoorTool diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/nodes/src/door/panel.tsx similarity index 92% rename from packages/editor/src/components/ui/panels/door-panel.tsx rename to packages/nodes/src/door/panel.tsx index 599d1e21..c6f82e4e 100644 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -8,21 +8,23 @@ import { useInteractive, useScene, } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + cn, + PanelSection, + PanelWrapper, + PresetsPopover, + SegmentedControl, + SliderControl, + ToggleControl, + triggerSFX, + useEditor, + usePresetsAdapter, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' -import { usePresetsAdapter } from '../../../contexts/presets-context' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { cn } from '../../../lib/utils' -import useEditor from '../../../store/use-editor' -import { ActionButton, ActionGroup } from '../controls/action-button' -import { MetricControl } from '../controls/metric-control' -import { PanelSection } from '../controls/panel-section' -import { SegmentedControl } from '../controls/segmented-control' -import { SliderControl } from '../controls/slider-control' -import { ToggleControl } from '../controls/toggle-control' -import { PanelWrapper } from './panel-wrapper' -import { PresetsPopover } from './presets/presets-popover' const doorTypeOptions = [ { label: 'Hinged', value: 'hinged', available: true }, @@ -106,7 +108,7 @@ function isSameDoorValue(current: unknown, next: unknown): boolean { return Object.is(current, next) } -export function DoorPanel() { +export default function DoorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) @@ -189,7 +191,9 @@ export function DoorPanel() { } previewRef.current = null - useScene.getState().updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + useScene + .getState() + .updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) scene.dirtyNodes.add(selectedId as AnyNodeId) }, [selectedId], @@ -209,14 +213,14 @@ export function DoorPanel() { const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return - sfxEmitter.emit('sfx:item-delete') + triggerSFX('sfx:item-delete') deleteNode(selectedId as AnyNode['id']) if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) setSelection({ selectedIds: [] }) @@ -224,7 +228,7 @@ export function DoorPanel() { const handleDuplicate = useCallback(() => { if (!node?.parentId) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') useScene.temporal.getState().pause() const cloned = structuredClone(node) as any delete cloned.id @@ -985,75 +989,75 @@ export function DoorPanel() { /> - {!isGarageDoor && ( - - handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} - precision={3} - step={0.005} - unit="m" - value={Math.round(node.contentPadding[0] * 1000) / 1000} - /> - handleUpdate({ contentPadding: [node.contentPadding[0], v] })} - precision={3} - step={0.005} - unit="m" - value={Math.round(node.contentPadding[1] * 1000) / 1000} - /> - - )} - - {isSwingDoor && ( - -
- {supportsHingeSide && ( -
- - Hinges Side - - handleUpdate({ hingesSide: v })} - options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - ]} - value={node.hingesSide} - /> -
- )} -
- - Direction - - handleUpdate({ swingDirection: v })} - options={[ - { label: 'Inward', value: 'inward' }, - { label: 'Outward', value: 'outward' }, - ]} - value={node.swingDirection} + {!isGarageDoor && ( + + handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.contentPadding[0] * 1000) / 1000} /> -
-
-
- )} + handleUpdate({ contentPadding: [node.contentPadding[0], v] })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.contentPadding[1] * 1000) / 1000} + /> + + )} - {isSwingDoor && ( - - handleUpdate({ threshold: checked })} - /> - {node.threshold && ( -
+ {isSwingDoor && ( + +
+ {supportsHingeSide && ( +
+ + Hinges Side + + handleUpdate({ hingesSide: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + value={node.hingesSide} + /> +
+ )} +
+ + Direction + + handleUpdate({ swingDirection: v })} + options={[ + { label: 'Inward', value: 'inward' }, + { label: 'Outward', value: 'outward' }, + ]} + value={node.swingDirection} + /> +
+
+
+ )} + + {isSwingDoor && ( + + handleUpdate({ threshold: checked })} + /> + {node.threshold && ( +
` has - * 29 SliderControls covering segments, hardware, hinges, panic bar, - * opening shape, etc. — too elaborate for the auto-inspector at Stage A. - * Legacy panel keeps rendering via the hardcoded `case 'door':` in - * panel-manager.tsx. This descriptor only exposes the simple dimension - * fields so the registry knows door has parametric data. Phase 5 Stage E - * (drop legacy panel) will extend this — likely via - * `parametrics.customPanel?` since door has too much non-numeric UI - * (segmented controls, presets) to fit the generic auto-UI. + * Stage E inspector for door. Mounts the kind-owned panel + * (`panel.tsx`) via `customPanel` — door has 29+ controls (segments, + * hardware, hinges, panic bar, opening shape, etc.) that can't fit + * into the generic auto-inspector. The `groups` entries stay populated + * so the registry still considers door "parametric" (for tooling that + * lists kinds with editable schema). */ export const doorParametrics: ParametricDescriptor = { groups: [ @@ -29,4 +26,5 @@ export const doorParametrics: ParametricDescriptor = { ], }, ], + customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 519fa4c5..17cb3c3e 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -1,11 +1,36 @@ 'use client' -import { DoorRenderer } from '@pascal-app/viewer' +import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' +import { useNodeEvents } from '@pascal-app/viewer' +import { useLayoutEffect, useRef } from 'react' +import { type Mesh, MeshBasicMaterial } from 'three' + +const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) + +export const DoorRenderer = ({ node }: { node: DoorNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'door', ref) + useLayoutEffect(() => { + useScene.getState().markDirty(node.id) + }, [node.id]) + const handlers = useNodeEvents(node, 'door') + const isTransient = !!(node.metadata as Record | null)?.isTransient + + return ( + + + + ) +} -/** - * Wrap-export of the legacy `DoorRenderer`. The renderer is 33 lines - * (thin placeholder + register + dirty-on-mount) — could be duplicated - * but at Stage A re-export is sufficient. Phase 5 Stage F will inline - * it here and delete the viewer-side file. - */ export default DoorRenderer diff --git a/packages/nodes/src/door/system.tsx b/packages/nodes/src/door/system.tsx index c03a1cb0..a8abd140 100644 --- a/packages/nodes/src/door/system.tsx +++ b/packages/nodes/src/door/system.tsx @@ -3,7 +3,7 @@ import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer' /** - * Registry-driven door system bundle. Door has TWO per-frame systems: + * Registry-driven door system bundle. * * - **`DoorSystem`** — rebuilds frame / leaf / glass / hardware * geometry from `dirtyNodes`. Cascades dirty to the parent wall so @@ -13,14 +13,9 @@ import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer' * folding) at frame priority 2, then marks the door dirty so the * geometry system rebuilds at priority 3. * - * Both are wrapped in `` at the legacy mount - * point; with door registered, those wrappers short-circuit and this - * bundle takes over. - * - * Future Phase 5 Stage B: extract the geometry into a pure - * `buildDoorGeometry(node, ctx)` and migrate to `def.geometry`. The - * animation system stays as `def.system` (it's a real per-frame - * concern, not a geometry build). + * Future: extract the geometry into a pure `buildDoorGeometry(node, ctx)` + * and migrate to `def.geometry`. The animation system stays as + * `def.system` (it's a real per-frame concern, not a geometry build). */ const DoorSystems = () => { return ( diff --git a/packages/editor/src/components/tools/door/door-tool.tsx b/packages/nodes/src/door/tool.tsx similarity index 97% rename from packages/editor/src/components/tools/door/door-tool.tsx rename to packages/nodes/src/door/tool.tsx index 76da29e7..69c0ce87 100644 --- a/packages/editor/src/components/tools/door/door-tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -8,19 +8,19 @@ import { useScene, type WallEvent, } from '@pascal-app/core' +import { + calculateCursorRotation, + calculateItemRotation, + EDITOR_LAYER, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, + triggerSFX, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { - calculateCursorRotation, - calculateItemRotation, - getSideFromNormal, - isValidWallSideFace, - snapToHalf, -} from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' const edgeMaterial = new LineBasicNodeMaterial({ @@ -34,7 +34,7 @@ const edgeMaterial = new LineBasicNodeMaterial({ * Door tool — places DoorNodes on walls only. * Doors always sit at floor level (clampedY = height/2). */ -export const DoorTool: React.FC = () => { +const DoorTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) const edgesRef = useRef(null!) @@ -273,7 +273,7 @@ export const DoorTool: React.FC = () => { useScene.getState().createNode(node, event.node.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') event.stopPropagation() } @@ -322,3 +322,5 @@ export const DoorTool: React.FC = () => { ) } + +export default DoorTool diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts new file mode 100644 index 00000000..7ca27966 --- /dev/null +++ b/packages/nodes/src/elevator/definition.ts @@ -0,0 +1,54 @@ +import { ElevatorNode as ElevatorNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { buildElevatorFloorplan } from './floorplan' +import { elevatorParametrics } from './parametrics' +import { ElevatorNode } from './schema' + +/** + * Elevator — Stage A registration. Wrap-exports the legacy renderer + + * the three legacy systems (runtime / interaction / opening) bundled + * as one `def.system`. Move / inspector still go through legacy + * (`MoveElevatorTool`, ``) via panel-manager's + * hardcoded switch. + */ +export const elevatorDefinition: NodeDefinition = { + kind: 'elevator', + schemaVersion: 1, + schema: ElevatorNode, + category: 'structure', + + defaults: () => { + const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: elevatorParametrics, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + system: { + module: () => import('./system'), + priority: 3, + }, + floorplan: buildElevatorFloorplan, + + presentation: { + label: 'Elevator', + description: 'A multi-level elevator shaft with configurable openings per level.', + icon: { kind: 'url', src: '/icons/wallcut.png' }, + paletteSection: 'structure', + paletteOrder: 80, + }, + + mcp: { + description: 'A multi-level elevator with shaft + openings per level.', + }, +} diff --git a/packages/nodes/src/elevator/floorplan.ts b/packages/nodes/src/elevator/floorplan.ts new file mode 100644 index 00000000..8fdfa492 --- /dev/null +++ b/packages/nodes/src/elevator/floorplan.ts @@ -0,0 +1,313 @@ +import { + type AnyNodeId, + type ElevatorNode, + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + resolveElevatorServiceLevelIds, + useInteractive, + useLiveNodeOverrides, +} from '@pascal-app/core' + +/** + * Stage C floor-plan emitter for elevator. Renders: + * + * - **Outer shaft footprint** — rotated rectangle (cab + wall thickness). + * - **Cab indicator** — inner rectangle showing the cab's position within + * the shaft. Highlighted when `runtime.currentLevelId` matches the + * active level (i.e. the car is *on this floor*). + * - **Door opening indicator** — a short marker on the front face + * spanning `doorWidth` so users can see which way the doors open. + * - **Selection / target / queued chrome** — selection stroke when + * the elevator is selected, accent stroke when the runtime targets + * this level (cab is travelling here) or this level is queued. + * + * Reads the elevator's live state via `useLiveNodeOverrides.getState()` + * (inspector edits) and `useInteractive.getState().elevators[id]` + * (runtime cab travel). Those reads are non-reactive on their own — + * `FloorplanRegistryLayer` subscribes to both stores so the layer + * re-renders when they change, propagating into this builder. + * + * Per-level served-level chips (the small floor-label badges on each + * shaft side) are not emitted yet — they need an HTML-overlay primitive + * in `FloorplanGeometry` to render properly (SVG `` rotates with + * the plan, which mangles label legibility). Tracked as follow-up; the + * legacy `` still renders the chips for + * pre-registry builds while we figure out the right primitive shape. + */ + +const STAGE_LEVEL_FILTER_HIDE = true + +export function buildElevatorFloorplan( + node: ElevatorNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + // Merge in any live overrides (inspector edits not yet committed). + const overrides = useLiveNodeOverrides.getState().get(node.id) + const display: ElevatorNode = overrides ? ({ ...node, ...overrides } as ElevatorNode) : node + + // Service-level gate. If the active level isn't one the elevator + // serves, render nothing — legacy behaviour. The level id comes via + // `ctx.parent` (the elevator's parent in the tree is the level it's + // hosted on, which is the active level when the registry layer walks + // from `levelId`). + const parentLevelId = ctx.parent?.id + if (STAGE_LEVEL_FILTER_HIDE && parentLevelId) { + const sceneNodes = collectAllNodes(ctx) + const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes) + if (!serviceLevelIds.includes(parentLevelId as AnyNodeId)) { + return null + } + } + + const wallThickness = Math.max(display.shaftWallThickness ?? 0.09, 0.04) + const cabWidth = Math.max(display.width, 0.8) + const cabDepth = Math.max(display.depth, 0.8) + const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8) + const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8) + const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18) + const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) + const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness) + + const center = { x: display.position[0], y: display.position[2] } + const cos = Math.cos(display.rotation) + const sin = Math.sin(display.rotation) + const rotate = (lx: number, ly: number): [number, number] => { + // Same clockwise convention as `rotatePlanVector` in editor — see + // `wiki/architecture/tools.md` for why every plan-space rotation + // uses this matrix and not the standard counter-clockwise one. + return [lx * cos + ly * sin, -lx * sin + ly * cos] + } + + // Outer shaft footprint corners. + const outerCorners: Array = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => { + const [rx, ry] = rotate(lx, ly) + return [center.x + rx, center.y + ry] + }) + + // Cab inner rectangle. The cab sits flush against the front face + // (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2` + // away from shaft center. + const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2 + const cabHalfW = cabWidth / 2 + const cabHalfD = cabDepth / 2 + const cabCorners: Array = [ + [-cabHalfW, cabCenterLocalY - cabHalfD], + [cabHalfW, cabCenterLocalY - cabHalfD], + [cabHalfW, cabCenterLocalY + cabHalfD], + [-cabHalfW, cabCenterLocalY + cabHalfD], + ] + const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => { + const [rx, ry] = rotate(lx, ly) + return [center.x + rx, center.y + ry] + }) + + // Runtime state — current level / target level / queued. + const runtime = useInteractive.getState().elevators[node.id] + const isCarOnLevel = parentLevelId ? runtime?.currentLevelId === parentLevelId : false + const isTargetLevel = parentLevelId ? runtime?.targetLevelId === parentLevelId : false + const isQueuedLevel = parentLevelId + ? (runtime?.queue.includes(parentLevelId as never) ?? false) + : false + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Stroke selection — selected wins, then runtime target / queued + // states get the accent palette colour so users can spot "the cab is + // coming here" at a glance. + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isTargetLevel || isQueuedLevel + ? '#0ea5e9' + : '#475569' + // Shaft fill — orange when selected, light slate otherwise. When the + // car is *on this level*, the cab indicator inside gets the highlight + // instead of the whole shaft (more legible). + const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1' + const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0' + const cabStroke = isCarOnLevel ? '#15803d' : '#475569' + + const children: FloorplanGeometry[] = [] + + // Outer shaft. + children.push({ + kind: 'polygon', + points: outerPoints, + fill: shaftFill, + stroke, + strokeWidth: showSelectedChrome ? 0.04 : 0.03, + strokeLinejoin: 'round', + opacity: 0.85, + }) + + // Cab inner rectangle. + children.push({ + kind: 'polygon', + points: cabPoints, + fill: cabFill, + fillOpacity: isCarOnLevel ? 0.85 : 0.55, + stroke: cabStroke, + strokeWidth: 0.018, + strokeLinejoin: 'round', + opacity: 0.92, + }) + + // Door opening indicator — a short line on the front edge centered + // on the cab. The legacy renders a more complex slide / center-open + // hint; this is the minimum useful signal. + const doorY = -halfDepth + const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY) + const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY) + children.push({ + kind: 'line', + x1: center.x + doorStartX, + y1: center.y + doorStartY, + x2: center.x + doorEndX, + y2: center.y + doorEndY, + stroke: isCarOnLevel ? '#15803d' : '#0f172a', + strokeWidth: 0.05, + strokeLinecap: 'round', + opacity: 0.92, + }) + + // Served-level chips — vertical column of marker circles + level + // numbers to the right of the shaft, only when selected and the + // elevator serves more than one level. Mirrors the legacy + // `` chip rendering (~line 6423 in + // floorplan-panel.tsx). + if (isSelected && parentLevelId) { + const sceneNodes = collectAllNodes(ctx) + const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes) + if (serviceLevelIds.length > 1) { + const disabledLevelIds = new Set(display.disabledLevelIds ?? []) + const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? []) + const rangeStep = 0.18 + const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep) + const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0) + const rangeX = center.x + rangeOffsetX + const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2 + const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2 + + // Connector spine — single vertical line tying the chips to the + // shaft. Sky blue, semi-transparent. + children.push({ + kind: 'line', + x1: rangeX, + y1: rangeTopY, + x2: rangeX, + y2: rangeBottomY, + stroke: '#0ea5e9', + strokeOpacity: 0.52, + strokeWidth: 0.018, + strokeLinecap: 'round', + vectorEffect: 'non-scaling-stroke', + }) + + // One chip per served level. Lowest level at the bottom of the + // column, index increases upward — matches legacy ordering. + serviceLevelIds.forEach((levelId, index) => { + const isCurrent = runtime?.currentLevelId === levelId + const isTarget = runtime?.targetLevelId === levelId + // `resolveElevatorServiceLevelIds` returns plain `string[]`, but + // the runtime queue is `AnyNodeId[]` (branded). The values agree + // at runtime — narrowing through `as never` keeps the includes + // call type-safe without dragging the brand into the helper's + // public return type. + const isQueued = runtime?.queue.includes(levelId as never) ?? false + const isDisabled = disabledLevelIds.has(levelId) + const isServiceOnly = serviceOnlyLevelIds.has(levelId) + const isUnavailable = isDisabled || isServiceOnly + + const markerFill = isCurrent + ? '#22c55e' + : isTarget || isQueued + ? '#38bdf8' + : isUnavailable + ? '#94a3b8' + : '#ffffff' + const markerStroke = isUnavailable ? '#64748b' : '#0369a1' + const labelColor = isUnavailable ? '#64748b' : '#075985' + const y = rangeBottomY - index * rangeStep + + children.push({ + kind: 'circle', + cx: rangeX, + cy: y, + r: 0.055, + fill: markerFill, + fillOpacity: isUnavailable ? 0.72 : 0.95, + stroke: markerStroke, + strokeWidth: 0.012, + }) + children.push({ + kind: 'text', + x: rangeX + 0.11, + y, + text: String(index + 1), + fontSize: 0.13, + fontWeight: 700, + fill: labelColor, + textAnchor: 'start', + dominantBaseline: 'middle', + }) + }) + } + } + + if (isSelected) { + children.push({ + kind: 'move-handle', + point: [display.position[0], display.position[2]], + }) + } + + return { kind: 'group', children } +} + +/** + * `ctx` exposes `resolve` and `children` / `siblings` / `parent`, but + * not the full nodes map. `resolveElevatorServiceLevelIds` wants a + * `Record`; we rebuild it by walking the chain we DO have + * access to. For the elevator's service-level check we only need the + * elevator's parent (the level), its building, and any level siblings. + * This is the minimum graph the resolver needs. + * + * If a future use needs the full nodes map for a builder, we'd surface + * it through ctx — but doing so leaks the whole scene store into every + * `def.floorplan` call. Narrow opt-in is the better default. + */ +function collectAllNodes(ctx: GeometryContext): Record { + // We need the building → levels graph for service-level resolution. + // Walk up from the elevator: parent (level) → its parent (building) → + // building.children (all levels). That's enough for the resolver. + const out: Record = {} + const level = ctx.parent + if (level) { + out[level.id] = level + const building = (level as { parentId?: string }).parentId + ? ctx.resolve((level as { parentId: string }).parentId as never) + : undefined + if (building) { + out[building.id] = building + const childIds = (building as unknown as { children?: string[] }).children + if (Array.isArray(childIds)) { + for (const cid of childIds) { + const child = ctx.resolve(cid as never) + if (child) out[child.id] = child + } + } + } + } + return out as Record +} diff --git a/packages/nodes/src/elevator/index.ts b/packages/nodes/src/elevator/index.ts new file mode 100644 index 00000000..3ea05436 --- /dev/null +++ b/packages/nodes/src/elevator/index.ts @@ -0,0 +1 @@ +export { elevatorDefinition } from './definition' diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/nodes/src/elevator/panel.tsx similarity index 97% rename from packages/editor/src/components/ui/panels/elevator-panel.tsx rename to packages/nodes/src/elevator/panel.tsx index 02bbc4aa..47c254b0 100644 --- a/packages/editor/src/components/ui/panels/elevator-panel.tsx +++ b/packages/nodes/src/elevator/panel.tsx @@ -12,18 +12,22 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + MetricControl, + PanelSection, + PanelWrapper, + resolveElevatorNodeSupportY, + resolveElevatorSupportY, + SliderControl, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Send, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useShallow } from 'zustand/react/shallow' -import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { ActionButton, ActionGroup } from '../controls/action-button' -import { MetricControl } from '../controls/metric-control' -import { PanelSection } from '../controls/panel-section' -import { SliderControl } from '../controls/slider-control' -import { PanelWrapper } from './panel-wrapper' function findLevelId(levels: LevelNode[], levelId: string | null | undefined) { if (!levelId) return null @@ -153,7 +157,7 @@ function degreesToRadians(degrees: number) { return (degrees * Math.PI) / 180 } -export function ElevatorPanel() { +export default function ElevatorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) @@ -302,7 +306,7 @@ export function ElevatorPanel() { const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') clearLivePreview() setMovingNode(node) setSelection({ selectedIds: [] }) @@ -310,7 +314,7 @@ export function ElevatorPanel() { const handleDuplicate = useCallback(() => { if (!(node && node.parentId)) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') const duplicate = ElevatorNodeSchema.parse({ ...structuredClone(node), @@ -328,7 +332,7 @@ export function ElevatorPanel() { const handleDelete = useCallback(() => { if (!(selectedId && node)) return - sfxEmitter.emit('sfx:structure-delete') + triggerSFX('sfx:structure-delete') clearLivePreview() useScene.getState().deleteNode(selectedId as AnyNodeId) setSelection({ selectedIds: [] }) @@ -442,7 +446,11 @@ export function ElevatorPanel() { ) const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id)) const defaultLevelOptions = - enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels + enabledServedLevels.length > 0 + ? enabledServedLevels + : servedLevels.length > 0 + ? servedLevels + : levels const selectedDefaultLevelId = defaultLevelOptions.some( (level) => level.id === node.defaultLevelId, ) @@ -570,14 +578,14 @@ export function ElevatorPanel() { { - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation - Math.PI / 4) }} /> { - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') commitTransform(displayPosition, displayRotation + Math.PI / 4) }} /> @@ -753,9 +761,7 @@ export function ElevatorPanel() {