@pascal-app/editor 0.8.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +7 -7
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +332 -0
- package/src/lib/scene.ts +40 -8
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +39 -0
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import '../../../three-types'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type AlignmentGuide,
|
|
7
|
+
type AnyNode,
|
|
8
|
+
type AnyNodeId,
|
|
9
|
+
analyzePortConnectivity,
|
|
10
|
+
bboxCornerAnchors,
|
|
11
|
+
collectAlignmentAnchors,
|
|
12
|
+
createSceneApi,
|
|
13
|
+
type EventSuffix,
|
|
14
|
+
emitter,
|
|
15
|
+
footprintAABBFrom,
|
|
16
|
+
type GridEvent,
|
|
17
|
+
getFloorPlacedFootprints,
|
|
18
|
+
movingFootprintAnchors,
|
|
19
|
+
type NodeEvent,
|
|
20
|
+
nodeRegistry,
|
|
21
|
+
type ParentFrameSnapMatch,
|
|
22
|
+
type PortConnectivity,
|
|
23
|
+
resolveAlignment,
|
|
24
|
+
resolveConnectivityUpdates,
|
|
25
|
+
resolveFacingIndicator,
|
|
26
|
+
sceneRegistry,
|
|
27
|
+
spatialGridManager,
|
|
28
|
+
useLiveNodeOverrides,
|
|
29
|
+
useLiveTransforms,
|
|
30
|
+
useScene,
|
|
31
|
+
} from '@pascal-app/core'
|
|
32
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
33
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
34
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
35
|
+
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
|
|
36
|
+
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
|
37
|
+
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
|
38
|
+
import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
|
|
39
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
40
|
+
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
|
41
|
+
|
|
42
|
+
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
|
43
|
+
import useEditor, {
|
|
44
|
+
getActiveSnappingMode,
|
|
45
|
+
isAlignmentGuideActive,
|
|
46
|
+
isGridSnapActive,
|
|
47
|
+
isMagneticSnapActive,
|
|
48
|
+
} from '../../../store/use-editor'
|
|
49
|
+
|
|
50
|
+
import useFacingPose from '../../../store/use-facing-pose'
|
|
51
|
+
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
|
52
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
53
|
+
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
|
54
|
+
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
55
|
+
import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
|
|
56
|
+
import { PlacementBox } from '../shared/placement-box'
|
|
57
|
+
|
|
58
|
+
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
|
59
|
+
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
|
|
60
|
+
const snapToGridStep = (value: number) => {
|
|
61
|
+
if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value
|
|
62
|
+
const step = useEditor.getState().gridSnapStep
|
|
63
|
+
return Math.round(value / step) * step
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 45° steps, matching the GLB item placement rotation. */
|
|
67
|
+
const ROTATION_STEP = Math.PI / 4
|
|
68
|
+
|
|
69
|
+
/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
|
|
70
|
+
const PORT_SNAP_RADIUS_M = 0.5
|
|
71
|
+
const VALID_COLOR = 0x22_c5_5e
|
|
72
|
+
const INVALID_COLOR = 0xef_44_44
|
|
73
|
+
|
|
74
|
+
type DragBoundsOverride = {
|
|
75
|
+
size: [number, number, number]
|
|
76
|
+
center?: [number, number, number]
|
|
77
|
+
centerY?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function offsetPlanPositionByLocalCenter(
|
|
81
|
+
position: [number, number, number],
|
|
82
|
+
center: [number, number, number],
|
|
83
|
+
rotationY: number,
|
|
84
|
+
): [number, number, number] {
|
|
85
|
+
const cos = Math.cos(rotationY)
|
|
86
|
+
const sin = Math.sin(rotationY)
|
|
87
|
+
return [
|
|
88
|
+
position[0] + center[0] * cos + center[2] * sin,
|
|
89
|
+
position[1] + center[1],
|
|
90
|
+
position[2] - center[0] * sin + center[2] * cos,
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Alignment anchors for the moving node. When the kind declares
|
|
96
|
+
* `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
|
|
97
|
+
* run whose modules extend past the node origin), the anchors come from that
|
|
98
|
+
* declared box instead of the origin-centred footprint.
|
|
99
|
+
*/
|
|
100
|
+
function movingDragBoundsAnchors(
|
|
101
|
+
node: AnyNode,
|
|
102
|
+
bounds: DragBoundsOverride | null,
|
|
103
|
+
x: number,
|
|
104
|
+
z: number,
|
|
105
|
+
rotationY: number,
|
|
106
|
+
) {
|
|
107
|
+
if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY)
|
|
108
|
+
const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY)
|
|
109
|
+
const aabb = footprintAABBFrom(center, bounds.size, rotationY)
|
|
110
|
+
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide {
|
|
114
|
+
return {
|
|
115
|
+
axis: match.axis,
|
|
116
|
+
coord: match.axis === 'x' ? match.from.x : match.from.z,
|
|
117
|
+
from: match.from,
|
|
118
|
+
to: match.to,
|
|
119
|
+
anchor: match.from,
|
|
120
|
+
movingAnchorKind: 'corner',
|
|
121
|
+
candidateAnchorKind: 'corner',
|
|
122
|
+
candidateNodeId: match.candidateNodeId,
|
|
123
|
+
distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Magnetic port snap for a dragged node: if one of the node's own ports
|
|
129
|
+
* (read live from `def.ports`) lands within `radius` of a matching scene
|
|
130
|
+
* port at the candidate XZ, return the node XZ that mates them exactly.
|
|
131
|
+
*
|
|
132
|
+
* Pure core: ports come through `nodeRegistry` so this stays layer-clean.
|
|
133
|
+
* Ports are level-local meters — the same frame as the cursor's
|
|
134
|
+
* `localPosition`, so no extra transform is needed. The dragged node's
|
|
135
|
+
* ports move rigidly with its position, so a port at candidate `(x,z)`
|
|
136
|
+
* sits at `portStored + (candidate - nodeStored)`. We pick the closest
|
|
137
|
+
* (own-port, target-port) pair and shift the node so they coincide in XZ.
|
|
138
|
+
*/
|
|
139
|
+
function resolvePortSnap(
|
|
140
|
+
node: AnyNode,
|
|
141
|
+
candidate: [number, number],
|
|
142
|
+
config: { systems?: readonly string[]; radius?: number },
|
|
143
|
+
): [number, number] | null {
|
|
144
|
+
const nodePos = (node as { position?: [number, number, number] }).position
|
|
145
|
+
if (!nodePos) return null
|
|
146
|
+
const ownPorts = nodeRegistry.get(node.type)?.ports?.(node)
|
|
147
|
+
if (!ownPorts || ownPorts.length === 0) return null
|
|
148
|
+
|
|
149
|
+
const radius = config.radius ?? PORT_SNAP_RADIUS_M
|
|
150
|
+
const radiusSq = radius * radius
|
|
151
|
+
const { systems } = config
|
|
152
|
+
const dragDx = candidate[0] - nodePos[0]
|
|
153
|
+
const dragDz = candidate[1] - nodePos[2]
|
|
154
|
+
|
|
155
|
+
const nodes = useScene.getState().nodes
|
|
156
|
+
let bestDistSq = radiusSq
|
|
157
|
+
let snap: [number, number] | null = null
|
|
158
|
+
|
|
159
|
+
for (const node2 of Object.values(nodes)) {
|
|
160
|
+
if (!node2 || node2.id === node.id) continue
|
|
161
|
+
const targets = nodeRegistry.get(node2.type)?.ports?.(node2)
|
|
162
|
+
if (!targets) continue
|
|
163
|
+
for (const target of targets) {
|
|
164
|
+
if (systems && target.system !== undefined && !systems.includes(target.system)) continue
|
|
165
|
+
for (const own of ownPorts) {
|
|
166
|
+
// Own port at the candidate position = stored port + drag delta.
|
|
167
|
+
const ownX = own.position[0] + dragDx
|
|
168
|
+
const ownZ = own.position[2] + dragDz
|
|
169
|
+
const dx = target.position[0] - ownX
|
|
170
|
+
const dz = target.position[2] - ownZ
|
|
171
|
+
const distSq = dx * dx + dz * dz
|
|
172
|
+
if (distSq <= bestDistSq) {
|
|
173
|
+
bestDistSq = distSq
|
|
174
|
+
// Shift the node so this own port lands on the target (XZ only).
|
|
175
|
+
snap = [candidate[0] + dx, candidate[1] + dz]
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return snap
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
|
184
|
+
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
|
|
185
|
+
* without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
|
|
186
|
+
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Generic move tool for any registry-backed kind.
|
|
190
|
+
*
|
|
191
|
+
* Imperative-only motion during drag:
|
|
192
|
+
* - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
|
|
193
|
+
* directly. The node's store data is unchanged → the renderer doesn't
|
|
194
|
+
* re-render → R3F doesn't reapply `position={node.position}` → the
|
|
195
|
+
* imperative mutation sticks. Movement is smooth, framerate-locked,
|
|
196
|
+
* and React-free.
|
|
197
|
+
*
|
|
198
|
+
* Store update happens only on commit (single undoable action).
|
|
199
|
+
*
|
|
200
|
+
* Cancel imperatively snaps the mesh back to its original position and
|
|
201
|
+
* resumes history without ever having touched the store mid-drag.
|
|
202
|
+
*
|
|
203
|
+
* **Commit triggers**: the tool listens for `grid:click` *and* the
|
|
204
|
+
* common node click events (shelf / item / slab / ceiling / wall /
|
|
205
|
+
* fence / column / roof / stair). A click on the grid plane fires
|
|
206
|
+
* `grid:click`; a click on the moved node itself (or any other 3D
|
|
207
|
+
* geometry the ray happens to land on) fires the corresponding node
|
|
208
|
+
* click event. Without the node-click listeners, clicking on the
|
|
209
|
+
* cursor's own mesh during a move would silently drop the commit —
|
|
210
|
+
* the user perceives "click did nothing" because the click hit the
|
|
211
|
+
* vertical face of e.g. a shelf instead of the grid plane below it.
|
|
212
|
+
*
|
|
213
|
+
* The latest cursor position from `grid:move` is stored in a ref so
|
|
214
|
+
* any of these click variants commit at the same spot the cursor was
|
|
215
|
+
* indicating.
|
|
216
|
+
*/
|
|
217
|
+
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
|
|
218
|
+
|
|
219
|
+
const CLICK_TRIGGER_KINDS = [
|
|
220
|
+
'shelf',
|
|
221
|
+
'item',
|
|
222
|
+
'slab',
|
|
223
|
+
'ceiling',
|
|
224
|
+
'wall',
|
|
225
|
+
'fence',
|
|
226
|
+
'column',
|
|
227
|
+
'roof',
|
|
228
|
+
'roof-segment',
|
|
229
|
+
'stair',
|
|
230
|
+
'stair-segment',
|
|
231
|
+
] as const
|
|
232
|
+
|
|
233
|
+
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
234
|
+
// Kinds whose `position` lives in a host parent's local frame declare
|
|
235
|
+
// `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
|
|
236
|
+
// plan-frame cursor through the capability's hooks and previews via
|
|
237
|
+
// `useLiveNodeOverrides` so the parent's composite geometry re-flows.
|
|
238
|
+
const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null
|
|
239
|
+
const frameParent = useMemo(
|
|
240
|
+
() => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null,
|
|
241
|
+
[parentFrame, node],
|
|
242
|
+
)
|
|
243
|
+
const originalPosition: [number, number, number] = useMemo(
|
|
244
|
+
() =>
|
|
245
|
+
'position' in node && Array.isArray((node as { position?: unknown }).position)
|
|
246
|
+
? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
|
|
247
|
+
: [0, 0, 0],
|
|
248
|
+
[node],
|
|
249
|
+
)
|
|
250
|
+
/**
|
|
251
|
+
* Y-axis rotation of the node at move-start. Captured so the
|
|
252
|
+
* imperative drag preview (and the `useLiveTransforms` mirror) keeps
|
|
253
|
+
* the original orientation — otherwise hardcoding `rotation: 0` in
|
|
254
|
+
* `useLiveTransforms.set` would override `node.rotation[1]` during
|
|
255
|
+
* the drag, the shelf would visually un-rotate to 0, then snap back
|
|
256
|
+
* to its true rotation on commit (when the live transform clears).
|
|
257
|
+
* The user reads that snap as "reverts to a weird position".
|
|
258
|
+
*/
|
|
259
|
+
const originalRotationY: number = useMemo(() => {
|
|
260
|
+
if ('rotation' in node) {
|
|
261
|
+
const r = (node as { rotation?: unknown }).rotation
|
|
262
|
+
if (typeof r === 'number') return r
|
|
263
|
+
if (Array.isArray(r)) return (r as [number, number, number])[1] ?? 0
|
|
264
|
+
}
|
|
265
|
+
return 0
|
|
266
|
+
}, [node])
|
|
267
|
+
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
|
|
268
|
+
const previousSnapRef = useRef<string | null>(null)
|
|
269
|
+
/**
|
|
270
|
+
* The latest snapped cursor position from `grid:move`. We commit at
|
|
271
|
+
* THIS position regardless of which event variant fires the click —
|
|
272
|
+
* a `grid:click` carries the same coords, but a node-click (e.g.
|
|
273
|
+
* `shelf:click`) carries the hit point on the clicked node's mesh,
|
|
274
|
+
* which can be slightly off-cursor when the user clicks the vertical
|
|
275
|
+
* face of the moved node itself. Reading from the ref keeps the
|
|
276
|
+
* commit position consistent with the visible cursor.
|
|
277
|
+
*/
|
|
278
|
+
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
|
|
279
|
+
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
280
|
+
/**
|
|
281
|
+
* Becomes true on the first `grid:move` after this move arms. Commits are
|
|
282
|
+
* ignored until then so a click that *armed* this move (e.g. the trailing
|
|
283
|
+
* `click` event of the click that just committed the previous copy, when a
|
|
284
|
+
* preset placement immediately re-arms the next one) can't auto-drop a
|
|
285
|
+
* second copy at the spot. Every real placement moves the cursor into
|
|
286
|
+
* position before the drop click, so this never blocks a legitimate commit.
|
|
287
|
+
*/
|
|
288
|
+
const hasMovedRef = useRef(false)
|
|
289
|
+
// Live Y-rotation during the drag, seeded from the node's current rotation
|
|
290
|
+
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
|
291
|
+
// and committed to the scene on drop.
|
|
292
|
+
const rotationRef = useRef(originalRotationY)
|
|
293
|
+
// Snapshot of which ducts / fittings are mated to this node's ports at
|
|
294
|
+
// drag-start (duct fittings only). Drives the "connected ductwork follows"
|
|
295
|
+
// behaviour: connected nodes preview through `useLiveNodeOverrides` during
|
|
296
|
+
// the drag and commit alongside the moved node on drop. Null for kinds with
|
|
297
|
+
// no ports, so every other movable kind is unaffected.
|
|
298
|
+
const connectivityRef = useRef<PortConnectivity | null>(null)
|
|
299
|
+
// Node ids this drag has pushed live overrides onto — cleared on
|
|
300
|
+
// commit / cancel / unmount so a follow-on drag starts clean.
|
|
301
|
+
const overriddenIdsRef = useRef<AnyNodeId[]>([])
|
|
302
|
+
|
|
303
|
+
// Colliding floor kinds (item / shelf / column) show the same green/red
|
|
304
|
+
// footprint box GLB items use (instead of the vertical-arrow cursor) and
|
|
305
|
+
// refuse an invalid drop unless Alt forces it. The gate + footprint both come
|
|
306
|
+
// from the kind's declarative `floorPlaced` capability, so opting a new kind
|
|
307
|
+
// in is just `collides: true` — no change here.
|
|
308
|
+
// Parent-frame kinds skip the world-frame floor-collision box — their
|
|
309
|
+
// position isn't in the level frame the spatial grid indexes.
|
|
310
|
+
const collides =
|
|
311
|
+
!frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
|
|
312
|
+
// Snapshot the scene once at drag-start — bounds depend on `node` (locked
|
|
313
|
+
// for the lifetime of this tool) and any sibling state the kind reads. If a
|
|
314
|
+
// future kind needs live sibling state mid-drag, switch to a subscribed
|
|
315
|
+
// selector; for v1 (elevator shaft, cabinet run) start-time is correct and
|
|
316
|
+
// avoids subscribing the whole `nodes` map.
|
|
317
|
+
const dragBounds = useMemo(
|
|
318
|
+
(): DragBoundsOverride | null =>
|
|
319
|
+
(nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as
|
|
320
|
+
| DragBoundsOverride
|
|
321
|
+
| undefined) ?? null,
|
|
322
|
+
[node],
|
|
323
|
+
)
|
|
324
|
+
// Collision extents: the declared drag bounds (composite kinds — a cabinet
|
|
325
|
+
// run spans its modules) win over the single-node footprint.
|
|
326
|
+
const resolvedFootprint = useMemo(
|
|
327
|
+
() =>
|
|
328
|
+
dragBounds?.size ??
|
|
329
|
+
nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
|
|
330
|
+
null,
|
|
331
|
+
[dragBounds, node],
|
|
332
|
+
)
|
|
333
|
+
const boxDimensions = useMemo(
|
|
334
|
+
() => (collides ? resolvedFootprint : null),
|
|
335
|
+
[collides, resolvedFootprint],
|
|
336
|
+
)
|
|
337
|
+
const [valid, setValid] = useState(true)
|
|
338
|
+
const previewRotationY = useCallback(
|
|
339
|
+
(rotationY = rotationRef.current) =>
|
|
340
|
+
parentFrame && frameParent
|
|
341
|
+
? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
|
|
342
|
+
: rotationY,
|
|
343
|
+
[parentFrame, frameParent],
|
|
344
|
+
)
|
|
345
|
+
const visualPositionFor = useCallback(
|
|
346
|
+
(position: [number, number, number], rotationY = rotationRef.current) => {
|
|
347
|
+
if (parentFrame && frameParent) {
|
|
348
|
+
return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
|
|
349
|
+
}
|
|
350
|
+
return getFloorStackPreviewPosition({
|
|
351
|
+
node,
|
|
352
|
+
position,
|
|
353
|
+
rotation: (() => {
|
|
354
|
+
const r = (node as { rotation?: unknown }).rotation
|
|
355
|
+
return Array.isArray(r)
|
|
356
|
+
? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0]
|
|
357
|
+
: rotationY
|
|
358
|
+
})(),
|
|
359
|
+
})
|
|
360
|
+
},
|
|
361
|
+
[parentFrame, frameParent, node],
|
|
362
|
+
)
|
|
363
|
+
const canonicalPositionFromPlan = useCallback(
|
|
364
|
+
(planX: number, localY: number, planZ: number): [number, number, number] =>
|
|
365
|
+
parentFrame && frameParent
|
|
366
|
+
? parentFrame.planToLocal(frameParent, planX, localY, planZ, useScene.getState().nodes)
|
|
367
|
+
: [planX, localY, planZ],
|
|
368
|
+
[parentFrame, frameParent],
|
|
369
|
+
)
|
|
370
|
+
const originalPlanPosition = useMemo(
|
|
371
|
+
() => visualPositionFor(originalPosition, originalRotationY),
|
|
372
|
+
[originalPosition, originalRotationY, visualPositionFor],
|
|
373
|
+
)
|
|
374
|
+
const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY))
|
|
375
|
+
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
|
|
376
|
+
useFreshPlacementVisibility({ node })
|
|
377
|
+
// Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
|
|
378
|
+
// cursor instead of preserving the grab offset — small connector-like
|
|
379
|
+
// nodes read an offset drag as "lagging behind the mouse".
|
|
380
|
+
const cursorAttached = nodeRegistry.get(node.type)?.capabilities?.movable?.cursorAttached === true
|
|
381
|
+
// Kinds that declare `movable.portSnap` (duct terminals) magnetically
|
|
382
|
+
// mate one of their own ports onto a nearby scene port while dragging —
|
|
383
|
+
// a register collar drops onto a duct run end. Reads `def.ports` through
|
|
384
|
+
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
|
|
385
|
+
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
|
|
386
|
+
// Kind-owned magnetic snap for the generic 3D move path. Cabinets use this
|
|
387
|
+
// to settle a dragged run flush against a wall without forking the move tool.
|
|
388
|
+
const groupMoveSnapConfig =
|
|
389
|
+
nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
|
|
390
|
+
// Mirrors of `valid` / Alt for the event handlers inside the effect, which
|
|
391
|
+
// can't read React state without stale closures.
|
|
392
|
+
const validRef = useRef(true)
|
|
393
|
+
const altRef = useRef(false)
|
|
394
|
+
|
|
395
|
+
const exitMoveMode = useCallback(() => {
|
|
396
|
+
useEditor.getState().setMovingNode(null)
|
|
397
|
+
}, [])
|
|
398
|
+
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
useScene.temporal.getState().pause()
|
|
401
|
+
previousSnapRef.current = null
|
|
402
|
+
dragAnchorRef.current = null
|
|
403
|
+
hasMovedRef.current = false
|
|
404
|
+
rotationRef.current = originalRotationY
|
|
405
|
+
altRef.current = false
|
|
406
|
+
validRef.current = true
|
|
407
|
+
// Re-sync the box transform to the (possibly new) node. `node` changes
|
|
408
|
+
// without this component remounting whenever a positioned preset re-arms a
|
|
409
|
+
// fresh clone after a drop, or the user picks a different catalog tile —
|
|
410
|
+
// and `useState` only honours its initial value, so without this the box
|
|
411
|
+
// would keep the previous clone's rotation/position until the next R/T.
|
|
412
|
+
setCursorRotationY(previewRotationY(originalRotationY))
|
|
413
|
+
lastCursorRef.current = originalPosition
|
|
414
|
+
let committed = false
|
|
415
|
+
const isNew = isFreshPlacement
|
|
416
|
+
|
|
417
|
+
const baseRotation = (node as { rotation?: unknown }).rotation
|
|
418
|
+
const toCommitRotation = (y: number): number | [number, number, number] =>
|
|
419
|
+
Array.isArray(baseRotation)
|
|
420
|
+
? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
|
|
421
|
+
: y
|
|
422
|
+
|
|
423
|
+
const getVisualPosition = visualPositionFor
|
|
424
|
+
const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
|
|
425
|
+
const object = sceneRegistry.nodes.get(node.id)
|
|
426
|
+
if (!object) return
|
|
427
|
+
object.position.set(...position)
|
|
428
|
+
object.rotation.y = rotationY
|
|
429
|
+
}
|
|
430
|
+
const markMovedNodeDirty = () => {
|
|
431
|
+
if (useScene.getState().nodes[node.id]) {
|
|
432
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Connectivity follow (duct fittings): the moved node with its live drag
|
|
437
|
+
// transform, so `def.ports` recomputes for `resolveConnectivityUpdates`.
|
|
438
|
+
// Uses the logical (un-stacked) position + Y rotation that commit writes,
|
|
439
|
+
// not the floor-lifted visual position.
|
|
440
|
+
const buildPreviewNode = (position: [number, number, number], rotationY: number): AnyNode =>
|
|
441
|
+
({
|
|
442
|
+
...(node as Record<string, unknown>),
|
|
443
|
+
position,
|
|
444
|
+
rotation: toCommitRotation(rotationY),
|
|
445
|
+
}) as AnyNode
|
|
446
|
+
|
|
447
|
+
// Resolve the patches that keep connected ductwork attached and preview
|
|
448
|
+
// them through `useLiveNodeOverrides` (transient — no history churn;
|
|
449
|
+
// GeometrySystem merges overrides via getEffectiveNode). Each connected
|
|
450
|
+
// node is re-dirtied so its geometry rebuilds against the new override.
|
|
451
|
+
const previewConnectivity = (position: [number, number, number], rotationY: number) => {
|
|
452
|
+
const connectivity = connectivityRef.current
|
|
453
|
+
if (!connectivity) return
|
|
454
|
+
const updates = resolveConnectivityUpdates(
|
|
455
|
+
connectivity,
|
|
456
|
+
buildPreviewNode(position, rotationY),
|
|
457
|
+
)
|
|
458
|
+
if (updates.length === 0) return
|
|
459
|
+
useLiveNodeOverrides
|
|
460
|
+
.getState()
|
|
461
|
+
.setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
|
|
462
|
+
overriddenIdsRef.current = updates.map((u) => u.id)
|
|
463
|
+
for (const u of updates) {
|
|
464
|
+
if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const clearConnectivityOverrides = () => {
|
|
469
|
+
for (const id of overriddenIdsRef.current) {
|
|
470
|
+
useLiveNodeOverrides.getState().clear(id)
|
|
471
|
+
if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const syncParentFramePreview = (position: [number, number, number]) => {
|
|
476
|
+
if (!frameParent) return
|
|
477
|
+
useLiveNodeOverrides.getState().set(node.id, {
|
|
478
|
+
position,
|
|
479
|
+
rotation: rotationRef.current,
|
|
480
|
+
})
|
|
481
|
+
useScene.getState().markDirty(frameParent.id as AnyNodeId)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const clearParentFramePreview = () => {
|
|
485
|
+
if (!frameParent) return
|
|
486
|
+
useLiveNodeOverrides.getState().clear(node.id)
|
|
487
|
+
useScene.getState().markDirty(frameParent.id as AnyNodeId)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
|
491
|
+
|
|
492
|
+
// Re-run the floor-collision check at the live cursor + rotation and push
|
|
493
|
+
// the result to the box colour. Alt (free place) forces a valid (green)
|
|
494
|
+
// override so the user can drop on top of an existing item on purpose. Only
|
|
495
|
+
// shelves show the box, so this no-ops for every other movable kind.
|
|
496
|
+
const recomputeValidity = () => {
|
|
497
|
+
if (!boxDimensions) return
|
|
498
|
+
if (altRef.current) {
|
|
499
|
+
validRef.current = true
|
|
500
|
+
setValid(true)
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
const levelId = useViewer.getState().selection.levelId ?? node.parentId
|
|
504
|
+
if (!levelId) {
|
|
505
|
+
validRef.current = true
|
|
506
|
+
setValid(true)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
const livePosition = lastCursorRef.current
|
|
510
|
+
const liveRotation = previewRotationY(rotationRef.current)
|
|
511
|
+
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
|
512
|
+
const effectiveNode = {
|
|
513
|
+
...(node as Record<string, unknown>),
|
|
514
|
+
position: livePosition,
|
|
515
|
+
rotation: Array.isArray((node as { rotation?: unknown }).rotation)
|
|
516
|
+
? [
|
|
517
|
+
((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0,
|
|
518
|
+
rotationRef.current,
|
|
519
|
+
((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0,
|
|
520
|
+
]
|
|
521
|
+
: rotationRef.current,
|
|
522
|
+
} as AnyNode
|
|
523
|
+
const footprints = floorPlaced
|
|
524
|
+
? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes })
|
|
525
|
+
: []
|
|
526
|
+
const resolvedFootprints: Array<{
|
|
527
|
+
position: [number, number, number]
|
|
528
|
+
dimensions: [number, number, number]
|
|
529
|
+
rotation: [number, number, number]
|
|
530
|
+
}> = footprints.map((footprint) => ({
|
|
531
|
+
position: footprint.position ?? livePosition,
|
|
532
|
+
dimensions: footprint.dimensions,
|
|
533
|
+
rotation: footprint.rotation,
|
|
534
|
+
}))
|
|
535
|
+
const { valid: placeable } =
|
|
536
|
+
resolvedFootprints.length > 0
|
|
537
|
+
? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id])
|
|
538
|
+
: spatialGridManager.canPlaceOnFloor(
|
|
539
|
+
levelId,
|
|
540
|
+
getVisualPosition(livePosition),
|
|
541
|
+
boxDimensions,
|
|
542
|
+
[0, liveRotation, 0],
|
|
543
|
+
[node.id],
|
|
544
|
+
)
|
|
545
|
+
validRef.current = placeable
|
|
546
|
+
setValid(placeable)
|
|
547
|
+
}
|
|
548
|
+
recomputeValidity()
|
|
549
|
+
|
|
550
|
+
// Disable raycast on the moved node's meshes for the duration of
|
|
551
|
+
// the drag. As the shelf follows the cursor, the cursor ray would
|
|
552
|
+
// otherwise hit the moved mesh first → only `${kind}:move` fires →
|
|
553
|
+
// `grid:move` stops updating `lastCursorRef` → clicks would commit
|
|
554
|
+
// at the stale (initial) position. With raycast disabled, the ray
|
|
555
|
+
// passes through the moved mesh and continues to the grid plane,
|
|
556
|
+
// so `grid:move` keeps firing and the cursor tracks correctly.
|
|
557
|
+
// We restore the original raycast on cleanup.
|
|
558
|
+
const mesh = sceneRegistry.nodes.get(node.id)
|
|
559
|
+
const restoreRaycasts: Array<() => void> = []
|
|
560
|
+
if (mesh) {
|
|
561
|
+
mesh.traverse((child) => {
|
|
562
|
+
const original = child.raycast
|
|
563
|
+
child.raycast = () => {}
|
|
564
|
+
restoreRaycasts.push(() => {
|
|
565
|
+
child.raycast = original
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Static alignment candidates — anchors of every OTHER alignable object
|
|
571
|
+
// (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
|
|
572
|
+
// gathered once at drag start (the scene graph is stable during an
|
|
573
|
+
// imperative move). Level-scoped so a node directly below on another
|
|
574
|
+
// floor doesn't snap (alignment is XZ-only). Coords are building-local,
|
|
575
|
+
// the same frame as `event.localPosition` and the rendered cursor, so
|
|
576
|
+
// the guide dots line up with the cursor.
|
|
577
|
+
const alignmentCandidates = collectAlignmentAnchors(
|
|
578
|
+
useScene.getState().nodes,
|
|
579
|
+
node.id,
|
|
580
|
+
useViewer.getState().selection.levelId ?? node.parentId,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
// Connectivity snapshot (existing port-bearing nodes only — fresh
|
|
584
|
+
// placements aren't connected to anything yet). Records which ducts /
|
|
585
|
+
// fittings are mated to this node's ports so they can follow the drag.
|
|
586
|
+
connectivityRef.current = null
|
|
587
|
+
overriddenIdsRef.current = []
|
|
588
|
+
if (!isNew && nodeRegistry.get(node.type)?.ports) {
|
|
589
|
+
const snapshot = analyzePortConnectivity(node, useScene.getState().nodes)
|
|
590
|
+
if (snapshot.connections.length > 0) connectivityRef.current = snapshot
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const onGridMove = (event: GridEvent) => {
|
|
594
|
+
const rawX = event.localPosition[0]
|
|
595
|
+
const rawZ = event.localPosition[2]
|
|
596
|
+
revealFreshPlacement()
|
|
597
|
+
|
|
598
|
+
const resolved = resolvePlanarCursorPosition({
|
|
599
|
+
cursor: [rawX, rawZ],
|
|
600
|
+
original: [originalPlanPosition[0], originalPlanPosition[2]],
|
|
601
|
+
anchor: dragAnchorRef.current,
|
|
602
|
+
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
|
|
603
|
+
// Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
|
|
604
|
+
snap: snapToGridStep,
|
|
605
|
+
})
|
|
606
|
+
dragAnchorRef.current = resolved.anchor
|
|
607
|
+
let [x, z] = resolved.point
|
|
608
|
+
|
|
609
|
+
// Figma-style alignment snap layered on top of grid snap: when the
|
|
610
|
+
// moving item's edge lines up (on X or Z) with another item's edge,
|
|
611
|
+
// publish a guide. The guide connects to the nearest real corner of the
|
|
612
|
+
// candidate (resolver tie-break), so the dot always sits on an actual
|
|
613
|
+
// point. Alignment "lines" are DISPLAYED in every mode except Off
|
|
614
|
+
// (isAlignmentGuideActive); the magnetic pull toward them applies only in
|
|
615
|
+
// 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
|
|
616
|
+
const magnetic = isMagneticSnapActive()
|
|
617
|
+
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
|
|
618
|
+
const result = resolveAlignment({
|
|
619
|
+
moving: movingDragBoundsAnchors(
|
|
620
|
+
node,
|
|
621
|
+
dragBounds,
|
|
622
|
+
x,
|
|
623
|
+
z,
|
|
624
|
+
previewRotationY(rotationRef.current),
|
|
625
|
+
),
|
|
626
|
+
candidates: alignmentCandidates,
|
|
627
|
+
threshold: ALIGNMENT_THRESHOLD_M,
|
|
628
|
+
})
|
|
629
|
+
if (result.snap && magnetic) {
|
|
630
|
+
x += result.snap.dx
|
|
631
|
+
z += result.snap.dz
|
|
632
|
+
}
|
|
633
|
+
useAlignmentGuides.getState().set(result.guides)
|
|
634
|
+
} else {
|
|
635
|
+
useAlignmentGuides.getState().clear()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Kind-owned attachment snap (cabinet → wall): an attach behavior like
|
|
639
|
+
// door/window wall placement, not an alignment guide — active in every
|
|
640
|
+
// snapping mode except Off.
|
|
641
|
+
if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
|
|
642
|
+
const snappedPosition = groupMoveSnapConfig({
|
|
643
|
+
node,
|
|
644
|
+
candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
|
|
645
|
+
movingIds: [node.id as AnyNodeId],
|
|
646
|
+
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
647
|
+
levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
|
|
648
|
+
})
|
|
649
|
+
if (snappedPosition) {
|
|
650
|
+
const snappedPlanPosition = getVisualPosition(snappedPosition)
|
|
651
|
+
x = snappedPlanPosition[0]
|
|
652
|
+
z = snappedPlanPosition[2]
|
|
653
|
+
useAlignmentGuides.getState().clear()
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Magnetic port snap (duct terminals): mate a collar onto a nearby
|
|
658
|
+
// duct run end. Takes precedence over grid / alignment snap; Alt
|
|
659
|
+
// bypasses. Only kinds that opted in via `movable.portSnap`.
|
|
660
|
+
if (magnetic && portSnapConfig) {
|
|
661
|
+
// Build the preview node at the ORIGINAL position but with the LIVE
|
|
662
|
+
// rotation so `def.ports` reflects any mid-drag R/T rotation. Without
|
|
663
|
+
// this the snap solver mates the pre-rotation collar and commit then
|
|
664
|
+
// writes the rotated node offset from the port it visually snapped to.
|
|
665
|
+
const snapNode = buildPreviewNode(originalPosition, rotationRef.current)
|
|
666
|
+
const mated = resolvePortSnap(snapNode, [x, z], portSnapConfig)
|
|
667
|
+
if (mated) {
|
|
668
|
+
x = mated[0]
|
|
669
|
+
z = mated[1]
|
|
670
|
+
useAlignmentGuides.getState().clear()
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let position = canonicalPositionFromPlan(x, originalPosition[1], z)
|
|
675
|
+
if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
|
|
676
|
+
const preSnapPosition = position
|
|
677
|
+
const snappedPosition = parentFrame.magneticSnap(
|
|
678
|
+
node,
|
|
679
|
+
frameParent,
|
|
680
|
+
position,
|
|
681
|
+
useScene.getState().nodes,
|
|
682
|
+
)
|
|
683
|
+
if (
|
|
684
|
+
snappedPosition[0] !== position[0] ||
|
|
685
|
+
snappedPosition[1] !== position[1] ||
|
|
686
|
+
snappedPosition[2] !== position[2]
|
|
687
|
+
) {
|
|
688
|
+
position = snappedPosition
|
|
689
|
+
const snappedPlanPosition = getVisualPosition(position)
|
|
690
|
+
x = snappedPlanPosition[0]
|
|
691
|
+
z = snappedPlanPosition[2]
|
|
692
|
+
}
|
|
693
|
+
if (isAlignmentGuideActive() && parentFrame.magneticSnapMatches) {
|
|
694
|
+
const guides = parentFrame
|
|
695
|
+
.magneticSnapMatches(
|
|
696
|
+
node,
|
|
697
|
+
frameParent,
|
|
698
|
+
preSnapPosition,
|
|
699
|
+
snappedPosition,
|
|
700
|
+
useScene.getState().nodes,
|
|
701
|
+
)
|
|
702
|
+
.map(alignmentGuideFromParentFrameMatch)
|
|
703
|
+
if (guides.length > 0) useAlignmentGuides.getState().set(guides)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const visualPosition = getVisualPosition(position)
|
|
707
|
+
hasMovedRef.current = true
|
|
708
|
+
setCursorPosition(visualPosition)
|
|
709
|
+
lastCursorRef.current = position
|
|
710
|
+
recomputeValidity()
|
|
711
|
+
|
|
712
|
+
// Pure imperative: move the mesh via its registered Object3D ref.
|
|
713
|
+
applyMeshPose(position)
|
|
714
|
+
// Publish to `useLiveTransforms` so the 2D floor plan can mirror
|
|
715
|
+
// the drag in real-time (the floor-plan layer subscribes to this
|
|
716
|
+
// store and overrides the node's rendered position when an entry
|
|
717
|
+
// is set). Without this the 2D representation stays at the
|
|
718
|
+
// committed scene position until the move ends.
|
|
719
|
+
//
|
|
720
|
+
// For position-based kinds (shelf, item, column, spawn) we write
|
|
721
|
+
// the absolute world plan position here. Polygon-based kinds
|
|
722
|
+
// (slab / ceiling / fence) follow a different delta contract —
|
|
723
|
+
// their floor-plan move-targets handle the override themselves.
|
|
724
|
+
useLiveTransforms.getState().set(node.id, {
|
|
725
|
+
position,
|
|
726
|
+
rotation: rotationRef.current,
|
|
727
|
+
})
|
|
728
|
+
syncParentFramePreview(position)
|
|
729
|
+
markMovedNodeDirty()
|
|
730
|
+
// Carry connected ductwork along (preview only — committed on drop).
|
|
731
|
+
previewConnectivity(position, rotationRef.current)
|
|
732
|
+
|
|
733
|
+
const nextSnapKey = movementSfxStepKey({
|
|
734
|
+
coords: [x, z],
|
|
735
|
+
gridSnapActive: isGridSnapActive(),
|
|
736
|
+
gridStep: useEditor.getState().gridSnapStep,
|
|
737
|
+
})
|
|
738
|
+
const prev = previousSnapRef.current
|
|
739
|
+
if (prev !== nextSnapKey) {
|
|
740
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
741
|
+
previousSnapRef.current = nextSnapKey
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Commit the move at the latest cursor position. Shared by every
|
|
746
|
+
* click variant — grid plane, the moved node itself, or any other
|
|
747
|
+
* 3D surface the user happens to click on during the move.
|
|
748
|
+
*
|
|
749
|
+
* Order is deliberate: write scene FIRST, then clear
|
|
750
|
+
* `useLiveTransforms`. If we cleared the live transform first,
|
|
751
|
+
* `ParametricNodeRenderer` would re-render with
|
|
752
|
+
* `position = liveTransform?.position ?? node.position` → undefined
|
|
753
|
+
* → original `node.position` (the scene write hasn't happened yet),
|
|
754
|
+
* briefly snapping the mesh back to its starting spot before the
|
|
755
|
+
* next render lands the new position. Writing scene first means
|
|
756
|
+
* every render shows either the live drag position (liveTransform
|
|
757
|
+
* still set) or the new committed position (liveTransform cleared
|
|
758
|
+
* AND scene updated) — never the original.
|
|
759
|
+
*/
|
|
760
|
+
const commitAtCursor = (event: ClickTriggerEvent) => {
|
|
761
|
+
// One physical click can reach here twice: node clicks (`slab:click`,
|
|
762
|
+
// `item:click`, …) are synthesized on *pointerup* (`use-node-events`),
|
|
763
|
+
// while `grid:click` rides the browser's native *click* event from a
|
|
764
|
+
// canvas DOM listener (`use-grid-events`) that deliberately ignores
|
|
765
|
+
// stopPropagation — and this effect stays subscribed until React
|
|
766
|
+
// re-renders after `exitMoveMode`. Without this guard the second pass
|
|
767
|
+
// finds the fresh draft already deleted and takes the orphan re-create
|
|
768
|
+
// path below, minting a hidden ghost copy and replaying the SFX.
|
|
769
|
+
if (committed) return
|
|
770
|
+
// Ignore a commit that fires before the cursor has moved into place —
|
|
771
|
+
// it's the stray trailing click of whatever armed this move, not a
|
|
772
|
+
// deliberate drop. Prevents preset re-arm from double-placing.
|
|
773
|
+
if (!hasMovedRef.current) return
|
|
774
|
+
// Refuse a drop on an invalid (red) footprint, matching the GLB item
|
|
775
|
+
// tool — unless Alt (free place) is held to force placement. Other kinds
|
|
776
|
+
// carry no validity box (`validRef` stays true), so they're never blocked.
|
|
777
|
+
if (!validRef.current && !altRef.current) return
|
|
778
|
+
const position: [number, number, number] = [...lastCursorRef.current]
|
|
779
|
+
|
|
780
|
+
const rotation = toCommitRotation(rotationRef.current)
|
|
781
|
+
let committedId = node.id as AnyNodeId
|
|
782
|
+
|
|
783
|
+
if (useScene.getState().nodes[node.id]) {
|
|
784
|
+
const data = {
|
|
785
|
+
position,
|
|
786
|
+
rotation,
|
|
787
|
+
...(isNew
|
|
788
|
+
? {
|
|
789
|
+
metadata: stripPlacementMetadataFlags(node.metadata),
|
|
790
|
+
visible: true,
|
|
791
|
+
}
|
|
792
|
+
: null),
|
|
793
|
+
} as Partial<AnyNode>
|
|
794
|
+
|
|
795
|
+
if (isNew) {
|
|
796
|
+
const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data)
|
|
797
|
+
if (finalId) {
|
|
798
|
+
committed = true
|
|
799
|
+
committedId = finalId
|
|
800
|
+
}
|
|
801
|
+
} else {
|
|
802
|
+
// Fold the connected-ductwork follow-updates into the SAME
|
|
803
|
+
// batch as the moved node so the whole thing is one undo step.
|
|
804
|
+
const connectivityUpdates = connectivityRef.current
|
|
805
|
+
? resolveConnectivityUpdates(
|
|
806
|
+
connectivityRef.current,
|
|
807
|
+
buildPreviewNode(position, rotationRef.current),
|
|
808
|
+
).filter((u) => useScene.getState().nodes[u.id])
|
|
809
|
+
: []
|
|
810
|
+
useScene.temporal.getState().resume()
|
|
811
|
+
useScene
|
|
812
|
+
.getState()
|
|
813
|
+
.updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
|
|
814
|
+
// Kind-owned derived-state maintenance after a parent-frame move
|
|
815
|
+
// (cabinet run re-flow + linked corner-run re-anchor). Runs in the
|
|
816
|
+
// resumed window so its writes are undoable alongside the move.
|
|
817
|
+
if (parentFrame?.onCommit && frameParent) {
|
|
818
|
+
const liveNode = useScene.getState().nodes[node.id as AnyNodeId]
|
|
819
|
+
const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId]
|
|
820
|
+
if (liveNode && liveParent) {
|
|
821
|
+
parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene))
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
useScene.temporal.getState().pause()
|
|
825
|
+
committed = true
|
|
826
|
+
}
|
|
827
|
+
} else if (node.parentId) {
|
|
828
|
+
// Orphan re-create path: re-parse via the registry's schema.
|
|
829
|
+
const def = nodeRegistry.get(node.type)
|
|
830
|
+
if (def) {
|
|
831
|
+
const reparsed = def.schema.parse({
|
|
832
|
+
...(node as Record<string, unknown>),
|
|
833
|
+
id: undefined,
|
|
834
|
+
metadata: {},
|
|
835
|
+
position,
|
|
836
|
+
rotation,
|
|
837
|
+
})
|
|
838
|
+
useScene.temporal.getState().resume()
|
|
839
|
+
useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
|
|
840
|
+
useScene.temporal.getState().pause()
|
|
841
|
+
committed = true
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Clear after the scene write so React reconciles against the new
|
|
846
|
+
// canonical position, then restamp the lifted presentation Y for the
|
|
847
|
+
// current frame.
|
|
848
|
+
useLiveTransforms.getState().clear(node.id)
|
|
849
|
+
// Connected ductwork is now committed to the store — drop its live
|
|
850
|
+
// overrides so the renderers read the canonical path/position.
|
|
851
|
+
clearConnectivityOverrides()
|
|
852
|
+
clearParentFramePreview()
|
|
853
|
+
applyMeshPose(position)
|
|
854
|
+
|
|
855
|
+
useAlignmentGuides.getState().clear()
|
|
856
|
+
if (isNew && committed) {
|
|
857
|
+
useViewer.getState().setSelection({ selectedIds: [committedId] })
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
sfxEmitter.emit('sfx:item-place')
|
|
861
|
+
useEditor.getState().setMovingNodeOrigin('3d')
|
|
862
|
+
exitMoveMode()
|
|
863
|
+
|
|
864
|
+
// Stop further propagation so other listeners (e.g. a selection
|
|
865
|
+
// change on the clicked node) don't fire during the commit click.
|
|
866
|
+
const native = (event as { nativeEvent?: unknown }).nativeEvent
|
|
867
|
+
if (
|
|
868
|
+
native &&
|
|
869
|
+
typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
|
|
870
|
+
) {
|
|
871
|
+
;(native as { stopPropagation: () => void }).stopPropagation()
|
|
872
|
+
}
|
|
873
|
+
const direct = (event as { stopPropagation?: () => void }).stopPropagation
|
|
874
|
+
if (typeof direct === 'function') direct.call(event)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// R / T rotate the dragged node about Y in 45° steps — matching the GLB
|
|
878
|
+
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
|
879
|
+
// imperatively + mirrored to the live transform; committed on drop.
|
|
880
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
881
|
+
// Hold Alt (free place) to force placement on an invalid (red) footprint,
|
|
882
|
+
// matching the GLB item tool. Recolour the box to green while held.
|
|
883
|
+
if (e.key === 'Alt') {
|
|
884
|
+
altRef.current = true
|
|
885
|
+
recomputeValidity()
|
|
886
|
+
return
|
|
887
|
+
}
|
|
888
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
889
|
+
let delta = 0
|
|
890
|
+
if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
|
|
891
|
+
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
|
|
892
|
+
else return
|
|
893
|
+
e.preventDefault()
|
|
894
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
895
|
+
rotationRef.current += delta
|
|
896
|
+
setCursorRotationY(previewRotationY(rotationRef.current))
|
|
897
|
+
const position = lastCursorRef.current
|
|
898
|
+
const visualPosition = getVisualPosition(position)
|
|
899
|
+
setCursorPosition(visualPosition)
|
|
900
|
+
applyMeshPose(position)
|
|
901
|
+
useLiveTransforms.getState().set(node.id, {
|
|
902
|
+
position,
|
|
903
|
+
rotation: rotationRef.current,
|
|
904
|
+
})
|
|
905
|
+
syncParentFramePreview(position)
|
|
906
|
+
markMovedNodeDirty()
|
|
907
|
+
// Rotating the fitting swings its collars — connected ducts follow.
|
|
908
|
+
previewConnectivity(position, rotationRef.current)
|
|
909
|
+
// Rotation changes the footprint's collision span — re-check validity.
|
|
910
|
+
recomputeValidity()
|
|
911
|
+
}
|
|
912
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
913
|
+
if (e.key === 'Alt') {
|
|
914
|
+
altRef.current = false
|
|
915
|
+
recomputeValidity()
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
window.addEventListener('keydown', onKeyDown)
|
|
919
|
+
window.addEventListener('keyup', onKeyUp)
|
|
920
|
+
|
|
921
|
+
emitter.on('grid:move', onGridMove)
|
|
922
|
+
emitter.on('grid:click', commitAtCursor)
|
|
923
|
+
|
|
924
|
+
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
|
925
|
+
if (!useEditor.getState().placementDragMode) return
|
|
926
|
+
if (event.button !== 0) return
|
|
927
|
+
swallowNextClick()
|
|
928
|
+
if (!hasMovedRef.current) {
|
|
929
|
+
exitMoveMode()
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
commitAtCursor({
|
|
933
|
+
nativeEvent: event,
|
|
934
|
+
stopPropagation: () => event.stopPropagation(),
|
|
935
|
+
} as unknown as ClickTriggerEvent)
|
|
936
|
+
}
|
|
937
|
+
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
|
938
|
+
|
|
939
|
+
// Listen on every common kind's click event too. mitt's typing keeps
|
|
940
|
+
// `${kind}:click` as a fixed union so the cast is safe at runtime —
|
|
941
|
+
// we're just routing them through the shared commit path.
|
|
942
|
+
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
|
|
943
|
+
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
|
|
944
|
+
for (const kind of CLICK_TRIGGER_KINDS) {
|
|
945
|
+
const key = `${kind}:click` as ClickKey
|
|
946
|
+
emitter.on(key, commitAtCursor as never)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const onCancel = () => {
|
|
950
|
+
useLiveTransforms.getState().clear(node.id)
|
|
951
|
+
clearConnectivityOverrides()
|
|
952
|
+
clearParentFramePreview()
|
|
953
|
+
if (isNew) {
|
|
954
|
+
useScene.getState().deleteNode(node.id as AnyNodeId)
|
|
955
|
+
} else {
|
|
956
|
+
applyMeshPose(originalPosition, originalRotationY)
|
|
957
|
+
markMovedNodeDirty()
|
|
958
|
+
}
|
|
959
|
+
useAlignmentGuides.getState().clear()
|
|
960
|
+
useScene.temporal.getState().resume()
|
|
961
|
+
markToolCancelConsumed()
|
|
962
|
+
exitMoveMode()
|
|
963
|
+
}
|
|
964
|
+
emitter.on('tool:cancel', onCancel)
|
|
965
|
+
|
|
966
|
+
return () => {
|
|
967
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
968
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
969
|
+
emitter.off('grid:move', onGridMove)
|
|
970
|
+
emitter.off('grid:click', commitAtCursor)
|
|
971
|
+
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
|
972
|
+
for (const kind of CLICK_TRIGGER_KINDS) {
|
|
973
|
+
const key = `${kind}:click` as ClickKey
|
|
974
|
+
emitter.off(key, commitAtCursor as never)
|
|
975
|
+
}
|
|
976
|
+
emitter.off('tool:cancel', onCancel)
|
|
977
|
+
// Restore the moved meshes' raycast so they're hoverable / selectable
|
|
978
|
+
// again after the drag ends.
|
|
979
|
+
for (const restore of restoreRaycasts) restore()
|
|
980
|
+
// Drop any alignment guides this drag published — covers Esc / mid-drag
|
|
981
|
+
// unmount / commit paths uniformly.
|
|
982
|
+
useAlignmentGuides.getState().clear()
|
|
983
|
+
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
|
984
|
+
if (!(committed || isNew || finalisedBy2D)) {
|
|
985
|
+
useLiveTransforms.getState().clear(node.id)
|
|
986
|
+
clearConnectivityOverrides()
|
|
987
|
+
clearParentFramePreview()
|
|
988
|
+
applyMeshPose(originalPosition, originalRotationY)
|
|
989
|
+
markMovedNodeDirty()
|
|
990
|
+
}
|
|
991
|
+
useScene.temporal.getState().resume()
|
|
992
|
+
}
|
|
993
|
+
}, [
|
|
994
|
+
boxDimensions,
|
|
995
|
+
dragBounds,
|
|
996
|
+
canonicalPositionFromPlan,
|
|
997
|
+
parentFrame,
|
|
998
|
+
frameParent,
|
|
999
|
+
cursorAttached,
|
|
1000
|
+
portSnapConfig,
|
|
1001
|
+
groupMoveSnapConfig,
|
|
1002
|
+
exitMoveMode,
|
|
1003
|
+
isFreshPlacement,
|
|
1004
|
+
node,
|
|
1005
|
+
originalPosition,
|
|
1006
|
+
originalPlanPosition,
|
|
1007
|
+
originalRotationY,
|
|
1008
|
+
previewRotationY,
|
|
1009
|
+
revealFreshPlacement,
|
|
1010
|
+
useAbsoluteCursorPlacement,
|
|
1011
|
+
visualPositionFor,
|
|
1012
|
+
])
|
|
1013
|
+
|
|
1014
|
+
// Forward-facing triangle for the footprint-box branch (item / shelf / column
|
|
1015
|
+
// — anything that renders `<PlacementBox>`). Published to the editor-side
|
|
1016
|
+
// overlay; the `<DragBoundingBox>` branch (e.g. stair, which has no centred
|
|
1017
|
+
// footprint) publishes its own. The box is centred on `cursorPosition`, so
|
|
1018
|
+
// the footprint centre is the origin. Clears on unmount.
|
|
1019
|
+
const facing = resolveFacingIndicator(node.type)
|
|
1020
|
+
useEffect(() => {
|
|
1021
|
+
if (!previewVisible || !facing || !boxDimensions) return
|
|
1022
|
+
useFacingPose.getState().set({
|
|
1023
|
+
position: cursorPosition,
|
|
1024
|
+
rotationY: cursorRotationY,
|
|
1025
|
+
depth: boxDimensions[2],
|
|
1026
|
+
center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined,
|
|
1027
|
+
reversed: facing.reversed,
|
|
1028
|
+
})
|
|
1029
|
+
}, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY])
|
|
1030
|
+
useEffect(() => () => useFacingPose.getState().clear(), [])
|
|
1031
|
+
|
|
1032
|
+
if (!previewVisible) return null
|
|
1033
|
+
|
|
1034
|
+
if (boxDimensions && !dragBounds?.center) {
|
|
1035
|
+
return (
|
|
1036
|
+
<PlacementBox
|
|
1037
|
+
dimensions={boxDimensions}
|
|
1038
|
+
position={cursorPosition}
|
|
1039
|
+
rotationY={cursorRotationY}
|
|
1040
|
+
valid={valid}
|
|
1041
|
+
/>
|
|
1042
|
+
)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const dragCenterPosition =
|
|
1046
|
+
dragBounds?.center && dragBounds.size
|
|
1047
|
+
? offsetPlanPositionByLocalCenter(cursorPosition, dragBounds.center, cursorRotationY)
|
|
1048
|
+
: cursorPosition
|
|
1049
|
+
|
|
1050
|
+
return (
|
|
1051
|
+
<>
|
|
1052
|
+
<CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
|
|
1053
|
+
<DragBoundingBox
|
|
1054
|
+
center={dragBounds?.center}
|
|
1055
|
+
centerY={dragBounds?.centerY}
|
|
1056
|
+
color={boxDimensions ? (valid ? VALID_COLOR : INVALID_COLOR) : undefined}
|
|
1057
|
+
nodeId={node.id}
|
|
1058
|
+
position={cursorPosition}
|
|
1059
|
+
rotationY={cursorRotationY}
|
|
1060
|
+
size={dragBounds?.size}
|
|
1061
|
+
/>
|
|
1062
|
+
</>
|
|
1063
|
+
)
|
|
1064
|
+
}
|