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