@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,3325 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeDefinition,
|
|
6
|
+
type AnyNodeId,
|
|
7
|
+
createSceneApi,
|
|
8
|
+
emitter,
|
|
9
|
+
type FloorplanAffordanceSession,
|
|
10
|
+
type FloorplanGeometry,
|
|
11
|
+
type FloorplanPalette,
|
|
12
|
+
type FloorplanPoint,
|
|
13
|
+
type GeometryContext,
|
|
14
|
+
isNodeKindEnabled,
|
|
15
|
+
isRegistryMovable,
|
|
16
|
+
kindsWithFloorplanScope,
|
|
17
|
+
type LiveNodeOverrides,
|
|
18
|
+
type LiveTransform,
|
|
19
|
+
nodeRegistry,
|
|
20
|
+
pauseSceneHistory,
|
|
21
|
+
resolveBuildingForLevel,
|
|
22
|
+
resolveSelectionProxyId,
|
|
23
|
+
resumeSceneHistory,
|
|
24
|
+
useInteractive,
|
|
25
|
+
useLiveNodeOverrides,
|
|
26
|
+
useLiveTransforms,
|
|
27
|
+
useScene,
|
|
28
|
+
} from '@pascal-app/core'
|
|
29
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
30
|
+
import {
|
|
31
|
+
memo,
|
|
32
|
+
type MouseEvent as ReactMouseEvent,
|
|
33
|
+
type PointerEvent as ReactPointerEvent,
|
|
34
|
+
useCallback,
|
|
35
|
+
useEffect,
|
|
36
|
+
useMemo,
|
|
37
|
+
useRef,
|
|
38
|
+
useState,
|
|
39
|
+
} from 'react'
|
|
40
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
41
|
+
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
|
|
42
|
+
import {
|
|
43
|
+
canDirectRotateNode,
|
|
44
|
+
resolveDirectManipulationNode,
|
|
45
|
+
resolveDirectRotationDragDelta,
|
|
46
|
+
resolveDirectRotationPatch,
|
|
47
|
+
snapDirectRotationDelta,
|
|
48
|
+
} from '../../../lib/direct-manipulation'
|
|
49
|
+
import { createEditorApi } from '../../../lib/editor-api'
|
|
50
|
+
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
|
|
51
|
+
import {
|
|
52
|
+
type ActiveInteractionScope,
|
|
53
|
+
boundaryReshapeScope,
|
|
54
|
+
controlPointReshapeScope,
|
|
55
|
+
curveReshapeScope,
|
|
56
|
+
endpointReshapeScope,
|
|
57
|
+
holeEditScope,
|
|
58
|
+
tangentReshapeScope,
|
|
59
|
+
} from '../../../lib/interaction/scope'
|
|
60
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
61
|
+
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
|
62
|
+
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
|
63
|
+
import useEditor from '../../../store/use-editor'
|
|
64
|
+
import useInteractionScope, {
|
|
65
|
+
useEndpointReshape,
|
|
66
|
+
useMovingNode,
|
|
67
|
+
} from '../../../store/use-interaction-scope'
|
|
68
|
+
import { startGroupPickUp } from '../../editor/group-actions'
|
|
69
|
+
import { classifyParticipant } from '../../editor/group-transform-shared'
|
|
70
|
+
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
|
71
|
+
import {
|
|
72
|
+
FloorplanGroupSelectionBox,
|
|
73
|
+
startFloorplanGroupMove,
|
|
74
|
+
startFloorplanGroupRotate,
|
|
75
|
+
} from '../floorplan-group-move'
|
|
76
|
+
import { useFloorplanRender } from '../floorplan-render-context'
|
|
77
|
+
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
78
|
+
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Registry-driven floor-plan layer.
|
|
82
|
+
*
|
|
83
|
+
* For every node in the active level whose definition exposes
|
|
84
|
+
* `def.floorplan`, builds a `GeometryContext` (with `viewState` so the
|
|
85
|
+
* kind can theme its output), calls the builder, and walks the resulting
|
|
86
|
+
* tree. Static primitives (polygon / line / circle / etc.) defer to
|
|
87
|
+
* `<FloorplanGeometryRenderer>`. Interactive primitives — `hatch`,
|
|
88
|
+
* `hit-line`, `endpoint-handle`, `dimension-label` — render here so they
|
|
89
|
+
* can access the SVG context for pointer events + units-per-pixel.
|
|
90
|
+
*
|
|
91
|
+
* Selection: clicking the entry's `<g>` selects the node. The wall
|
|
92
|
+
* `def.floorplan` also emits a `hit-line` along the centerline so the
|
|
93
|
+
* user can grab the wall body even at zoom levels where the polygon is
|
|
94
|
+
* skinny.
|
|
95
|
+
*
|
|
96
|
+
* 2D endpoint drag: when an `endpoint-handle` is pointer-downed and its
|
|
97
|
+
* `affordance === 'move-endpoint'`, this layer drives the legacy wall
|
|
98
|
+
* endpoint flow inline — snap pointer to walls/grid, run linked-wall
|
|
99
|
+
* cascade, live-update positions with history paused, single undo on
|
|
100
|
+
* commit. The kind-generic abstraction lands once fence + slab + ceiling
|
|
101
|
+
* pick up their 2D drags too (next iteration).
|
|
102
|
+
*/
|
|
103
|
+
// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
|
|
104
|
+
// constants in floorplan-panel.tsx. Sizes are in screen pixels — the
|
|
105
|
+
// dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
|
|
106
|
+
// screen size at any zoom.
|
|
107
|
+
const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
|
|
108
|
+
const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
|
|
109
|
+
const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
|
|
110
|
+
const ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4
|
|
111
|
+
const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
|
|
112
|
+
const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
|
|
113
|
+
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
|
|
114
|
+
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
|
115
|
+
const DIRECT_DRAG_THRESHOLD_PX = 4
|
|
116
|
+
const DIRECT_ROTATE_EPSILON = 1e-6
|
|
117
|
+
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Snapshot of node fields captured at drag-start, used by the single-undo
|
|
121
|
+
* dance to revert untracked before re-applying as a single tracked
|
|
122
|
+
* change. The dispatcher only knows about the `affectedIds` the
|
|
123
|
+
* affordance declares; it captures whatever fields exist on each node by
|
|
124
|
+
* cloning the full record minus the registry-managed `id` / `type`.
|
|
125
|
+
*/
|
|
126
|
+
type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
|
|
127
|
+
|
|
128
|
+
type ActiveDrag = {
|
|
129
|
+
pointerId: number
|
|
130
|
+
captureTarget: Element
|
|
131
|
+
/** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
|
|
132
|
+
handleId: string
|
|
133
|
+
session: FloorplanAffordanceSession
|
|
134
|
+
snapshots: NodeSnapshot[]
|
|
135
|
+
historyPaused: boolean
|
|
136
|
+
/**
|
|
137
|
+
* Last plan point handed to `session.apply` (the grab point until the first
|
|
138
|
+
* move). Lets the modifier-key listeners re-run the session immediately on
|
|
139
|
+
* an Alt/Shift flip instead of waiting for the next pointer move.
|
|
140
|
+
*/
|
|
141
|
+
lastPlanPoint: FloorplanPoint
|
|
142
|
+
/**
|
|
143
|
+
* Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
|
|
144
|
+
* the live angle wedge + degree readout — the 2D twin of the 3D rotate
|
|
145
|
+
* gizmo's readout. The bearing sweep is measured the same way every
|
|
146
|
+
* rotate affordance measures it: `atan2(pointer − pivot)`.
|
|
147
|
+
*/
|
|
148
|
+
rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
|
|
149
|
+
/**
|
|
150
|
+
* Node id of the reshaping scope this drag began (boundary / curve / endpoint
|
|
151
|
+
* edits), so the matching `endIf` on release/cancel tears down exactly this
|
|
152
|
+
* scope. Unset for affordances that drive no snapping scope (resize / rotate).
|
|
153
|
+
*/
|
|
154
|
+
reshapeScopeNodeId?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type FloorplanAffordanceCancelEffects = {
|
|
158
|
+
restoreSnapshots: (snapshots: NodeSnapshot[]) => void
|
|
159
|
+
resumeHistory: () => void
|
|
160
|
+
clearPreview: (id: AnyNodeId) => void
|
|
161
|
+
clearSnapFeedback: () => void
|
|
162
|
+
endReshapeScope: (drag: ActiveDrag) => void
|
|
163
|
+
clearDragFeedback?: () => void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function cancelFloorplanAffordanceDrag(
|
|
167
|
+
dragRef: { current: ActiveDrag | null },
|
|
168
|
+
effects: FloorplanAffordanceCancelEffects,
|
|
169
|
+
pointerId?: number,
|
|
170
|
+
): boolean {
|
|
171
|
+
const drag = dragRef.current
|
|
172
|
+
if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
|
|
173
|
+
|
|
174
|
+
// Clear ownership before cleanup so a queued pointer-up cannot commit the
|
|
175
|
+
// session while cancellation side effects are still running.
|
|
176
|
+
dragRef.current = null
|
|
177
|
+
|
|
178
|
+
if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
|
|
179
|
+
drag.captureTarget.releasePointerCapture?.(drag.pointerId)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
effects.restoreSnapshots(drag.snapshots)
|
|
183
|
+
if (drag.historyPaused) {
|
|
184
|
+
effects.resumeHistory()
|
|
185
|
+
drag.historyPaused = false
|
|
186
|
+
}
|
|
187
|
+
effects.clearSnapFeedback()
|
|
188
|
+
for (const id of drag.session.affectedIds) effects.clearPreview(id)
|
|
189
|
+
effects.endReshapeScope(drag)
|
|
190
|
+
effects.clearDragFeedback?.()
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function subscribeFloorplanAffordanceToolCancel(
|
|
195
|
+
cancelActiveDrag: () => boolean,
|
|
196
|
+
consumeToolCancel: () => void,
|
|
197
|
+
): () => void {
|
|
198
|
+
const onToolCancel = () => {
|
|
199
|
+
if (cancelActiveDrag()) consumeToolCancel()
|
|
200
|
+
}
|
|
201
|
+
emitter.on('tool:cancel', onToolCancel)
|
|
202
|
+
return () => emitter.off('tool:cancel', onToolCancel)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Map a floor-plan affordance to the reshaping scope it represents, so the
|
|
206
|
+
// dispatcher can drive the contextual snapping HUD (the chip) AND make
|
|
207
|
+
// `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
|
|
208
|
+
// edits that set a direction/shape map to a scope; resize / rotate / body-move
|
|
209
|
+
// affordances return `null` (no polygon/wall snapping chip). Keyed off the
|
|
210
|
+
// affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
|
|
211
|
+
// / `curve` / `move-endpoint`).
|
|
212
|
+
function affordanceReshapeScope(
|
|
213
|
+
affordance: string,
|
|
214
|
+
nodeId: string,
|
|
215
|
+
payload: unknown,
|
|
216
|
+
): ActiveInteractionScope | null {
|
|
217
|
+
if (affordance.includes('vertex') || affordance.includes('edge')) {
|
|
218
|
+
const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
|
|
219
|
+
return holeIndex !== undefined
|
|
220
|
+
? holeEditScope({ nodeId, holeIndex })
|
|
221
|
+
: boundaryReshapeScope(nodeId)
|
|
222
|
+
}
|
|
223
|
+
if (affordance.includes('curve')) {
|
|
224
|
+
return curveReshapeScope(nodeId)
|
|
225
|
+
}
|
|
226
|
+
if (affordance.includes('control-point')) {
|
|
227
|
+
const index = (payload as { index?: number } | undefined)?.index ?? 0
|
|
228
|
+
return controlPointReshapeScope(nodeId, index)
|
|
229
|
+
}
|
|
230
|
+
if (affordance.includes('tangent')) {
|
|
231
|
+
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
|
|
232
|
+
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
|
|
233
|
+
}
|
|
234
|
+
if (affordance.includes('endpoint')) {
|
|
235
|
+
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
|
236
|
+
return endpointReshapeScope(nodeId, endpoint)
|
|
237
|
+
}
|
|
238
|
+
// Roof-segment width/depth resize — a no-angle dimension edit, so the
|
|
239
|
+
// no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
|
|
240
|
+
// Matched exactly so a still-legacy `*-resize` affordance on another kind
|
|
241
|
+
// doesn't get a chip its snap math can't honour yet.
|
|
242
|
+
if (affordance === 'roof-segment-resize') {
|
|
243
|
+
return boundaryReshapeScope(nodeId)
|
|
244
|
+
}
|
|
245
|
+
// 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
|
|
246
|
+
// stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
|
|
247
|
+
// matched, so the contextual HUD shows the "Shift = rotate freely" hint over
|
|
248
|
+
// the drag. The affordance applies the 15° angle step itself.
|
|
249
|
+
if (affordance.includes('rotate')) {
|
|
250
|
+
return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Transient live-rotation readout state. Rebuilt each pointer-move while a
|
|
257
|
+
* rotate-arrow is dragged and cleared on release. World-plan coords.
|
|
258
|
+
*/
|
|
259
|
+
type RotationOverlayState = {
|
|
260
|
+
pivot: FloorplanPoint
|
|
261
|
+
startAngle: number
|
|
262
|
+
endAngle: number
|
|
263
|
+
radius: number
|
|
264
|
+
/** Swept magnitude in radians, for the degree chip. */
|
|
265
|
+
sweep: number
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type FloorplanEntryDescriptor = {
|
|
269
|
+
id: AnyNodeId
|
|
270
|
+
node: AnyNode
|
|
271
|
+
dependsOnSiblingInputs: boolean
|
|
272
|
+
ctxOverrides?: FloorplanContextOverrides
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type NodeDeps = {
|
|
276
|
+
node: AnyNode
|
|
277
|
+
live: LiveTransform | undefined
|
|
278
|
+
unit: 'metric' | 'imperial'
|
|
279
|
+
selected: boolean
|
|
280
|
+
highlighted: boolean
|
|
281
|
+
hovered: boolean
|
|
282
|
+
moving: boolean
|
|
283
|
+
liveOverride: LiveNodeOverrides | undefined
|
|
284
|
+
palette: FloorplanPalette | undefined
|
|
285
|
+
siblingEpoch: number
|
|
286
|
+
committedNodes: Record<string, AnyNode> | null
|
|
287
|
+
dependencyNodes: AnyNode[]
|
|
288
|
+
interactiveElevators: unknown
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type CacheEntry = {
|
|
292
|
+
deps: NodeDeps
|
|
293
|
+
base: FloorplanGeometry | null
|
|
294
|
+
overlay: FloorplanGeometry | null
|
|
295
|
+
node: AnyNode
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
type LevelDataCacheEntry = {
|
|
299
|
+
nodes: Record<string, AnyNode>
|
|
300
|
+
liveOverrides: Map<string, LiveNodeOverrides>
|
|
301
|
+
ids: readonly AnyNodeId[]
|
|
302
|
+
value: unknown
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type FloorplanContextOverrides = {
|
|
306
|
+
children: AnyNode[]
|
|
307
|
+
siblings: AnyNode[]
|
|
308
|
+
parent: AnyNode | null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type FloorplanLevelDataHook = (args: {
|
|
312
|
+
siblings: ReadonlyArray<AnyNode>
|
|
313
|
+
nodes: Record<string, AnyNode>
|
|
314
|
+
}) => unknown
|
|
315
|
+
|
|
316
|
+
type FloorplanRenderPass = 'base' | 'overlay'
|
|
317
|
+
|
|
318
|
+
const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
|
|
319
|
+
// Group members advertise the drag-to-move-the-selection gesture.
|
|
320
|
+
const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
|
|
321
|
+
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
|
|
322
|
+
|
|
323
|
+
function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
324
|
+
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
|
325
|
+
// deep-cloned to detach from the live store reference.
|
|
326
|
+
const data: Record<string, unknown> = {}
|
|
327
|
+
for (const [key, value] of Object.entries(node)) {
|
|
328
|
+
if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') continue
|
|
329
|
+
data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
|
|
330
|
+
}
|
|
331
|
+
return { id: node.id, data }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
|
335
|
+
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Stable empty sentinel used by per-entry builders while the floor plan is
|
|
339
|
+
// hidden; committed scene edits still flow through `useScene`.
|
|
340
|
+
const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
|
|
341
|
+
|
|
342
|
+
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
343
|
+
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
|
344
|
+
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
|
345
|
+
const unit = useViewer((s) => s.unit)
|
|
346
|
+
const showMeasurements = useViewer((s) => s.showMeasurements)
|
|
347
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
348
|
+
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
349
|
+
const hoveredId = useViewer((s) => s.hoveredId)
|
|
350
|
+
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
|
|
351
|
+
const setHoveredId = useViewer((s) => s.setHoveredId)
|
|
352
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
353
|
+
const nodes = useScene((s) => s.nodes)
|
|
354
|
+
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
355
|
+
const movingNode = useMovingNode()
|
|
356
|
+
// When a building is being moved, its explicit selection may be
|
|
357
|
+
// cleared as part of the move handoff. Fall back to the
|
|
358
|
+
// mid-drag building id so the dimmed floor keeps rendering
|
|
359
|
+
// throughout the gesture.
|
|
360
|
+
const movingBuildingId =
|
|
361
|
+
movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
|
|
362
|
+
? movingNode.id
|
|
363
|
+
: null
|
|
364
|
+
const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
|
|
365
|
+
|
|
366
|
+
// When only a building is in scope (no specific level), fall back to
|
|
367
|
+
// its level 0 (or the lowest-indexed level) so the floor still
|
|
368
|
+
// renders as context — dimmed and non-interactive — instead of
|
|
369
|
+
// disappearing entirely.
|
|
370
|
+
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
|
|
371
|
+
if (selectedLevelId || !ambientBuildingSourceId) return null
|
|
372
|
+
const building = nodes[ambientBuildingSourceId]
|
|
373
|
+
if (building?.type !== 'building') return null
|
|
374
|
+
let zero: AnyNodeId | null = null
|
|
375
|
+
let lowestId: AnyNodeId | null = null
|
|
376
|
+
let lowestIdx = Number.POSITIVE_INFINITY
|
|
377
|
+
const childIds = (building as unknown as { children?: AnyNodeId[] }).children ?? []
|
|
378
|
+
for (const childId of childIds) {
|
|
379
|
+
const child = nodes[childId]
|
|
380
|
+
if (child?.type !== 'level') continue
|
|
381
|
+
if (child.level === 0) {
|
|
382
|
+
zero = child.id
|
|
383
|
+
break
|
|
384
|
+
}
|
|
385
|
+
if (child.level < lowestIdx) {
|
|
386
|
+
lowestIdx = child.level
|
|
387
|
+
lowestId = child.id
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return zero ?? lowestId
|
|
391
|
+
}, [selectedLevelId, ambientBuildingSourceId, nodes])
|
|
392
|
+
|
|
393
|
+
const levelId = selectedLevelId ?? ambientLevelId
|
|
394
|
+
const isAmbient = !selectedLevelId && !!ambientLevelId
|
|
395
|
+
const renderCtx = useFloorplanRender()
|
|
396
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
397
|
+
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
398
|
+
// Door / window placement (both build and move) needs the SVG's
|
|
399
|
+
// background click handler to run — it finds the closest wall via
|
|
400
|
+
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
|
401
|
+
// tool. When the user clicks *on top of* a wall in this mode, the
|
|
402
|
+
// wall's registry entry would otherwise swallow the click via
|
|
403
|
+
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
|
404
|
+
// Pass clicks through in that case.
|
|
405
|
+
const editorPhase = useEditor((s) => s.phase)
|
|
406
|
+
const editorMode = useEditor((s) => s.mode)
|
|
407
|
+
const editorTool = useEditor((s) => s.tool)
|
|
408
|
+
const structureLayer = useEditor((s) => s.structureLayer)
|
|
409
|
+
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
410
|
+
const endpointReshape = useEndpointReshape()
|
|
411
|
+
const isOpeningPlacementActive =
|
|
412
|
+
(editorPhase === 'structure' &&
|
|
413
|
+
editorMode === 'build' &&
|
|
414
|
+
(editorTool === 'door' || editorTool === 'window')) ||
|
|
415
|
+
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
|
416
|
+
const isMarqueeSelectionActive =
|
|
417
|
+
editorMode === 'select' &&
|
|
418
|
+
floorplanSelectionTool === 'marquee' &&
|
|
419
|
+
structureLayer !== 'zones' &&
|
|
420
|
+
!movingNode &&
|
|
421
|
+
!endpointReshape
|
|
422
|
+
// While the floor plan is not on screen (pure 3D view), per-entry live
|
|
423
|
+
// selectors freeze to `undefined` so drag publishes do not re-render the
|
|
424
|
+
// hidden floor-plan tree.
|
|
425
|
+
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
|
|
426
|
+
// Elevator builders read runtime state imperatively, so entries include this
|
|
427
|
+
// rare-changing ref in their cache deps.
|
|
428
|
+
const interactiveElevators = useInteractive((s) => s.elevators)
|
|
429
|
+
|
|
430
|
+
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
431
|
+
// Marquee preview selection — matches the legacy `highlightedIdSet` use
|
|
432
|
+
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
|
|
433
|
+
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
|
|
434
|
+
// Multi-selection: members show highlight only (per-node edit chrome hidden)
|
|
435
|
+
// and transformable members advertise the drag-to-move gesture.
|
|
436
|
+
const isMultiSelect = selectedIds.length > 1
|
|
437
|
+
const groupParticipantIdSet = useMemo(() => {
|
|
438
|
+
if (selectedIds.length < 2 || !levelId) return null
|
|
439
|
+
return new Set(
|
|
440
|
+
selectedIds.filter(
|
|
441
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
}, [selectedIds, levelId, nodes])
|
|
445
|
+
|
|
446
|
+
// Interactive state lives in refs; only the visible feedback bits go
|
|
447
|
+
// into React state to keep re-renders cheap during drag.
|
|
448
|
+
const dragRef = useRef<ActiveDrag | null>(null)
|
|
449
|
+
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
|
450
|
+
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
|
451
|
+
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
|
452
|
+
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
|
|
453
|
+
const levelDataCacheRef = useRef<Map<string, LevelDataCacheEntry>>(new Map())
|
|
454
|
+
const nodesRef = useRef(nodes)
|
|
455
|
+
const [siblingEpochs, setSiblingEpochs] = useState<Map<AnyNodeId, number>>(() => new Map())
|
|
456
|
+
// Per-node sibling epoch (replaces a single global epoch). Bumped only for the
|
|
457
|
+
// nodes affected by this frame's live drags, so an unaffected wall/opening
|
|
458
|
+
// keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which
|
|
459
|
+
// sibling-dependent nodes were live last frame, so a node that just STOPPED
|
|
460
|
+
// being dragged (override cleared, no commit) still gets one final rebuild to
|
|
461
|
+
// revert — its dependents (host wall, junction neighbours) don't carry its
|
|
462
|
+
// override in their own deps.
|
|
463
|
+
const nodeSiblingEpochRef = useRef<Map<AnyNodeId, number>>(new Map())
|
|
464
|
+
const prevLiveFlaggedIdsRef = useRef<AnyNodeId[]>([])
|
|
465
|
+
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
nodesRef.current = nodes
|
|
468
|
+
}, [nodes])
|
|
469
|
+
|
|
470
|
+
const bumpAffectedSiblingEpochs = useCallback(() => {
|
|
471
|
+
if (!floorplanVisible) return
|
|
472
|
+
|
|
473
|
+
const sceneNodes = nodesRef.current
|
|
474
|
+
const liveTransforms = useLiveTransforms.getState().transforms
|
|
475
|
+
const liveOverrides = useLiveNodeOverrides.getState().overrides
|
|
476
|
+
const liveFlaggedIds: AnyNodeId[] = []
|
|
477
|
+
|
|
478
|
+
for (const [id] of liveTransforms) {
|
|
479
|
+
const node = sceneNodes[id as AnyNodeId]
|
|
480
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
481
|
+
if (
|
|
482
|
+
node &&
|
|
483
|
+
(def?.floorplanDependsOnSiblings ||
|
|
484
|
+
def?.floorplanSiblingOverrides ||
|
|
485
|
+
def?.floorplanAffectedIds)
|
|
486
|
+
) {
|
|
487
|
+
liveFlaggedIds.push(id as AnyNodeId)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
for (const [id] of liveOverrides) {
|
|
491
|
+
const node = sceneNodes[id as AnyNodeId]
|
|
492
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
493
|
+
if (
|
|
494
|
+
node &&
|
|
495
|
+
(def?.floorplanDependsOnSiblings ||
|
|
496
|
+
def?.floorplanSiblingOverrides ||
|
|
497
|
+
def?.floorplanAffectedIds)
|
|
498
|
+
) {
|
|
499
|
+
liveFlaggedIds.push(id as AnyNodeId)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current]))
|
|
504
|
+
const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, sceneNodes, liveOverrides)
|
|
505
|
+
const nodeSiblingEpochs = nodeSiblingEpochRef.current
|
|
506
|
+
for (const id of affectedSiblingIds) {
|
|
507
|
+
nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1)
|
|
508
|
+
}
|
|
509
|
+
prevLiveFlaggedIdsRef.current = liveFlaggedIds
|
|
510
|
+
if (affectedSiblingIds.size > 0) {
|
|
511
|
+
setSiblingEpochs(new Map(nodeSiblingEpochs))
|
|
512
|
+
}
|
|
513
|
+
}, [floorplanVisible])
|
|
514
|
+
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
bumpAffectedSiblingEpochs()
|
|
517
|
+
const unsubscribeTransforms = useLiveTransforms.subscribe(bumpAffectedSiblingEpochs)
|
|
518
|
+
const unsubscribeOverrides = useLiveNodeOverrides.subscribe(bumpAffectedSiblingEpochs)
|
|
519
|
+
return () => {
|
|
520
|
+
unsubscribeTransforms()
|
|
521
|
+
unsubscribeOverrides()
|
|
522
|
+
}
|
|
523
|
+
}, [bumpAffectedSiblingEpochs])
|
|
524
|
+
|
|
525
|
+
const applyEntrySelection = useCallback(
|
|
526
|
+
(id: AnyNodeId, shouldToggle: boolean) => {
|
|
527
|
+
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
528
|
+
setSelection({
|
|
529
|
+
selectedIds: shouldToggle
|
|
530
|
+
? currentSelectedIds.includes(id)
|
|
531
|
+
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
|
532
|
+
: [...currentSelectedIds, id]
|
|
533
|
+
: [id],
|
|
534
|
+
})
|
|
535
|
+
// Setting selection re-renders the entry — the overlay pass mounts
|
|
536
|
+
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
|
537
|
+
// pointerdown and click. If the click target ends up on the SVG
|
|
538
|
+
// background, `<g floorplan-registry-layer onClick=handleClickStop>`
|
|
539
|
+
// never sees it, and the SVG's `handleBackgroundClick` clears the
|
|
540
|
+
// selection we just set. Swallow the next click globally to break
|
|
541
|
+
// that race; the listener removes itself after firing (or after a
|
|
542
|
+
// safety timeout if no click follows).
|
|
543
|
+
swallowNextClick(200)
|
|
544
|
+
},
|
|
545
|
+
[setSelection],
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
const handleSelect = useCallback(
|
|
549
|
+
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
|
550
|
+
if (event.button !== 0) return
|
|
551
|
+
event.stopPropagation()
|
|
552
|
+
applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
|
|
553
|
+
},
|
|
554
|
+
[applyEntrySelection],
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
|
558
|
+
event.stopPropagation()
|
|
559
|
+
}, [])
|
|
560
|
+
|
|
561
|
+
const startDirectMoveDrag = useCallback(
|
|
562
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
|
563
|
+
if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
|
|
564
|
+
|
|
565
|
+
const node = useScene.getState().nodes[id]
|
|
566
|
+
if (!node || !isRegistryMovable(node.type)) return false
|
|
567
|
+
// Sole selection only: per-node direct manipulation stands down for a
|
|
568
|
+
// multi-selection (the group session owns plain drags there, and Cmd is
|
|
569
|
+
// the selection-toggle key — a wobbly Cmd+click must not yank one
|
|
570
|
+
// member out of the group).
|
|
571
|
+
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
572
|
+
if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
|
|
573
|
+
|
|
574
|
+
event.preventDefault()
|
|
575
|
+
event.stopPropagation()
|
|
576
|
+
|
|
577
|
+
const startX = event.clientX
|
|
578
|
+
const startY = event.clientY
|
|
579
|
+
const pointerId = event.pointerId
|
|
580
|
+
let engaged = false
|
|
581
|
+
|
|
582
|
+
const cleanup = () => {
|
|
583
|
+
window.removeEventListener('pointermove', onMove)
|
|
584
|
+
window.removeEventListener('pointerup', onEnd)
|
|
585
|
+
window.removeEventListener('pointercancel', onEnd)
|
|
586
|
+
if (engaged) {
|
|
587
|
+
useViewer.getState().setInputDragging(false)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
592
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
593
|
+
if (engaged) return
|
|
594
|
+
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY)
|
|
595
|
+
if (distance < DIRECT_DRAG_THRESHOLD_PX) return
|
|
596
|
+
|
|
597
|
+
engaged = true
|
|
598
|
+
useViewer.getState().setInputDragging(true)
|
|
599
|
+
swallowNextClick(300)
|
|
600
|
+
createEditorApi().engageMoveDrag(node)
|
|
601
|
+
|
|
602
|
+
requestAnimationFrame(() => {
|
|
603
|
+
window.dispatchEvent(
|
|
604
|
+
new PointerEvent('pointermove', {
|
|
605
|
+
altKey: moveEvent.altKey,
|
|
606
|
+
bubbles: true,
|
|
607
|
+
buttons: moveEvent.buttons,
|
|
608
|
+
clientX: moveEvent.clientX,
|
|
609
|
+
clientY: moveEvent.clientY,
|
|
610
|
+
ctrlKey: moveEvent.ctrlKey,
|
|
611
|
+
metaKey: moveEvent.metaKey,
|
|
612
|
+
pointerId,
|
|
613
|
+
pointerType: moveEvent.pointerType,
|
|
614
|
+
shiftKey: moveEvent.shiftKey,
|
|
615
|
+
}),
|
|
616
|
+
)
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const onEnd = (endEvent: PointerEvent) => {
|
|
621
|
+
if (endEvent.pointerId !== pointerId) return
|
|
622
|
+
cleanup()
|
|
623
|
+
if (!engaged) {
|
|
624
|
+
applyEntrySelection(id, true)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
window.addEventListener('pointermove', onMove)
|
|
629
|
+
window.addEventListener('pointerup', onEnd)
|
|
630
|
+
window.addEventListener('pointercancel', onEnd)
|
|
631
|
+
return true
|
|
632
|
+
},
|
|
633
|
+
[applyEntrySelection],
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
const startDirectRotateDrag = useCallback(
|
|
637
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
|
638
|
+
if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
|
|
639
|
+
|
|
640
|
+
const sceneNodes = useScene.getState().nodes
|
|
641
|
+
const selectedNode = sceneNodes[id]
|
|
642
|
+
const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
|
|
643
|
+
if (!node || !canDirectRotateNode(node)) return false
|
|
644
|
+
// Sole selection only — same stand-down as the direct move above.
|
|
645
|
+
const selectedIds = useViewer.getState().selection.selectedIds
|
|
646
|
+
if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
|
|
647
|
+
event.preventDefault()
|
|
648
|
+
event.stopPropagation()
|
|
649
|
+
|
|
650
|
+
const nodeId = node.id as AnyNodeId
|
|
651
|
+
const pointerId = event.pointerId
|
|
652
|
+
const startX = event.clientX
|
|
653
|
+
const sceneApi = createSceneApi(useScene)
|
|
654
|
+
let lastPatch: Partial<AnyNode> | null = null
|
|
655
|
+
|
|
656
|
+
const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
|
|
657
|
+
const delta = resolveDirectRotationDragDelta(
|
|
658
|
+
startX,
|
|
659
|
+
pointerEvent.clientX,
|
|
660
|
+
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
|
661
|
+
pointerEvent.shiftKey,
|
|
662
|
+
)
|
|
663
|
+
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
|
664
|
+
lastPatch = null
|
|
665
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
666
|
+
useScene.getState().markDirty(nodeId)
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
const patch = resolveDirectRotationPatch(node, delta, sceneApi)
|
|
670
|
+
if (!patch) return
|
|
671
|
+
lastPatch = patch
|
|
672
|
+
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
|
|
673
|
+
useScene.getState().markDirty(nodeId)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const cleanup = () => {
|
|
677
|
+
window.removeEventListener('pointermove', onMove, true)
|
|
678
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
679
|
+
window.removeEventListener('pointercancel', onCancel, true)
|
|
680
|
+
window.removeEventListener('contextmenu', preventContextMenu, true)
|
|
681
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
682
|
+
useScene.getState().markDirty(nodeId)
|
|
683
|
+
resumeSceneHistory(useScene)
|
|
684
|
+
useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
|
|
685
|
+
useViewer.getState().setInputDragging(false)
|
|
686
|
+
if (document.body.style.cursor === 'ew-resize') {
|
|
687
|
+
document.body.style.cursor = ''
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
692
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
693
|
+
moveEvent.preventDefault()
|
|
694
|
+
moveEvent.stopPropagation()
|
|
695
|
+
applyDelta(moveEvent)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
699
|
+
if (upEvent.pointerId !== pointerId) return
|
|
700
|
+
upEvent.preventDefault()
|
|
701
|
+
upEvent.stopPropagation()
|
|
702
|
+
swallowNextClick(300)
|
|
703
|
+
if (lastPatch) {
|
|
704
|
+
sceneApi.update(nodeId, lastPatch)
|
|
705
|
+
sfxEmitter.emit('sfx:item-place')
|
|
706
|
+
}
|
|
707
|
+
cleanup()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const onCancel = (cancelEvent: PointerEvent) => {
|
|
711
|
+
if (cancelEvent.pointerId !== pointerId) return
|
|
712
|
+
cleanup()
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const preventContextMenu = (contextEvent: Event) => {
|
|
716
|
+
contextEvent.preventDefault()
|
|
717
|
+
contextEvent.stopPropagation()
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
pauseSceneHistory(useScene)
|
|
721
|
+
useViewer.getState().setInputDragging(true)
|
|
722
|
+
useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
|
|
723
|
+
document.body.style.cursor = 'ew-resize'
|
|
724
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
725
|
+
applyDelta(event)
|
|
726
|
+
|
|
727
|
+
window.addEventListener('pointermove', onMove, true)
|
|
728
|
+
window.addEventListener('pointerup', onUp, true)
|
|
729
|
+
window.addEventListener('pointercancel', onCancel, true)
|
|
730
|
+
window.addEventListener('contextmenu', preventContextMenu, true)
|
|
731
|
+
return true
|
|
732
|
+
},
|
|
733
|
+
[],
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
// Photoshop-style group drag: plain pointer-down on a transformable member
|
|
737
|
+
// of a multi-selection slides the whole selection rigidly; a plain click
|
|
738
|
+
// (no drag) enters the group pick-up instead — parity with the single-item
|
|
739
|
+
// click-to-move (clicking outside still deselects). Modified clicks
|
|
740
|
+
// (selection toggle) and Cmd-drag / direct-rotate keep their existing
|
|
741
|
+
// paths. `immediate` engages without the drag threshold — the move-handle
|
|
742
|
+
// dot's pick-up semantics.
|
|
743
|
+
const startGroupMoveDrag = useCallback(
|
|
744
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>, immediate = false): boolean => {
|
|
745
|
+
if (event.button !== 0) return false
|
|
746
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
|
|
747
|
+
if (movingNode) return false
|
|
748
|
+
if (useEditor.getState().mode === 'delete') return false
|
|
749
|
+
const started = startFloorplanGroupMove(id, event, {
|
|
750
|
+
immediate,
|
|
751
|
+
onClickFallthrough: immediate ? undefined : () => startGroupPickUp(),
|
|
752
|
+
})
|
|
753
|
+
if (!started) return false
|
|
754
|
+
event.preventDefault()
|
|
755
|
+
event.stopPropagation()
|
|
756
|
+
suppressBoxSelectForPointer(event)
|
|
757
|
+
return true
|
|
758
|
+
},
|
|
759
|
+
[movingNode],
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
// Move-handle dot variant — routes the dot through the group session when
|
|
763
|
+
// the owning node is part of a multi-selection.
|
|
764
|
+
const handleGroupMoveHandlePointerDown = useCallback(
|
|
765
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => startGroupMoveDrag(id, event, true),
|
|
766
|
+
[startGroupMoveDrag],
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
// The dashed selection box is itself the group's drag handle: a press
|
|
770
|
+
// anywhere inside it slides the group (or picks it up on a plain click),
|
|
771
|
+
// anchored on the first transformable member.
|
|
772
|
+
const handleGroupBoxPointerDown = useCallback(
|
|
773
|
+
(event: ReactPointerEvent<SVGGElement>) => {
|
|
774
|
+
const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
|
|
775
|
+
const sceneNodes = useScene.getState().nodes
|
|
776
|
+
const anchor = currentIds.find(
|
|
777
|
+
(id) =>
|
|
778
|
+
classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
|
|
779
|
+
)
|
|
780
|
+
if (!anchor) return
|
|
781
|
+
startGroupMoveDrag(anchor as AnyNodeId, event)
|
|
782
|
+
},
|
|
783
|
+
[startGroupMoveDrag],
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
// Corner rotate handles on the dashed selection box — 15° steps, Shift
|
|
787
|
+
// free, mirroring the 3D group rotate gizmo.
|
|
788
|
+
const handleGroupBoxRotatePointerDown = useCallback((event: ReactPointerEvent<SVGGElement>) => {
|
|
789
|
+
if (event.button !== 0) return
|
|
790
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
791
|
+
if (useEditor.getState().mode === 'delete') return
|
|
792
|
+
if (startFloorplanGroupRotate(event)) {
|
|
793
|
+
event.preventDefault()
|
|
794
|
+
suppressBoxSelectForPointer(event)
|
|
795
|
+
}
|
|
796
|
+
}, [])
|
|
797
|
+
|
|
798
|
+
const handleEntryPointerDown = useCallback(
|
|
799
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
|
|
800
|
+
if (startDirectMoveDrag(id, event)) return
|
|
801
|
+
if (startDirectRotateDrag(id, event)) return
|
|
802
|
+
if (startGroupMoveDrag(id, event)) return
|
|
803
|
+
handleSelect(id, event)
|
|
804
|
+
},
|
|
805
|
+
[handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
const floorplanData = useMemo(() => {
|
|
809
|
+
if (!levelId) {
|
|
810
|
+
geometryCacheRef.current.clear()
|
|
811
|
+
levelDataCacheRef.current.clear()
|
|
812
|
+
return {
|
|
813
|
+
entries: [] as FloorplanEntryDescriptor[],
|
|
814
|
+
levelNodeIdsByType: new Map<string, AnyNodeId[]>(),
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const out: FloorplanEntryDescriptor[] = []
|
|
819
|
+
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
|
820
|
+
|
|
821
|
+
const collectLevelDataKind = (id: AnyNodeId) => {
|
|
822
|
+
const node = nodes[id]
|
|
823
|
+
if (!node) return
|
|
824
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
|
825
|
+
const def = nodeRegistry.get(node.type)
|
|
826
|
+
if (def?.computeFloorplanLevelData) {
|
|
827
|
+
const ids = levelNodeIdsByType.get(node.type)
|
|
828
|
+
if (ids) ids.push(id)
|
|
829
|
+
else levelNodeIdsByType.set(node.type, [id])
|
|
830
|
+
}
|
|
831
|
+
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
832
|
+
if (Array.isArray(childIds)) {
|
|
833
|
+
for (const cid of childIds) collectLevelDataKind(cid)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
collectLevelDataKind(levelId as AnyNodeId)
|
|
838
|
+
|
|
839
|
+
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
|
840
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
|
841
|
+
const def = nodeRegistry.get(node.type)
|
|
842
|
+
if (!def?.floorplan) return
|
|
843
|
+
if (node.type === 'measurement' && !showMeasurements) return
|
|
844
|
+
const dependsOnSiblingInputs = !!(
|
|
845
|
+
def.floorplanDependsOnSiblings ||
|
|
846
|
+
def.floorplanSiblingOverrides ||
|
|
847
|
+
def.floorplanAffectedIds
|
|
848
|
+
)
|
|
849
|
+
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
|
|
850
|
+
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
|
|
851
|
+
out.push(descriptor)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const visit = (id: AnyNodeId) => {
|
|
855
|
+
const node = nodes[id]
|
|
856
|
+
if (!node) return
|
|
857
|
+
pushEntry(id, node)
|
|
858
|
+
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
859
|
+
if (Array.isArray(childIds)) {
|
|
860
|
+
for (const cid of childIds) visit(cid)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
visit(levelId as AnyNodeId)
|
|
865
|
+
|
|
866
|
+
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
|
|
867
|
+
// as siblings of the level, not under it — the `visit(levelId)` DFS
|
|
868
|
+
// above doesn't reach them. Walk every node of those kinds whose
|
|
869
|
+
// parent matches the active level's building, and synthesise a
|
|
870
|
+
// `GeometryContext` whose `parent` is the active level (so kind
|
|
871
|
+
// builders that gate on the current floor — e.g. elevator service
|
|
872
|
+
// range — keep working). Pure registry-driven dispatch: no kind
|
|
873
|
+
// name appears in this file.
|
|
874
|
+
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
|
875
|
+
const activeBuildingId = activeLevelNode
|
|
876
|
+
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
|
|
877
|
+
: null
|
|
878
|
+
if (activeLevelNode && activeBuildingId) {
|
|
879
|
+
const buildingScopedKinds = kindsWithFloorplanScope('building')
|
|
880
|
+
const buildingScopedKindSet = new Set(buildingScopedKinds)
|
|
881
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
882
|
+
if (!node || !buildingScopedKindSet.has(node.type)) continue
|
|
883
|
+
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
|
884
|
+
if (parentId !== activeBuildingId) continue
|
|
885
|
+
const cid = id as AnyNodeId
|
|
886
|
+
pushEntry(cid, node, {
|
|
887
|
+
children: [],
|
|
888
|
+
siblings: [],
|
|
889
|
+
parent: activeLevelNode,
|
|
890
|
+
})
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Stable z-order sort. SVG renders in document order — later siblings
|
|
895
|
+
// paint on top of earlier ones — so anything that should sit *under*
|
|
896
|
+
// other floor-plan geometry has to come first in the entries array.
|
|
897
|
+
// Zones are conceptual room/area regions; walls / slabs / furniture
|
|
898
|
+
// all belong on top of them. Within a layer bucket we preserve the
|
|
899
|
+
// DFS visit order (stable sort) so siblings keep their relative
|
|
900
|
+
// priority.
|
|
901
|
+
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
|
902
|
+
const entryIds = new Set(out.map((entry) => entry.id))
|
|
903
|
+
for (const id of geometryCacheRef.current.keys()) {
|
|
904
|
+
if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id)
|
|
905
|
+
}
|
|
906
|
+
for (const type of levelDataCacheRef.current.keys()) {
|
|
907
|
+
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
|
908
|
+
}
|
|
909
|
+
return { entries: out, levelNodeIdsByType }
|
|
910
|
+
}, [installedPlugins, levelId, nodes, showMeasurements])
|
|
911
|
+
|
|
912
|
+
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
|
913
|
+
//
|
|
914
|
+
// Pointer-down on an interactive handle resolves the kind's
|
|
915
|
+
// `def.floorplanAffordances?.[affordance]` and starts a session. The
|
|
916
|
+
// dispatcher then owns: history pause/resume, snapshot capture,
|
|
917
|
+
// pointer-move/up/cancel routing, and the single-undo dance on
|
|
918
|
+
// commit. Each kind owns the actual mutation logic inside `apply`.
|
|
919
|
+
const commitAffordanceAction = useCallback(
|
|
920
|
+
(
|
|
921
|
+
nodeId: AnyNodeId,
|
|
922
|
+
affordance: string,
|
|
923
|
+
payload: unknown,
|
|
924
|
+
event: ReactMouseEvent<SVGElement>,
|
|
925
|
+
) => {
|
|
926
|
+
if (event.button !== 0 || movingNode || dragRef.current) return
|
|
927
|
+
|
|
928
|
+
const sceneNodes = useScene.getState().nodes
|
|
929
|
+
const node = sceneNodes[nodeId]
|
|
930
|
+
if (!node) return
|
|
931
|
+
const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
|
|
932
|
+
if (!handler) return
|
|
933
|
+
const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
|
|
934
|
+
if (!initialPlanPoint) return
|
|
935
|
+
|
|
936
|
+
const session = handler.start({
|
|
937
|
+
node,
|
|
938
|
+
payload,
|
|
939
|
+
nodes: sceneNodes,
|
|
940
|
+
initialPlanPoint,
|
|
941
|
+
gridSnapStep: useEditor.getState().gridSnapStep,
|
|
942
|
+
})
|
|
943
|
+
if (!(session.commit && session.canCommit())) return
|
|
944
|
+
session.commit()
|
|
945
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
946
|
+
},
|
|
947
|
+
[movingNode],
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
const startAffordanceDrag = useCallback(
|
|
951
|
+
(
|
|
952
|
+
nodeId: AnyNodeId,
|
|
953
|
+
handleId: string,
|
|
954
|
+
affordance: string,
|
|
955
|
+
payload: unknown,
|
|
956
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
957
|
+
// Present only for rotate-arrow handles — the pivot the node turns
|
|
958
|
+
// around, used to drive the live angle wedge + degree readout.
|
|
959
|
+
rotationPivot?: FloorplanPoint,
|
|
960
|
+
) => {
|
|
961
|
+
if (event.button !== 0) return
|
|
962
|
+
if (movingNode) return
|
|
963
|
+
|
|
964
|
+
const sceneNodes = useScene.getState().nodes
|
|
965
|
+
const node = sceneNodes[nodeId]
|
|
966
|
+
if (!node) return
|
|
967
|
+
|
|
968
|
+
const def = nodeRegistry.get(node.type)
|
|
969
|
+
const handler = def?.floorplanAffordances?.[affordance]
|
|
970
|
+
if (!handler) return
|
|
971
|
+
|
|
972
|
+
const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
|
|
973
|
+
if (!initialPlanPoint) return
|
|
974
|
+
|
|
975
|
+
event.preventDefault()
|
|
976
|
+
event.stopPropagation()
|
|
977
|
+
suppressBoxSelectForPointer(event)
|
|
978
|
+
|
|
979
|
+
const session = handler.start({
|
|
980
|
+
node,
|
|
981
|
+
payload,
|
|
982
|
+
nodes: sceneNodes,
|
|
983
|
+
initialPlanPoint,
|
|
984
|
+
gridSnapStep: useEditor.getState().gridSnapStep,
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
const snapshots: NodeSnapshot[] = []
|
|
988
|
+
for (const id of session.affectedIds) {
|
|
989
|
+
const n = sceneNodes[id]
|
|
990
|
+
if (n) snapshots.push(snapshotNode(n))
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
pauseSceneHistory(useScene)
|
|
994
|
+
|
|
995
|
+
// Rotation readout setup. The wedge radius tracks the grab distance
|
|
996
|
+
// from the pivot (≈ the handle's orbit), nudged inward so the swept
|
|
997
|
+
// fill reads as the handle swinging round rather than overlapping it,
|
|
998
|
+
// and floored so a tight footprint still shows a legible wedge.
|
|
999
|
+
let rotation: ActiveDrag['rotation']
|
|
1000
|
+
if (rotationPivot) {
|
|
1001
|
+
const dx = initialPlanPoint[0] - rotationPivot[0]
|
|
1002
|
+
const dz = initialPlanPoint[1] - rotationPivot[1]
|
|
1003
|
+
rotation = {
|
|
1004
|
+
pivot: rotationPivot,
|
|
1005
|
+
initialAngle: Math.atan2(dz, dx),
|
|
1006
|
+
radius: Math.max(Math.hypot(dx, dz) * 0.72, 0.25),
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Begin the matching reshaping scope so the contextual snapping HUD shows
|
|
1011
|
+
// the right chip during the edit AND `getActiveSnapContext()` resolves the
|
|
1012
|
+
// polygon / wall mode-set the affordance's snap math reads. Torn down on
|
|
1013
|
+
// release / cancel below. `null` for resize / rotate (no snapping chip).
|
|
1014
|
+
const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload)
|
|
1015
|
+
if (reshapeScope) {
|
|
1016
|
+
useInteractionScope.getState().begin(reshapeScope)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const captureTarget = event.currentTarget as Element
|
|
1020
|
+
dragRef.current = {
|
|
1021
|
+
pointerId: event.pointerId,
|
|
1022
|
+
captureTarget,
|
|
1023
|
+
handleId,
|
|
1024
|
+
session,
|
|
1025
|
+
snapshots,
|
|
1026
|
+
historyPaused: true,
|
|
1027
|
+
lastPlanPoint: initialPlanPoint,
|
|
1028
|
+
rotation,
|
|
1029
|
+
reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
|
|
1030
|
+
}
|
|
1031
|
+
setActiveDragId(handleId)
|
|
1032
|
+
setSelection({ selectedIds: [nodeId] })
|
|
1033
|
+
captureTarget.setPointerCapture?.(event.pointerId)
|
|
1034
|
+
},
|
|
1035
|
+
[movingNode, setSelection],
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
useEffect(() => {
|
|
1039
|
+
// Tear down the scope this drag opened (if any) — a reshaping scope for an
|
|
1040
|
+
// edit affordance, or a handle-drag scope for a rotate-arrow — matched by
|
|
1041
|
+
// node id so a concurrent scope from another path is never ended by mistake.
|
|
1042
|
+
const endReshapeScope = (drag: ActiveDrag) => {
|
|
1043
|
+
if (drag.reshapeScopeNodeId) {
|
|
1044
|
+
useInteractionScope
|
|
1045
|
+
.getState()
|
|
1046
|
+
.endIf(
|
|
1047
|
+
(s) =>
|
|
1048
|
+
(s.kind === 'reshaping' || s.kind === 'handle-drag') &&
|
|
1049
|
+
s.nodeId === drag.reshapeScopeNodeId,
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
|
|
1055
|
+
cancelFloorplanAffordanceDrag(
|
|
1056
|
+
dragRef,
|
|
1057
|
+
{
|
|
1058
|
+
restoreSnapshots: (snapshots) =>
|
|
1059
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
|
|
1060
|
+
resumeHistory: () => resumeSceneHistory(useScene),
|
|
1061
|
+
clearPreview: (id) => {
|
|
1062
|
+
useLiveNodeOverrides.getState().clear(id)
|
|
1063
|
+
useLiveTransforms.getState().clear(id)
|
|
1064
|
+
},
|
|
1065
|
+
clearSnapFeedback: clearSurfacePlanSnapFeedback,
|
|
1066
|
+
endReshapeScope,
|
|
1067
|
+
clearDragFeedback: clearDragFeedback
|
|
1068
|
+
? () => {
|
|
1069
|
+
setActiveDragId(null)
|
|
1070
|
+
setRotationOverlay(null)
|
|
1071
|
+
}
|
|
1072
|
+
: undefined,
|
|
1073
|
+
},
|
|
1074
|
+
pointerId,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
1078
|
+
const drag = dragRef.current
|
|
1079
|
+
if (!drag || event.pointerId !== drag.pointerId) return
|
|
1080
|
+
|
|
1081
|
+
const planPoint = clientToPlan(event.clientX, event.clientY)
|
|
1082
|
+
if (!planPoint) return
|
|
1083
|
+
|
|
1084
|
+
drag.lastPlanPoint = planPoint
|
|
1085
|
+
drag.session.apply({
|
|
1086
|
+
planPoint,
|
|
1087
|
+
modifiers: {
|
|
1088
|
+
shiftKey: event.shiftKey,
|
|
1089
|
+
altKey: event.altKey,
|
|
1090
|
+
ctrlKey: event.ctrlKey,
|
|
1091
|
+
metaKey: event.metaKey,
|
|
1092
|
+
},
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
// Live rotation readout. Sweep from the bearing at grab to the
|
|
1096
|
+
// current pointer bearing around the pivot — the same measurement
|
|
1097
|
+
// every rotate affordance applies — and surface it as a wedge +
|
|
1098
|
+
// degree chip. Suppressed below ~0.5° so a fresh grab doesn't flash
|
|
1099
|
+
// a zero-width sliver.
|
|
1100
|
+
const rot = drag.rotation
|
|
1101
|
+
if (rot) {
|
|
1102
|
+
const current = Math.atan2(planPoint[1] - rot.pivot[1], planPoint[0] - rot.pivot[0])
|
|
1103
|
+
let delta = current - rot.initialAngle
|
|
1104
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
1105
|
+
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
1106
|
+
// Match the affordance's 15° angle step (Shift = free) so the wedge +
|
|
1107
|
+
// degree chip read the committed rotation, not the raw pointer bearing.
|
|
1108
|
+
delta = snapDirectRotationDelta(delta, event.shiftKey)
|
|
1109
|
+
if (Math.abs(delta) < 0.0087) {
|
|
1110
|
+
setRotationOverlay(null)
|
|
1111
|
+
} else {
|
|
1112
|
+
setRotationOverlay({
|
|
1113
|
+
pivot: rot.pivot,
|
|
1114
|
+
startAngle: rot.initialAngle,
|
|
1115
|
+
endAngle: rot.initialAngle + delta,
|
|
1116
|
+
radius: rot.radius,
|
|
1117
|
+
sweep: Math.abs(delta),
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
1124
|
+
const drag = dragRef.current
|
|
1125
|
+
if (!drag || event.pointerId !== drag.pointerId) return
|
|
1126
|
+
|
|
1127
|
+
const commitValid = drag.session.canCommit()
|
|
1128
|
+
|
|
1129
|
+
// Sessions with a `commit` hook own their atomic write (e.g.
|
|
1130
|
+
// affordances that publish to `useLiveNodeOverrides` during
|
|
1131
|
+
// `apply()` and never touch scene mid-drag). Mirrors the move
|
|
1132
|
+
// overlay's `session.commit` path — revert untracked (no-op when
|
|
1133
|
+
// the session never wrote to scene), resume history, then let
|
|
1134
|
+
// the session do the tracked write.
|
|
1135
|
+
if (commitValid && drag.session.commit) {
|
|
1136
|
+
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
1137
|
+
if (drag.historyPaused) {
|
|
1138
|
+
resumeSceneHistory(useScene)
|
|
1139
|
+
drag.historyPaused = false
|
|
1140
|
+
}
|
|
1141
|
+
drag.session.commit()
|
|
1142
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
1143
|
+
clearSurfacePlanSnapFeedback()
|
|
1144
|
+
endReshapeScope(drag)
|
|
1145
|
+
dragRef.current = null
|
|
1146
|
+
setActiveDragId(null)
|
|
1147
|
+
setRotationOverlay(null)
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Legacy compatibility for sessions that still wrote preview state into
|
|
1152
|
+
// `useScene` during `apply()`: capture the final state BEFORE the revert
|
|
1153
|
+
// so we know what to re-apply post-resume. New sessions should provide a
|
|
1154
|
+
// `commit()` hook and preview through live overrides/transforms instead.
|
|
1155
|
+
const sceneNodes = useScene.getState().nodes
|
|
1156
|
+
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
|
|
1157
|
+
for (const snap of drag.snapshots) {
|
|
1158
|
+
const current = sceneNodes[snap.id]
|
|
1159
|
+
if (!current) continue
|
|
1160
|
+
const data: Record<string, unknown> = {}
|
|
1161
|
+
let changed = false
|
|
1162
|
+
for (const [key, before] of Object.entries(snap.data)) {
|
|
1163
|
+
const after = (current as unknown as Record<string, unknown>)[key]
|
|
1164
|
+
if (!deepEqual(before, after)) {
|
|
1165
|
+
data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
|
|
1166
|
+
changed = true
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (changed) finalUpdates.push({ id: snap.id, data })
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (commitValid && finalUpdates.length > 0) {
|
|
1173
|
+
// Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
|
|
1174
|
+
// 1. Revert to baseline while history is still paused (untracked).
|
|
1175
|
+
// 2. Resume history.
|
|
1176
|
+
// 3. Re-apply the final state — recorded as one tracked change.
|
|
1177
|
+
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
1178
|
+
if (drag.historyPaused) {
|
|
1179
|
+
resumeSceneHistory(useScene)
|
|
1180
|
+
drag.historyPaused = false
|
|
1181
|
+
}
|
|
1182
|
+
useScene.getState().updateNodes(finalUpdates)
|
|
1183
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
1184
|
+
} else {
|
|
1185
|
+
// Either no net change or canCommit() rejected — revert and
|
|
1186
|
+
// resume without committing. Also clear any live overrides
|
|
1187
|
+
// the session published (no-op when the session writes to
|
|
1188
|
+
// scene directly).
|
|
1189
|
+
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
|
1190
|
+
if (drag.historyPaused) {
|
|
1191
|
+
resumeSceneHistory(useScene)
|
|
1192
|
+
drag.historyPaused = false
|
|
1193
|
+
}
|
|
1194
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
1195
|
+
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
clearSurfacePlanSnapFeedback()
|
|
1199
|
+
endReshapeScope(drag)
|
|
1200
|
+
dragRef.current = null
|
|
1201
|
+
setActiveDragId(null)
|
|
1202
|
+
setRotationOverlay(null)
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const onPointerCancel = (event: PointerEvent) => {
|
|
1206
|
+
cancelActiveDrag(event.pointerId)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Re-run the active session the moment a modifier key flips so behaviors
|
|
1210
|
+
// like the wall endpoint's Alt-detach / re-attach take effect immediately
|
|
1211
|
+
// instead of waiting for the next pointer move. `event.altKey` & co
|
|
1212
|
+
// already reflect the post-transition state on both keydown and keyup.
|
|
1213
|
+
const onModifierKeyChange = (event: KeyboardEvent) => {
|
|
1214
|
+
const drag = dragRef.current
|
|
1215
|
+
if (!drag || event.repeat) return
|
|
1216
|
+
if (
|
|
1217
|
+
event.key !== 'Alt' &&
|
|
1218
|
+
event.key !== 'Shift' &&
|
|
1219
|
+
event.key !== 'Control' &&
|
|
1220
|
+
event.key !== 'Meta'
|
|
1221
|
+
) {
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
drag.session.apply({
|
|
1225
|
+
planPoint: drag.lastPlanPoint,
|
|
1226
|
+
modifiers: {
|
|
1227
|
+
shiftKey: event.shiftKey,
|
|
1228
|
+
altKey: event.altKey,
|
|
1229
|
+
ctrlKey: event.ctrlKey,
|
|
1230
|
+
metaKey: event.metaKey,
|
|
1231
|
+
},
|
|
1232
|
+
})
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
1236
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
1237
|
+
window.addEventListener('pointercancel', onPointerCancel)
|
|
1238
|
+
window.addEventListener('keydown', onModifierKeyChange)
|
|
1239
|
+
window.addEventListener('keyup', onModifierKeyChange)
|
|
1240
|
+
const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
|
|
1241
|
+
() => cancelActiveDrag(),
|
|
1242
|
+
markToolCancelConsumed,
|
|
1243
|
+
)
|
|
1244
|
+
return () => {
|
|
1245
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
1246
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
1247
|
+
window.removeEventListener('pointercancel', onPointerCancel)
|
|
1248
|
+
window.removeEventListener('keydown', onModifierKeyChange)
|
|
1249
|
+
window.removeEventListener('keyup', onModifierKeyChange)
|
|
1250
|
+
unsubscribeToolCancel()
|
|
1251
|
+
if (!cancelActiveDrag(undefined, false)) {
|
|
1252
|
+
clearSurfacePlanSnapFeedback()
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}, [])
|
|
1256
|
+
|
|
1257
|
+
const entries = floorplanData.entries
|
|
1258
|
+
if (entries.length === 0) return null
|
|
1259
|
+
|
|
1260
|
+
const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
|
|
1261
|
+
const palette = renderCtx?.palette
|
|
1262
|
+
|
|
1263
|
+
return (
|
|
1264
|
+
// The outer wrapper stops `click` events that escape an entry's
|
|
1265
|
+
// `onClick={handleClickStop}`. The base+overlay split means
|
|
1266
|
+
// pointer-down can land on the base `<g>` and pointer-up on the
|
|
1267
|
+
// overlay `<g>` (selection mounts the overlay on top mid-gesture).
|
|
1268
|
+
// When the down/up targets differ, the browser dispatches `click`
|
|
1269
|
+
// to the lowest common ancestor — which sits ABOVE the entry-level
|
|
1270
|
+
// handler. Without this guard the click reaches the SVG's
|
|
1271
|
+
// `handleBackgroundClick`, which calls
|
|
1272
|
+
// `resolveFloorplanBackgroundSelection` → `clear-elements` (because
|
|
1273
|
+
// registry-driven items aren't in the legacy hit-test set) →
|
|
1274
|
+
// clearing the selection that pointer-down just set, so items
|
|
1275
|
+
// appear to "deselect themselves a fraction of a second after
|
|
1276
|
+
// clicking." Scoped to `onClick` so hover / drag / pointer events
|
|
1277
|
+
// still propagate normally inside the registry tree.
|
|
1278
|
+
<g
|
|
1279
|
+
className="floorplan-registry-layer"
|
|
1280
|
+
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
|
|
1281
|
+
opacity={isAmbient ? 0.3 : undefined}
|
|
1282
|
+
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
|
|
1283
|
+
>
|
|
1284
|
+
{/* Base pass — rank-sorted body geometry (polygons, paths, fills,
|
|
1285
|
+
strokes, hatches). Lower-rank kinds (zones) paint first so
|
|
1286
|
+
higher-rank kinds (slabs, then walls / items / shelves) layer
|
|
1287
|
+
on top in the expected document-order z-stack. */}
|
|
1288
|
+
<g className="floorplan-registry-base">
|
|
1289
|
+
{entries.map((entry) => (
|
|
1290
|
+
<FloorplanRegistryEntry
|
|
1291
|
+
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1292
|
+
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
|
1293
|
+
floorplanVisible={floorplanVisible}
|
|
1294
|
+
geometryCacheRef={geometryCacheRef}
|
|
1295
|
+
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1296
|
+
highlighted={highlightedIdSet.has(entry.id)}
|
|
1297
|
+
hovered={hoveredId === entry.id}
|
|
1298
|
+
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1299
|
+
interactiveElevators={interactiveElevators}
|
|
1300
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1301
|
+
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
1302
|
+
key={`base-${entry.id}`}
|
|
1303
|
+
levelDataCacheRef={levelDataCacheRef}
|
|
1304
|
+
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
1305
|
+
moving={movingNode?.id === entry.id}
|
|
1306
|
+
node={entry.node}
|
|
1307
|
+
nodeId={entry.id}
|
|
1308
|
+
nodes={nodes}
|
|
1309
|
+
onClickStop={handleClickStop}
|
|
1310
|
+
onEntryPointerDown={handleEntryPointerDown}
|
|
1311
|
+
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
|
1312
|
+
onHandleHoverChange={setHoveredHandleId}
|
|
1313
|
+
onHandleDoubleClick={commitAffordanceAction}
|
|
1314
|
+
onHandlePointerDown={startAffordanceDrag}
|
|
1315
|
+
onHoveredIdChange={setHoveredId}
|
|
1316
|
+
palette={palette}
|
|
1317
|
+
pass="base"
|
|
1318
|
+
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
1319
|
+
selected={selectedIdSet.has(entry.id)}
|
|
1320
|
+
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
|
1321
|
+
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
|
1322
|
+
setMovingNode={setMovingNode}
|
|
1323
|
+
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1324
|
+
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1325
|
+
unit={unit}
|
|
1326
|
+
unitsPerPixel={unitsPerPixel}
|
|
1327
|
+
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1328
|
+
ctxOverrides={entry.ctxOverrides}
|
|
1329
|
+
/>
|
|
1330
|
+
))}
|
|
1331
|
+
</g>
|
|
1332
|
+
{/* Overlay pass — interactive handles (vertex / midpoint / edge /
|
|
1333
|
+
move) and labels (text / dimensions). Painted after every base
|
|
1334
|
+
entry so polygon-editor chrome on a selected slab stays above
|
|
1335
|
+
neighbouring walls, and a zone name stays readable above the
|
|
1336
|
+
slab + wall geometry sitting on top of the zone. Each overlay
|
|
1337
|
+
still routes through the same selection-handling `<g>` so a
|
|
1338
|
+
click on a zone's name selects the zone. */}
|
|
1339
|
+
<g className="floorplan-registry-overlay">
|
|
1340
|
+
{entries.map((entry) => (
|
|
1341
|
+
<FloorplanRegistryEntry
|
|
1342
|
+
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1343
|
+
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
|
1344
|
+
floorplanVisible={floorplanVisible}
|
|
1345
|
+
geometryCacheRef={geometryCacheRef}
|
|
1346
|
+
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1347
|
+
highlighted={highlightedIdSet.has(entry.id)}
|
|
1348
|
+
hovered={hoveredId === entry.id}
|
|
1349
|
+
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1350
|
+
interactiveElevators={interactiveElevators}
|
|
1351
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1352
|
+
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
1353
|
+
key={`overlay-${entry.id}`}
|
|
1354
|
+
levelDataCacheRef={levelDataCacheRef}
|
|
1355
|
+
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
1356
|
+
moving={movingNode?.id === entry.id}
|
|
1357
|
+
node={entry.node}
|
|
1358
|
+
nodeId={entry.id}
|
|
1359
|
+
nodes={nodes}
|
|
1360
|
+
onClickStop={handleClickStop}
|
|
1361
|
+
onEntryPointerDown={handleEntryPointerDown}
|
|
1362
|
+
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
|
1363
|
+
onHandleHoverChange={setHoveredHandleId}
|
|
1364
|
+
onHandleDoubleClick={commitAffordanceAction}
|
|
1365
|
+
onHandlePointerDown={startAffordanceDrag}
|
|
1366
|
+
onHoveredIdChange={setHoveredId}
|
|
1367
|
+
palette={palette}
|
|
1368
|
+
pass="overlay"
|
|
1369
|
+
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
1370
|
+
selected={selectedIdSet.has(entry.id)}
|
|
1371
|
+
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
|
1372
|
+
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
|
1373
|
+
setMovingNode={setMovingNode}
|
|
1374
|
+
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1375
|
+
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1376
|
+
unit={unit}
|
|
1377
|
+
unitsPerPixel={unitsPerPixel}
|
|
1378
|
+
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1379
|
+
ctxOverrides={entry.ctxOverrides}
|
|
1380
|
+
/>
|
|
1381
|
+
))}
|
|
1382
|
+
</g>
|
|
1383
|
+
{/* Dashed group bbox — shows what a group drag carries along while a
|
|
1384
|
+
multi-selection exists, rides the live delta mid-drag, and doubles
|
|
1385
|
+
as the group's whole-area drag handle. */}
|
|
1386
|
+
<FloorplanGroupSelectionBox
|
|
1387
|
+
onPointerDown={handleGroupBoxPointerDown}
|
|
1388
|
+
onRotatePointerDown={handleGroupBoxRotatePointerDown}
|
|
1389
|
+
palette={palette}
|
|
1390
|
+
unitsPerPixel={unitsPerPixel}
|
|
1391
|
+
/>
|
|
1392
|
+
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
|
1393
|
+
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
|
1394
|
+
{rotationOverlay && palette ? (
|
|
1395
|
+
<RotationAngleOverlay
|
|
1396
|
+
overlay={rotationOverlay}
|
|
1397
|
+
palette={palette}
|
|
1398
|
+
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
1399
|
+
unitsPerPixel={unitsPerPixel}
|
|
1400
|
+
/>
|
|
1401
|
+
) : null}
|
|
1402
|
+
</g>
|
|
1403
|
+
)
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
type FloorplanRegistryEntryProps = {
|
|
1407
|
+
activeDragId: string | null
|
|
1408
|
+
activeRotateNodeId: AnyNodeId | null
|
|
1409
|
+
ctxOverrides: FloorplanContextOverrides | undefined
|
|
1410
|
+
floorplanVisible: boolean
|
|
1411
|
+
geometryCacheRef: { current: Map<string, CacheEntry> }
|
|
1412
|
+
hatchPatternId: string | undefined
|
|
1413
|
+
highlighted: boolean
|
|
1414
|
+
hovered: boolean
|
|
1415
|
+
hoveredHandleId: string | null
|
|
1416
|
+
interactiveElevators: unknown
|
|
1417
|
+
isMarqueeSelectionActive: boolean
|
|
1418
|
+
isOpeningPlacementActive: boolean
|
|
1419
|
+
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
|
|
1420
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
|
1421
|
+
moving: boolean
|
|
1422
|
+
node: AnyNode
|
|
1423
|
+
nodeId: AnyNodeId
|
|
1424
|
+
nodes: Record<string, AnyNode>
|
|
1425
|
+
/** Selected member of a multi-selection: hide its per-node edit chrome. */
|
|
1426
|
+
suppressHandles: boolean
|
|
1427
|
+
/** Transformable member of a multi-selection: advertise drag-to-move. */
|
|
1428
|
+
groupMoveCursor: boolean
|
|
1429
|
+
onClickStop: (event: React.MouseEvent<SVGGElement>) => void
|
|
1430
|
+
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
|
|
1431
|
+
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
|
|
1432
|
+
onHandleHoverChange: (id: string | null) => void
|
|
1433
|
+
onHandleDoubleClick: (
|
|
1434
|
+
nodeId: AnyNodeId,
|
|
1435
|
+
affordance: string,
|
|
1436
|
+
payload: unknown,
|
|
1437
|
+
event: ReactMouseEvent<SVGElement>,
|
|
1438
|
+
) => void
|
|
1439
|
+
onHandlePointerDown: (
|
|
1440
|
+
nodeId: AnyNodeId,
|
|
1441
|
+
handleId: string,
|
|
1442
|
+
affordance: string,
|
|
1443
|
+
payload: unknown,
|
|
1444
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
1445
|
+
rotationPivot?: FloorplanPoint,
|
|
1446
|
+
) => void
|
|
1447
|
+
onHoveredIdChange: (id: AnyNodeId | null) => void
|
|
1448
|
+
palette: FloorplanPalette | undefined
|
|
1449
|
+
pass: FloorplanRenderPass
|
|
1450
|
+
sceneRotationDeg: number
|
|
1451
|
+
selected: boolean
|
|
1452
|
+
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
|
|
1453
|
+
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
|
|
1454
|
+
siblingEpoch: number
|
|
1455
|
+
unit: 'metric' | 'imperial'
|
|
1456
|
+
unitsPerPixel: number
|
|
1457
|
+
visibilityRootId: AnyNodeId | undefined
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
1461
|
+
activeDragId,
|
|
1462
|
+
activeRotateNodeId,
|
|
1463
|
+
ctxOverrides,
|
|
1464
|
+
floorplanVisible,
|
|
1465
|
+
geometryCacheRef,
|
|
1466
|
+
hatchPatternId,
|
|
1467
|
+
highlighted,
|
|
1468
|
+
hovered,
|
|
1469
|
+
hoveredHandleId,
|
|
1470
|
+
interactiveElevators,
|
|
1471
|
+
isMarqueeSelectionActive,
|
|
1472
|
+
isOpeningPlacementActive,
|
|
1473
|
+
levelDataCacheRef,
|
|
1474
|
+
levelNodeIdsByType,
|
|
1475
|
+
moving,
|
|
1476
|
+
node,
|
|
1477
|
+
nodeId,
|
|
1478
|
+
nodes,
|
|
1479
|
+
suppressHandles,
|
|
1480
|
+
groupMoveCursor,
|
|
1481
|
+
onClickStop,
|
|
1482
|
+
onEntryPointerDown,
|
|
1483
|
+
onGroupMovePointerDown,
|
|
1484
|
+
onHandleHoverChange,
|
|
1485
|
+
onHandleDoubleClick,
|
|
1486
|
+
onHandlePointerDown,
|
|
1487
|
+
onHoveredIdChange,
|
|
1488
|
+
palette,
|
|
1489
|
+
pass,
|
|
1490
|
+
sceneRotationDeg,
|
|
1491
|
+
selected,
|
|
1492
|
+
setMovingNode,
|
|
1493
|
+
setMovingNodeOrigin,
|
|
1494
|
+
siblingEpoch,
|
|
1495
|
+
unit,
|
|
1496
|
+
unitsPerPixel,
|
|
1497
|
+
visibilityRootId,
|
|
1498
|
+
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
|
1499
|
+
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
|
|
1500
|
+
const liveOverride = useLiveNodeOverrides((s) =>
|
|
1501
|
+
floorplanVisible ? s.overrides.get(nodeId) : undefined,
|
|
1502
|
+
)
|
|
1503
|
+
const liveOverrides = floorplanVisible
|
|
1504
|
+
? useLiveNodeOverrides.getState().overrides
|
|
1505
|
+
: EMPTY_LIVE_OVERRIDES
|
|
1506
|
+
|
|
1507
|
+
const handlePointerDown = useCallback(
|
|
1508
|
+
(event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
|
|
1509
|
+
[nodeId, onEntryPointerDown],
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
// Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
|
|
1513
|
+
// the highlight halo in 3D as well as registry floor-plan hover strokes.
|
|
1514
|
+
const handlePointerEnter = useCallback(() => {
|
|
1515
|
+
const node = useScene.getState().nodes[nodeId]
|
|
1516
|
+
onHoveredIdChange(
|
|
1517
|
+
node
|
|
1518
|
+
? resolveSelectionProxyId(
|
|
1519
|
+
node,
|
|
1520
|
+
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1521
|
+
)
|
|
1522
|
+
: nodeId,
|
|
1523
|
+
)
|
|
1524
|
+
}, [nodeId, onHoveredIdChange])
|
|
1525
|
+
|
|
1526
|
+
const handlePointerLeave = useCallback(() => {
|
|
1527
|
+
const node = useScene.getState().nodes[nodeId]
|
|
1528
|
+
const targetId = node
|
|
1529
|
+
? resolveSelectionProxyId(
|
|
1530
|
+
node,
|
|
1531
|
+
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1532
|
+
)
|
|
1533
|
+
: nodeId
|
|
1534
|
+
if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
|
|
1535
|
+
}, [nodeId, onHoveredIdChange])
|
|
1536
|
+
|
|
1537
|
+
const handleHandlePointerDown = useCallback(
|
|
1538
|
+
(
|
|
1539
|
+
affordance: string,
|
|
1540
|
+
payload: unknown,
|
|
1541
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
1542
|
+
rotationPivot?: FloorplanPoint,
|
|
1543
|
+
) => {
|
|
1544
|
+
onHandlePointerDown(
|
|
1545
|
+
nodeId,
|
|
1546
|
+
makeHandleId(nodeId, payload),
|
|
1547
|
+
affordance,
|
|
1548
|
+
payload,
|
|
1549
|
+
event,
|
|
1550
|
+
rotationPivot,
|
|
1551
|
+
)
|
|
1552
|
+
},
|
|
1553
|
+
[nodeId, onHandlePointerDown],
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
const handleHandleDoubleClick = useCallback(
|
|
1557
|
+
(affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
|
|
1558
|
+
onHandleDoubleClick(nodeId, affordance, payload, event)
|
|
1559
|
+
},
|
|
1560
|
+
[nodeId, onHandleDoubleClick],
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
const handleMoveHandlePointerDown = useCallback(
|
|
1564
|
+
(event: ReactPointerEvent<SVGGElement>) => {
|
|
1565
|
+
if (event.button !== 0) return
|
|
1566
|
+
const currentNode = useScene.getState().nodes[nodeId]
|
|
1567
|
+
if (!currentNode) return
|
|
1568
|
+
event.preventDefault()
|
|
1569
|
+
event.stopPropagation()
|
|
1570
|
+
suppressBoxSelectForPointer(event)
|
|
1571
|
+
// In a multi-selection the move dot drives the group session, matching
|
|
1572
|
+
// the body-drag gesture — the whole selection slides, not one member.
|
|
1573
|
+
if (onGroupMovePointerDown(nodeId, event)) return
|
|
1574
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
1575
|
+
setMovingNode(currentNode as never)
|
|
1576
|
+
// Claim 2D ownership of this move at the source. `setMovingNode`
|
|
1577
|
+
// resets the origin to null, so this must follow it.
|
|
1578
|
+
setMovingNodeOrigin('2d')
|
|
1579
|
+
},
|
|
1580
|
+
[nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
const cacheEntry = buildFloorplanEntryGeometry({
|
|
1584
|
+
ctxOverrides,
|
|
1585
|
+
geometryCache: geometryCacheRef.current,
|
|
1586
|
+
highlighted,
|
|
1587
|
+
hovered,
|
|
1588
|
+
interactiveElevators,
|
|
1589
|
+
levelDataCache: levelDataCacheRef.current,
|
|
1590
|
+
levelNodeIdsByType,
|
|
1591
|
+
live,
|
|
1592
|
+
liveOverride,
|
|
1593
|
+
liveOverrides,
|
|
1594
|
+
moving,
|
|
1595
|
+
node,
|
|
1596
|
+
nodeId,
|
|
1597
|
+
nodes,
|
|
1598
|
+
palette,
|
|
1599
|
+
selected,
|
|
1600
|
+
siblingEpoch,
|
|
1601
|
+
unit,
|
|
1602
|
+
visibilityRootId,
|
|
1603
|
+
})
|
|
1604
|
+
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
|
|
1605
|
+
// Multi-selection shows highlight only: strip this member's edit handles /
|
|
1606
|
+
// dimension chrome (all of which live in the overlay pass) while keeping
|
|
1607
|
+
// its highlighted body geometry.
|
|
1608
|
+
const geometry =
|
|
1609
|
+
rawGeometry && suppressHandles && pass === 'overlay'
|
|
1610
|
+
? stripHandleChrome(rawGeometry)
|
|
1611
|
+
: rawGeometry
|
|
1612
|
+
if (!geometry) return null
|
|
1613
|
+
|
|
1614
|
+
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
|
|
1615
|
+
const entryPointerDown =
|
|
1616
|
+
isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
|
|
1617
|
+
|
|
1618
|
+
return (
|
|
1619
|
+
<g
|
|
1620
|
+
className="floorplan-registry-entry"
|
|
1621
|
+
data-node-id={nodeId}
|
|
1622
|
+
onClick={entryClick}
|
|
1623
|
+
onPointerDown={entryPointerDown}
|
|
1624
|
+
onPointerEnter={handlePointerEnter}
|
|
1625
|
+
onPointerLeave={handlePointerLeave}
|
|
1626
|
+
style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
|
|
1627
|
+
>
|
|
1628
|
+
<InteractiveGeometry
|
|
1629
|
+
activeDragId={activeDragId}
|
|
1630
|
+
activeRotateNodeId={activeRotateNodeId}
|
|
1631
|
+
geometry={geometry}
|
|
1632
|
+
hatchPatternId={hatchPatternId}
|
|
1633
|
+
hoveredHandleId={hoveredHandleId}
|
|
1634
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1635
|
+
nodeId={nodeId}
|
|
1636
|
+
onHandleDoubleClick={handleHandleDoubleClick}
|
|
1637
|
+
onHandleHoverChange={onHandleHoverChange}
|
|
1638
|
+
onHandlePointerDown={handleHandlePointerDown}
|
|
1639
|
+
onMoveHandlePointerDown={handleMoveHandlePointerDown}
|
|
1640
|
+
palette={palette}
|
|
1641
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1642
|
+
unitsPerPixel={unitsPerPixel}
|
|
1643
|
+
/>
|
|
1644
|
+
</g>
|
|
1645
|
+
)
|
|
1646
|
+
}, shallowPropsAreEqual)
|
|
1647
|
+
|
|
1648
|
+
type BuildFloorplanEntryGeometryArgs = {
|
|
1649
|
+
ctxOverrides: FloorplanContextOverrides | undefined
|
|
1650
|
+
geometryCache: Map<string, CacheEntry>
|
|
1651
|
+
highlighted: boolean
|
|
1652
|
+
hovered: boolean
|
|
1653
|
+
interactiveElevators: unknown
|
|
1654
|
+
levelDataCache: Map<string, LevelDataCacheEntry>
|
|
1655
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
|
1656
|
+
live: LiveTransform | undefined
|
|
1657
|
+
liveOverride: LiveNodeOverrides | undefined
|
|
1658
|
+
liveOverrides: Map<string, LiveNodeOverrides>
|
|
1659
|
+
moving: boolean
|
|
1660
|
+
node: AnyNode
|
|
1661
|
+
nodeId: AnyNodeId
|
|
1662
|
+
nodes: Record<string, AnyNode>
|
|
1663
|
+
palette: FloorplanPalette | undefined
|
|
1664
|
+
selected: boolean
|
|
1665
|
+
siblingEpoch: number
|
|
1666
|
+
unit: 'metric' | 'imperial'
|
|
1667
|
+
visibilityRootId: AnyNodeId | undefined
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
export function collectFloorplanDependencyNodes(
|
|
1671
|
+
def: AnyNodeDefinition,
|
|
1672
|
+
node: AnyNode,
|
|
1673
|
+
nodes: Record<string, AnyNode>,
|
|
1674
|
+
liveOverrides?: Map<string, LiveNodeOverrides>,
|
|
1675
|
+
): AnyNode[] {
|
|
1676
|
+
return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
|
|
1677
|
+
const dependency = nodes[id]
|
|
1678
|
+
if (!dependency) return []
|
|
1679
|
+
const dependencyOverride = liveOverrides?.get(dependency.id)
|
|
1680
|
+
const effectiveDependency = dependencyOverride
|
|
1681
|
+
? ({ ...dependency, ...dependencyOverride } as AnyNode)
|
|
1682
|
+
: dependency
|
|
1683
|
+
const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
|
|
1684
|
+
if (!parent) return [effectiveDependency]
|
|
1685
|
+
const parentOverride = liveOverrides?.get(parent.id)
|
|
1686
|
+
const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
|
|
1687
|
+
return [effectiveDependency, effectiveParent]
|
|
1688
|
+
})
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function buildFloorplanEntryGeometry({
|
|
1692
|
+
ctxOverrides,
|
|
1693
|
+
geometryCache,
|
|
1694
|
+
highlighted,
|
|
1695
|
+
hovered,
|
|
1696
|
+
interactiveElevators,
|
|
1697
|
+
levelDataCache,
|
|
1698
|
+
levelNodeIdsByType,
|
|
1699
|
+
live,
|
|
1700
|
+
liveOverride,
|
|
1701
|
+
liveOverrides,
|
|
1702
|
+
moving,
|
|
1703
|
+
node,
|
|
1704
|
+
nodeId,
|
|
1705
|
+
nodes,
|
|
1706
|
+
palette,
|
|
1707
|
+
selected,
|
|
1708
|
+
siblingEpoch,
|
|
1709
|
+
unit,
|
|
1710
|
+
visibilityRootId,
|
|
1711
|
+
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
|
|
1712
|
+
const def = nodeRegistry.get(node.type)
|
|
1713
|
+
const builder = def?.floorplan
|
|
1714
|
+
if (!builder) return null
|
|
1715
|
+
|
|
1716
|
+
const visible = visibilityRootId
|
|
1717
|
+
? isFloorplanHierarchyVisible(node, nodes, liveOverrides, visibilityRootId)
|
|
1718
|
+
: isFloorplanNodeVisible(node, liveOverride)
|
|
1719
|
+
if (!visible) {
|
|
1720
|
+
geometryCache.delete(nodeId)
|
|
1721
|
+
return null
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const dependsOnSiblingInputs = !!(
|
|
1725
|
+
def.floorplanDependsOnSiblings ||
|
|
1726
|
+
def.floorplanSiblingOverrides ||
|
|
1727
|
+
def.floorplanAffectedIds
|
|
1728
|
+
)
|
|
1729
|
+
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
|
|
1730
|
+
const deps: NodeDeps = {
|
|
1731
|
+
node,
|
|
1732
|
+
live,
|
|
1733
|
+
unit,
|
|
1734
|
+
selected,
|
|
1735
|
+
highlighted,
|
|
1736
|
+
hovered,
|
|
1737
|
+
moving,
|
|
1738
|
+
liveOverride,
|
|
1739
|
+
palette,
|
|
1740
|
+
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
|
|
1741
|
+
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
|
1742
|
+
// committed state via `ctx`, so committed sibling edits still invalidate.
|
|
1743
|
+
committedNodes: dependsOnSiblingInputs ? nodes : null,
|
|
1744
|
+
dependencyNodes,
|
|
1745
|
+
interactiveElevators,
|
|
1746
|
+
}
|
|
1747
|
+
const cached = geometryCache.get(nodeId)
|
|
1748
|
+
if (cached && nodeDepsEqual(cached.deps, deps)) return cached
|
|
1749
|
+
|
|
1750
|
+
const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
|
|
1751
|
+
if (!live) return sourceNode
|
|
1752
|
+
const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
|
|
1753
|
+
const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable
|
|
1754
|
+
?.parentFrame?.floorplanLiveTransform
|
|
1755
|
+
if (parentFrameProjection) {
|
|
1756
|
+
return parentFrameProjection({ node: sourceNode, live })
|
|
1757
|
+
}
|
|
1758
|
+
if (sourceNode.type === 'door' || sourceNode.type === 'window') {
|
|
1759
|
+
const r = (sourceNode as { rotation?: unknown }).rotation
|
|
1760
|
+
return {
|
|
1761
|
+
...sourceNode,
|
|
1762
|
+
position: live.position,
|
|
1763
|
+
rotation: Array.isArray(r)
|
|
1764
|
+
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
|
1765
|
+
: r,
|
|
1766
|
+
} as AnyNode
|
|
1767
|
+
}
|
|
1768
|
+
if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
|
|
1769
|
+
return applyPositionLiveTransform(sourceNode, live)
|
|
1770
|
+
}
|
|
1771
|
+
if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') {
|
|
1772
|
+
const dx = live.position[0]
|
|
1773
|
+
const dz = live.position[2]
|
|
1774
|
+
if (dx === 0 && dz === 0) return sourceNode
|
|
1775
|
+
const surface = sourceNode as {
|
|
1776
|
+
polygon: Array<[number, number]>
|
|
1777
|
+
holes?: Array<Array<[number, number]>>
|
|
1778
|
+
}
|
|
1779
|
+
return {
|
|
1780
|
+
...sourceNode,
|
|
1781
|
+
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
1782
|
+
holes: (surface.holes ?? []).map((h) =>
|
|
1783
|
+
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
1784
|
+
),
|
|
1785
|
+
} as AnyNode
|
|
1786
|
+
}
|
|
1787
|
+
return sourceNode
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
const contextNodes = def.floorplanSiblingOverrides
|
|
1791
|
+
? def.floorplanSiblingOverrides({
|
|
1792
|
+
nodeId,
|
|
1793
|
+
nodes,
|
|
1794
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
1795
|
+
liveOverrides,
|
|
1796
|
+
})
|
|
1797
|
+
: nodes
|
|
1798
|
+
const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node
|
|
1799
|
+
const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode
|
|
1800
|
+
const effectiveNode = applyLiveTransform(overrideNode)
|
|
1801
|
+
const levelData = getFloorplanLevelData(
|
|
1802
|
+
node.type,
|
|
1803
|
+
nodes,
|
|
1804
|
+
liveOverrides,
|
|
1805
|
+
levelNodeIdsByType,
|
|
1806
|
+
levelDataCache,
|
|
1807
|
+
)
|
|
1808
|
+
const viewState = {
|
|
1809
|
+
selected,
|
|
1810
|
+
unit,
|
|
1811
|
+
highlighted,
|
|
1812
|
+
hovered,
|
|
1813
|
+
moving,
|
|
1814
|
+
palette,
|
|
1815
|
+
}
|
|
1816
|
+
const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
|
|
1817
|
+
const contextNode = contextNodes[rid]
|
|
1818
|
+
if (!contextNode) return undefined
|
|
1819
|
+
const contextOverride = liveOverrides.get(contextNode.id)
|
|
1820
|
+
return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
|
|
1821
|
+
}
|
|
1822
|
+
const ctx: GeometryContext = ctxOverrides
|
|
1823
|
+
? {
|
|
1824
|
+
resolve: resolveContextNode,
|
|
1825
|
+
children: ctxOverrides.children,
|
|
1826
|
+
siblings: ctxOverrides.siblings,
|
|
1827
|
+
parent: ctxOverrides.parent,
|
|
1828
|
+
levelData,
|
|
1829
|
+
viewState: palette
|
|
1830
|
+
? {
|
|
1831
|
+
selected,
|
|
1832
|
+
unit,
|
|
1833
|
+
highlighted,
|
|
1834
|
+
hovered,
|
|
1835
|
+
moving,
|
|
1836
|
+
palette,
|
|
1837
|
+
}
|
|
1838
|
+
: undefined,
|
|
1839
|
+
}
|
|
1840
|
+
: {
|
|
1841
|
+
...buildContext(effectiveNode, contextNodes, viewState, levelData),
|
|
1842
|
+
resolve: resolveContextNode,
|
|
1843
|
+
}
|
|
1844
|
+
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
1845
|
+
effectiveNode,
|
|
1846
|
+
ctx,
|
|
1847
|
+
)
|
|
1848
|
+
const { base, overlay } = geometry
|
|
1849
|
+
? splitFloorplanOverlay(geometry)
|
|
1850
|
+
: { base: null, overlay: null }
|
|
1851
|
+
const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
|
|
1852
|
+
geometryCache.set(nodeId, entry)
|
|
1853
|
+
return entry
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
export function getFloorplanLevelData(
|
|
1857
|
+
type: string,
|
|
1858
|
+
nodes: Record<string, AnyNode>,
|
|
1859
|
+
liveOverrides: Map<string, LiveNodeOverrides>,
|
|
1860
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>,
|
|
1861
|
+
levelDataCache: Map<string, LevelDataCacheEntry>,
|
|
1862
|
+
): unknown {
|
|
1863
|
+
const def = nodeRegistry.get(type)
|
|
1864
|
+
if (!def?.computeFloorplanLevelData) return undefined
|
|
1865
|
+
const ids = levelNodeIdsByType.get(type)
|
|
1866
|
+
const sampleId = ids?.[0]
|
|
1867
|
+
if (!ids || !sampleId) return undefined
|
|
1868
|
+
|
|
1869
|
+
const cached = levelDataCache.get(type)
|
|
1870
|
+
if (
|
|
1871
|
+
cached &&
|
|
1872
|
+
cached.nodes === nodes &&
|
|
1873
|
+
cached.liveOverrides === liveOverrides &&
|
|
1874
|
+
cached.ids === ids
|
|
1875
|
+
) {
|
|
1876
|
+
return cached.value
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
|
|
1880
|
+
const contextNodes = def.floorplanSiblingOverrides
|
|
1881
|
+
? def.floorplanSiblingOverrides({
|
|
1882
|
+
nodeId: sampleId,
|
|
1883
|
+
nodes,
|
|
1884
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
1885
|
+
liveOverrides,
|
|
1886
|
+
})
|
|
1887
|
+
: nodes
|
|
1888
|
+
const siblings: AnyNode[] = []
|
|
1889
|
+
for (const id of ids) {
|
|
1890
|
+
const sibling = contextNodes[id]
|
|
1891
|
+
if (sibling?.type === type) siblings.push(sibling)
|
|
1892
|
+
}
|
|
1893
|
+
const value = computeLevelData({ siblings, nodes: contextNodes })
|
|
1894
|
+
levelDataCache.set(type, { nodes, liveOverrides, ids, value })
|
|
1895
|
+
return value
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// ── Interactive geometry walker ──────────────────────────────────────
|
|
1899
|
+
|
|
1900
|
+
type InteractiveGeometryProps = {
|
|
1901
|
+
geometry: FloorplanGeometry
|
|
1902
|
+
unitsPerPixel: number
|
|
1903
|
+
palette: FloorplanPalette | undefined
|
|
1904
|
+
hatchPatternId: string | undefined
|
|
1905
|
+
hoveredHandleId: string | null
|
|
1906
|
+
activeDragId: string | null
|
|
1907
|
+
activeRotateNodeId: AnyNodeId | null
|
|
1908
|
+
isMarqueeSelectionActive: boolean
|
|
1909
|
+
nodeId: AnyNodeId
|
|
1910
|
+
sceneRotationDeg: number
|
|
1911
|
+
onHandleHoverChange: (id: string | null) => void
|
|
1912
|
+
onHandleDoubleClick: (
|
|
1913
|
+
affordance: string,
|
|
1914
|
+
payload: unknown,
|
|
1915
|
+
event: ReactMouseEvent<SVGElement>,
|
|
1916
|
+
) => void
|
|
1917
|
+
onHandlePointerDown: (
|
|
1918
|
+
affordance: string,
|
|
1919
|
+
payload: unknown,
|
|
1920
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
1921
|
+
// Forwarded only by rotate-arrow handles — the pivot the drag turns
|
|
1922
|
+
// the node around, used to drive the live angle wedge + degree chip.
|
|
1923
|
+
rotationPivot?: FloorplanPoint,
|
|
1924
|
+
) => void
|
|
1925
|
+
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
1929
|
+
geometry,
|
|
1930
|
+
unitsPerPixel,
|
|
1931
|
+
palette,
|
|
1932
|
+
hatchPatternId,
|
|
1933
|
+
hoveredHandleId,
|
|
1934
|
+
activeDragId,
|
|
1935
|
+
activeRotateNodeId,
|
|
1936
|
+
isMarqueeSelectionActive,
|
|
1937
|
+
nodeId,
|
|
1938
|
+
sceneRotationDeg,
|
|
1939
|
+
onHandleDoubleClick,
|
|
1940
|
+
onHandleHoverChange,
|
|
1941
|
+
onHandlePointerDown,
|
|
1942
|
+
onMoveHandlePointerDown,
|
|
1943
|
+
}: InteractiveGeometryProps): React.ReactElement {
|
|
1944
|
+
return renderInteractive(geometry, 0)
|
|
1945
|
+
|
|
1946
|
+
function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
|
|
1947
|
+
switch (g.kind) {
|
|
1948
|
+
case 'group': {
|
|
1949
|
+
const transform = formatGroupTransform(g.transform)
|
|
1950
|
+
return (
|
|
1951
|
+
<g key={keyHint} transform={transform}>
|
|
1952
|
+
{g.children.map((child, i) => renderInteractive(child, i))}
|
|
1953
|
+
</g>
|
|
1954
|
+
)
|
|
1955
|
+
}
|
|
1956
|
+
case 'hatch': {
|
|
1957
|
+
if (!hatchPatternId) return <></>
|
|
1958
|
+
return (
|
|
1959
|
+
<polygon
|
|
1960
|
+
fill={`url(#${hatchPatternId})`}
|
|
1961
|
+
key={keyHint}
|
|
1962
|
+
opacity={g.opacity}
|
|
1963
|
+
pointerEvents="none"
|
|
1964
|
+
points={g.points.map(([x, y]) => `${x},${y}`).join(' ')}
|
|
1965
|
+
/>
|
|
1966
|
+
)
|
|
1967
|
+
}
|
|
1968
|
+
case 'hit-line': {
|
|
1969
|
+
return (
|
|
1970
|
+
<line
|
|
1971
|
+
key={keyHint}
|
|
1972
|
+
pointerEvents={isMarqueeSelectionActive ? 'none' : (g.pointerEvents ?? 'stroke')}
|
|
1973
|
+
stroke="transparent"
|
|
1974
|
+
strokeLinecap="round"
|
|
1975
|
+
strokeWidth={g.strokeWidthPx * unitsPerPixel}
|
|
1976
|
+
style={{ cursor: g.cursor ?? 'pointer' }}
|
|
1977
|
+
vectorEffect="non-scaling-stroke"
|
|
1978
|
+
x1={g.x1}
|
|
1979
|
+
x2={g.x2}
|
|
1980
|
+
y1={g.y1}
|
|
1981
|
+
y2={g.y2}
|
|
1982
|
+
/>
|
|
1983
|
+
)
|
|
1984
|
+
}
|
|
1985
|
+
case 'endpoint-handle': {
|
|
1986
|
+
if (!palette) return <></>
|
|
1987
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
1988
|
+
const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
|
|
1989
|
+
const isHovered = hoveredHandleId === handleId
|
|
1990
|
+
const isActive = activeDragId === handleId
|
|
1991
|
+
// Variant picks the colour-set. Endpoint dots use the orange
|
|
1992
|
+
// legacy palette; curve sagitta dots use the teal set so users
|
|
1993
|
+
// can tell them apart at a glance.
|
|
1994
|
+
const isCurve = g.variant === 'curve'
|
|
1995
|
+
const stroke = isCurve
|
|
1996
|
+
? palette.curveHandleStroke
|
|
1997
|
+
: isActive
|
|
1998
|
+
? palette.endpointHandleActiveStroke
|
|
1999
|
+
: palette.endpointHandleStroke
|
|
2000
|
+
const hoverStroke = isCurve
|
|
2001
|
+
? palette.curveHandleHoverStroke
|
|
2002
|
+
: isActive
|
|
2003
|
+
? palette.endpointHandleActiveStroke
|
|
2004
|
+
: palette.endpointHandleHoverStroke
|
|
2005
|
+
const fill = isCurve
|
|
2006
|
+
? palette.curveHandleFill
|
|
2007
|
+
: isActive
|
|
2008
|
+
? palette.endpointHandleActiveFill
|
|
2009
|
+
: palette.endpointHandleFill
|
|
2010
|
+
const outerRadius =
|
|
2011
|
+
(isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
|
|
2012
|
+
unitsPerPixel
|
|
2013
|
+
const dotRadius =
|
|
2014
|
+
(isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
|
|
2015
|
+
unitsPerPixel
|
|
2016
|
+
return (
|
|
2017
|
+
<g
|
|
2018
|
+
key={keyHint}
|
|
2019
|
+
onClick={(e) => e.stopPropagation()}
|
|
2020
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
2021
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
2022
|
+
>
|
|
2023
|
+
<circle
|
|
2024
|
+
cx={g.point[0]}
|
|
2025
|
+
cy={g.point[1]}
|
|
2026
|
+
fill="none"
|
|
2027
|
+
pointerEvents="none"
|
|
2028
|
+
r={outerRadius}
|
|
2029
|
+
stroke={hoverStroke}
|
|
2030
|
+
strokeOpacity={isActive ? 0.24 : 0.16}
|
|
2031
|
+
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
|
|
2032
|
+
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2033
|
+
vectorEffect="non-scaling-stroke"
|
|
2034
|
+
/>
|
|
2035
|
+
<circle
|
|
2036
|
+
cx={g.point[0]}
|
|
2037
|
+
cy={g.point[1]}
|
|
2038
|
+
fill="none"
|
|
2039
|
+
pointerEvents="none"
|
|
2040
|
+
r={outerRadius}
|
|
2041
|
+
stroke={hoverStroke}
|
|
2042
|
+
strokeOpacity={isActive ? 0.72 : 0.52}
|
|
2043
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
|
|
2044
|
+
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2045
|
+
vectorEffect="non-scaling-stroke"
|
|
2046
|
+
/>
|
|
2047
|
+
<circle
|
|
2048
|
+
cx={g.point[0]}
|
|
2049
|
+
cy={g.point[1]}
|
|
2050
|
+
fill={fill}
|
|
2051
|
+
fillOpacity={0.96}
|
|
2052
|
+
pointerEvents="none"
|
|
2053
|
+
r={outerRadius}
|
|
2054
|
+
stroke={stroke}
|
|
2055
|
+
strokeWidth="0.05"
|
|
2056
|
+
vectorEffect="non-scaling-stroke"
|
|
2057
|
+
/>
|
|
2058
|
+
<circle
|
|
2059
|
+
cx={g.point[0]}
|
|
2060
|
+
cy={g.point[1]}
|
|
2061
|
+
fill={stroke}
|
|
2062
|
+
pointerEvents="none"
|
|
2063
|
+
r={dotRadius}
|
|
2064
|
+
vectorEffect="non-scaling-stroke"
|
|
2065
|
+
/>
|
|
2066
|
+
<circle
|
|
2067
|
+
cx={g.point[0]}
|
|
2068
|
+
cy={g.point[1]}
|
|
2069
|
+
fill="transparent"
|
|
2070
|
+
onDoubleClick={
|
|
2071
|
+
doubleClickAffordance
|
|
2072
|
+
? (event) => {
|
|
2073
|
+
event.preventDefault()
|
|
2074
|
+
event.stopPropagation()
|
|
2075
|
+
onHandleDoubleClick(doubleClickAffordance, g.payload, event)
|
|
2076
|
+
}
|
|
2077
|
+
: undefined
|
|
2078
|
+
}
|
|
2079
|
+
onPointerDown={(e) =>
|
|
2080
|
+
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
2081
|
+
}
|
|
2082
|
+
pointerEvents="all"
|
|
2083
|
+
r={outerRadius}
|
|
2084
|
+
stroke="transparent"
|
|
2085
|
+
strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
|
|
2086
|
+
style={{ cursor: 'pointer' }}
|
|
2087
|
+
vectorEffect="non-scaling-stroke"
|
|
2088
|
+
/>
|
|
2089
|
+
</g>
|
|
2090
|
+
)
|
|
2091
|
+
}
|
|
2092
|
+
case 'move-handle': {
|
|
2093
|
+
if (!palette) return <></>
|
|
2094
|
+
const moveHandleId = `${nodeId}:move`
|
|
2095
|
+
const isHovered = hoveredHandleId === moveHandleId
|
|
2096
|
+
// World-relative sizing: the move dot is anchored to the door,
|
|
2097
|
+
// not to the screen, so it grows when the user zooms in and
|
|
2098
|
+
// shrinks when they zoom out — same scaling rule as the door
|
|
2099
|
+
// footprint itself. Sizes are tuned for a ~0.9 m door at default
|
|
2100
|
+
// zoom; the ratios match the legacy 13/15/6/16/7/18 px stack.
|
|
2101
|
+
const baseRadius = 0.1
|
|
2102
|
+
const hoverRadius = 0.115
|
|
2103
|
+
const outerRadius = isHovered ? hoverRadius : baseRadius
|
|
2104
|
+
const dotRadius = 0.045
|
|
2105
|
+
const fillStroke = 0.005
|
|
2106
|
+
const glowStroke = 0.12
|
|
2107
|
+
const ringStroke = 0.055
|
|
2108
|
+
const hitStroke = 0.14
|
|
2109
|
+
// Same 5-circle stack as the orange endpoint dot — hover glow +
|
|
2110
|
+
// hover ring + filled outer + inner dot + transparent hit. On
|
|
2111
|
+
// pointer-down, the layer calls `setMovingNode(node)`, which
|
|
2112
|
+
// FloorplanRegistryMoveOverlay picks up and routes to the
|
|
2113
|
+
// kind's `def.floorplanMoveTarget`.
|
|
2114
|
+
return (
|
|
2115
|
+
<g
|
|
2116
|
+
key={keyHint}
|
|
2117
|
+
onClick={(e) => e.stopPropagation()}
|
|
2118
|
+
onPointerEnter={() => onHandleHoverChange(moveHandleId)}
|
|
2119
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
2120
|
+
>
|
|
2121
|
+
<circle
|
|
2122
|
+
cx={g.point[0]}
|
|
2123
|
+
cy={g.point[1]}
|
|
2124
|
+
fill="none"
|
|
2125
|
+
pointerEvents="none"
|
|
2126
|
+
r={outerRadius}
|
|
2127
|
+
stroke={palette.endpointHandleHoverStroke}
|
|
2128
|
+
strokeOpacity={0.16}
|
|
2129
|
+
strokeWidth={glowStroke}
|
|
2130
|
+
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2131
|
+
/>
|
|
2132
|
+
<circle
|
|
2133
|
+
cx={g.point[0]}
|
|
2134
|
+
cy={g.point[1]}
|
|
2135
|
+
fill="none"
|
|
2136
|
+
pointerEvents="none"
|
|
2137
|
+
r={outerRadius}
|
|
2138
|
+
stroke={palette.endpointHandleHoverStroke}
|
|
2139
|
+
strokeOpacity={0.52}
|
|
2140
|
+
strokeWidth={ringStroke}
|
|
2141
|
+
style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2142
|
+
/>
|
|
2143
|
+
<circle
|
|
2144
|
+
cx={g.point[0]}
|
|
2145
|
+
cy={g.point[1]}
|
|
2146
|
+
fill={palette.endpointHandleFill}
|
|
2147
|
+
fillOpacity={0.96}
|
|
2148
|
+
pointerEvents="none"
|
|
2149
|
+
r={outerRadius}
|
|
2150
|
+
stroke={palette.endpointHandleStroke}
|
|
2151
|
+
strokeWidth={fillStroke}
|
|
2152
|
+
/>
|
|
2153
|
+
<circle
|
|
2154
|
+
cx={g.point[0]}
|
|
2155
|
+
cy={g.point[1]}
|
|
2156
|
+
fill={palette.endpointHandleStroke}
|
|
2157
|
+
pointerEvents="none"
|
|
2158
|
+
r={dotRadius}
|
|
2159
|
+
/>
|
|
2160
|
+
<circle
|
|
2161
|
+
cx={g.point[0]}
|
|
2162
|
+
cy={g.point[1]}
|
|
2163
|
+
fill="transparent"
|
|
2164
|
+
onPointerDown={(e) => onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)}
|
|
2165
|
+
pointerEvents="all"
|
|
2166
|
+
r={outerRadius}
|
|
2167
|
+
stroke="transparent"
|
|
2168
|
+
strokeWidth={hitStroke}
|
|
2169
|
+
style={{ cursor: 'move' }}
|
|
2170
|
+
/>
|
|
2171
|
+
</g>
|
|
2172
|
+
)
|
|
2173
|
+
}
|
|
2174
|
+
case 'rotate-arrow': {
|
|
2175
|
+
if (!palette) return <></>
|
|
2176
|
+
// 2D counterpart of the 3D `arc-resize` rotate gizmo. Local
|
|
2177
|
+
// frame: +X is the radial-outward direction (away from the
|
|
2178
|
+
// pivot); the arc bows in that direction with arrowheads on
|
|
2179
|
+
// each end pointing tangentially in opposite directions —
|
|
2180
|
+
// "rotate either way."
|
|
2181
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
2182
|
+
const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
|
|
2183
|
+
// Arc geometry (all values precomputed for a 72° arc of
|
|
2184
|
+
// radius 0.13 — comparable footprint to `move-arrow`).
|
|
2185
|
+
const R = 0.13
|
|
2186
|
+
const halfSpan = Math.PI / 5
|
|
2187
|
+
const cosH = Math.cos(halfSpan)
|
|
2188
|
+
const sinH = Math.sin(halfSpan)
|
|
2189
|
+
const endY = R * sinH
|
|
2190
|
+
const headLen = 0.06
|
|
2191
|
+
const headHalfBase = 0.045
|
|
2192
|
+
// End-1 (top) arrowhead — tip along CCW tangent.
|
|
2193
|
+
const t1x = -sinH * headLen
|
|
2194
|
+
const t1y = endY + cosH * headLen
|
|
2195
|
+
const b1ax = cosH * headHalfBase
|
|
2196
|
+
const b1ay = endY + sinH * headHalfBase
|
|
2197
|
+
const b1bx = -cosH * headHalfBase
|
|
2198
|
+
const b1by = endY - sinH * headHalfBase
|
|
2199
|
+
// End-2 (bottom) arrowhead — mirror of End-1.
|
|
2200
|
+
const t2x = -sinH * headLen
|
|
2201
|
+
const t2y = -endY - cosH * headLen
|
|
2202
|
+
const b2ax = cosH * headHalfBase
|
|
2203
|
+
const b2ay = -endY - sinH * headHalfBase
|
|
2204
|
+
const b2bx = -cosH * headHalfBase
|
|
2205
|
+
const b2by = -endY + sinH * headHalfBase
|
|
2206
|
+
const arcPath = `M 0 ${-endY} A ${R} ${R} 0 0 1 0 ${endY}`
|
|
2207
|
+
const head1 = `M ${t1x} ${t1y} L ${b1ax} ${b1ay} L ${b1bx} ${b1by} Z`
|
|
2208
|
+
const head2 = `M ${t2x} ${t2y} L ${b2ax} ${b2ay} L ${b2bx} ${b2by} Z`
|
|
2209
|
+
const fill = isHovered ? '#a5b4fc' : '#8381ed'
|
|
2210
|
+
const strokeWidthPx = isHovered ? 2.4 : 1.8
|
|
2211
|
+
const angleDeg = (g.angle * 180) / Math.PI
|
|
2212
|
+
const affordance = g.affordance
|
|
2213
|
+
const payload = g.payload
|
|
2214
|
+
const pivot = g.pivot
|
|
2215
|
+
return (
|
|
2216
|
+
<g
|
|
2217
|
+
key={keyHint}
|
|
2218
|
+
onClick={(e) => e.stopPropagation()}
|
|
2219
|
+
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
|
|
2220
|
+
>
|
|
2221
|
+
<path
|
|
2222
|
+
d={arcPath}
|
|
2223
|
+
fill="none"
|
|
2224
|
+
pointerEvents="none"
|
|
2225
|
+
stroke={fill}
|
|
2226
|
+
strokeLinecap="round"
|
|
2227
|
+
strokeWidth={strokeWidthPx}
|
|
2228
|
+
vectorEffect="non-scaling-stroke"
|
|
2229
|
+
/>
|
|
2230
|
+
<path d={head1} fill={fill} pointerEvents="none" />
|
|
2231
|
+
<path d={head2} fill={fill} pointerEvents="none" />
|
|
2232
|
+
{/* Hit target — fat invisible stroke along the arc + filled
|
|
2233
|
+
triangles at the heads so the user can grab anywhere on
|
|
2234
|
+
the visible icon. */}
|
|
2235
|
+
<path
|
|
2236
|
+
d={arcPath}
|
|
2237
|
+
fill="none"
|
|
2238
|
+
onPointerDown={(e) =>
|
|
2239
|
+
onHandlePointerDown(
|
|
2240
|
+
affordance,
|
|
2241
|
+
payload,
|
|
2242
|
+
e as ReactPointerEvent<SVGPathElement>,
|
|
2243
|
+
pivot,
|
|
2244
|
+
)
|
|
2245
|
+
}
|
|
2246
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
2247
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
2248
|
+
pointerEvents="stroke"
|
|
2249
|
+
stroke="transparent"
|
|
2250
|
+
strokeWidth={0.06}
|
|
2251
|
+
style={{ cursor: 'grab' }}
|
|
2252
|
+
/>
|
|
2253
|
+
<path
|
|
2254
|
+
d={`${head1} ${head2}`}
|
|
2255
|
+
fill="transparent"
|
|
2256
|
+
onPointerDown={(e) =>
|
|
2257
|
+
onHandlePointerDown(
|
|
2258
|
+
affordance,
|
|
2259
|
+
payload,
|
|
2260
|
+
e as ReactPointerEvent<SVGPathElement>,
|
|
2261
|
+
pivot,
|
|
2262
|
+
)
|
|
2263
|
+
}
|
|
2264
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
2265
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
2266
|
+
pointerEvents="fill"
|
|
2267
|
+
style={{ cursor: 'grab' }}
|
|
2268
|
+
/>
|
|
2269
|
+
</g>
|
|
2270
|
+
)
|
|
2271
|
+
}
|
|
2272
|
+
case 'move-arrow': {
|
|
2273
|
+
if (!palette) return <></>
|
|
2274
|
+
// Affordance-routed arrows (door width-resize) get a per-payload
|
|
2275
|
+
// handle id so each side can hover independently; default
|
|
2276
|
+
// (move-flow) arrows share the node's :move id like the dot.
|
|
2277
|
+
const handleId = g.affordance ? makeHandleId(nodeId, g.payload) : `${nodeId}:move`
|
|
2278
|
+
const isHovered = hoveredHandleId === handleId
|
|
2279
|
+
// Arrow geometry in plan units (meters) — scales with the scene
|
|
2280
|
+
// so it shrinks on zoom-out and grows on zoom-in, matching the
|
|
2281
|
+
// wall it accompanies. Composed of a rectangular shaft + triangular
|
|
2282
|
+
// head, drawn as a single path for a clean fill + stroke outline.
|
|
2283
|
+
const sl = 0.1 // shaft length (shortened body)
|
|
2284
|
+
const hl = 0.12 // head length
|
|
2285
|
+
const sh = 0.04 // shaft half-height
|
|
2286
|
+
const hh = 0.1 // head half-height
|
|
2287
|
+
// Inset the shaft start so the arrow sits a little off the wall
|
|
2288
|
+
// body (matches the 3D `HANDLE_OFFSET`).
|
|
2289
|
+
const bi = 0.03 // base inset
|
|
2290
|
+
const arrowD = `M ${bi},${-sh} L ${bi + sl},${-sh} L ${bi + sl},${-hh} L ${bi + sl + hl},0 L ${bi + sl},${hh} L ${bi + sl},${sh} L ${bi},${sh} Z`
|
|
2291
|
+
// Indigo palette to match the 3D `WallMoveSideHandles` arrows
|
|
2292
|
+
// (`ARROW_COLOR` / `ARROW_HOVER_COLOR`) and the corner-sphere
|
|
2293
|
+
// accent in `floating-action-menu.tsx`.
|
|
2294
|
+
const fill = isHovered ? '#a5b4fc' : '#8381ed'
|
|
2295
|
+
const angleDeg = (g.angle * 180) / Math.PI
|
|
2296
|
+
const cursor = g.affordance ? 'ew-resize' : 'move'
|
|
2297
|
+
const affordance = g.affordance
|
|
2298
|
+
const payload = g.payload
|
|
2299
|
+
// No hover-grow: a scaling transform would enlarge the hit area
|
|
2300
|
+
// too, letting clicks just outside the visible arrow still start
|
|
2301
|
+
// a drag. Hover feedback is colour-only so the click region
|
|
2302
|
+
// always matches the painted arrow shape exactly.
|
|
2303
|
+
return (
|
|
2304
|
+
<g
|
|
2305
|
+
key={keyHint}
|
|
2306
|
+
onClick={(e) => e.stopPropagation()}
|
|
2307
|
+
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
|
|
2308
|
+
>
|
|
2309
|
+
<path d={arrowD} fill={fill} pointerEvents="none" />
|
|
2310
|
+
<path
|
|
2311
|
+
d={arrowD}
|
|
2312
|
+
fill="transparent"
|
|
2313
|
+
onPointerDown={(e) => {
|
|
2314
|
+
if (affordance) {
|
|
2315
|
+
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGGElement>)
|
|
2316
|
+
} else {
|
|
2317
|
+
onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
|
|
2318
|
+
}
|
|
2319
|
+
}}
|
|
2320
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
2321
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
2322
|
+
pointerEvents="fill"
|
|
2323
|
+
style={{ cursor }}
|
|
2324
|
+
/>
|
|
2325
|
+
</g>
|
|
2326
|
+
)
|
|
2327
|
+
}
|
|
2328
|
+
case 'edge-handle': {
|
|
2329
|
+
if (!palette) return <></>
|
|
2330
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
2331
|
+
const isHovered = hoveredHandleId === handleId
|
|
2332
|
+
const isActive = activeDragId === handleId
|
|
2333
|
+
const showVisible = isHovered || isActive
|
|
2334
|
+
const stroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke
|
|
2335
|
+
// Stroke widths in screen pixels — non-scaling-stroke keeps the
|
|
2336
|
+
// hit area + glow consistent at every zoom.
|
|
2337
|
+
const glowWidthPx = 14
|
|
2338
|
+
const visibleWidthPx = 3
|
|
2339
|
+
const hitWidthPx = 18
|
|
2340
|
+
return (
|
|
2341
|
+
<g
|
|
2342
|
+
key={keyHint}
|
|
2343
|
+
onClick={(e) => e.stopPropagation()}
|
|
2344
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
2345
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
2346
|
+
>
|
|
2347
|
+
{/* Soft glow — visible only on hover / active. */}
|
|
2348
|
+
<line
|
|
2349
|
+
pointerEvents="none"
|
|
2350
|
+
stroke={stroke}
|
|
2351
|
+
strokeLinecap="round"
|
|
2352
|
+
strokeOpacity={0.18}
|
|
2353
|
+
strokeWidth={glowWidthPx * unitsPerPixel}
|
|
2354
|
+
style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2355
|
+
vectorEffect="non-scaling-stroke"
|
|
2356
|
+
x1={g.x1}
|
|
2357
|
+
x2={g.x2}
|
|
2358
|
+
y1={g.y1}
|
|
2359
|
+
y2={g.y2}
|
|
2360
|
+
/>
|
|
2361
|
+
{/* Solid stroke on top — slightly more opaque when active. */}
|
|
2362
|
+
<line
|
|
2363
|
+
pointerEvents="none"
|
|
2364
|
+
stroke={stroke}
|
|
2365
|
+
strokeLinecap="round"
|
|
2366
|
+
strokeOpacity={isActive ? 0.95 : 0.82}
|
|
2367
|
+
strokeWidth={visibleWidthPx * unitsPerPixel}
|
|
2368
|
+
style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2369
|
+
vectorEffect="non-scaling-stroke"
|
|
2370
|
+
x1={g.x1}
|
|
2371
|
+
x2={g.x2}
|
|
2372
|
+
y1={g.y1}
|
|
2373
|
+
y2={g.y2}
|
|
2374
|
+
/>
|
|
2375
|
+
{/* Transparent hit area along the edge. */}
|
|
2376
|
+
<line
|
|
2377
|
+
onPointerDown={(e) =>
|
|
2378
|
+
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
2379
|
+
}
|
|
2380
|
+
pointerEvents="stroke"
|
|
2381
|
+
stroke="transparent"
|
|
2382
|
+
strokeLinecap="round"
|
|
2383
|
+
strokeWidth={hitWidthPx * unitsPerPixel}
|
|
2384
|
+
style={{ cursor: 'pointer' }}
|
|
2385
|
+
vectorEffect="non-scaling-stroke"
|
|
2386
|
+
x1={g.x1}
|
|
2387
|
+
x2={g.x2}
|
|
2388
|
+
y1={g.y1}
|
|
2389
|
+
y2={g.y2}
|
|
2390
|
+
/>
|
|
2391
|
+
</g>
|
|
2392
|
+
)
|
|
2393
|
+
}
|
|
2394
|
+
case 'midpoint-handle': {
|
|
2395
|
+
if (!palette) return <></>
|
|
2396
|
+
const handleId = makeHandleId(nodeId, g.payload)
|
|
2397
|
+
const isHovered = hoveredHandleId === handleId
|
|
2398
|
+
const isActive = activeDragId === handleId
|
|
2399
|
+
const stroke = palette.endpointHandleStroke
|
|
2400
|
+
const hoverStroke = palette.endpointHandleHoverStroke
|
|
2401
|
+
// Slightly smaller than endpoint dots; hover-expanded.
|
|
2402
|
+
const baseRadiusPx = 6
|
|
2403
|
+
const hoverRadiusPx = 8
|
|
2404
|
+
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
|
|
2405
|
+
const plusHalf = 3 * unitsPerPixel
|
|
2406
|
+
return (
|
|
2407
|
+
<g
|
|
2408
|
+
key={keyHint}
|
|
2409
|
+
onClick={(e) => e.stopPropagation()}
|
|
2410
|
+
onPointerEnter={() => onHandleHoverChange(handleId)}
|
|
2411
|
+
onPointerLeave={() => onHandleHoverChange(null)}
|
|
2412
|
+
>
|
|
2413
|
+
<circle
|
|
2414
|
+
cx={g.point[0]}
|
|
2415
|
+
cy={g.point[1]}
|
|
2416
|
+
fill="none"
|
|
2417
|
+
pointerEvents="none"
|
|
2418
|
+
r={radius + 2 * unitsPerPixel}
|
|
2419
|
+
stroke={hoverStroke}
|
|
2420
|
+
strokeOpacity={0.16}
|
|
2421
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
|
|
2422
|
+
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
2423
|
+
vectorEffect="non-scaling-stroke"
|
|
2424
|
+
/>
|
|
2425
|
+
<circle
|
|
2426
|
+
cx={g.point[0]}
|
|
2427
|
+
cy={g.point[1]}
|
|
2428
|
+
fill="#ffffff"
|
|
2429
|
+
fillOpacity={1}
|
|
2430
|
+
pointerEvents="none"
|
|
2431
|
+
r={radius}
|
|
2432
|
+
stroke={stroke}
|
|
2433
|
+
strokeOpacity={0.9}
|
|
2434
|
+
strokeWidth={1.4}
|
|
2435
|
+
vectorEffect="non-scaling-stroke"
|
|
2436
|
+
/>
|
|
2437
|
+
{/* `+` icon — only when the user is close enough to see it
|
|
2438
|
+
clearly (hover or active state). Keeps the resting state
|
|
2439
|
+
visually quiet on busy polygons. */}
|
|
2440
|
+
<line
|
|
2441
|
+
pointerEvents="none"
|
|
2442
|
+
stroke={stroke}
|
|
2443
|
+
strokeLinecap="round"
|
|
2444
|
+
strokeWidth={1.6}
|
|
2445
|
+
vectorEffect="non-scaling-stroke"
|
|
2446
|
+
x1={g.point[0] - plusHalf}
|
|
2447
|
+
x2={g.point[0] + plusHalf}
|
|
2448
|
+
y1={g.point[1]}
|
|
2449
|
+
y2={g.point[1]}
|
|
2450
|
+
/>
|
|
2451
|
+
<line
|
|
2452
|
+
pointerEvents="none"
|
|
2453
|
+
stroke={stroke}
|
|
2454
|
+
strokeLinecap="round"
|
|
2455
|
+
strokeWidth={1.6}
|
|
2456
|
+
vectorEffect="non-scaling-stroke"
|
|
2457
|
+
x1={g.point[0]}
|
|
2458
|
+
x2={g.point[0]}
|
|
2459
|
+
y1={g.point[1] - plusHalf}
|
|
2460
|
+
y2={g.point[1] + plusHalf}
|
|
2461
|
+
/>
|
|
2462
|
+
<circle
|
|
2463
|
+
cx={g.point[0]}
|
|
2464
|
+
cy={g.point[1]}
|
|
2465
|
+
fill="transparent"
|
|
2466
|
+
onPointerDown={(e) =>
|
|
2467
|
+
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
2468
|
+
}
|
|
2469
|
+
pointerEvents="all"
|
|
2470
|
+
r={radius + unitsPerPixel * 2}
|
|
2471
|
+
stroke="transparent"
|
|
2472
|
+
strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
|
|
2473
|
+
style={{ cursor: 'pointer' }}
|
|
2474
|
+
vectorEffect="non-scaling-stroke"
|
|
2475
|
+
/>
|
|
2476
|
+
</g>
|
|
2477
|
+
)
|
|
2478
|
+
}
|
|
2479
|
+
case 'dimension-label': {
|
|
2480
|
+
if (!palette) return <></>
|
|
2481
|
+
// Flip the label upright relative to the SCREEN, not the local
|
|
2482
|
+
// coord system. The registry layer's parent `<g>` is rotated by
|
|
2483
|
+
// `sceneRotationDeg` (default 90° in the floor plan), so a label
|
|
2484
|
+
// we draw "upright" in local coords ends up sideways on screen.
|
|
2485
|
+
// Combine local angle + scene rotation, normalise to (-180, 180],
|
|
2486
|
+
// and flip by 180° if it falls outside (-90, 90] — that keeps
|
|
2487
|
+
// text reading left-to-right, top-to-bottom regardless of the
|
|
2488
|
+
// building's orientation.
|
|
2489
|
+
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
|
|
2490
|
+
|
|
2491
|
+
const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
|
|
2492
|
+
const outlined = g.appearance === 'outlined'
|
|
2493
|
+
const padX = labelUnitsPerPixel * 6
|
|
2494
|
+
const padY = labelUnitsPerPixel * 3
|
|
2495
|
+
const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
|
|
2496
|
+
// Rough text width approximation — SVG can't measure text without
|
|
2497
|
+
// the DOM. 6.2px per char at 10px font keeps the plate visually
|
|
2498
|
+
// balanced for the short length strings ("3.24m", "1'2\"", etc.).
|
|
2499
|
+
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
|
|
2500
|
+
const plateW = textWidth + padX * 2
|
|
2501
|
+
const plateH = fontSize + padY * 2
|
|
2502
|
+
return (
|
|
2503
|
+
<g
|
|
2504
|
+
key={keyHint}
|
|
2505
|
+
pointerEvents="none"
|
|
2506
|
+
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
|
|
2507
|
+
>
|
|
2508
|
+
{outlined ? null : (
|
|
2509
|
+
<rect
|
|
2510
|
+
fill={palette.measurementLabelBackground}
|
|
2511
|
+
height={plateH}
|
|
2512
|
+
opacity={0.92}
|
|
2513
|
+
rx={labelUnitsPerPixel * 3}
|
|
2514
|
+
ry={labelUnitsPerPixel * 3}
|
|
2515
|
+
stroke={palette.measurementStroke}
|
|
2516
|
+
strokeWidth={labelUnitsPerPixel * 0.5}
|
|
2517
|
+
vectorEffect="non-scaling-stroke"
|
|
2518
|
+
width={plateW}
|
|
2519
|
+
x={-plateW / 2}
|
|
2520
|
+
y={-plateH / 2}
|
|
2521
|
+
/>
|
|
2522
|
+
)}
|
|
2523
|
+
<text
|
|
2524
|
+
dominantBaseline="middle"
|
|
2525
|
+
fill={outlined ? '#ffffff' : palette.measurementLabelText}
|
|
2526
|
+
fontFamily={
|
|
2527
|
+
outlined
|
|
2528
|
+
? 'system-ui, -apple-system, sans-serif'
|
|
2529
|
+
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
|
|
2530
|
+
}
|
|
2531
|
+
fontSize={fontSize}
|
|
2532
|
+
fontWeight={outlined ? 500 : 600}
|
|
2533
|
+
paintOrder={outlined ? 'stroke' : undefined}
|
|
2534
|
+
stroke={outlined ? palette.measurementStroke : undefined}
|
|
2535
|
+
strokeLinecap={outlined ? 'round' : undefined}
|
|
2536
|
+
strokeLinejoin={outlined ? 'round' : undefined}
|
|
2537
|
+
strokeWidth={outlined ? fontSize * 0.35 : undefined}
|
|
2538
|
+
textAnchor="middle"
|
|
2539
|
+
x={0}
|
|
2540
|
+
y={0}
|
|
2541
|
+
>
|
|
2542
|
+
{g.text}
|
|
2543
|
+
</text>
|
|
2544
|
+
</g>
|
|
2545
|
+
)
|
|
2546
|
+
}
|
|
2547
|
+
case 'equal-spacing-badge': {
|
|
2548
|
+
// A distinct accent (Figma-style "=" rhythm) so equal spacing reads
|
|
2549
|
+
// apart from the orange placement dimensions. Same screen-upright flip
|
|
2550
|
+
// as the dimension-label case above.
|
|
2551
|
+
const accent = '#ec4899'
|
|
2552
|
+
let degrees = (g.angle * 180) / Math.PI
|
|
2553
|
+
let screenDegrees = degrees + sceneRotationDeg
|
|
2554
|
+
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
|
2555
|
+
if (screenDegrees > 90) degrees -= 180
|
|
2556
|
+
else if (screenDegrees <= -90) degrees += 180
|
|
2557
|
+
|
|
2558
|
+
const label = `= ${g.text}`
|
|
2559
|
+
const padX = unitsPerPixel * 6
|
|
2560
|
+
const padY = unitsPerPixel * 3
|
|
2561
|
+
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
|
2562
|
+
const textWidth = label.length * unitsPerPixel * 6.2
|
|
2563
|
+
const plateW = textWidth + padX * 2
|
|
2564
|
+
const plateH = fontSize + padY * 2
|
|
2565
|
+
return (
|
|
2566
|
+
<g
|
|
2567
|
+
key={keyHint}
|
|
2568
|
+
pointerEvents="none"
|
|
2569
|
+
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
|
|
2570
|
+
>
|
|
2571
|
+
<rect
|
|
2572
|
+
fill="#ffffff"
|
|
2573
|
+
height={plateH}
|
|
2574
|
+
opacity={0.95}
|
|
2575
|
+
rx={unitsPerPixel * 3}
|
|
2576
|
+
ry={unitsPerPixel * 3}
|
|
2577
|
+
stroke={accent}
|
|
2578
|
+
strokeWidth={unitsPerPixel * 0.75}
|
|
2579
|
+
vectorEffect="non-scaling-stroke"
|
|
2580
|
+
width={plateW}
|
|
2581
|
+
x={-plateW / 2}
|
|
2582
|
+
y={-plateH / 2}
|
|
2583
|
+
/>
|
|
2584
|
+
<text
|
|
2585
|
+
dominantBaseline="middle"
|
|
2586
|
+
fill={accent}
|
|
2587
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
2588
|
+
fontSize={fontSize}
|
|
2589
|
+
fontWeight={700}
|
|
2590
|
+
textAnchor="middle"
|
|
2591
|
+
x={0}
|
|
2592
|
+
y={0}
|
|
2593
|
+
>
|
|
2594
|
+
{label}
|
|
2595
|
+
</text>
|
|
2596
|
+
</g>
|
|
2597
|
+
)
|
|
2598
|
+
}
|
|
2599
|
+
case 'dimension': {
|
|
2600
|
+
if (!palette) return <></>
|
|
2601
|
+
const stroke = g.stroke ?? palette.measurementStroke
|
|
2602
|
+
// Offset endpoints along the outward normal — this is where the
|
|
2603
|
+
// dimension line sits, parallel to the edge.
|
|
2604
|
+
const ox = g.offsetNormal[0] * g.offsetDistance
|
|
2605
|
+
const oy = g.offsetNormal[1] * g.offsetDistance
|
|
2606
|
+
const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
|
|
2607
|
+
const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
|
|
2608
|
+
|
|
2609
|
+
// Extension line endpoints — extend past the dimension line by
|
|
2610
|
+
// `extensionOvershoot` so the tip clears the dimension stroke.
|
|
2611
|
+
const eOvershoot = g.extensionOvershoot
|
|
2612
|
+
const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
|
|
2613
|
+
const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
|
|
2614
|
+
const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
|
|
2615
|
+
const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
|
|
2616
|
+
|
|
2617
|
+
const dx = dEnd[0] - dStart[0]
|
|
2618
|
+
const dy = dEnd[1] - dStart[1]
|
|
2619
|
+
const length = Math.hypot(dx, dy)
|
|
2620
|
+
if (length < 1e-6) return <></>
|
|
2621
|
+
const dirX = dx / length
|
|
2622
|
+
const dirY = dy / length
|
|
2623
|
+
|
|
2624
|
+
// Plan-unit constants matching the legacy `floorplan-
|
|
2625
|
+
// measurements-layer.tsx`. `strokeWidth` is intentionally a
|
|
2626
|
+
// raw value (not multiplied by `unitsPerPixel`) because every
|
|
2627
|
+
// stroke here uses `vectorEffect: non-scaling-stroke` — the
|
|
2628
|
+
// browser interprets it as screen-pixel-stable. Multiplying
|
|
2629
|
+
// by `unitsPerPixel` would shrink the strokes by ~100× and
|
|
2630
|
+
// make them invisible. Tick length, dash pattern, font size,
|
|
2631
|
+
// and the label gap stay in plan units (they're geometry,
|
|
2632
|
+
// not stroke width).
|
|
2633
|
+
const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
|
|
2634
|
+
const perpX = -dirY * tickHalf
|
|
2635
|
+
const perpY = dirX * tickHalf
|
|
2636
|
+
|
|
2637
|
+
const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
|
|
2638
|
+
const labelGap = 0.5 // plan units — gap in the dimension line for the label
|
|
2639
|
+
const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
|
|
2640
|
+
|
|
2641
|
+
const midX = (dStart[0] + dEnd[0]) / 2
|
|
2642
|
+
const midY = (dStart[1] + dEnd[1]) / 2
|
|
2643
|
+
const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
|
|
2644
|
+
const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
|
|
2645
|
+
|
|
2646
|
+
// Keep the label parallel to the dimension line, but decide the
|
|
2647
|
+
// 180° flip from the on-SCREEN angle, not the local one. The parent
|
|
2648
|
+
// `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
|
|
2649
|
+
// plan), so a label kept upright in local coords still renders
|
|
2650
|
+
// upside down for half of the wall orientations. Same fix as the
|
|
2651
|
+
// `dimension-label` case above.
|
|
2652
|
+
let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
|
|
2653
|
+
let screenDeg = labelDeg + sceneRotationDeg
|
|
2654
|
+
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
|
2655
|
+
if (screenDeg > 90) labelDeg -= 180
|
|
2656
|
+
else if (screenDeg <= -90) labelDeg += 180
|
|
2657
|
+
|
|
2658
|
+
return (
|
|
2659
|
+
<g key={keyHint} pointerEvents="none">
|
|
2660
|
+
{/* Extension lines (dashed). */}
|
|
2661
|
+
<line
|
|
2662
|
+
stroke={stroke}
|
|
2663
|
+
strokeDasharray="0.08 0.12"
|
|
2664
|
+
strokeLinecap="round"
|
|
2665
|
+
strokeOpacity={0.95}
|
|
2666
|
+
strokeWidth={1.35}
|
|
2667
|
+
vectorEffect="non-scaling-stroke"
|
|
2668
|
+
x1={g.start[0]}
|
|
2669
|
+
x2={eStartTip[0]}
|
|
2670
|
+
y1={g.start[1]}
|
|
2671
|
+
y2={eStartTip[1]}
|
|
2672
|
+
/>
|
|
2673
|
+
<line
|
|
2674
|
+
stroke={stroke}
|
|
2675
|
+
strokeDasharray="0.08 0.12"
|
|
2676
|
+
strokeLinecap="round"
|
|
2677
|
+
strokeOpacity={0.95}
|
|
2678
|
+
strokeWidth={1.35}
|
|
2679
|
+
vectorEffect="non-scaling-stroke"
|
|
2680
|
+
x1={g.end[0]}
|
|
2681
|
+
x2={eEndTip[0]}
|
|
2682
|
+
y1={g.end[1]}
|
|
2683
|
+
y2={eEndTip[1]}
|
|
2684
|
+
/>
|
|
2685
|
+
{/* Dimension line: two halves with the label in between. */}
|
|
2686
|
+
<line
|
|
2687
|
+
stroke={stroke}
|
|
2688
|
+
strokeLinecap="round"
|
|
2689
|
+
strokeWidth={1.35}
|
|
2690
|
+
vectorEffect="non-scaling-stroke"
|
|
2691
|
+
x1={dStart[0]}
|
|
2692
|
+
x2={gapStart[0]}
|
|
2693
|
+
y1={dStart[1]}
|
|
2694
|
+
y2={gapStart[1]}
|
|
2695
|
+
/>
|
|
2696
|
+
<line
|
|
2697
|
+
stroke={stroke}
|
|
2698
|
+
strokeLinecap="round"
|
|
2699
|
+
strokeWidth={1.35}
|
|
2700
|
+
vectorEffect="non-scaling-stroke"
|
|
2701
|
+
x1={gapEnd[0]}
|
|
2702
|
+
x2={dEnd[0]}
|
|
2703
|
+
y1={gapEnd[1]}
|
|
2704
|
+
y2={dEnd[1]}
|
|
2705
|
+
/>
|
|
2706
|
+
{/* End ticks. */}
|
|
2707
|
+
<line
|
|
2708
|
+
stroke={stroke}
|
|
2709
|
+
strokeLinecap="round"
|
|
2710
|
+
strokeWidth={1.35}
|
|
2711
|
+
vectorEffect="non-scaling-stroke"
|
|
2712
|
+
x1={dStart[0] - perpX}
|
|
2713
|
+
x2={dStart[0] + perpX}
|
|
2714
|
+
y1={dStart[1] - perpY}
|
|
2715
|
+
y2={dStart[1] + perpY}
|
|
2716
|
+
/>
|
|
2717
|
+
<line
|
|
2718
|
+
stroke={stroke}
|
|
2719
|
+
strokeLinecap="round"
|
|
2720
|
+
strokeWidth={1.35}
|
|
2721
|
+
vectorEffect="non-scaling-stroke"
|
|
2722
|
+
x1={dEnd[0] - perpX}
|
|
2723
|
+
x2={dEnd[0] + perpX}
|
|
2724
|
+
y1={dEnd[1] - perpY}
|
|
2725
|
+
y2={dEnd[1] + perpY}
|
|
2726
|
+
/>
|
|
2727
|
+
{/* Rotated label centered in the gap. */}
|
|
2728
|
+
<text
|
|
2729
|
+
dominantBaseline="central"
|
|
2730
|
+
fill={stroke}
|
|
2731
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
2732
|
+
fontSize={fontSize}
|
|
2733
|
+
fontWeight={600}
|
|
2734
|
+
textAnchor="middle"
|
|
2735
|
+
transform={`rotate(${labelDeg} ${midX} ${midY})`}
|
|
2736
|
+
x={midX}
|
|
2737
|
+
y={midY}
|
|
2738
|
+
>
|
|
2739
|
+
{g.text}
|
|
2740
|
+
</text>
|
|
2741
|
+
</g>
|
|
2742
|
+
)
|
|
2743
|
+
}
|
|
2744
|
+
case 'text': {
|
|
2745
|
+
if (!g.upright) return <FloorplanGeometryRenderer geometry={g} key={keyHint} />
|
|
2746
|
+
// Counter-rotate by the scene rotation so the label reads
|
|
2747
|
+
// horizontally on screen even when the floor-plan view is
|
|
2748
|
+
// rotated (default `sceneRotationDeg` is 90°).
|
|
2749
|
+
return (
|
|
2750
|
+
<g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
|
|
2751
|
+
<text
|
|
2752
|
+
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
|
2753
|
+
fill={g.fill ?? '#171717'}
|
|
2754
|
+
fontFamily={g.fontFamily}
|
|
2755
|
+
fontSize={g.fontSize}
|
|
2756
|
+
fontWeight={g.fontWeight}
|
|
2757
|
+
opacity={g.opacity}
|
|
2758
|
+
paintOrder={g.paintOrder}
|
|
2759
|
+
stroke={g.stroke}
|
|
2760
|
+
strokeLinecap={g.stroke ? 'round' : undefined}
|
|
2761
|
+
strokeLinejoin={g.stroke ? 'round' : undefined}
|
|
2762
|
+
strokeWidth={g.strokeWidth}
|
|
2763
|
+
textAnchor={g.textAnchor ?? 'start'}
|
|
2764
|
+
x={0}
|
|
2765
|
+
y={0}
|
|
2766
|
+
>
|
|
2767
|
+
{g.text}
|
|
2768
|
+
</text>
|
|
2769
|
+
</g>
|
|
2770
|
+
)
|
|
2771
|
+
}
|
|
2772
|
+
default:
|
|
2773
|
+
return (
|
|
2774
|
+
<FloorplanGeometryRenderer
|
|
2775
|
+
geometry={g}
|
|
2776
|
+
key={keyHint}
|
|
2777
|
+
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
|
|
2778
|
+
/>
|
|
2779
|
+
)
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
}, shallowPropsAreEqual)
|
|
2783
|
+
|
|
2784
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
2785
|
+
|
|
2786
|
+
function shallowPropsAreEqual<T extends object>(a: T, b: T): boolean {
|
|
2787
|
+
const aKeys = Object.keys(a) as Array<keyof T>
|
|
2788
|
+
const bKeys = Object.keys(b) as Array<keyof T>
|
|
2789
|
+
if (aKeys.length !== bKeys.length) return false
|
|
2790
|
+
for (const key of aKeys) {
|
|
2791
|
+
if (!Object.is(a[key], b[key])) return false
|
|
2792
|
+
}
|
|
2793
|
+
return true
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function handleIdForNode(handleId: string | null, nodeId: AnyNodeId): string | null {
|
|
2797
|
+
if (!handleId) return null
|
|
2798
|
+
return handleId === nodeId || handleId.startsWith(`${nodeId}:`) ? handleId : null
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
function applyPositionLiveTransform(
|
|
2802
|
+
node: AnyNode,
|
|
2803
|
+
live: { position: [number, number, number]; rotation: number },
|
|
2804
|
+
): AnyNode {
|
|
2805
|
+
const currentRotation = (node as { rotation?: unknown }).rotation
|
|
2806
|
+
const rotation = Array.isArray(currentRotation)
|
|
2807
|
+
? ([
|
|
2808
|
+
(currentRotation[0] as number) ?? 0,
|
|
2809
|
+
live.rotation,
|
|
2810
|
+
(currentRotation[2] as number) ?? 0,
|
|
2811
|
+
] as [number, number, number])
|
|
2812
|
+
: typeof currentRotation === 'number'
|
|
2813
|
+
? live.rotation
|
|
2814
|
+
: currentRotation
|
|
2815
|
+
|
|
2816
|
+
return {
|
|
2817
|
+
...node,
|
|
2818
|
+
position: live.position,
|
|
2819
|
+
...(rotation !== undefined ? { rotation } : {}),
|
|
2820
|
+
parentId: null,
|
|
2821
|
+
} as AnyNode
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
|
|
2825
|
+
const overrideVisible = liveOverride?.visible
|
|
2826
|
+
if (typeof overrideVisible === 'boolean') return overrideVisible
|
|
2827
|
+
return (node as { visible?: boolean }).visible !== false
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
function isFloorplanHierarchyVisible(
|
|
2831
|
+
node: AnyNode,
|
|
2832
|
+
nodes: Record<string, AnyNode>,
|
|
2833
|
+
liveOverrides: Map<string, LiveNodeOverrides>,
|
|
2834
|
+
rootId: AnyNodeId,
|
|
2835
|
+
): boolean {
|
|
2836
|
+
let current: AnyNode | undefined = node
|
|
2837
|
+
const seen = new Set<AnyNodeId>()
|
|
2838
|
+
while (current) {
|
|
2839
|
+
if (seen.has(current.id)) return true
|
|
2840
|
+
seen.add(current.id)
|
|
2841
|
+
if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
|
|
2842
|
+
if (current.id === rootId) return true
|
|
2843
|
+
const parentId = current.parentId as AnyNodeId | null
|
|
2844
|
+
if (!parentId) return true
|
|
2845
|
+
current = nodes[parentId]
|
|
2846
|
+
}
|
|
2847
|
+
return true
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
export function buildContext(
|
|
2851
|
+
node: AnyNode,
|
|
2852
|
+
nodes: Record<string, AnyNode>,
|
|
2853
|
+
viewState: {
|
|
2854
|
+
selected: boolean
|
|
2855
|
+
unit: 'metric' | 'imperial'
|
|
2856
|
+
highlighted: boolean
|
|
2857
|
+
hovered: boolean
|
|
2858
|
+
moving: boolean
|
|
2859
|
+
palette: FloorplanPalette | undefined
|
|
2860
|
+
},
|
|
2861
|
+
levelData?: unknown,
|
|
2862
|
+
): GeometryContext {
|
|
2863
|
+
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
2864
|
+
|
|
2865
|
+
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
2866
|
+
const children: AnyNode[] = Array.isArray(childIds)
|
|
2867
|
+
? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
|
|
2868
|
+
: []
|
|
2869
|
+
|
|
2870
|
+
const parentId = node.parentId as AnyNodeId | null
|
|
2871
|
+
const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
|
|
2872
|
+
|
|
2873
|
+
let siblings: AnyNode[] = []
|
|
2874
|
+
if (parent) {
|
|
2875
|
+
const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
|
|
2876
|
+
if (Array.isArray(parentChildIds)) {
|
|
2877
|
+
for (const sid of parentChildIds) {
|
|
2878
|
+
if (sid === node.id) continue
|
|
2879
|
+
const s = nodes[sid]
|
|
2880
|
+
if (s && s.type === node.type) siblings.push(s)
|
|
2881
|
+
}
|
|
2882
|
+
} else {
|
|
2883
|
+
siblings = Object.values(nodes).filter(
|
|
2884
|
+
(n) => n !== node && n.type === node.type && n.parentId === parentId,
|
|
2885
|
+
)
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
return {
|
|
2890
|
+
resolve,
|
|
2891
|
+
children,
|
|
2892
|
+
siblings,
|
|
2893
|
+
parent,
|
|
2894
|
+
levelData,
|
|
2895
|
+
viewState: viewState.palette
|
|
2896
|
+
? {
|
|
2897
|
+
selected: viewState.selected,
|
|
2898
|
+
unit: viewState.unit,
|
|
2899
|
+
highlighted: viewState.highlighted,
|
|
2900
|
+
hovered: viewState.hovered,
|
|
2901
|
+
moving: viewState.moving,
|
|
2902
|
+
palette: viewState.palette,
|
|
2903
|
+
}
|
|
2904
|
+
: undefined,
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/**
|
|
2909
|
+
* Stable id for a handle on a node, derived from the node id + opaque
|
|
2910
|
+
* payload. Used to track hover / active visual state when multiple
|
|
2911
|
+
* handles belong to the same node (start vs end endpoint, multiple
|
|
2912
|
+
* vertices of a polygon, etc.).
|
|
2913
|
+
*/
|
|
2914
|
+
function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
|
|
2915
|
+
if (payload == null) return `${nodeId}`
|
|
2916
|
+
if (typeof payload === 'object') {
|
|
2917
|
+
// Stable JSON serialisation of common shapes — endpoint discriminator,
|
|
2918
|
+
// vertex index, etc. Don't try to handle arbitrarily-deep payloads.
|
|
2919
|
+
try {
|
|
2920
|
+
return `${nodeId}:${JSON.stringify(payload)}`
|
|
2921
|
+
} catch {
|
|
2922
|
+
return `${nodeId}`
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
return `${nodeId}:${String(payload)}`
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
export function floorplanHandleDoubleClickAffordance(
|
|
2929
|
+
geometry: FloorplanGeometry,
|
|
2930
|
+
): 'delete-vertex' | null {
|
|
2931
|
+
return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
|
|
2932
|
+
? 'delete-vertex'
|
|
2933
|
+
: null
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* Geometry kinds that always render in the overlay pass — interactive
|
|
2938
|
+
* handles and node labels. These need to sit above every kind's base
|
|
2939
|
+
* geometry regardless of the owning node's z-bucket so that:
|
|
2940
|
+
* - polygon edit handles on a selected slab don't get hidden by the
|
|
2941
|
+
* walls / items resting on top of the slab,
|
|
2942
|
+
* - a zone's name stays legible above the slab covering the zone, and
|
|
2943
|
+
* - measurement labels never get clipped by structural fills.
|
|
2944
|
+
*/
|
|
2945
|
+
const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
2946
|
+
'text',
|
|
2947
|
+
'endpoint-handle',
|
|
2948
|
+
'midpoint-handle',
|
|
2949
|
+
'edge-handle',
|
|
2950
|
+
'move-handle',
|
|
2951
|
+
'move-arrow',
|
|
2952
|
+
'rotate-arrow',
|
|
2953
|
+
'dimension',
|
|
2954
|
+
'dimension-label',
|
|
2955
|
+
'equal-spacing-badge',
|
|
2956
|
+
])
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* Walk a `FloorplanGeometry` tree and split it into two trees: one with
|
|
2960
|
+
* only "base" primitives (polygons, paths, fills, strokes) and one with
|
|
2961
|
+
* only "overlay" primitives (handles, labels — see `OVERLAY_KINDS`).
|
|
2962
|
+
*
|
|
2963
|
+
* Groups recurse: a `kind: 'group'` is split into a base group and an
|
|
2964
|
+
* overlay group, both carrying the same `transform` so nested rotations
|
|
2965
|
+
* / translations apply in both passes. Empty groups collapse to `null`
|
|
2966
|
+
* so the caller can skip emitting an `<g>` when there's nothing to draw.
|
|
2967
|
+
*/
|
|
2968
|
+
export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
2969
|
+
base: FloorplanGeometry | null
|
|
2970
|
+
overlay: FloorplanGeometry | null
|
|
2971
|
+
} {
|
|
2972
|
+
if (OVERLAY_KINDS.has(g.kind)) {
|
|
2973
|
+
return { base: null, overlay: g }
|
|
2974
|
+
}
|
|
2975
|
+
if (g.kind === 'group') {
|
|
2976
|
+
const baseChildren: FloorplanGeometry[] = []
|
|
2977
|
+
const overlayChildren: FloorplanGeometry[] = []
|
|
2978
|
+
for (const child of g.children) {
|
|
2979
|
+
const split = splitFloorplanOverlay(child)
|
|
2980
|
+
if (split.base) baseChildren.push(split.base)
|
|
2981
|
+
if (split.overlay) overlayChildren.push(split.overlay)
|
|
2982
|
+
}
|
|
2983
|
+
const base: FloorplanGeometry | null =
|
|
2984
|
+
baseChildren.length > 0
|
|
2985
|
+
? { kind: 'group', children: baseChildren, transform: g.transform }
|
|
2986
|
+
: null
|
|
2987
|
+
const overlay: FloorplanGeometry | null =
|
|
2988
|
+
overlayChildren.length > 0
|
|
2989
|
+
? { kind: 'group', children: overlayChildren, transform: g.transform }
|
|
2990
|
+
: null
|
|
2991
|
+
return { base, overlay }
|
|
2992
|
+
}
|
|
2993
|
+
return { base: g, overlay: null }
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
/**
|
|
2997
|
+
* Per-node edit chrome hidden while a multi-selection is active: the group is
|
|
2998
|
+
* manipulated as one rigid piece (drag to move, R/T to rotate), so individual
|
|
2999
|
+
* handles / dimension labels on each member would mislead. `text` stays —
|
|
3000
|
+
* zone names are identification, not editing chrome.
|
|
3001
|
+
*/
|
|
3002
|
+
const HANDLE_CHROME_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
3003
|
+
'endpoint-handle',
|
|
3004
|
+
'midpoint-handle',
|
|
3005
|
+
'edge-handle',
|
|
3006
|
+
'move-handle',
|
|
3007
|
+
'move-arrow',
|
|
3008
|
+
'rotate-arrow',
|
|
3009
|
+
'dimension',
|
|
3010
|
+
'dimension-label',
|
|
3011
|
+
'equal-spacing-badge',
|
|
3012
|
+
])
|
|
3013
|
+
|
|
3014
|
+
function stripHandleChrome(g: FloorplanGeometry): FloorplanGeometry | null {
|
|
3015
|
+
if (HANDLE_CHROME_KINDS.has(g.kind)) return null
|
|
3016
|
+
if (g.kind === 'group') {
|
|
3017
|
+
const children = g.children
|
|
3018
|
+
.map(stripHandleChrome)
|
|
3019
|
+
.filter((c): c is FloorplanGeometry => c !== null)
|
|
3020
|
+
if (children.length === 0) return null
|
|
3021
|
+
return { kind: 'group', children, transform: g.transform }
|
|
3022
|
+
}
|
|
3023
|
+
return g
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
// Stable string key for a wall endpoint, rounded to 1 mm so floating-point
|
|
3027
|
+
// drift collapses while distinct corners stay distinct.
|
|
3028
|
+
function endpointKey(x: number, y: number): string {
|
|
3029
|
+
return `${Math.round(x * 1000)},${Math.round(y * 1000)}`
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
// Given the sibling-dependent nodes with a live drag in flight, the set of
|
|
3033
|
+
// floor-plan geometries that must rebuild this frame. A node's geometry depends
|
|
3034
|
+
// on more than its own data:
|
|
3035
|
+
// - a wall's miters depend on the walls meeting at each of its endpoints, so a
|
|
3036
|
+
// dragged wall invalidates the walls at its old AND new junctions, plus its
|
|
3037
|
+
// own door/window children (their cuts are drawn into it);
|
|
3038
|
+
// - a door/window cut is drawn into its host wall, so it invalidates that wall;
|
|
3039
|
+
// - a gutter join depends on sibling gutters under the same roof.
|
|
3040
|
+
// Everything else stays cached, so dragging one wall/opening rebuilds a handful
|
|
3041
|
+
// of geometries rather than every wall + opening on the level.
|
|
3042
|
+
export function computeAffectedSiblingIds(
|
|
3043
|
+
liveFlaggedIds: readonly AnyNodeId[],
|
|
3044
|
+
nodes: Record<string, AnyNode>,
|
|
3045
|
+
liveOverrides: Map<string, Record<string, unknown>>,
|
|
3046
|
+
): Set<AnyNodeId> {
|
|
3047
|
+
const affected = new Set<AnyNodeId>()
|
|
3048
|
+
if (liveFlaggedIds.length === 0) return affected
|
|
3049
|
+
|
|
3050
|
+
// Junction map (committed wall endpoint → wall ids), built lazily on first use.
|
|
3051
|
+
let junctions: Map<string, AnyNodeId[]> | null = null
|
|
3052
|
+
const wallsAtPoint = (x: number, y: number): AnyNodeId[] => {
|
|
3053
|
+
if (!junctions) {
|
|
3054
|
+
junctions = new Map()
|
|
3055
|
+
for (const id in nodes) {
|
|
3056
|
+
const n = nodes[id]
|
|
3057
|
+
if (n?.type !== 'wall') continue
|
|
3058
|
+
const w = n as unknown as { start: [number, number]; end: [number, number] }
|
|
3059
|
+
for (const [px, py] of [w.start, w.end]) {
|
|
3060
|
+
const key = endpointKey(px, py)
|
|
3061
|
+
const arr = junctions.get(key)
|
|
3062
|
+
if (arr) arr.push(id as AnyNodeId)
|
|
3063
|
+
else junctions.set(key, [id as AnyNodeId])
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
return junctions.get(endpointKey(x, y)) ?? []
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
for (const id of liveFlaggedIds) {
|
|
3071
|
+
const node = nodes[id]
|
|
3072
|
+
if (!node) continue
|
|
3073
|
+
affected.add(id)
|
|
3074
|
+
const def = nodeRegistry.get(node.type)
|
|
3075
|
+
const extraAffectedIds = def?.floorplanAffectedIds?.({
|
|
3076
|
+
nodeId: id,
|
|
3077
|
+
node,
|
|
3078
|
+
nodes: nodes as Record<AnyNodeId, AnyNode>,
|
|
3079
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
3080
|
+
liveOverrides,
|
|
3081
|
+
})
|
|
3082
|
+
if (extraAffectedIds) {
|
|
3083
|
+
for (const extraId of extraAffectedIds) affected.add(extraId)
|
|
3084
|
+
}
|
|
3085
|
+
if (node.type === 'wall') {
|
|
3086
|
+
const w = node as unknown as {
|
|
3087
|
+
start: [number, number]
|
|
3088
|
+
end: [number, number]
|
|
3089
|
+
children?: AnyNodeId[]
|
|
3090
|
+
}
|
|
3091
|
+
// Use the live (override-merged) endpoints as well as the committed ones,
|
|
3092
|
+
// so walls at both the wall's old and new junctions get fresh miters.
|
|
3093
|
+
const ov = liveOverrides.get(id) as
|
|
3094
|
+
| { start?: [number, number]; end?: [number, number] }
|
|
3095
|
+
| undefined
|
|
3096
|
+
const points: [number, number][] = [w.start, w.end]
|
|
3097
|
+
if (ov?.start) points.push(ov.start)
|
|
3098
|
+
if (ov?.end) points.push(ov.end)
|
|
3099
|
+
for (const [px, py] of points) {
|
|
3100
|
+
for (const wid of wallsAtPoint(px, py)) affected.add(wid)
|
|
3101
|
+
}
|
|
3102
|
+
if (Array.isArray(w.children)) {
|
|
3103
|
+
for (const cid of w.children) {
|
|
3104
|
+
const child = nodes[cid]
|
|
3105
|
+
if (child?.type === 'door' || child?.type === 'window') affected.add(cid)
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
} else if (node.type === 'door' || node.type === 'window') {
|
|
3109
|
+
const hostId = (node as { parentId?: string }).parentId
|
|
3110
|
+
if (hostId) affected.add(hostId as AnyNodeId)
|
|
3111
|
+
const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
|
|
3112
|
+
if (liveHostId) affected.add(liveHostId as AnyNodeId)
|
|
3113
|
+
} else if (node.type === 'gutter') {
|
|
3114
|
+
const roofId = (node as { parentId?: string }).parentId
|
|
3115
|
+
if (roofId) {
|
|
3116
|
+
for (const sid in nodes) {
|
|
3117
|
+
const s = nodes[sid]
|
|
3118
|
+
if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) {
|
|
3119
|
+
affected.add(sid as AnyNodeId)
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
return affected
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
|
3129
|
+
const keys: Array<keyof NodeDeps> = [
|
|
3130
|
+
'node',
|
|
3131
|
+
'live',
|
|
3132
|
+
'unit',
|
|
3133
|
+
'selected',
|
|
3134
|
+
'highlighted',
|
|
3135
|
+
'hovered',
|
|
3136
|
+
'moving',
|
|
3137
|
+
'liveOverride',
|
|
3138
|
+
'palette',
|
|
3139
|
+
'siblingEpoch',
|
|
3140
|
+
'committedNodes',
|
|
3141
|
+
'dependencyNodes',
|
|
3142
|
+
'interactiveElevators',
|
|
3143
|
+
]
|
|
3144
|
+
for (const key of keys) {
|
|
3145
|
+
if (!depsValueEqual(a[key], b[key])) return false
|
|
3146
|
+
}
|
|
3147
|
+
return true
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function depsValueEqual(a: unknown, b: unknown): boolean {
|
|
3151
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
3152
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
|
3153
|
+
if (a.length !== b.length) return false
|
|
3154
|
+
for (let i = 0; i < a.length; i++) {
|
|
3155
|
+
if (!Object.is(a[i], b[i])) return false
|
|
3156
|
+
}
|
|
3157
|
+
return true
|
|
3158
|
+
}
|
|
3159
|
+
return Object.is(a, b)
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
/**
|
|
3163
|
+
* Z-order bucket for floor-plan rendering. Lower rank = painted first =
|
|
3164
|
+
* sits under everything with a higher rank. SVG renders in document
|
|
3165
|
+
* order, so an earlier entry in the array ends up beneath a later one.
|
|
3166
|
+
*
|
|
3167
|
+
* Three buckets today:
|
|
3168
|
+
* 0 — `zone`: conceptual area regions, always under everything else.
|
|
3169
|
+
* 1 — `slab` / `ceiling`: the floor / ceiling surface; sits over the
|
|
3170
|
+
* zone but under any structural / furniture geometry placed on it.
|
|
3171
|
+
* 2 — every other kind (walls, items, shelves, columns, stairs, …):
|
|
3172
|
+
* structure + furniture, painted on top.
|
|
3173
|
+
*
|
|
3174
|
+
* Sort is stable in modern JS engines, so siblings within the same
|
|
3175
|
+
* bucket keep their DFS order (= scene tree order).
|
|
3176
|
+
*/
|
|
3177
|
+
export function floorplanLayerRank(type: string): number {
|
|
3178
|
+
switch (type) {
|
|
3179
|
+
case 'zone':
|
|
3180
|
+
return 0
|
|
3181
|
+
case 'slab':
|
|
3182
|
+
case 'ceiling':
|
|
3183
|
+
return 1
|
|
3184
|
+
default:
|
|
3185
|
+
return 2
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
3190
|
+
if (a === b) return true
|
|
3191
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
3192
|
+
if (a.length !== b.length) return false
|
|
3193
|
+
for (let i = 0; i < a.length; i++) {
|
|
3194
|
+
if (!deepEqual(a[i], b[i])) return false
|
|
3195
|
+
}
|
|
3196
|
+
return true
|
|
3197
|
+
}
|
|
3198
|
+
if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
|
|
3199
|
+
const aKeys = Object.keys(a as Record<string, unknown>)
|
|
3200
|
+
const bKeys = Object.keys(b as Record<string, unknown>)
|
|
3201
|
+
if (aKeys.length !== bKeys.length) return false
|
|
3202
|
+
for (const key of aKeys) {
|
|
3203
|
+
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
|
3204
|
+
return false
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
return true
|
|
3208
|
+
}
|
|
3209
|
+
return false
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
const ROTATION_WEDGE_COLOR = '#8381ed'
|
|
3213
|
+
const ROTATION_WEDGE_SEGMENTS = 48
|
|
3214
|
+
|
|
3215
|
+
/**
|
|
3216
|
+
* Live rotation readout for the floor plan — the 2D twin of the 3D rotate
|
|
3217
|
+
* gizmo's wedge. Draws a filled sector + outline swept from the pointer's
|
|
3218
|
+
* bearing at grab (`startAngle`) to its current bearing (`endAngle`) around
|
|
3219
|
+
* the pivot, plus an upright degree chip at the wedge midpoint. All geometry
|
|
3220
|
+
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
|
|
3221
|
+
* horizontally regardless of the building's on-screen orientation.
|
|
3222
|
+
*/
|
|
3223
|
+
export function RotationAngleOverlay({
|
|
3224
|
+
overlay,
|
|
3225
|
+
palette,
|
|
3226
|
+
unitsPerPixel,
|
|
3227
|
+
sceneRotationDeg,
|
|
3228
|
+
}: {
|
|
3229
|
+
overlay: RotationOverlayState
|
|
3230
|
+
palette: Pick<
|
|
3231
|
+
FloorplanPalette,
|
|
3232
|
+
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
|
|
3233
|
+
>
|
|
3234
|
+
unitsPerPixel: number
|
|
3235
|
+
sceneRotationDeg: number
|
|
3236
|
+
}): React.ReactElement {
|
|
3237
|
+
const { pivot, startAngle, endAngle, radius, sweep } = overlay
|
|
3238
|
+
const span = endAngle - startAngle
|
|
3239
|
+
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
|
|
3240
|
+
let d = `M ${pivot[0]} ${pivot[1]}`
|
|
3241
|
+
for (let i = 0; i <= count; i++) {
|
|
3242
|
+
const a = startAngle + (span * i) / count
|
|
3243
|
+
d += ` L ${pivot[0] + Math.cos(a) * radius} ${pivot[1] + Math.sin(a) * radius}`
|
|
3244
|
+
}
|
|
3245
|
+
d += ' Z'
|
|
3246
|
+
|
|
3247
|
+
const midAngle = startAngle + span / 2
|
|
3248
|
+
const labelDist = radius + unitsPerPixel * 14
|
|
3249
|
+
const lx = pivot[0] + Math.cos(midAngle) * labelDist
|
|
3250
|
+
const ly = pivot[1] + Math.sin(midAngle) * labelDist
|
|
3251
|
+
|
|
3252
|
+
const text = `${Math.round((sweep * 180) / Math.PI)}°`
|
|
3253
|
+
const padX = unitsPerPixel * 6
|
|
3254
|
+
const padY = unitsPerPixel * 3
|
|
3255
|
+
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
|
3256
|
+
const textWidth = text.length * unitsPerPixel * 6.2
|
|
3257
|
+
const plateW = textWidth + padX * 2
|
|
3258
|
+
const plateH = fontSize + padY * 2
|
|
3259
|
+
|
|
3260
|
+
return (
|
|
3261
|
+
<g className="floorplan-rotation-readout" pointerEvents="none">
|
|
3262
|
+
<path d={d} fill={ROTATION_WEDGE_COLOR} fillOpacity={0.18} stroke="none" />
|
|
3263
|
+
<path
|
|
3264
|
+
d={d}
|
|
3265
|
+
fill="none"
|
|
3266
|
+
stroke={ROTATION_WEDGE_COLOR}
|
|
3267
|
+
strokeLinejoin="round"
|
|
3268
|
+
strokeOpacity={0.95}
|
|
3269
|
+
strokeWidth={1.8}
|
|
3270
|
+
vectorEffect="non-scaling-stroke"
|
|
3271
|
+
/>
|
|
3272
|
+
{/* Counter-rotate the scene transform so the chip stays horizontal. */}
|
|
3273
|
+
<g transform={`translate(${lx} ${ly}) rotate(${-sceneRotationDeg})`}>
|
|
3274
|
+
<rect
|
|
3275
|
+
fill={palette.measurementLabelBackground}
|
|
3276
|
+
height={plateH}
|
|
3277
|
+
opacity={0.92}
|
|
3278
|
+
rx={unitsPerPixel * 3}
|
|
3279
|
+
ry={unitsPerPixel * 3}
|
|
3280
|
+
stroke={palette.measurementStroke}
|
|
3281
|
+
strokeWidth={unitsPerPixel * 0.5}
|
|
3282
|
+
vectorEffect="non-scaling-stroke"
|
|
3283
|
+
width={plateW}
|
|
3284
|
+
x={-plateW / 2}
|
|
3285
|
+
y={-plateH / 2}
|
|
3286
|
+
/>
|
|
3287
|
+
<text
|
|
3288
|
+
dominantBaseline="middle"
|
|
3289
|
+
fill={palette.measurementLabelText}
|
|
3290
|
+
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
3291
|
+
fontSize={fontSize}
|
|
3292
|
+
fontWeight={600}
|
|
3293
|
+
textAnchor="middle"
|
|
3294
|
+
x={0}
|
|
3295
|
+
y={0}
|
|
3296
|
+
>
|
|
3297
|
+
{text}
|
|
3298
|
+
</text>
|
|
3299
|
+
</g>
|
|
3300
|
+
</g>
|
|
3301
|
+
)
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
function formatGroupTransform(t?: {
|
|
3305
|
+
translate?: readonly [number, number]
|
|
3306
|
+
rotate?: number
|
|
3307
|
+
}): string | undefined {
|
|
3308
|
+
if (!t) return undefined
|
|
3309
|
+
const parts: string[] = []
|
|
3310
|
+
if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`)
|
|
3311
|
+
if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
|
|
3312
|
+
return parts.length > 0 ? parts.join(' ') : undefined
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
function swallowNextClick(timeoutMs = 0) {
|
|
3316
|
+
const swallowClick = (event: MouseEvent) => {
|
|
3317
|
+
event.stopPropagation()
|
|
3318
|
+
event.preventDefault()
|
|
3319
|
+
window.removeEventListener('click', swallowClick, true)
|
|
3320
|
+
}
|
|
3321
|
+
window.addEventListener('click', swallowClick, true)
|
|
3322
|
+
setTimeout(() => {
|
|
3323
|
+
window.removeEventListener('click', swallowClick, true)
|
|
3324
|
+
}, timeoutMs)
|
|
3325
|
+
}
|