@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,857 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AlignmentAnchor,
|
|
5
|
+
type AnyNode,
|
|
6
|
+
type AnyNodeId,
|
|
7
|
+
bboxAnchors,
|
|
8
|
+
bboxCornerAnchors,
|
|
9
|
+
emitter,
|
|
10
|
+
type FloorplanMoveTargetSession,
|
|
11
|
+
nodeRegistry,
|
|
12
|
+
pauseSceneHistory,
|
|
13
|
+
resumeSceneHistory,
|
|
14
|
+
useLiveNodeOverrides,
|
|
15
|
+
useLiveTransforms,
|
|
16
|
+
useScene,
|
|
17
|
+
} from '@pascal-app/core'
|
|
18
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
19
|
+
import { useEffect } from 'react'
|
|
20
|
+
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
|
21
|
+
import { isHistoryShortcut } from '../../lib/history'
|
|
22
|
+
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
|
23
|
+
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
|
24
|
+
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
|
|
25
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
26
|
+
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
|
|
27
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
28
|
+
import useEditor, {
|
|
29
|
+
isAlignmentGuideActive,
|
|
30
|
+
isGridSnapActive,
|
|
31
|
+
isMagneticSnapActive,
|
|
32
|
+
} from '../../store/use-editor'
|
|
33
|
+
import { useMovingNode } from '../../store/use-interaction-scope'
|
|
34
|
+
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
|
35
|
+
|
|
36
|
+
// Figma-style alignment snap threshold. Meters in world space; 8cm gives
|
|
37
|
+
// a comfortable "magnetic" pull at default zoom without fighting the
|
|
38
|
+
// grid snap. Held fixed for v1 — a future revision can scale this with
|
|
39
|
+
// the SVG's units-per-pixel so the feel stays constant across zoom.
|
|
40
|
+
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cursor-driven placement for registered kinds in the floor plan.
|
|
44
|
+
*
|
|
45
|
+
* Activates when `useEditor.movingNode` is set to a node whose kind is
|
|
46
|
+
* registered with `def.floorplan`. Two dispatch paths:
|
|
47
|
+
*
|
|
48
|
+
* 1. **`def.floorplanMoveTarget` present** (door / window / item):
|
|
49
|
+
* kind-specific 2D move handler with wall / ceiling / slab
|
|
50
|
+
* anchor logic. Pointer events feed `session.apply` which writes
|
|
51
|
+
* directly to `useScene`; pointer-up does the single-undo dance
|
|
52
|
+
* (revert→resume→re-apply) if `canCommit()` is true.
|
|
53
|
+
* 2. **Fallback — generic free-floating translate**: imperatively
|
|
54
|
+
* translates the rendered SVG entry on pointer-move, commits via
|
|
55
|
+
* `updateNode` on pointer-up. Used by shelf / spawn / fence /
|
|
56
|
+
* etc. whose move is "translate position on X/Z plane".
|
|
57
|
+
*
|
|
58
|
+
* Lives outside the `floorplan-panel.tsx` monolith. Coordinate
|
|
59
|
+
* conversion routes through the scene `<g>`'s `getScreenCTM` so
|
|
60
|
+
* cursor → meters accounts for pan / zoom / building rotation.
|
|
61
|
+
*/
|
|
62
|
+
export function FloorplanRegistryMoveOverlay() {
|
|
63
|
+
const movingNode = useMovingNode()
|
|
64
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
65
|
+
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
66
|
+
|
|
67
|
+
const def = movingNode ? nodeRegistry.get(movingNode.type) : null
|
|
68
|
+
const isActive = !!movingNode && !!def?.floorplan
|
|
69
|
+
const hasMoveTarget = !!def?.floorplanMoveTarget
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!isActive || !movingNode) return
|
|
73
|
+
|
|
74
|
+
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
|
|
75
|
+
if (!scene) return
|
|
76
|
+
|
|
77
|
+
const toMeters = (clientX: number, clientY: number): [number, number] | null => {
|
|
78
|
+
const svg = scene.ownerSVGElement
|
|
79
|
+
if (!svg) return null
|
|
80
|
+
const ctm = scene.getScreenCTM()
|
|
81
|
+
if (!ctm) return null
|
|
82
|
+
const pt = svg.createSVGPoint()
|
|
83
|
+
pt.x = clientX
|
|
84
|
+
pt.y = clientY
|
|
85
|
+
const m = pt.matrixTransform(ctm.inverse())
|
|
86
|
+
return [m.x, m.y]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
|
|
90
|
+
// The scene's `<g>` only covers painted SVG elements, so hovers over
|
|
91
|
+
// empty grid background often target the parent SVG. Bounds keep the
|
|
92
|
+
// cursor active anywhere inside the floor-plan viewport.
|
|
93
|
+
const svg = scene.ownerSVGElement
|
|
94
|
+
if (!svg) return false
|
|
95
|
+
const rect = svg.getBoundingClientRect()
|
|
96
|
+
return (
|
|
97
|
+
clientX >= rect.left &&
|
|
98
|
+
clientX <= rect.right &&
|
|
99
|
+
clientY >= rect.top &&
|
|
100
|
+
clientY <= rect.bottom
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
|
|
105
|
+
if (hasMoveTarget && def?.floorplanMoveTarget) {
|
|
106
|
+
const sceneNodes = useScene.getState().nodes
|
|
107
|
+
const session: FloorplanMoveTargetSession = (
|
|
108
|
+
def.floorplanMoveTarget as (a: {
|
|
109
|
+
node: AnyNode
|
|
110
|
+
nodes: Record<AnyNodeId, AnyNode>
|
|
111
|
+
}) => FloorplanMoveTargetSession
|
|
112
|
+
)({ node: movingNode, nodes: sceneNodes })
|
|
113
|
+
|
|
114
|
+
// Capture snapshots of every affected node BEFORE the first apply
|
|
115
|
+
// so the single-undo dance has a clean baseline to revert to.
|
|
116
|
+
const snapshots = session.affectedIds
|
|
117
|
+
.map((id) => sceneNodes[id])
|
|
118
|
+
.filter((n): n is AnyNode => !!n)
|
|
119
|
+
.map((n) => snapshotNode(n))
|
|
120
|
+
|
|
121
|
+
pauseSceneHistory(useScene)
|
|
122
|
+
let historyPaused = true
|
|
123
|
+
|
|
124
|
+
const clearLivePreviews = () => {
|
|
125
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
126
|
+
const liveOverrides = useLiveNodeOverrides.getState()
|
|
127
|
+
const scene = useScene.getState()
|
|
128
|
+
for (const id of session.affectedIds) {
|
|
129
|
+
liveTransforms.clear(id)
|
|
130
|
+
liveOverrides.clear(id)
|
|
131
|
+
scene.markDirty(id)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The registry action menu's Move button portals to `document.body`,
|
|
136
|
+
// so the trigger click's pointer-up happens OUTSIDE the floor-plan
|
|
137
|
+
// scene and never reaches `onPointerUp` here. That means: the very
|
|
138
|
+
// first window-pointer-up the overlay sees is the user's intended
|
|
139
|
+
// commit click. No "click-to-enter" gesture to detect — the older
|
|
140
|
+
// flow used an orange "Move" dot rendered inside the slab itself,
|
|
141
|
+
// where the trigger click DID hit the overlay's listener and had
|
|
142
|
+
// to be consumed. That legacy flow is gone in the registry layer;
|
|
143
|
+
// all entries use the action menu now.
|
|
144
|
+
let hasMovedSinceStart = false
|
|
145
|
+
// Last resolved position of the moved node — drives the move "tick" SFX.
|
|
146
|
+
// Parity with the 3D move, which emits on any change of the resolved
|
|
147
|
+
// position (every snapping mode, not only grid).
|
|
148
|
+
let lastSnapKey: string | null = null
|
|
149
|
+
// Live cursor location — updated on EVERY pointermove (even over the 3D
|
|
150
|
+
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
|
|
151
|
+
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
|
|
152
|
+
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
|
|
153
|
+
let pointerOverFloorplan = false
|
|
154
|
+
const onPointerTrack = (event: PointerEvent) => {
|
|
155
|
+
pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const onMove = (event: PointerEvent) => {
|
|
159
|
+
// Skip 3D-canvas / other-UI cursor moves so the overlay only
|
|
160
|
+
// tracks pointer events that actually correspond to a floor-plan
|
|
161
|
+
// location. The bounding-rect check (vs the legacy
|
|
162
|
+
// `target.closest('[data-floorplan-scene]')`) also picks up
|
|
163
|
+
// hovers over empty grid background — without it, the cursor
|
|
164
|
+
// only updated the shelf when it happened to brush over an
|
|
165
|
+
// existing SVG entry, leaving the move feeling "stuck" elsewhere.
|
|
166
|
+
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
|
167
|
+
const planPoint = toMeters(event.clientX, event.clientY)
|
|
168
|
+
if (!planPoint) return
|
|
169
|
+
hasMovedSinceStart = true
|
|
170
|
+
session.apply({
|
|
171
|
+
planPoint,
|
|
172
|
+
modifiers: {
|
|
173
|
+
shiftKey: event.shiftKey,
|
|
174
|
+
altKey: event.altKey,
|
|
175
|
+
ctrlKey: event.ctrlKey,
|
|
176
|
+
metaKey: event.metaKey,
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
// Move "tick" — same feedback the 3D move gives, which fires whenever the
|
|
180
|
+
// resolved position changes (any snapping mode, not just grid), so it
|
|
181
|
+
// ticks as the item lands on each new snapped/free position.
|
|
182
|
+
const movedId = session.affectedIds[0]
|
|
183
|
+
const moved = movedId ? useScene.getState().nodes[movedId] : undefined
|
|
184
|
+
const pos = (moved as { position?: [number, number, number] } | undefined)?.position
|
|
185
|
+
if (pos) {
|
|
186
|
+
const key = movementSfxStepKey({
|
|
187
|
+
coords: [pos[0], pos[2]],
|
|
188
|
+
gridSnapActive: isGridSnapActive(),
|
|
189
|
+
gridStep: useEditor.getState().gridSnapStep,
|
|
190
|
+
})
|
|
191
|
+
if (key !== lastSnapKey) {
|
|
192
|
+
lastSnapKey = key
|
|
193
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const commitFinalStateOrRevert = () => {
|
|
199
|
+
const commitValid = session.canCommit()
|
|
200
|
+
|
|
201
|
+
// Claim ownership of the drag teardown so the 3D move tool's
|
|
202
|
+
// unmount-time cleanup skips its restore-from-snapshot — see
|
|
203
|
+
// `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
|
|
204
|
+
// `setMovingNode(null)`) so that by the time the 3D effect's
|
|
205
|
+
// cleanup runs the origin is observable in the store.
|
|
206
|
+
setMovingNodeOrigin('2d')
|
|
207
|
+
|
|
208
|
+
// Sessions with a `commit` hook own their atomic write (e.g.
|
|
209
|
+
// wall move emits creates + deletes + updates via the junction
|
|
210
|
+
// planner). For those we still do Phase 1 (revert to baseline)
|
|
211
|
+
// and Phase 2's resume — but Phase 2's write is delegated, and
|
|
212
|
+
// we skip the snapshot-diff finalUpdates path.
|
|
213
|
+
if (commitValid && session.commit) {
|
|
214
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
|
215
|
+
if (historyPaused) {
|
|
216
|
+
resumeSceneHistory(useScene)
|
|
217
|
+
historyPaused = false
|
|
218
|
+
}
|
|
219
|
+
session.commit()
|
|
220
|
+
sfxEmitter.emit('sfx:item-place')
|
|
221
|
+
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const sceneState = useScene.getState().nodes
|
|
226
|
+
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
|
|
227
|
+
for (const snap of snapshots) {
|
|
228
|
+
const current = sceneState[snap.id]
|
|
229
|
+
if (!current) continue
|
|
230
|
+
const data: Record<string, unknown> = {}
|
|
231
|
+
let changed = false
|
|
232
|
+
for (const [key, before] of Object.entries(snap.data)) {
|
|
233
|
+
const after = (current as unknown as Record<string, unknown>)[key]
|
|
234
|
+
if (!deepEqual(before, after)) {
|
|
235
|
+
data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
|
|
236
|
+
changed = true
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (changed) finalUpdates.push({ id: snap.id, data })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const snap of snapshots) {
|
|
243
|
+
const current = sceneState[snap.id]
|
|
244
|
+
if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) {
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
const existing = finalUpdates.find((update) => update.id === snap.id)
|
|
248
|
+
const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata)
|
|
249
|
+
if (existing) {
|
|
250
|
+
existing.data.metadata = metadata
|
|
251
|
+
existing.data.visible = true
|
|
252
|
+
} else {
|
|
253
|
+
finalUpdates.push({
|
|
254
|
+
id: snap.id,
|
|
255
|
+
data: { metadata, visible: true },
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (commitValid && finalUpdates.length > 0) {
|
|
261
|
+
// Single-undo dance:
|
|
262
|
+
// 1. Revert to baseline while history is still paused.
|
|
263
|
+
// 2. Resume history.
|
|
264
|
+
// 3. Re-apply the final state — recorded as one tracked change.
|
|
265
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
|
266
|
+
if (historyPaused) {
|
|
267
|
+
resumeSceneHistory(useScene)
|
|
268
|
+
historyPaused = false
|
|
269
|
+
}
|
|
270
|
+
useScene.getState().updateNodes(finalUpdates)
|
|
271
|
+
sfxEmitter.emit('sfx:item-place')
|
|
272
|
+
// Re-select the moved node(s) — mirrors the legacy 3D move
|
|
273
|
+
// tool. The action menu cleared selection on Move click so
|
|
274
|
+
// selection-gated affordances (slab/ceiling boundary editor,
|
|
275
|
+
// etc.) would unmount during the drag; restoring it here
|
|
276
|
+
// brings them back at the new position.
|
|
277
|
+
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
|
278
|
+
} else {
|
|
279
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
|
280
|
+
if (historyPaused) {
|
|
281
|
+
resumeSceneHistory(useScene)
|
|
282
|
+
historyPaused = false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
288
|
+
if (event.button !== 0) return
|
|
289
|
+
// Bounding-rect check (see `isPointerOverFloorplanScene`) — same
|
|
290
|
+
// reason as `onMove`: commits should land for any pointer-up
|
|
291
|
+
// inside the SVG viewport, including empty grid background.
|
|
292
|
+
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
|
293
|
+
if (!hasMovedSinceStart) return
|
|
294
|
+
|
|
295
|
+
// Commit using the LAST pointermove's state — no re-apply at
|
|
296
|
+
// pointer-up coords. A previous version re-applied here to
|
|
297
|
+
// close a sub-pixel "drift" window when pointer-up fires
|
|
298
|
+
// without a preceding pointermove, but that re-apply also
|
|
299
|
+
// re-snaps: if the pointer-up coord crosses a grid boundary
|
|
300
|
+
// relative to the last pointermove, the snapped result flips
|
|
301
|
+
// to a different grid cell and the wall (or other moved node)
|
|
302
|
+
// visibly jumps from where it was painted during the drag to
|
|
303
|
+
// a different commit position. Trusting the last pointermove
|
|
304
|
+
// means "what you saw is what gets committed", which is the
|
|
305
|
+
// UX users expect — at the cost of a sub-pixel drift in the
|
|
306
|
+
// rare case where the OS fires pointerup with no preceding
|
|
307
|
+
// pointermove. Modern browsers reliably emit a final
|
|
308
|
+
// pointermove right before pointerup, so the trade-off lands
|
|
309
|
+
// on the side of WYSIWYG.
|
|
310
|
+
|
|
311
|
+
commitFinalStateOrRevert()
|
|
312
|
+
setMovingNode(null)
|
|
313
|
+
|
|
314
|
+
// Swallow the click event that follows this pointer-up — the
|
|
315
|
+
// floor-plan SVG's `handleBackgroundClick` would otherwise route
|
|
316
|
+
// it through `resolveFloorplanBackgroundSelection`, which clears
|
|
317
|
+
// the selection if the click resolved to empty space. We already
|
|
318
|
+
// set selection back to the moved node in `commitFinalStateOrRevert`;
|
|
319
|
+
// letting the background-click handler run would undo that for
|
|
320
|
+
// any commit click that doesn't happen to land directly on the
|
|
321
|
+
// node's hit-test geometry.
|
|
322
|
+
//
|
|
323
|
+
// The 3D mover doesn't need this because its grid-click fires
|
|
324
|
+
// via the emitter inside the R3F pointer event and can call
|
|
325
|
+
// `event.nativeEvent.stopPropagation()`; the 2D pointerup and
|
|
326
|
+
// the following click are separate DOM events, so we listen on
|
|
327
|
+
// window in the capture phase to intercept the click before any
|
|
328
|
+
// bubble-phase handler (the floor-plan SVG) sees it.
|
|
329
|
+
swallowNextClick()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const onKey = (event: KeyboardEvent) => {
|
|
333
|
+
// R flips a directional kind's facing mid-placement (door / window:
|
|
334
|
+
// front ↔ back). The session records the flip and re-runs its last
|
|
335
|
+
// apply so the 2D symbol updates immediately; kinds without a facing
|
|
336
|
+
// leave `flipSide` unset and R falls through to the global handler.
|
|
337
|
+
//
|
|
338
|
+
// Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
|
|
339
|
+
// move tool ALSO listens for R on `window`. We own R only while the
|
|
340
|
+
// cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
|
|
341
|
+
// 2D mover has actually engaged (`hasMovedSinceStart`); then we
|
|
342
|
+
// `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
|
|
343
|
+
// first) so the 3D tool can't also flip. When the cursor is over the 3D
|
|
344
|
+
// pane we yield — the 3D tool owns R there. (The old sticky
|
|
345
|
+
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
|
|
346
|
+
// the first 2D move, killing the 3D flip.)
|
|
347
|
+
if (event.key === 'r' || event.key === 'R') {
|
|
348
|
+
// Yield Cmd/Ctrl+R to the browser reload instead of flipping the side.
|
|
349
|
+
if (event.metaKey || event.ctrlKey) return
|
|
350
|
+
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
|
|
351
|
+
if (event.repeat) return
|
|
352
|
+
const t = event.target as HTMLElement | null
|
|
353
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
event.preventDefault()
|
|
357
|
+
event.stopImmediatePropagation()
|
|
358
|
+
session.flipSide()
|
|
359
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
|
|
363
|
+
if (isHistoryShortcut(event)) {
|
|
364
|
+
// ⌘Z mid-move cancels like Escape — keep it from reaching the
|
|
365
|
+
// global undo arm (this handler is capture-phase, that one bubbles).
|
|
366
|
+
event.preventDefault()
|
|
367
|
+
event.stopImmediatePropagation()
|
|
368
|
+
}
|
|
369
|
+
// Claim teardown ownership so the 3D move tool's cleanup skips
|
|
370
|
+
// its own restore — without this, both sides would race to
|
|
371
|
+
// write the same baseline, harmless but wasteful.
|
|
372
|
+
setMovingNodeOrigin('2d')
|
|
373
|
+
if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
|
|
374
|
+
emitter.emit('tool:cancel')
|
|
375
|
+
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
|
|
376
|
+
if (historyPaused) {
|
|
377
|
+
resumeSceneHistory(useScene)
|
|
378
|
+
historyPaused = false
|
|
379
|
+
}
|
|
380
|
+
clearLivePreviews()
|
|
381
|
+
useAlignmentGuides.getState().clear()
|
|
382
|
+
setMovingNode(null)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
// Revert untracked, then resume — no history entry.
|
|
386
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
|
387
|
+
if (historyPaused) {
|
|
388
|
+
resumeSceneHistory(useScene)
|
|
389
|
+
historyPaused = false
|
|
390
|
+
}
|
|
391
|
+
// Clear any live previews the session wrote. Slab / ceiling
|
|
392
|
+
// 2D move stages a translation delta in `useLiveTransforms`;
|
|
393
|
+
// wall move publishes `{ start, end, ... }` to
|
|
394
|
+
// `useLiveNodeOverrides`. Either way, leaving them in place
|
|
395
|
+
// after Esc would freeze the 2D / 3D view at the cancelled
|
|
396
|
+
// position.
|
|
397
|
+
clearLivePreviews()
|
|
398
|
+
// Restore selection cleared by the action menu's Move click.
|
|
399
|
+
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
|
400
|
+
setMovingNode(null)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
window.addEventListener('pointermove', onPointerTrack)
|
|
404
|
+
window.addEventListener('pointermove', onMove)
|
|
405
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
406
|
+
// Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
|
|
407
|
+
// listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
|
|
408
|
+
// then pre-empts it so only one handler flips.
|
|
409
|
+
window.addEventListener('keydown', onKey, true)
|
|
410
|
+
return () => {
|
|
411
|
+
window.removeEventListener('pointermove', onPointerTrack)
|
|
412
|
+
window.removeEventListener('pointermove', onMove)
|
|
413
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
414
|
+
window.removeEventListener('keydown', onKey, true)
|
|
415
|
+
// Unmount cleanup. `historyPaused === true` here means none of
|
|
416
|
+
// our terminal paths (commit, Esc) ran in this overlay — they
|
|
417
|
+
// each call `resumeSceneHistory` and flip the flag.
|
|
418
|
+
//
|
|
419
|
+
// If `movingNodeOrigin === '3d'`, a 3D move tool finalised
|
|
420
|
+
// while our overlay was still mounted (split view); the live
|
|
421
|
+
// scene IS the committed state and reverting would stomp it.
|
|
422
|
+
// Otherwise (origin is `null` or `'2d'`) we own the teardown
|
|
423
|
+
// and revert any untracked apply() writes back to baseline.
|
|
424
|
+
//
|
|
425
|
+
// The two prior scenarios this block guarded against:
|
|
426
|
+
// - mid-drag unmount with apply() writes still present
|
|
427
|
+
// - 3D mover committing via `draftNode.commit` just before
|
|
428
|
+
// our unmount
|
|
429
|
+
// are now distinguished by the origin flag — no scene-state
|
|
430
|
+
// diff heuristic required.
|
|
431
|
+
if (historyPaused) {
|
|
432
|
+
if (hasMovedSinceStart) {
|
|
433
|
+
const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
|
|
434
|
+
if (!finalisedBy3D) {
|
|
435
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
resumeSceneHistory(useScene)
|
|
439
|
+
}
|
|
440
|
+
// Belt-and-suspenders: clear any live previews on abnormal
|
|
441
|
+
// unmount paths too. Slab / ceiling sessions write to
|
|
442
|
+
// `useLiveTransforms`; wall sessions write to
|
|
443
|
+
// `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
|
|
444
|
+
// tool's cleanup isn't there to clear them for us.
|
|
445
|
+
clearLivePreviews()
|
|
446
|
+
// Sessions that publish Figma-style alignment guides during `apply`
|
|
447
|
+
// (item / shelf / column) leave them in the store; this cleanup runs
|
|
448
|
+
// after every terminal path (commit + Esc both unmount via
|
|
449
|
+
// `setMovingNode(null)`), so clearing here drops any lingering guide.
|
|
450
|
+
useAlignmentGuides.getState().clear()
|
|
451
|
+
// Same belt-and-suspenders pattern for the wall bridge ghost
|
|
452
|
+
// previews — clear unconditionally so Esc / mid-drag unmount /
|
|
453
|
+
// 3D-takeover paths all end up with no stale ghosts left over.
|
|
454
|
+
// The wall session's `commit()` already clears them on the
|
|
455
|
+
// happy path; this just covers the rest.
|
|
456
|
+
useWallMoveGhosts.getState().clear()
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Path 2 — generic free-floating translate ────────────────────
|
|
461
|
+
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
|
462
|
+
if (!entry) return
|
|
463
|
+
const sceneNodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
464
|
+
const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes)
|
|
465
|
+
const relatedEntries = Array.from(relatedEntryIds)
|
|
466
|
+
.map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null)
|
|
467
|
+
.filter((value): value is SVGGElement => value != null)
|
|
468
|
+
|
|
469
|
+
// Polyline kinds (duct / pipe / lineset) carry a `path`, not a
|
|
470
|
+
// `position` — translating a `position` here would write a field their
|
|
471
|
+
// schema ignores and snap the run back. For those we move every path
|
|
472
|
+
// point by the cursor delta and commit the translated `path` instead.
|
|
473
|
+
// The reference origin is the path centre so the SVG `translate` delta
|
|
474
|
+
// matches the geometry's actual location (which isn't at [0,0,0]).
|
|
475
|
+
// Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
|
|
476
|
+
// here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
|
|
477
|
+
// moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
|
|
478
|
+
const rawPath = (movingNode as { path?: unknown }).path
|
|
479
|
+
const originalPath =
|
|
480
|
+
Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
|
|
481
|
+
? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
|
|
482
|
+
: null
|
|
483
|
+
const originalPosition: [number, number, number] = originalPath
|
|
484
|
+
? (() => {
|
|
485
|
+
let cx = 0
|
|
486
|
+
let cz = 0
|
|
487
|
+
for (const p of originalPath) {
|
|
488
|
+
cx += p[0]
|
|
489
|
+
cz += p[2]
|
|
490
|
+
}
|
|
491
|
+
const n = originalPath.length || 1
|
|
492
|
+
return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
|
|
493
|
+
})()
|
|
494
|
+
: (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
|
|
495
|
+
0, 0, 0,
|
|
496
|
+
]) as [number, number, number])
|
|
497
|
+
const isFreshPlacement = isFreshPlacementMetadata(
|
|
498
|
+
(movingNode as { metadata?: unknown }).metadata,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
// SVG units in this floorplan map 1:1 to world meters, and the
|
|
502
|
+
// `<g data-node-id>` entry has no transform of its own when at rest,
|
|
503
|
+
// so its untransformed bbox IS the world-space footprint. Cache the
|
|
504
|
+
// moving entry's local bbox once (relative to originalPosition) and
|
|
505
|
+
// derive anchors at any proposed (sx, sz) by translating it.
|
|
506
|
+
const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries)
|
|
507
|
+
const candidateAnchors: AlignmentAnchor[] = []
|
|
508
|
+
const allEntries = scene.querySelectorAll('[data-node-id]')
|
|
509
|
+
for (const el of Array.from(allEntries)) {
|
|
510
|
+
const otherId = el.getAttribute('data-node-id')
|
|
511
|
+
if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue
|
|
512
|
+
const b = (el as SVGGraphicsElement).getBBox()
|
|
513
|
+
// Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
|
|
514
|
+
// lineset drawn as a line) has one zero dimension but is still a valid
|
|
515
|
+
// alignment target — its endpoints become line anchors.
|
|
516
|
+
if (b.width <= 0 && b.height <= 0) continue
|
|
517
|
+
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let lastSnapped: [number, number] | null = null
|
|
521
|
+
let dragAnchor: [number, number] | null = null
|
|
522
|
+
|
|
523
|
+
// Footprint bounding box drawn around the dragged entry — the 2D
|
|
524
|
+
// counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
|
|
525
|
+
// reads the same in both views. Green wireframe rect over the entry's
|
|
526
|
+
// own bbox, translated in lockstep with it. The entry stays visible the
|
|
527
|
+
// whole drag (no hide-until-move) so it never appears to vanish.
|
|
528
|
+
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
529
|
+
const boxEl = document.createElementNS(SVG_NS, 'rect')
|
|
530
|
+
boxEl.setAttribute('x', String(movingLocalBBox.x))
|
|
531
|
+
boxEl.setAttribute('y', String(movingLocalBBox.y))
|
|
532
|
+
boxEl.setAttribute('width', String(movingLocalBBox.width))
|
|
533
|
+
boxEl.setAttribute('height', String(movingLocalBBox.height))
|
|
534
|
+
boxEl.setAttribute('fill', 'none')
|
|
535
|
+
boxEl.setAttribute('stroke', '#22c55e')
|
|
536
|
+
boxEl.setAttribute('stroke-width', '1.5')
|
|
537
|
+
boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
|
|
538
|
+
boxEl.setAttribute('pointer-events', 'none')
|
|
539
|
+
scene.appendChild(boxEl)
|
|
540
|
+
|
|
541
|
+
const onMove = (event: PointerEvent) => {
|
|
542
|
+
// Same target guard as Path 1 — pointer must be over the floor
|
|
543
|
+
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
|
544
|
+
// plan coords.
|
|
545
|
+
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
|
546
|
+
const m = toMeters(event.clientX, event.clientY)
|
|
547
|
+
if (!m) return
|
|
548
|
+
|
|
549
|
+
// 1) Grid snap baseline. Fresh catalog placement is absolute under
|
|
550
|
+
// the cursor; existing moves preserve the cursor's grab offset. Grid
|
|
551
|
+
// follows the active snapping mode (Shift cycles it); raw cursor in
|
|
552
|
+
// any non-grid mode.
|
|
553
|
+
const gridStep = useEditor.getState().gridSnapStep
|
|
554
|
+
const snap = (value: number) =>
|
|
555
|
+
isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
|
|
556
|
+
const resolved = resolvePlanarCursorPosition({
|
|
557
|
+
cursor: [m[0], m[1]],
|
|
558
|
+
original: [originalPosition[0], originalPosition[2]],
|
|
559
|
+
anchor: dragAnchor,
|
|
560
|
+
mode: isFreshPlacement ? 'absolute' : 'relative',
|
|
561
|
+
snap,
|
|
562
|
+
})
|
|
563
|
+
dragAnchor = resolved.anchor
|
|
564
|
+
const [gridX, gridZ] = resolved.point
|
|
565
|
+
|
|
566
|
+
// 2) Alignment snap layered on top. Treat the grid-snapped point
|
|
567
|
+
// as the "proposed" position so alignment competes from a stable
|
|
568
|
+
// base rather than the raw cursor jitter. Alignment "lines" are
|
|
569
|
+
// DISPLAYED in every mode except Off (isAlignmentGuideActive); the
|
|
570
|
+
// magnetic pull toward them applies only in 'lines' mode. Alt is
|
|
571
|
+
// force-place, not a snap bypass.
|
|
572
|
+
let finalX = gridX
|
|
573
|
+
let finalZ = gridZ
|
|
574
|
+
if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
|
|
575
|
+
// Translate the cached local bbox to the proposed pos to get the
|
|
576
|
+
// moving anchors at that location. The entry's untransformed
|
|
577
|
+
// bbox is in world meters relative to the node's origin, so a
|
|
578
|
+
// simple translate suffices.
|
|
579
|
+
const dxProposed = gridX - originalPosition[0]
|
|
580
|
+
const dzProposed = gridZ - originalPosition[2]
|
|
581
|
+
// Corner-only for the moving node so it aligns by its edges, never
|
|
582
|
+
// its centreline — matching the placement tools and Path 1 move
|
|
583
|
+
// sessions. Candidates keep their full 9-point set (we DO want to
|
|
584
|
+
// align to a neighbour's centre / edge-midpoints).
|
|
585
|
+
const movingAnchors = bboxCornerAnchors(
|
|
586
|
+
movingNode.id,
|
|
587
|
+
movingLocalBBox.x + dxProposed,
|
|
588
|
+
movingLocalBBox.y + dzProposed,
|
|
589
|
+
movingLocalBBox.x + movingLocalBBox.width + dxProposed,
|
|
590
|
+
movingLocalBBox.y + movingLocalBBox.height + dzProposed,
|
|
591
|
+
)
|
|
592
|
+
// Local-frame resolve (anchors come from the building-local
|
|
593
|
+
// SVG `getBBox()`). Guides land in the editor-local alignment
|
|
594
|
+
// store, which the 2D FloorplanAlignmentGuideLayer renders
|
|
595
|
+
// inside the rotated scene <g>. The 3D pipeline uses a
|
|
596
|
+
// separate store, so frames stay isolated per surface.
|
|
597
|
+
const result = resolveAlignmentForFloorplanView({
|
|
598
|
+
moving: movingAnchors,
|
|
599
|
+
candidates: candidateAnchors,
|
|
600
|
+
threshold: ALIGNMENT_THRESHOLD_M,
|
|
601
|
+
})
|
|
602
|
+
if (result.snap && isMagneticSnapActive()) {
|
|
603
|
+
finalX += result.snap.dx
|
|
604
|
+
finalZ += result.snap.dz
|
|
605
|
+
}
|
|
606
|
+
useAlignmentGuides.getState().set(result.guides)
|
|
607
|
+
} else {
|
|
608
|
+
useAlignmentGuides.getState().clear()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
|
|
612
|
+
// 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
|
|
613
|
+
// alignment guide, so it runs in every snapping mode except Off.
|
|
614
|
+
const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
|
|
615
|
+
if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
|
|
616
|
+
const snappedPosition = groupMoveSnap({
|
|
617
|
+
node: movingNode,
|
|
618
|
+
candidatePosition: [finalX, originalPosition[1], finalZ],
|
|
619
|
+
movingIds: [movingNode.id as AnyNodeId],
|
|
620
|
+
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
621
|
+
levelId:
|
|
622
|
+
(useViewer.getState().selection.levelId as AnyNodeId | null) ??
|
|
623
|
+
(movingNode.parentId as AnyNodeId | undefined) ??
|
|
624
|
+
null,
|
|
625
|
+
})
|
|
626
|
+
if (snappedPosition) {
|
|
627
|
+
finalX = snappedPosition[0]
|
|
628
|
+
finalZ = snappedPosition[2]
|
|
629
|
+
useAlignmentGuides.getState().clear()
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const dx = finalX - originalPosition[0]
|
|
634
|
+
const dz = finalZ - originalPosition[2]
|
|
635
|
+
for (const relatedEntry of relatedEntries) {
|
|
636
|
+
relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
|
|
637
|
+
}
|
|
638
|
+
boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
|
|
639
|
+
lastSnapped = [finalX, finalZ]
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
643
|
+
if (event.button !== 0) return
|
|
644
|
+
if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
|
|
645
|
+
|
|
646
|
+
const snapped = lastSnapped
|
|
647
|
+
if (!snapped) return
|
|
648
|
+
const [sx, sz] = snapped
|
|
649
|
+
const [, oldY] = originalPosition
|
|
650
|
+
setMovingNodeOrigin('2d')
|
|
651
|
+
let selectedId = movingNode.id as AnyNodeId
|
|
652
|
+
if (originalPath) {
|
|
653
|
+
// Polyline kinds: shift every point by the committed delta and
|
|
654
|
+
// write `path`. Strip the fresh-placement flags on first drop.
|
|
655
|
+
const dx = sx - originalPosition[0]
|
|
656
|
+
const dz = sz - originalPosition[2]
|
|
657
|
+
const nextPath = originalPath.map(
|
|
658
|
+
([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
|
|
659
|
+
)
|
|
660
|
+
useScene.getState().updateNode(
|
|
661
|
+
movingNode.id as AnyNodeId,
|
|
662
|
+
(isFreshPlacement
|
|
663
|
+
? {
|
|
664
|
+
path: nextPath,
|
|
665
|
+
metadata: stripPlacementMetadataFlags(
|
|
666
|
+
(movingNode as { metadata?: unknown }).metadata,
|
|
667
|
+
),
|
|
668
|
+
visible: true,
|
|
669
|
+
}
|
|
670
|
+
: { path: nextPath }) as Partial<AnyNode>,
|
|
671
|
+
)
|
|
672
|
+
useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
|
|
673
|
+
for (const relatedEntry of relatedEntries) {
|
|
674
|
+
relatedEntry.removeAttribute('transform')
|
|
675
|
+
}
|
|
676
|
+
useAlignmentGuides.getState().clear()
|
|
677
|
+
setMovingNode(null)
|
|
678
|
+
swallowNextClick()
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
if (isFreshPlacement) {
|
|
682
|
+
selectedId =
|
|
683
|
+
commitFreshPlacementSubtree(
|
|
684
|
+
movingNode.id as AnyNodeId,
|
|
685
|
+
{
|
|
686
|
+
position: [sx, oldY, sz],
|
|
687
|
+
metadata: stripPlacementMetadataFlags(
|
|
688
|
+
(movingNode as { metadata?: unknown }).metadata,
|
|
689
|
+
),
|
|
690
|
+
visible: true,
|
|
691
|
+
} as Partial<AnyNode>,
|
|
692
|
+
) ?? selectedId
|
|
693
|
+
} else {
|
|
694
|
+
useScene.getState().updateNode(
|
|
695
|
+
movingNode.id as AnyNodeId,
|
|
696
|
+
{
|
|
697
|
+
position: [sx, oldY, sz],
|
|
698
|
+
} as Partial<AnyNode>,
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
useViewer.getState().setSelection({ selectedIds: [selectedId] })
|
|
702
|
+
for (const relatedEntry of relatedEntries) {
|
|
703
|
+
relatedEntry.removeAttribute('transform')
|
|
704
|
+
}
|
|
705
|
+
useAlignmentGuides.getState().clear()
|
|
706
|
+
setMovingNode(null)
|
|
707
|
+
swallowNextClick()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const onKey = (event: KeyboardEvent) => {
|
|
711
|
+
if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
|
|
712
|
+
if (isHistoryShortcut(event)) {
|
|
713
|
+
// ⌘Z mid-move cancels like Escape — keep it from reaching the
|
|
714
|
+
// global undo arm (this handler is capture-phase, that one bubbles).
|
|
715
|
+
event.preventDefault()
|
|
716
|
+
event.stopImmediatePropagation()
|
|
717
|
+
}
|
|
718
|
+
setMovingNodeOrigin('2d')
|
|
719
|
+
if (isFreshPlacement) {
|
|
720
|
+
emitter.emit('tool:cancel')
|
|
721
|
+
const temporal = useScene.temporal.getState()
|
|
722
|
+
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
|
|
723
|
+
if (wasTracking) temporal.pause()
|
|
724
|
+
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
|
|
725
|
+
if (wasTracking) temporal.resume()
|
|
726
|
+
}
|
|
727
|
+
for (const relatedEntry of relatedEntries) {
|
|
728
|
+
relatedEntry.removeAttribute('transform')
|
|
729
|
+
}
|
|
730
|
+
useAlignmentGuides.getState().clear()
|
|
731
|
+
setMovingNode(null)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
window.addEventListener('pointermove', onMove)
|
|
735
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
736
|
+
window.addEventListener('keydown', onKey, true)
|
|
737
|
+
return () => {
|
|
738
|
+
window.removeEventListener('pointermove', onMove)
|
|
739
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
740
|
+
window.removeEventListener('keydown', onKey, true)
|
|
741
|
+
for (const relatedEntry of relatedEntries) {
|
|
742
|
+
relatedEntry.removeAttribute('transform')
|
|
743
|
+
}
|
|
744
|
+
// Always un-hide on teardown so a committed copy shows and a
|
|
745
|
+
// never-revealed entry doesn't leak a hidden style onto a reused node.
|
|
746
|
+
for (const relatedEntry of relatedEntries) {
|
|
747
|
+
relatedEntry.style.visibility = ''
|
|
748
|
+
}
|
|
749
|
+
boxEl.remove()
|
|
750
|
+
useAlignmentGuides.getState().clear()
|
|
751
|
+
}
|
|
752
|
+
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
|
753
|
+
|
|
754
|
+
return null
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
|
|
758
|
+
//
|
|
759
|
+
// Kept inline here to avoid a circular dependency through a shared
|
|
760
|
+
// utility module. If a third call site shows up, extract.
|
|
761
|
+
|
|
762
|
+
type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
|
|
763
|
+
|
|
764
|
+
function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
765
|
+
const data: Record<string, unknown> = {}
|
|
766
|
+
for (const [key, value] of Object.entries(node)) {
|
|
767
|
+
if (key === 'id' || key === 'type' || key === 'object') continue
|
|
768
|
+
data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
|
|
769
|
+
}
|
|
770
|
+
return { id: node.id, data }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
|
774
|
+
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
778
|
+
if (a === b) return true
|
|
779
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
780
|
+
if (a.length !== b.length) return false
|
|
781
|
+
for (let i = 0; i < a.length; i++) {
|
|
782
|
+
if (!deepEqual(a[i], b[i])) return false
|
|
783
|
+
}
|
|
784
|
+
return true
|
|
785
|
+
}
|
|
786
|
+
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
|
|
787
|
+
const aKeys = Object.keys(a as Record<string, unknown>)
|
|
788
|
+
const bKeys = Object.keys(b as Record<string, unknown>)
|
|
789
|
+
if (aKeys.length !== bKeys.length) return false
|
|
790
|
+
for (const key of aKeys) {
|
|
791
|
+
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
|
792
|
+
return false
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return true
|
|
796
|
+
}
|
|
797
|
+
return false
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function collectFloorplanMoveEntryIds(
|
|
801
|
+
rootId: AnyNodeId,
|
|
802
|
+
nodes: Record<string, AnyNode>,
|
|
803
|
+
): Set<AnyNodeId> {
|
|
804
|
+
const root = nodes[rootId]
|
|
805
|
+
if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') {
|
|
806
|
+
return new Set([rootId])
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const ids = new Set<AnyNodeId>()
|
|
810
|
+
const queue: AnyNodeId[] = [rootId]
|
|
811
|
+
while (queue.length > 0) {
|
|
812
|
+
const id = queue.pop()!
|
|
813
|
+
if (ids.has(id)) continue
|
|
814
|
+
const node = nodes[id]
|
|
815
|
+
if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue
|
|
816
|
+
ids.add(id)
|
|
817
|
+
for (const childId of node.children ?? []) {
|
|
818
|
+
const child = nodes[childId as AnyNodeId]
|
|
819
|
+
if (child?.type === 'cabinet' || child?.type === 'cabinet-module') {
|
|
820
|
+
queue.push(childId as AnyNodeId)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return ids
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect {
|
|
828
|
+
const first = entries[0]?.getBBox()
|
|
829
|
+
if (!first) return new DOMRect(0, 0, 0, 0)
|
|
830
|
+
|
|
831
|
+
let minX = first.x
|
|
832
|
+
let minY = first.y
|
|
833
|
+
let maxX = first.x + first.width
|
|
834
|
+
let maxY = first.y + first.height
|
|
835
|
+
for (const entry of entries.slice(1)) {
|
|
836
|
+
const box = entry.getBBox()
|
|
837
|
+
minX = Math.min(minX, box.x)
|
|
838
|
+
minY = Math.min(minY, box.y)
|
|
839
|
+
maxX = Math.max(maxX, box.x + box.width)
|
|
840
|
+
maxY = Math.max(maxY, box.y + box.height)
|
|
841
|
+
}
|
|
842
|
+
return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY))
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function swallowNextClick() {
|
|
846
|
+
const swallowClick = (e: MouseEvent) => {
|
|
847
|
+
e.stopPropagation()
|
|
848
|
+
e.preventDefault()
|
|
849
|
+
window.removeEventListener('click', swallowClick, true)
|
|
850
|
+
}
|
|
851
|
+
window.addEventListener('click', swallowClick, true)
|
|
852
|
+
// Safety net: if no click fires (e.g. user dragged enough to suppress it),
|
|
853
|
+
// drop the listener on the next tick.
|
|
854
|
+
setTimeout(() => {
|
|
855
|
+
window.removeEventListener('click', swallowClick, true)
|
|
856
|
+
}, 0)
|
|
857
|
+
}
|