@pascal-app/editor 0.9.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +49 -0
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +332 -199
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
- package/src/components/editor/first-person/build-collider-world.ts +52 -5
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +410 -136
- package/src/components/editor/floating-action-menu.tsx +435 -64
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +72 -0
- package/src/components/editor/floorplan-background-selection.ts +7 -20
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +2517 -1492
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +597 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +49 -6
- package/src/components/editor/index.tsx +289 -35
- package/src/components/editor/measurement-pill.tsx +68 -23
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +409 -180
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +76 -0
- package/src/components/editor/quick-measurement-hud.tsx +34 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +814 -466
- package/src/components/editor/site-edge-labels.tsx +28 -6
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +118 -253
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
- package/src/components/editor/wall-measurement-label.tsx +10 -28
- package/src/components/editor/wall-move-side-handles.tsx +229 -39
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +107 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +146 -57
- package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
- package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
- package/src/components/tools/roof/roof-tool.tsx +336 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +101 -42
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +164 -42
- package/src/components/tools/tool-manager.tsx +149 -30
- package/src/components/tools/wall/wall-drafting.test.ts +447 -0
- package/src/components/tools/wall/wall-drafting.ts +304 -108
- package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +98 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +44 -94
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +267 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +16 -3
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +93 -18
- package/src/components/ui/controls/material-picker.tsx +162 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +74 -20
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +273 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +36 -14
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +19 -4
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +36 -596
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +438 -70
- package/src/index.tsx +355 -9
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +42 -0
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +275 -0
- package/src/lib/direct-manipulation.ts +118 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +978 -0
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +52 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +283 -3
- package/src/lib/fresh-planar-placement.ts +85 -1
- package/src/lib/glb-export.test.ts +556 -0
- package/src/lib/glb-export.ts +1071 -0
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +31 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +52 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +243 -0
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +188 -0
- package/src/lib/measurements.ts +210 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +325 -0
- package/src/lib/scene-clipboard.ts +310 -21
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +313 -0
- package/src/lib/selection-routing.ts +180 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +82 -14
- package/src/lib/sfx-player.test.ts +266 -0
- package/src/lib/sfx-player.ts +262 -69
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +165 -0
- package/src/lib/snapping-mode.ts +191 -0
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +58 -86
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +665 -126
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +51 -0
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +151 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +206 -0
- package/src/store/use-interaction-scope.ts +140 -0
- package/src/store/use-measurement-draft.test.ts +559 -0
- package/src/store/use-measurement-draft.ts +570 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +53 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/floorplan/selection-tool.ts +0 -271
- package/src/lib/level-name.ts +0 -11
|
@@ -2,38 +2,121 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
|
+
type AnyNodeDefinition,
|
|
5
6
|
type AnyNodeId,
|
|
6
|
-
|
|
7
|
+
createSceneApi,
|
|
8
|
+
emitter,
|
|
7
9
|
type FloorplanAffordanceSession,
|
|
8
10
|
type FloorplanGeometry,
|
|
9
11
|
type FloorplanPalette,
|
|
10
12
|
type FloorplanPoint,
|
|
11
13
|
type GeometryContext,
|
|
14
|
+
isNodeKindEnabled,
|
|
15
|
+
isRegistryMovable,
|
|
12
16
|
kindsWithFloorplanScope,
|
|
17
|
+
type LiveNodeOverrides,
|
|
18
|
+
type LiveTransform,
|
|
13
19
|
nodeRegistry,
|
|
14
20
|
pauseSceneHistory,
|
|
15
21
|
resolveBuildingForLevel,
|
|
22
|
+
resolveSelectionProxyId,
|
|
16
23
|
resumeSceneHistory,
|
|
17
24
|
useInteractive,
|
|
18
25
|
useLiveNodeOverrides,
|
|
19
26
|
useLiveTransforms,
|
|
20
27
|
useScene,
|
|
21
28
|
} from '@pascal-app/core'
|
|
22
|
-
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
23
29
|
import { useViewer } from '@pascal-app/viewer'
|
|
24
30
|
import {
|
|
31
|
+
type ComponentProps,
|
|
25
32
|
memo,
|
|
33
|
+
type MouseEvent as ReactMouseEvent,
|
|
26
34
|
type PointerEvent as ReactPointerEvent,
|
|
27
35
|
useCallback,
|
|
28
36
|
useEffect,
|
|
37
|
+
useLayoutEffect,
|
|
29
38
|
useMemo,
|
|
30
39
|
useRef,
|
|
31
40
|
useState,
|
|
32
41
|
} from 'react'
|
|
42
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
43
|
+
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
|
|
44
|
+
import {
|
|
45
|
+
canDirectRotateNode,
|
|
46
|
+
resolveDirectManipulationNode,
|
|
47
|
+
resolveDirectRotationDragDelta,
|
|
48
|
+
resolveDirectRotationPatch,
|
|
49
|
+
snapDirectRotationDelta,
|
|
50
|
+
} from '../../../lib/direct-manipulation'
|
|
51
|
+
import { createEditorApi } from '../../../lib/editor-api'
|
|
52
|
+
import {
|
|
53
|
+
type FloorplanAnnotationVisibility,
|
|
54
|
+
filterFloorplanAnnotationGeometry,
|
|
55
|
+
} from '../../../lib/floorplan/annotation-visibility'
|
|
56
|
+
import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
|
|
57
|
+
import {
|
|
58
|
+
createFloorplanContextExtensions,
|
|
59
|
+
type FloorplanAnnotationRole,
|
|
60
|
+
type FloorplanWallDimensionReference,
|
|
61
|
+
getFloorplanNodeExtension,
|
|
62
|
+
readFloorplanGeometryMetadata,
|
|
63
|
+
withFloorplanGeometryMetadata,
|
|
64
|
+
} from '../../../lib/floorplan/floorplan-extension'
|
|
65
|
+
import {
|
|
66
|
+
type FloorplanMode,
|
|
67
|
+
resolveFloorplanAnnotationVisibility,
|
|
68
|
+
resolveFloorplanWallDimensionReference,
|
|
69
|
+
} from '../../../lib/floorplan/floorplan-mode'
|
|
70
|
+
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
|
|
71
|
+
import {
|
|
72
|
+
type ActiveInteractionScope,
|
|
73
|
+
boundaryReshapeScope,
|
|
74
|
+
controlPointReshapeScope,
|
|
75
|
+
curveReshapeScope,
|
|
76
|
+
endpointReshapeScope,
|
|
77
|
+
holeEditScope,
|
|
78
|
+
isIdle,
|
|
79
|
+
tangentReshapeScope,
|
|
80
|
+
} from '../../../lib/interaction/scope'
|
|
81
|
+
import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
|
|
33
82
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
34
|
-
import
|
|
35
|
-
import
|
|
83
|
+
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
|
84
|
+
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
|
85
|
+
import useDrawingView from '../../../store/use-drawing-view'
|
|
86
|
+
import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
|
|
87
|
+
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
|
|
88
|
+
import useFloorplanMode from '../../../store/use-floorplan-mode'
|
|
89
|
+
import useInteractionScope, {
|
|
90
|
+
getMovingNode,
|
|
91
|
+
useEndpointReshape,
|
|
92
|
+
useMovingNode,
|
|
93
|
+
} from '../../../store/use-interaction-scope'
|
|
94
|
+
import { startGroupPickUp } from '../../editor/group-actions'
|
|
95
|
+
import { classifyParticipant } from '../../editor/group-transform-shared'
|
|
96
|
+
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
|
97
|
+
import {
|
|
98
|
+
FloorplanGroupSelectionBox,
|
|
99
|
+
startFloorplanGroupMove,
|
|
100
|
+
startFloorplanGroupRotate,
|
|
101
|
+
} from '../floorplan-group-move'
|
|
102
|
+
import {
|
|
103
|
+
useFloorplanSceneRotation,
|
|
104
|
+
useFloorplanStaticRender,
|
|
105
|
+
useFloorplanStaticUnitsPerPixel,
|
|
106
|
+
} from '../floorplan-render-context'
|
|
107
|
+
import {
|
|
108
|
+
floorplanAnnotationObstacleMode,
|
|
109
|
+
isFloorplanAnnotationObstacleGeometry,
|
|
110
|
+
resolveSvgAnnotationCollisions,
|
|
111
|
+
svgAnnotationLabelId,
|
|
112
|
+
} from './floorplan-annotation-layout'
|
|
113
|
+
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
|
|
36
114
|
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
115
|
+
import {
|
|
116
|
+
resolveFloorplanAnnotationUpdate,
|
|
117
|
+
resolveFloorplanLabelAngle,
|
|
118
|
+
updateSvgFloorplanLabelOrientations,
|
|
119
|
+
} from './floorplan-label-angle'
|
|
37
120
|
|
|
38
121
|
/**
|
|
39
122
|
* Registry-driven floor-plan layer.
|
|
@@ -60,8 +143,8 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
|
60
143
|
*/
|
|
61
144
|
// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
|
|
62
145
|
// constants in floorplan-panel.tsx. Sizes are in screen pixels — the
|
|
63
|
-
// dispatcher multiplies by `unitsPerPixel` so handles stay
|
|
64
|
-
//
|
|
146
|
+
// dispatcher multiplies by `unitsPerPixel` so handles stay screen-sized
|
|
147
|
+
// until the world-space ceiling takes over at extreme zoom-out.
|
|
65
148
|
const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
|
|
66
149
|
const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
|
|
67
150
|
const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
|
|
@@ -70,6 +153,21 @@ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
|
|
|
70
153
|
const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
|
|
71
154
|
const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
|
|
72
155
|
const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
|
156
|
+
const DIRECT_DRAG_THRESHOLD_PX = 4
|
|
157
|
+
const DIRECT_ROTATE_EPSILON = 1e-6
|
|
158
|
+
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
|
159
|
+
const MAX_HANDLE_UNITS_PER_PIXEL = 0.015
|
|
160
|
+
|
|
161
|
+
export function resolveFloorplanHandleUnitsPerPixel(unitsPerPixel: number): number {
|
|
162
|
+
return Math.min(unitsPerPixel, MAX_HANDLE_UNITS_PER_PIXEL)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox(
|
|
166
|
+
props: Omit<ComponentProps<typeof FloorplanGroupSelectionBox>, 'unitsPerPixel'>,
|
|
167
|
+
) {
|
|
168
|
+
const unitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
|
169
|
+
return <FloorplanGroupSelectionBox {...props} unitsPerPixel={unitsPerPixel} />
|
|
170
|
+
})
|
|
73
171
|
|
|
74
172
|
/**
|
|
75
173
|
* Snapshot of node fields captured at drag-start, used by the single-undo
|
|
@@ -82,11 +180,18 @@ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
|
|
|
82
180
|
|
|
83
181
|
type ActiveDrag = {
|
|
84
182
|
pointerId: number
|
|
183
|
+
captureTarget: Element
|
|
85
184
|
/** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
|
|
86
185
|
handleId: string
|
|
87
186
|
session: FloorplanAffordanceSession
|
|
88
187
|
snapshots: NodeSnapshot[]
|
|
89
188
|
historyPaused: boolean
|
|
189
|
+
/**
|
|
190
|
+
* Last plan point handed to `session.apply` (the grab point until the first
|
|
191
|
+
* move). Lets the modifier-key listeners re-run the session immediately on
|
|
192
|
+
* an Alt/Shift flip instead of waiting for the next pointer move.
|
|
193
|
+
*/
|
|
194
|
+
lastPlanPoint: FloorplanPoint
|
|
90
195
|
/**
|
|
91
196
|
* Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
|
|
92
197
|
* the live angle wedge + degree readout — the 2D twin of the 3D rotate
|
|
@@ -94,6 +199,110 @@ type ActiveDrag = {
|
|
|
94
199
|
* rotate affordance measures it: `atan2(pointer − pivot)`.
|
|
95
200
|
*/
|
|
96
201
|
rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
|
|
202
|
+
/**
|
|
203
|
+
* Node id of the reshaping scope this drag began (boundary / curve / endpoint
|
|
204
|
+
* edits), so the matching `endIf` on release/cancel tears down exactly this
|
|
205
|
+
* scope. Unset for affordances that drive no snapping scope (resize / rotate).
|
|
206
|
+
*/
|
|
207
|
+
reshapeScopeNodeId?: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type FloorplanAffordanceCancelEffects = {
|
|
211
|
+
restoreSnapshots: (snapshots: NodeSnapshot[]) => void
|
|
212
|
+
resumeHistory: () => void
|
|
213
|
+
clearPreview: (id: AnyNodeId) => void
|
|
214
|
+
clearSnapFeedback: () => void
|
|
215
|
+
endReshapeScope: (drag: ActiveDrag) => void
|
|
216
|
+
clearDragFeedback?: () => void
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function cancelFloorplanAffordanceDrag(
|
|
220
|
+
dragRef: { current: ActiveDrag | null },
|
|
221
|
+
effects: FloorplanAffordanceCancelEffects,
|
|
222
|
+
pointerId?: number,
|
|
223
|
+
): boolean {
|
|
224
|
+
const drag = dragRef.current
|
|
225
|
+
if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
|
|
226
|
+
|
|
227
|
+
// Clear ownership before cleanup so a queued pointer-up cannot commit the
|
|
228
|
+
// session while cancellation side effects are still running.
|
|
229
|
+
dragRef.current = null
|
|
230
|
+
|
|
231
|
+
if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
|
|
232
|
+
drag.captureTarget.releasePointerCapture?.(drag.pointerId)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
effects.restoreSnapshots(drag.snapshots)
|
|
236
|
+
if (drag.historyPaused) {
|
|
237
|
+
effects.resumeHistory()
|
|
238
|
+
drag.historyPaused = false
|
|
239
|
+
}
|
|
240
|
+
effects.clearSnapFeedback()
|
|
241
|
+
for (const id of drag.session.affectedIds) effects.clearPreview(id)
|
|
242
|
+
effects.endReshapeScope(drag)
|
|
243
|
+
effects.clearDragFeedback?.()
|
|
244
|
+
return true
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function subscribeFloorplanAffordanceToolCancel(
|
|
248
|
+
cancelActiveDrag: () => boolean,
|
|
249
|
+
consumeToolCancel: () => void,
|
|
250
|
+
): () => void {
|
|
251
|
+
const onToolCancel = () => {
|
|
252
|
+
if (cancelActiveDrag()) consumeToolCancel()
|
|
253
|
+
}
|
|
254
|
+
emitter.on('tool:cancel', onToolCancel)
|
|
255
|
+
return () => emitter.off('tool:cancel', onToolCancel)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Map a floor-plan affordance to the reshaping scope it represents, so the
|
|
259
|
+
// dispatcher can drive the contextual snapping HUD (the chip) AND make
|
|
260
|
+
// `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
|
|
261
|
+
// edits that set a direction/shape map to a scope; resize / rotate / body-move
|
|
262
|
+
// affordances return `null` (no polygon/wall snapping chip). Keyed off the
|
|
263
|
+
// affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
|
|
264
|
+
// / `curve` / `move-endpoint`).
|
|
265
|
+
export function floorplanAffordanceReshapeScope(
|
|
266
|
+
affordance: string,
|
|
267
|
+
nodeId: string,
|
|
268
|
+
payload: unknown,
|
|
269
|
+
): ActiveInteractionScope | null {
|
|
270
|
+
if (affordance.includes('vertex') || affordance.includes('edge')) {
|
|
271
|
+
const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
|
|
272
|
+
return holeIndex !== undefined
|
|
273
|
+
? holeEditScope({ nodeId, holeIndex, driver: 'floorplan' })
|
|
274
|
+
: boundaryReshapeScope(nodeId, 'floorplan')
|
|
275
|
+
}
|
|
276
|
+
if (affordance.includes('curve')) {
|
|
277
|
+
return curveReshapeScope(nodeId, 'floorplan')
|
|
278
|
+
}
|
|
279
|
+
if (affordance.includes('control-point')) {
|
|
280
|
+
const index = (payload as { index?: number } | undefined)?.index ?? 0
|
|
281
|
+
return controlPointReshapeScope(nodeId, index, 'floorplan')
|
|
282
|
+
}
|
|
283
|
+
if (affordance.includes('tangent')) {
|
|
284
|
+
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
|
|
285
|
+
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out', 'floorplan')
|
|
286
|
+
}
|
|
287
|
+
if (affordance.includes('endpoint')) {
|
|
288
|
+
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
|
289
|
+
return endpointReshapeScope(nodeId, endpoint, 'floorplan')
|
|
290
|
+
}
|
|
291
|
+
// Roof-segment width/depth resize — a no-angle dimension edit, so the
|
|
292
|
+
// no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
|
|
293
|
+
// Matched exactly so a still-legacy `*-resize` affordance on another kind
|
|
294
|
+
// doesn't get a chip its snap math can't honour yet.
|
|
295
|
+
if (affordance === 'roof-segment-resize') {
|
|
296
|
+
return boundaryReshapeScope(nodeId, 'floorplan')
|
|
297
|
+
}
|
|
298
|
+
// 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
|
|
299
|
+
// stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
|
|
300
|
+
// matched, so the contextual HUD shows the "Shift = rotate freely" hint over
|
|
301
|
+
// the drag. The affordance applies the 15° angle step itself.
|
|
302
|
+
if (affordance.includes('rotate')) {
|
|
303
|
+
return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
|
|
304
|
+
}
|
|
305
|
+
return null
|
|
97
306
|
}
|
|
98
307
|
|
|
99
308
|
/**
|
|
@@ -109,6 +318,93 @@ type RotationOverlayState = {
|
|
|
109
318
|
sweep: number
|
|
110
319
|
}
|
|
111
320
|
|
|
321
|
+
type FloorplanEntryDescriptor = {
|
|
322
|
+
id: AnyNodeId
|
|
323
|
+
node: AnyNode
|
|
324
|
+
dependsOnSiblingInputs: boolean
|
|
325
|
+
ctxOverrides?: FloorplanContextOverrides
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
type NodeDeps = {
|
|
329
|
+
automaticDimensions: boolean
|
|
330
|
+
node: AnyNode
|
|
331
|
+
live: LiveTransform | undefined
|
|
332
|
+
unit: 'metric' | 'imperial'
|
|
333
|
+
metricNotation: 'meters' | 'millimeters'
|
|
334
|
+
wallDimensionReference: FloorplanWallDimensionReference
|
|
335
|
+
selected: boolean
|
|
336
|
+
highlighted: boolean
|
|
337
|
+
hovered: boolean
|
|
338
|
+
moving: boolean
|
|
339
|
+
liveOverride: LiveNodeOverrides | undefined
|
|
340
|
+
palette: FloorplanPalette | undefined
|
|
341
|
+
siblingEpoch: number
|
|
342
|
+
committedNodes: Record<string, AnyNode> | null
|
|
343
|
+
dependencyNodes: AnyNode[]
|
|
344
|
+
interactiveElevators: unknown
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
type CacheEntry = {
|
|
348
|
+
deps: NodeDeps
|
|
349
|
+
base: FloorplanGeometry | null
|
|
350
|
+
overlay: FloorplanGeometry | null
|
|
351
|
+
node: AnyNode
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
type LevelDataCacheEntry = {
|
|
355
|
+
nodes: Record<string, AnyNode>
|
|
356
|
+
liveOverrides: Map<string, LiveNodeOverrides>
|
|
357
|
+
ids: readonly AnyNodeId[]
|
|
358
|
+
value: unknown
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
type FloorplanContextOverrides = {
|
|
362
|
+
children: AnyNode[]
|
|
363
|
+
siblings: AnyNode[]
|
|
364
|
+
parent: AnyNode | null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
type FloorplanLevelDataHook = (args: {
|
|
368
|
+
siblings: ReadonlyArray<AnyNode>
|
|
369
|
+
nodes: Record<string, AnyNode>
|
|
370
|
+
}) => unknown
|
|
371
|
+
|
|
372
|
+
type FloorplanRenderPass = 'base' | 'overlay'
|
|
373
|
+
|
|
374
|
+
const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
|
|
375
|
+
// Group members advertise the drag-to-move-the-selection gesture.
|
|
376
|
+
const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
|
|
377
|
+
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
|
|
378
|
+
|
|
379
|
+
export function isFloorplanOpeningPlacementState({
|
|
380
|
+
phase,
|
|
381
|
+
mode,
|
|
382
|
+
tool,
|
|
383
|
+
movingNodeHasWallOpeningPlacement,
|
|
384
|
+
}: {
|
|
385
|
+
phase: string
|
|
386
|
+
mode: string
|
|
387
|
+
tool: string | null
|
|
388
|
+
movingNodeHasWallOpeningPlacement: boolean
|
|
389
|
+
}): boolean {
|
|
390
|
+
return (
|
|
391
|
+
(phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) ||
|
|
392
|
+
movingNodeHasWallOpeningPlacement
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function isFloorplanOpeningPlacementActiveNow(): boolean {
|
|
397
|
+
const { phase, mode, tool } = useEditor.getState()
|
|
398
|
+
const movingNode = getMovingNode()
|
|
399
|
+
return isFloorplanOpeningPlacementState({
|
|
400
|
+
phase,
|
|
401
|
+
mode,
|
|
402
|
+
tool,
|
|
403
|
+
movingNodeHasWallOpeningPlacement:
|
|
404
|
+
movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
112
408
|
function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
113
409
|
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
|
114
410
|
// deep-cloned to detach from the live store reference.
|
|
@@ -124,25 +420,27 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
|
|
124
420
|
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
|
125
421
|
}
|
|
126
422
|
|
|
423
|
+
// Stable empty sentinel used by per-entry builders while the floor plan is
|
|
424
|
+
// hidden; committed scene edits still flow through `useScene`.
|
|
425
|
+
const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
|
|
426
|
+
|
|
127
427
|
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
128
428
|
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
|
129
429
|
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const hoveredId = useViewer((s) => s.hoveredId)
|
|
133
|
-
const setHoveredId = useViewer((s) => s.setHoveredId)
|
|
430
|
+
const unit = useViewer((s) => s.unit)
|
|
431
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
134
432
|
const setSelection = useViewer((s) => s.setSelection)
|
|
135
433
|
const nodes = useScene((s) => s.nodes)
|
|
434
|
+
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
435
|
+
const movingNode = useMovingNode()
|
|
136
436
|
// When a building is being moved, its explicit selection may be
|
|
137
437
|
// cleared as part of the move handoff. Fall back to the
|
|
138
438
|
// mid-drag building id so the dimmed floor keeps rendering
|
|
139
439
|
// throughout the gesture.
|
|
140
|
-
const movingBuildingId =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return def?.capabilities?.floorplanLevelContainer ? moving.id : null
|
|
145
|
-
})
|
|
440
|
+
const movingBuildingId =
|
|
441
|
+
movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
|
|
442
|
+
? movingNode.id
|
|
443
|
+
: null
|
|
146
444
|
const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
|
|
147
445
|
|
|
148
446
|
// When only a building is in scope (no specific level), fall back to
|
|
@@ -152,7 +450,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
152
450
|
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
|
|
153
451
|
if (selectedLevelId || !ambientBuildingSourceId) return null
|
|
154
452
|
const building = nodes[ambientBuildingSourceId]
|
|
155
|
-
if (
|
|
453
|
+
if (building?.type !== 'building') return null
|
|
156
454
|
let zero: AnyNodeId | null = null
|
|
157
455
|
let lowestId: AnyNodeId | null = null
|
|
158
456
|
let lowestIdx = Number.POSITIVE_INFINITY
|
|
@@ -174,9 +472,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
174
472
|
|
|
175
473
|
const levelId = selectedLevelId ?? ambientLevelId
|
|
176
474
|
const isAmbient = !selectedLevelId && !!ambientLevelId
|
|
177
|
-
const renderCtx =
|
|
178
|
-
const
|
|
475
|
+
const renderCtx = useFloorplanStaticRender()
|
|
476
|
+
const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
|
|
179
477
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
478
|
+
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
180
479
|
// Door / window placement (both build and move) needs the SVG's
|
|
181
480
|
// background click handler to run — it finds the closest wall via
|
|
182
481
|
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
|
@@ -184,53 +483,124 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
184
483
|
// wall's registry entry would otherwise swallow the click via
|
|
185
484
|
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
|
186
485
|
// Pass clicks through in that case.
|
|
187
|
-
const editorPhase = useEditor((s) => s.phase)
|
|
188
486
|
const editorMode = useEditor((s) => s.mode)
|
|
189
|
-
const editorTool = useEditor((s) => s.tool)
|
|
190
487
|
const structureLayer = useEditor((s) => s.structureLayer)
|
|
191
488
|
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
192
|
-
const
|
|
193
|
-
const isOpeningPlacementActive =
|
|
194
|
-
(editorPhase === 'structure' &&
|
|
195
|
-
editorMode === 'build' &&
|
|
196
|
-
(editorTool === 'door' || editorTool === 'window')) ||
|
|
197
|
-
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
|
489
|
+
const endpointReshape = useEndpointReshape()
|
|
198
490
|
const isMarqueeSelectionActive =
|
|
199
491
|
editorMode === 'select' &&
|
|
200
492
|
floorplanSelectionTool === 'marquee' &&
|
|
201
493
|
structureLayer !== 'zones' &&
|
|
202
494
|
!movingNode &&
|
|
203
|
-
!
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
495
|
+
!endpointReshape
|
|
496
|
+
// While the floor plan is not on screen (pure 3D view), per-entry live
|
|
497
|
+
// selectors freeze to `undefined` so drag publishes do not re-render the
|
|
498
|
+
// hidden floor-plan tree.
|
|
499
|
+
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
|
|
500
|
+
const drawingType = useDrawingView((s) => s.drawingType)
|
|
501
|
+
const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
|
|
502
|
+
const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
|
|
503
|
+
const floorplanMode = useFloorplanMode((s) => s.mode)
|
|
504
|
+
const effectiveWallDimensionReference = resolveFloorplanWallDimensionReference(
|
|
505
|
+
floorplanMode,
|
|
506
|
+
wallDimensionReference,
|
|
507
|
+
)
|
|
508
|
+
// Elevator builders read runtime state imperatively, so entries include this
|
|
509
|
+
// rare-changing ref in their cache deps.
|
|
215
510
|
const interactiveElevators = useInteractive((s) => s.elevators)
|
|
216
511
|
|
|
217
|
-
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
218
|
-
// Marquee preview selection — matches the legacy `highlightedIdSet` use
|
|
219
|
-
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
|
|
220
|
-
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
|
|
221
|
-
|
|
222
512
|
// Interactive state lives in refs; only the visible feedback bits go
|
|
223
513
|
// into React state to keep re-renders cheap during drag.
|
|
224
514
|
const dragRef = useRef<ActiveDrag | null>(null)
|
|
225
515
|
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
|
226
516
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
|
227
517
|
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
|
518
|
+
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
|
|
519
|
+
const levelDataCacheRef = useRef<Map<string, LevelDataCacheEntry>>(new Map())
|
|
520
|
+
const nodesRef = useRef(nodes)
|
|
521
|
+
const [siblingEpochs, setSiblingEpochs] = useState<Map<AnyNodeId, number>>(() => new Map())
|
|
522
|
+
// Per-node sibling epoch (replaces a single global epoch). Bumped only for the
|
|
523
|
+
// nodes affected by this frame's live drags, so an unaffected wall/opening
|
|
524
|
+
// keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which
|
|
525
|
+
// sibling-dependent nodes were live last frame, so a node that just STOPPED
|
|
526
|
+
// being dragged (override cleared, no commit) still gets one final rebuild to
|
|
527
|
+
// revert — its dependents (host wall, junction neighbours) don't carry its
|
|
528
|
+
// override in their own deps.
|
|
529
|
+
const nodeSiblingEpochRef = useRef<Map<AnyNodeId, number>>(new Map())
|
|
530
|
+
const prevLiveFlaggedIdsRef = useRef<AnyNodeId[]>([])
|
|
228
531
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
nodesRef.current = nodes
|
|
534
|
+
}, [nodes])
|
|
535
|
+
|
|
536
|
+
const bumpAffectedSiblingEpochs = useCallback(() => {
|
|
537
|
+
if (!floorplanVisible) return
|
|
538
|
+
|
|
539
|
+
const sceneNodes = nodesRef.current
|
|
540
|
+
const liveTransforms = useLiveTransforms.getState().transforms
|
|
541
|
+
const liveOverrides = useLiveNodeOverrides.getState().overrides
|
|
542
|
+
const liveFlaggedIds: AnyNodeId[] = []
|
|
543
|
+
|
|
544
|
+
for (const [id] of liveTransforms) {
|
|
545
|
+
const node = sceneNodes[id as AnyNodeId]
|
|
546
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
547
|
+
if (
|
|
548
|
+
node &&
|
|
549
|
+
(def?.floorplanDependsOnSiblings ||
|
|
550
|
+
def?.floorplanSiblingOverrides ||
|
|
551
|
+
def?.floorplanAffectedIds)
|
|
552
|
+
) {
|
|
553
|
+
liveFlaggedIds.push(id as AnyNodeId)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const [id] of liveOverrides) {
|
|
557
|
+
const node = sceneNodes[id as AnyNodeId]
|
|
558
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
559
|
+
if (
|
|
560
|
+
node &&
|
|
561
|
+
(def?.floorplanDependsOnSiblings ||
|
|
562
|
+
def?.floorplanSiblingOverrides ||
|
|
563
|
+
def?.floorplanAffectedIds)
|
|
564
|
+
) {
|
|
565
|
+
liveFlaggedIds.push(id as AnyNodeId)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current]))
|
|
570
|
+
const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, sceneNodes, liveOverrides)
|
|
571
|
+
const nodeSiblingEpochs = nodeSiblingEpochRef.current
|
|
572
|
+
for (const id of affectedSiblingIds) {
|
|
573
|
+
nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1)
|
|
574
|
+
}
|
|
575
|
+
prevLiveFlaggedIdsRef.current = liveFlaggedIds
|
|
576
|
+
if (affectedSiblingIds.size > 0) {
|
|
577
|
+
setSiblingEpochs(new Map(nodeSiblingEpochs))
|
|
578
|
+
}
|
|
579
|
+
}, [floorplanVisible])
|
|
580
|
+
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
bumpAffectedSiblingEpochs()
|
|
583
|
+
const unsubscribeTransforms = useLiveTransforms.subscribe(bumpAffectedSiblingEpochs)
|
|
584
|
+
const unsubscribeOverrides = useLiveNodeOverrides.subscribe(bumpAffectedSiblingEpochs)
|
|
585
|
+
return () => {
|
|
586
|
+
unsubscribeTransforms()
|
|
587
|
+
unsubscribeOverrides()
|
|
588
|
+
}
|
|
589
|
+
}, [bumpAffectedSiblingEpochs])
|
|
590
|
+
|
|
591
|
+
const applyEntrySelection = useCallback(
|
|
592
|
+
(id: AnyNodeId, shouldToggle: boolean) => {
|
|
593
|
+
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
594
|
+
const nextSelectedIds = shouldToggle
|
|
595
|
+
? currentSelectedIds.includes(id)
|
|
596
|
+
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
|
597
|
+
: [...currentSelectedIds, id]
|
|
598
|
+
: [id]
|
|
599
|
+
setSelection({ selectedIds: nextSelectedIds })
|
|
600
|
+
if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
|
|
601
|
+
const node = useScene.getState().nodes[id]
|
|
602
|
+
if (node) emitCanvasNodeSelection(node)
|
|
603
|
+
}
|
|
234
604
|
// Setting selection re-renders the entry — the overlay pass mounts
|
|
235
605
|
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
|
236
606
|
// pointerdown and click. If the click target ends up on the SVG
|
|
@@ -239,129 +609,329 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
239
609
|
// selection we just set. Swallow the next click globally to break
|
|
240
610
|
// that race; the listener removes itself after firing (or after a
|
|
241
611
|
// safety timeout if no click follows).
|
|
242
|
-
|
|
243
|
-
ev.stopPropagation()
|
|
244
|
-
ev.preventDefault()
|
|
245
|
-
window.removeEventListener('click', swallowClick, true)
|
|
246
|
-
}
|
|
247
|
-
window.addEventListener('click', swallowClick, true)
|
|
248
|
-
setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
|
|
612
|
+
swallowNextClick(200)
|
|
249
613
|
},
|
|
250
614
|
[setSelection],
|
|
251
615
|
)
|
|
252
616
|
|
|
617
|
+
const handleSelect = useCallback(
|
|
618
|
+
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
|
619
|
+
if (event.button !== 0) return
|
|
620
|
+
event.stopPropagation()
|
|
621
|
+
applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
|
|
622
|
+
},
|
|
623
|
+
[applyEntrySelection],
|
|
624
|
+
)
|
|
625
|
+
|
|
253
626
|
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
|
627
|
+
if (isFloorplanOpeningPlacementActiveNow()) return
|
|
254
628
|
event.stopPropagation()
|
|
255
629
|
}, [])
|
|
256
630
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// Each entry carries TWO trees:
|
|
261
|
-
// - `base`: filled shapes, strokes, polygons, hatches — anything
|
|
262
|
-
// that should respect the kind's z-order bucket.
|
|
263
|
-
// - `overlay`: interactive handles (vertex / midpoint / edge / move)
|
|
264
|
-
// and labels (text / dimension). These always render on top of
|
|
265
|
-
// every base entry so selection chrome and node names stay visible
|
|
266
|
-
// above walls, items, etc.
|
|
267
|
-
//
|
|
268
|
-
// The split is computed by `splitFloorplanOverlay` from the single
|
|
269
|
-
// tree the builder returns. Builders don't need to know about the
|
|
270
|
-
// partition.
|
|
271
|
-
const entries = useMemo(() => {
|
|
272
|
-
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
|
|
273
|
-
void interactiveElevators
|
|
274
|
-
|
|
275
|
-
if (!levelId) return []
|
|
276
|
-
const out: {
|
|
277
|
-
id: AnyNodeId
|
|
278
|
-
node: AnyNode
|
|
279
|
-
base: FloorplanGeometry | null
|
|
280
|
-
overlay: FloorplanGeometry | null
|
|
281
|
-
selected: boolean
|
|
282
|
-
highlighted: boolean
|
|
283
|
-
}[] = []
|
|
631
|
+
const startDirectMoveDrag = useCallback(
|
|
632
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
|
633
|
+
if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
|
|
284
634
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (live) {
|
|
309
|
-
const floorPlaced = def?.capabilities?.floorPlaced
|
|
310
|
-
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
|
311
|
-
if (floorPlaced && hasPosition) {
|
|
312
|
-
effectiveNode = applyPositionLiveTransform(node, live)
|
|
313
|
-
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
|
314
|
-
const dx = live.position[0]
|
|
315
|
-
const dz = live.position[2]
|
|
316
|
-
if (dx !== 0 || dz !== 0) {
|
|
317
|
-
const surface = node as {
|
|
318
|
-
polygon: Array<[number, number]>
|
|
319
|
-
holes?: Array<Array<[number, number]>>
|
|
320
|
-
}
|
|
321
|
-
effectiveNode = {
|
|
322
|
-
...node,
|
|
323
|
-
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
324
|
-
holes: (surface.holes ?? []).map((h) =>
|
|
325
|
-
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
326
|
-
),
|
|
327
|
-
} as AnyNode
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
// Live-edit overrides: kinds whose `def.floorplan` builder
|
|
332
|
-
// reads cross-sibling data (wall miters, …) declare a
|
|
333
|
-
// `def.floorplanSiblingOverrides` hook that projects the
|
|
334
|
-
// override map into a merged `nodes` snapshot. The merged
|
|
335
|
-
// copy feeds `buildContext` so `ctx.siblings` reflects the
|
|
336
|
-
// live cursor positions, and replaces `effectiveNode` so the
|
|
337
|
-
// kind's own override lands too (covers the case where the
|
|
338
|
-
// node being rendered is itself the dragged one). Kinds
|
|
339
|
-
// without the hook hand the raw `nodes` through — most
|
|
340
|
-
// previews are self-contained.
|
|
341
|
-
const contextNodes = def?.floorplanSiblingOverrides
|
|
342
|
-
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
|
343
|
-
: nodes
|
|
344
|
-
if (contextNodes !== nodes) {
|
|
345
|
-
const merged = contextNodes[id]
|
|
346
|
-
if (merged) effectiveNode = merged
|
|
635
|
+
const node = useScene.getState().nodes[id]
|
|
636
|
+
if (!node || !isRegistryMovable(node.type)) return false
|
|
637
|
+
// Sole selection only: per-node direct manipulation stands down for a
|
|
638
|
+
// multi-selection (the group session owns plain drags there, and Cmd is
|
|
639
|
+
// the selection-toggle key — a wobbly Cmd+click must not yank one
|
|
640
|
+
// member out of the group).
|
|
641
|
+
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
642
|
+
if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
|
|
643
|
+
|
|
644
|
+
event.preventDefault()
|
|
645
|
+
event.stopPropagation()
|
|
646
|
+
|
|
647
|
+
const startX = event.clientX
|
|
648
|
+
const startY = event.clientY
|
|
649
|
+
const pointerId = event.pointerId
|
|
650
|
+
let engaged = false
|
|
651
|
+
|
|
652
|
+
const cleanup = () => {
|
|
653
|
+
window.removeEventListener('pointermove', onMove)
|
|
654
|
+
window.removeEventListener('pointerup', onEnd)
|
|
655
|
+
window.removeEventListener('pointercancel', onEnd)
|
|
656
|
+
if (engaged) {
|
|
657
|
+
useViewer.getState().setInputDragging(false)
|
|
347
658
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
662
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
663
|
+
if (engaged) return
|
|
664
|
+
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY)
|
|
665
|
+
if (distance < DIRECT_DRAG_THRESHOLD_PX) return
|
|
666
|
+
|
|
667
|
+
engaged = true
|
|
668
|
+
useViewer.getState().setInputDragging(true)
|
|
669
|
+
swallowNextClick(300)
|
|
670
|
+
createEditorApi().engageMoveDrag(node)
|
|
671
|
+
|
|
672
|
+
requestAnimationFrame(() => {
|
|
673
|
+
window.dispatchEvent(
|
|
674
|
+
new PointerEvent('pointermove', {
|
|
675
|
+
altKey: moveEvent.altKey,
|
|
676
|
+
bubbles: true,
|
|
677
|
+
buttons: moveEvent.buttons,
|
|
678
|
+
clientX: moveEvent.clientX,
|
|
679
|
+
clientY: moveEvent.clientY,
|
|
680
|
+
ctrlKey: moveEvent.ctrlKey,
|
|
681
|
+
metaKey: moveEvent.metaKey,
|
|
682
|
+
pointerId,
|
|
683
|
+
pointerType: moveEvent.pointerType,
|
|
684
|
+
shiftKey: moveEvent.shiftKey,
|
|
685
|
+
}),
|
|
686
|
+
)
|
|
354
687
|
})
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const onEnd = (endEvent: PointerEvent) => {
|
|
691
|
+
if (endEvent.pointerId !== pointerId) return
|
|
692
|
+
cleanup()
|
|
693
|
+
if (!engaged) {
|
|
694
|
+
applyEntrySelection(id, true)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
window.addEventListener('pointermove', onMove)
|
|
699
|
+
window.addEventListener('pointerup', onEnd)
|
|
700
|
+
window.addEventListener('pointercancel', onEnd)
|
|
701
|
+
return true
|
|
702
|
+
},
|
|
703
|
+
[applyEntrySelection],
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
const startDirectRotateDrag = useCallback(
|
|
707
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
|
|
708
|
+
if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
|
|
709
|
+
|
|
710
|
+
const sceneNodes = useScene.getState().nodes
|
|
711
|
+
const selectedNode = sceneNodes[id]
|
|
712
|
+
const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
|
|
713
|
+
if (!node || !canDirectRotateNode(node)) return false
|
|
714
|
+
// Sole selection only — same stand-down as the direct move above.
|
|
715
|
+
const selectedIds = useViewer.getState().selection.selectedIds
|
|
716
|
+
if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
|
|
717
|
+
event.preventDefault()
|
|
718
|
+
event.stopPropagation()
|
|
719
|
+
|
|
720
|
+
const nodeId = node.id as AnyNodeId
|
|
721
|
+
const pointerId = event.pointerId
|
|
722
|
+
const startX = event.clientX
|
|
723
|
+
const sceneApi = createSceneApi(useScene)
|
|
724
|
+
let lastPatch: Partial<AnyNode> | null = null
|
|
725
|
+
|
|
726
|
+
const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
|
|
727
|
+
const delta = resolveDirectRotationDragDelta(
|
|
728
|
+
startX,
|
|
729
|
+
pointerEvent.clientX,
|
|
730
|
+
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
|
731
|
+
!isAngleSnapActive(),
|
|
358
732
|
)
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
733
|
+
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
|
734
|
+
lastPatch = null
|
|
735
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
736
|
+
useScene.getState().markDirty(nodeId)
|
|
737
|
+
return
|
|
362
738
|
}
|
|
739
|
+
const patch = resolveDirectRotationPatch(node, delta, sceneApi)
|
|
740
|
+
if (!patch) return
|
|
741
|
+
lastPatch = patch
|
|
742
|
+
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
|
|
743
|
+
useScene.getState().markDirty(nodeId)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const cleanup = () => {
|
|
747
|
+
window.removeEventListener('pointermove', onMove, true)
|
|
748
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
749
|
+
window.removeEventListener('pointercancel', onCancel, true)
|
|
750
|
+
window.removeEventListener('contextmenu', preventContextMenu, true)
|
|
751
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
752
|
+
useScene.getState().markDirty(nodeId)
|
|
753
|
+
resumeSceneHistory(useScene)
|
|
754
|
+
useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
|
|
755
|
+
useViewer.getState().setInputDragging(false)
|
|
756
|
+
if (document.body.style.cursor === 'ew-resize') {
|
|
757
|
+
document.body.style.cursor = ''
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
762
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
763
|
+
moveEvent.preventDefault()
|
|
764
|
+
moveEvent.stopPropagation()
|
|
765
|
+
applyDelta(moveEvent)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
769
|
+
if (upEvent.pointerId !== pointerId) return
|
|
770
|
+
upEvent.preventDefault()
|
|
771
|
+
upEvent.stopPropagation()
|
|
772
|
+
swallowNextClick(300)
|
|
773
|
+
if (lastPatch) {
|
|
774
|
+
sceneApi.update(nodeId, lastPatch)
|
|
775
|
+
sfxEmitter.emit('sfx:item-place')
|
|
776
|
+
}
|
|
777
|
+
cleanup()
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const onCancel = (cancelEvent: PointerEvent) => {
|
|
781
|
+
if (cancelEvent.pointerId !== pointerId) return
|
|
782
|
+
cleanup()
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const preventContextMenu = (contextEvent: Event) => {
|
|
786
|
+
contextEvent.preventDefault()
|
|
787
|
+
contextEvent.stopPropagation()
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
pauseSceneHistory(useScene)
|
|
791
|
+
useViewer.getState().setInputDragging(true)
|
|
792
|
+
useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
|
|
793
|
+
document.body.style.cursor = 'ew-resize'
|
|
794
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
795
|
+
applyDelta(event)
|
|
796
|
+
|
|
797
|
+
window.addEventListener('pointermove', onMove, true)
|
|
798
|
+
window.addEventListener('pointerup', onUp, true)
|
|
799
|
+
window.addEventListener('pointercancel', onCancel, true)
|
|
800
|
+
window.addEventListener('contextmenu', preventContextMenu, true)
|
|
801
|
+
return true
|
|
802
|
+
},
|
|
803
|
+
[],
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
// Photoshop-style group drag: plain pointer-down on a transformable member
|
|
807
|
+
// of a multi-selection slides the whole selection rigidly; a plain click
|
|
808
|
+
// (no drag) enters the group pick-up instead — parity with the single-item
|
|
809
|
+
// click-to-move (clicking outside still deselects). Modified clicks
|
|
810
|
+
// (selection toggle) and Cmd-drag / direct-rotate keep their existing
|
|
811
|
+
// paths. `immediate` engages without the drag threshold — the move-handle
|
|
812
|
+
// dot's pick-up semantics.
|
|
813
|
+
const startGroupMoveDrag = useCallback(
|
|
814
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>, immediate = false): boolean => {
|
|
815
|
+
if (event.button !== 0) return false
|
|
816
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
|
|
817
|
+
if (movingNode) return false
|
|
818
|
+
if (useEditor.getState().mode === 'delete') return false
|
|
819
|
+
const started = startFloorplanGroupMove(id, event, {
|
|
820
|
+
immediate,
|
|
821
|
+
onClickFallthrough: immediate ? undefined : () => startGroupPickUp(),
|
|
822
|
+
})
|
|
823
|
+
if (!started) return false
|
|
824
|
+
event.preventDefault()
|
|
825
|
+
event.stopPropagation()
|
|
826
|
+
suppressBoxSelectForPointer(event)
|
|
827
|
+
return true
|
|
828
|
+
},
|
|
829
|
+
[movingNode],
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
// Move-handle dot variant — routes the dot through the group session when
|
|
833
|
+
// the owning node is part of a multi-selection.
|
|
834
|
+
const handleGroupMoveHandlePointerDown = useCallback(
|
|
835
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => startGroupMoveDrag(id, event, true),
|
|
836
|
+
[startGroupMoveDrag],
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
// The dashed selection box is itself the group's drag handle: a press
|
|
840
|
+
// anywhere inside it slides the group (or picks it up on a plain click),
|
|
841
|
+
// anchored on the first transformable member.
|
|
842
|
+
const handleGroupBoxPointerDown = useCallback(
|
|
843
|
+
(event: ReactPointerEvent<SVGGElement>) => {
|
|
844
|
+
const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
|
|
845
|
+
const sceneNodes = useScene.getState().nodes
|
|
846
|
+
const anchor = currentIds.find(
|
|
847
|
+
(id) =>
|
|
848
|
+
classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
|
|
849
|
+
)
|
|
850
|
+
if (!anchor) return
|
|
851
|
+
startGroupMoveDrag(anchor as AnyNodeId, event)
|
|
852
|
+
},
|
|
853
|
+
[startGroupMoveDrag],
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
// Corner rotate handles on the dashed selection box — 15° steps, Shift
|
|
857
|
+
// free, mirroring the 3D group rotate gizmo.
|
|
858
|
+
const handleGroupBoxRotatePointerDown = useCallback((event: ReactPointerEvent<SVGGElement>) => {
|
|
859
|
+
if (event.button !== 0) return
|
|
860
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
861
|
+
if (useEditor.getState().mode === 'delete') return
|
|
862
|
+
if (startFloorplanGroupRotate(event)) {
|
|
863
|
+
event.preventDefault()
|
|
864
|
+
suppressBoxSelectForPointer(event)
|
|
865
|
+
}
|
|
866
|
+
}, [])
|
|
867
|
+
|
|
868
|
+
const handleEntryPointerDown = useCallback(
|
|
869
|
+
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
|
|
870
|
+
// Keep this handler mounted during opening placement and arbitrate from
|
|
871
|
+
// the stores at event time. Commit clears the interaction scope before
|
|
872
|
+
// React paints the next frame; a render-time `undefined` handler leaves
|
|
873
|
+
// a short dead zone where the first post-placement selection is lost.
|
|
874
|
+
if (isFloorplanOpeningPlacementActiveNow()) return
|
|
875
|
+
if (startDirectMoveDrag(id, event)) return
|
|
876
|
+
if (startDirectRotateDrag(id, event)) return
|
|
877
|
+
if (startGroupMoveDrag(id, event)) return
|
|
878
|
+
handleSelect(id, event)
|
|
879
|
+
},
|
|
880
|
+
[handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
const floorplanData = useMemo(() => {
|
|
884
|
+
if (!levelId) {
|
|
885
|
+
geometryCacheRef.current.clear()
|
|
886
|
+
levelDataCacheRef.current.clear()
|
|
887
|
+
return {
|
|
888
|
+
entries: [] as FloorplanEntryDescriptor[],
|
|
889
|
+
levelNodeIdsByType: new Map<string, AnyNodeId[]>(),
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const out: FloorplanEntryDescriptor[] = []
|
|
894
|
+
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
|
895
|
+
|
|
896
|
+
const collectLevelDataKind = (id: AnyNodeId) => {
|
|
897
|
+
const node = nodes[id]
|
|
898
|
+
if (!node) return
|
|
899
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
|
900
|
+
const def = nodeRegistry.get(node.type)
|
|
901
|
+
if (def?.computeFloorplanLevelData) {
|
|
902
|
+
const ids = levelNodeIdsByType.get(node.type)
|
|
903
|
+
if (ids) ids.push(id)
|
|
904
|
+
else levelNodeIdsByType.set(node.type, [id])
|
|
363
905
|
}
|
|
364
906
|
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
907
|
+
if (Array.isArray(childIds)) {
|
|
908
|
+
for (const cid of childIds) collectLevelDataKind(cid)
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
collectLevelDataKind(levelId as AnyNodeId)
|
|
913
|
+
|
|
914
|
+
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
|
915
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
|
916
|
+
const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
|
|
917
|
+
if (!drawingNode) return
|
|
918
|
+
const def = nodeRegistry.get(drawingNode.type)
|
|
919
|
+
if (!def?.floorplan) return
|
|
920
|
+
const dependsOnSiblingInputs = !!(
|
|
921
|
+
def.floorplanDependsOnSiblings ||
|
|
922
|
+
def.floorplanSiblingOverrides ||
|
|
923
|
+
def.floorplanAffectedIds
|
|
924
|
+
)
|
|
925
|
+
const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
|
|
926
|
+
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
|
|
927
|
+
out.push(descriptor)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const visit = (id: AnyNodeId) => {
|
|
931
|
+
const node = nodes[id]
|
|
932
|
+
if (!node) return
|
|
933
|
+
pushEntry(id, node)
|
|
934
|
+
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
365
935
|
if (Array.isArray(childIds)) {
|
|
366
936
|
for (const cid of childIds) visit(cid)
|
|
367
937
|
}
|
|
@@ -369,6 +939,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
369
939
|
|
|
370
940
|
visit(levelId as AnyNodeId)
|
|
371
941
|
|
|
942
|
+
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
|
943
|
+
if (activeLevelNode) {
|
|
944
|
+
const collectedIds = new Set(out.map((entry) => entry.id))
|
|
945
|
+
for (const linked of collectFloorplanLinkedLevelNodes(
|
|
946
|
+
nodes,
|
|
947
|
+
levelId as AnyNodeId,
|
|
948
|
+
collectedIds,
|
|
949
|
+
)) {
|
|
950
|
+
pushEntry(linked.id, linked.node, {
|
|
951
|
+
children: linked.children,
|
|
952
|
+
siblings: [],
|
|
953
|
+
parent: activeLevelNode,
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
372
958
|
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
|
|
373
959
|
// as siblings of the level, not under it — the `visit(levelId)` DFS
|
|
374
960
|
// above doesn't reach them. Walk every node of those kinds whose
|
|
@@ -377,7 +963,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
377
963
|
// builders that gate on the current floor — e.g. elevator service
|
|
378
964
|
// range — keep working). Pure registry-driven dispatch: no kind
|
|
379
965
|
// name appears in this file.
|
|
380
|
-
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
|
381
966
|
const activeBuildingId = activeLevelNode
|
|
382
967
|
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
|
|
383
968
|
: null
|
|
@@ -386,54 +971,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
386
971
|
const buildingScopedKindSet = new Set(buildingScopedKinds)
|
|
387
972
|
for (const [id, node] of Object.entries(nodes)) {
|
|
388
973
|
if (!node || !buildingScopedKindSet.has(node.type)) continue
|
|
389
|
-
if ((node as { visible?: boolean }).visible === false) continue
|
|
390
974
|
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
|
391
975
|
if (parentId !== activeBuildingId) continue
|
|
392
976
|
const cid = id as AnyNodeId
|
|
393
|
-
|
|
394
|
-
const builder = def?.floorplan
|
|
395
|
-
if (!builder) continue
|
|
396
|
-
const selected = selectedIdSet.has(cid)
|
|
397
|
-
const highlighted = highlightedIdSet.has(cid)
|
|
398
|
-
const hovered = hoveredId === cid
|
|
399
|
-
const moving = movingNode?.id === cid
|
|
400
|
-
const live = liveTransforms.get(cid)
|
|
401
|
-
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
|
402
|
-
let effectiveNode: AnyNode =
|
|
403
|
-
live && hasPosition ? applyPositionLiveTransform(node, live) : node
|
|
404
|
-
const contextNodes = def?.floorplanSiblingOverrides
|
|
405
|
-
? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
|
|
406
|
-
: nodes
|
|
407
|
-
if (contextNodes !== nodes) {
|
|
408
|
-
const merged = contextNodes[cid]
|
|
409
|
-
if (merged) {
|
|
410
|
-
effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
const ctx: GeometryContext = {
|
|
414
|
-
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
|
415
|
-
contextNodes[rid] as N | undefined,
|
|
977
|
+
pushEntry(cid, node, {
|
|
416
978
|
children: [],
|
|
417
979
|
siblings: [],
|
|
418
980
|
parent: activeLevelNode,
|
|
419
|
-
|
|
420
|
-
? {
|
|
421
|
-
selected,
|
|
422
|
-
highlighted,
|
|
423
|
-
hovered,
|
|
424
|
-
moving,
|
|
425
|
-
palette: renderCtx.palette,
|
|
426
|
-
}
|
|
427
|
-
: undefined,
|
|
428
|
-
}
|
|
429
|
-
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
430
|
-
effectiveNode,
|
|
431
|
-
ctx,
|
|
432
|
-
)
|
|
433
|
-
if (geometry) {
|
|
434
|
-
const { base, overlay } = splitFloorplanOverlay(geometry)
|
|
435
|
-
out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
|
|
436
|
-
}
|
|
981
|
+
})
|
|
437
982
|
}
|
|
438
983
|
}
|
|
439
984
|
|
|
@@ -445,19 +990,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
445
990
|
// DFS visit order (stable sort) so siblings keep their relative
|
|
446
991
|
// priority.
|
|
447
992
|
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
movingNode?.id,
|
|
458
|
-
renderCtx?.palette,
|
|
459
|
-
interactiveElevators,
|
|
460
|
-
])
|
|
993
|
+
const entryIds = new Set(out.map((entry) => entry.id))
|
|
994
|
+
for (const id of geometryCacheRef.current.keys()) {
|
|
995
|
+
if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id)
|
|
996
|
+
}
|
|
997
|
+
for (const type of levelDataCacheRef.current.keys()) {
|
|
998
|
+
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
|
999
|
+
}
|
|
1000
|
+
return { entries: out, levelNodeIdsByType }
|
|
1001
|
+
}, [drawingType, installedPlugins, levelId, nodes])
|
|
461
1002
|
|
|
462
1003
|
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
|
463
1004
|
//
|
|
@@ -466,6 +1007,37 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
466
1007
|
// dispatcher then owns: history pause/resume, snapshot capture,
|
|
467
1008
|
// pointer-move/up/cancel routing, and the single-undo dance on
|
|
468
1009
|
// commit. Each kind owns the actual mutation logic inside `apply`.
|
|
1010
|
+
const commitAffordanceAction = useCallback(
|
|
1011
|
+
(
|
|
1012
|
+
nodeId: AnyNodeId,
|
|
1013
|
+
affordance: string,
|
|
1014
|
+
payload: unknown,
|
|
1015
|
+
event: ReactMouseEvent<SVGElement>,
|
|
1016
|
+
) => {
|
|
1017
|
+
if (event.button !== 0 || movingNode || dragRef.current) return
|
|
1018
|
+
|
|
1019
|
+
const sceneNodes = useScene.getState().nodes
|
|
1020
|
+
const node = sceneNodes[nodeId]
|
|
1021
|
+
if (!node) return
|
|
1022
|
+
const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
|
|
1023
|
+
if (!handler) return
|
|
1024
|
+
const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
|
|
1025
|
+
if (!initialPlanPoint) return
|
|
1026
|
+
|
|
1027
|
+
const session = handler.start({
|
|
1028
|
+
node,
|
|
1029
|
+
payload,
|
|
1030
|
+
nodes: sceneNodes,
|
|
1031
|
+
initialPlanPoint,
|
|
1032
|
+
gridSnapStep: useEditor.getState().gridSnapStep,
|
|
1033
|
+
})
|
|
1034
|
+
if (!(session.commit && session.canCommit())) return
|
|
1035
|
+
session.commit()
|
|
1036
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
1037
|
+
},
|
|
1038
|
+
[movingNode],
|
|
1039
|
+
)
|
|
1040
|
+
|
|
469
1041
|
const startAffordanceDrag = useCallback(
|
|
470
1042
|
(
|
|
471
1043
|
nodeId: AnyNodeId,
|
|
@@ -493,6 +1065,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
493
1065
|
|
|
494
1066
|
event.preventDefault()
|
|
495
1067
|
event.stopPropagation()
|
|
1068
|
+
suppressBoxSelectForPointer(event)
|
|
496
1069
|
|
|
497
1070
|
const session = handler.start({
|
|
498
1071
|
node,
|
|
@@ -525,22 +1098,73 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
525
1098
|
}
|
|
526
1099
|
}
|
|
527
1100
|
|
|
1101
|
+
// Begin the matching reshaping scope so the contextual snapping HUD shows
|
|
1102
|
+
// the right chip during the edit AND `getActiveSnapContext()` resolves the
|
|
1103
|
+
// polygon / wall mode-set the affordance's snap math reads. Torn down on
|
|
1104
|
+
// release / cancel below. `null` for resize / rotate (no snapping chip).
|
|
1105
|
+
const reshapeScope = floorplanAffordanceReshapeScope(affordance, nodeId, payload)
|
|
1106
|
+
if (reshapeScope) {
|
|
1107
|
+
useInteractionScope.getState().begin(reshapeScope)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const captureTarget = event.currentTarget as Element
|
|
528
1111
|
dragRef.current = {
|
|
529
1112
|
pointerId: event.pointerId,
|
|
1113
|
+
captureTarget,
|
|
530
1114
|
handleId,
|
|
531
1115
|
session,
|
|
532
1116
|
snapshots,
|
|
533
1117
|
historyPaused: true,
|
|
1118
|
+
lastPlanPoint: initialPlanPoint,
|
|
534
1119
|
rotation,
|
|
1120
|
+
reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
|
|
535
1121
|
}
|
|
536
1122
|
setActiveDragId(handleId)
|
|
537
1123
|
setSelection({ selectedIds: [nodeId] })
|
|
538
|
-
|
|
1124
|
+
captureTarget.setPointerCapture?.(event.pointerId)
|
|
539
1125
|
},
|
|
540
1126
|
[movingNode, setSelection],
|
|
541
1127
|
)
|
|
542
1128
|
|
|
543
1129
|
useEffect(() => {
|
|
1130
|
+
// Tear down the scope this drag opened (if any) — a reshaping scope for an
|
|
1131
|
+
// edit affordance, or a handle-drag scope for a rotate-arrow — matched by
|
|
1132
|
+
// node id so a concurrent scope from another path is never ended by mistake.
|
|
1133
|
+
const endReshapeScope = (drag: ActiveDrag) => {
|
|
1134
|
+
if (drag.reshapeScopeNodeId) {
|
|
1135
|
+
useInteractionScope
|
|
1136
|
+
.getState()
|
|
1137
|
+
.endIf(
|
|
1138
|
+
(s) =>
|
|
1139
|
+
(s.kind === 'reshaping' || s.kind === 'handle-drag') &&
|
|
1140
|
+
s.nodeId === drag.reshapeScopeNodeId,
|
|
1141
|
+
)
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
|
|
1146
|
+
cancelFloorplanAffordanceDrag(
|
|
1147
|
+
dragRef,
|
|
1148
|
+
{
|
|
1149
|
+
restoreSnapshots: (snapshots) =>
|
|
1150
|
+
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
|
|
1151
|
+
resumeHistory: () => resumeSceneHistory(useScene),
|
|
1152
|
+
clearPreview: (id) => {
|
|
1153
|
+
useLiveNodeOverrides.getState().clear(id)
|
|
1154
|
+
useLiveTransforms.getState().clear(id)
|
|
1155
|
+
},
|
|
1156
|
+
clearSnapFeedback: clearSurfacePlanSnapFeedback,
|
|
1157
|
+
endReshapeScope,
|
|
1158
|
+
clearDragFeedback: clearDragFeedback
|
|
1159
|
+
? () => {
|
|
1160
|
+
setActiveDragId(null)
|
|
1161
|
+
setRotationOverlay(null)
|
|
1162
|
+
}
|
|
1163
|
+
: undefined,
|
|
1164
|
+
},
|
|
1165
|
+
pointerId,
|
|
1166
|
+
)
|
|
1167
|
+
|
|
544
1168
|
const onPointerMove = (event: PointerEvent) => {
|
|
545
1169
|
const drag = dragRef.current
|
|
546
1170
|
if (!drag || event.pointerId !== drag.pointerId) return
|
|
@@ -548,6 +1172,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
548
1172
|
const planPoint = clientToPlan(event.clientX, event.clientY)
|
|
549
1173
|
if (!planPoint) return
|
|
550
1174
|
|
|
1175
|
+
drag.lastPlanPoint = planPoint
|
|
551
1176
|
drag.session.apply({
|
|
552
1177
|
planPoint,
|
|
553
1178
|
modifiers: {
|
|
@@ -569,6 +1194,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
569
1194
|
let delta = current - rot.initialAngle
|
|
570
1195
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
571
1196
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
1197
|
+
delta = snapDirectRotationDelta(delta, !isAngleSnapActive())
|
|
572
1198
|
if (Math.abs(delta) < 0.0087) {
|
|
573
1199
|
setRotationOverlay(null)
|
|
574
1200
|
} else {
|
|
@@ -603,14 +1229,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
603
1229
|
}
|
|
604
1230
|
drag.session.commit()
|
|
605
1231
|
sfxEmitter.emit('sfx:structure-build')
|
|
1232
|
+
clearSurfacePlanSnapFeedback()
|
|
1233
|
+
endReshapeScope(drag)
|
|
606
1234
|
dragRef.current = null
|
|
607
1235
|
setActiveDragId(null)
|
|
608
1236
|
setRotationOverlay(null)
|
|
609
1237
|
return
|
|
610
1238
|
}
|
|
611
1239
|
|
|
612
|
-
//
|
|
613
|
-
//
|
|
1240
|
+
// Legacy compatibility for sessions that still wrote preview state into
|
|
1241
|
+
// `useScene` during `apply()`: capture the final state BEFORE the revert
|
|
1242
|
+
// so we know what to re-apply post-resume. New sessions should provide a
|
|
1243
|
+
// `commit()` hook and preview through live overrides/transforms instead.
|
|
614
1244
|
const sceneNodes = useScene.getState().nodes
|
|
615
1245
|
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
|
|
616
1246
|
for (const snap of drag.snapshots) {
|
|
@@ -629,7 +1259,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
629
1259
|
}
|
|
630
1260
|
|
|
631
1261
|
if (commitValid && finalUpdates.length > 0) {
|
|
632
|
-
//
|
|
1262
|
+
// Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
|
|
633
1263
|
// 1. Revert to baseline while history is still paused (untracked).
|
|
634
1264
|
// 2. Resume history.
|
|
635
1265
|
// 3. Re-apply the final state — recorded as one tracked change.
|
|
@@ -654,209 +1284,1033 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
654
1284
|
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
655
1285
|
}
|
|
656
1286
|
|
|
1287
|
+
clearSurfacePlanSnapFeedback()
|
|
1288
|
+
endReshapeScope(drag)
|
|
657
1289
|
dragRef.current = null
|
|
658
1290
|
setActiveDragId(null)
|
|
659
1291
|
setRotationOverlay(null)
|
|
660
1292
|
}
|
|
661
1293
|
|
|
662
1294
|
const onPointerCancel = (event: PointerEvent) => {
|
|
663
|
-
|
|
664
|
-
|
|
1295
|
+
cancelActiveDrag(event.pointerId)
|
|
1296
|
+
}
|
|
665
1297
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1298
|
+
// Re-run the active session the moment a modifier key flips so behaviors
|
|
1299
|
+
// like the wall endpoint's Alt-detach / re-attach take effect immediately
|
|
1300
|
+
// instead of waiting for the next pointer move. `event.altKey` & co
|
|
1301
|
+
// already reflect the post-transition state on both keydown and keyup.
|
|
1302
|
+
const onModifierKeyChange = (event: KeyboardEvent) => {
|
|
1303
|
+
const drag = dragRef.current
|
|
1304
|
+
if (!drag || event.repeat) return
|
|
1305
|
+
if (
|
|
1306
|
+
event.key !== 'Alt' &&
|
|
1307
|
+
event.key !== 'Shift' &&
|
|
1308
|
+
event.key !== 'Control' &&
|
|
1309
|
+
event.key !== 'Meta'
|
|
1310
|
+
) {
|
|
1311
|
+
return
|
|
671
1312
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
for (const id of drag.session.affectedIds) overrides.clear(id)
|
|
682
|
-
|
|
683
|
-
dragRef.current = null
|
|
684
|
-
setActiveDragId(null)
|
|
685
|
-
setRotationOverlay(null)
|
|
1313
|
+
drag.session.apply({
|
|
1314
|
+
planPoint: drag.lastPlanPoint,
|
|
1315
|
+
modifiers: {
|
|
1316
|
+
shiftKey: event.shiftKey,
|
|
1317
|
+
altKey: event.altKey,
|
|
1318
|
+
ctrlKey: event.ctrlKey,
|
|
1319
|
+
metaKey: event.metaKey,
|
|
1320
|
+
},
|
|
1321
|
+
})
|
|
686
1322
|
}
|
|
687
1323
|
|
|
688
1324
|
window.addEventListener('pointermove', onPointerMove)
|
|
689
1325
|
window.addEventListener('pointerup', onPointerUp)
|
|
690
1326
|
window.addEventListener('pointercancel', onPointerCancel)
|
|
1327
|
+
window.addEventListener('keydown', onModifierKeyChange)
|
|
1328
|
+
window.addEventListener('keyup', onModifierKeyChange)
|
|
1329
|
+
const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
|
|
1330
|
+
() => cancelActiveDrag(),
|
|
1331
|
+
markToolCancelConsumed,
|
|
1332
|
+
)
|
|
691
1333
|
return () => {
|
|
692
1334
|
window.removeEventListener('pointermove', onPointerMove)
|
|
693
1335
|
window.removeEventListener('pointerup', onPointerUp)
|
|
694
1336
|
window.removeEventListener('pointercancel', onPointerCancel)
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1337
|
+
window.removeEventListener('keydown', onModifierKeyChange)
|
|
1338
|
+
window.removeEventListener('keyup', onModifierKeyChange)
|
|
1339
|
+
unsubscribeToolCancel()
|
|
1340
|
+
if (!cancelActiveDrag(undefined, false)) {
|
|
1341
|
+
clearSurfacePlanSnapFeedback()
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}, [])
|
|
1345
|
+
|
|
1346
|
+
const entries = floorplanData.entries
|
|
1347
|
+
if (entries.length === 0) return null
|
|
1348
|
+
|
|
1349
|
+
const palette = renderCtx?.palette
|
|
1350
|
+
|
|
1351
|
+
return (
|
|
1352
|
+
// The outer wrapper stops `click` events that escape an entry's
|
|
1353
|
+
// `onClick={handleClickStop}`. The base+overlay split means
|
|
1354
|
+
// pointer-down can land on the base `<g>` and pointer-up on the
|
|
1355
|
+
// overlay `<g>` (selection mounts the overlay on top mid-gesture).
|
|
1356
|
+
// When the down/up targets differ, the browser dispatches `click`
|
|
1357
|
+
// to the lowest common ancestor — which sits ABOVE the entry-level
|
|
1358
|
+
// handler. Without this guard the click reaches the SVG's
|
|
1359
|
+
// `handleBackgroundClick`, which calls
|
|
1360
|
+
// `resolveFloorplanBackgroundSelection` → `clear-elements` (because
|
|
1361
|
+
// registry-driven items aren't in the legacy hit-test set) →
|
|
1362
|
+
// clearing the selection that pointer-down just set, so items
|
|
1363
|
+
// appear to "deselect themselves a fraction of a second after
|
|
1364
|
+
// clicking." Scoped to `onClick` so hover / drag / pointer events
|
|
1365
|
+
// still propagate normally inside the registry tree.
|
|
1366
|
+
<g
|
|
1367
|
+
className="floorplan-registry-layer"
|
|
1368
|
+
onClick={handleClickStop}
|
|
1369
|
+
opacity={isAmbient ? 0.3 : undefined}
|
|
1370
|
+
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
|
|
1371
|
+
>
|
|
1372
|
+
{/* Base pass — rank-sorted body geometry (polygons, paths, fills,
|
|
1373
|
+
strokes, hatches). Lower-rank kinds (zones) paint first so
|
|
1374
|
+
higher-rank kinds (slabs, then walls / items / shelves) layer
|
|
1375
|
+
on top in the expected document-order z-stack. */}
|
|
1376
|
+
<g className="floorplan-registry-base">
|
|
1377
|
+
{entries.map((entry) => (
|
|
1378
|
+
<FloorplanRegistryEntry
|
|
1379
|
+
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1380
|
+
annotationVisibility={annotationVisibility}
|
|
1381
|
+
floorplanMode={floorplanMode}
|
|
1382
|
+
floorplanVisible={floorplanVisible}
|
|
1383
|
+
geometryCacheRef={geometryCacheRef}
|
|
1384
|
+
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1385
|
+
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1386
|
+
interactiveElevators={interactiveElevators}
|
|
1387
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1388
|
+
key={`base-${entry.id}`}
|
|
1389
|
+
levelDataCacheRef={levelDataCacheRef}
|
|
1390
|
+
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
1391
|
+
moving={movingNode?.id === entry.id}
|
|
1392
|
+
node={entry.node}
|
|
1393
|
+
nodeId={entry.id}
|
|
1394
|
+
nodes={nodes}
|
|
1395
|
+
onClickStop={handleClickStop}
|
|
1396
|
+
onEntryPointerDown={handleEntryPointerDown}
|
|
1397
|
+
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
|
1398
|
+
onHandleHoverChange={setHoveredHandleId}
|
|
1399
|
+
onHandleDoubleClick={commitAffordanceAction}
|
|
1400
|
+
onHandlePointerDown={startAffordanceDrag}
|
|
1401
|
+
palette={palette}
|
|
1402
|
+
pass="base"
|
|
1403
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1404
|
+
setMovingNode={setMovingNode}
|
|
1405
|
+
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1406
|
+
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1407
|
+
unit={unit}
|
|
1408
|
+
metricNotation={metricNotation}
|
|
1409
|
+
wallDimensionReference={effectiveWallDimensionReference}
|
|
1410
|
+
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1411
|
+
ctxOverrides={entry.ctxOverrides}
|
|
1412
|
+
/>
|
|
1413
|
+
))}
|
|
1414
|
+
</g>
|
|
1415
|
+
{/* Overlay pass — interactive handles (vertex / midpoint / edge /
|
|
1416
|
+
move) and labels (text / dimensions). Painted after every base
|
|
1417
|
+
entry so polygon-editor chrome on a selected slab stays above
|
|
1418
|
+
neighbouring walls, and a zone name stays readable above the
|
|
1419
|
+
slab + wall geometry sitting on top of the zone. Each overlay
|
|
1420
|
+
still routes through the same selection-handling `<g>` so a
|
|
1421
|
+
click on a zone's name selects the zone. */}
|
|
1422
|
+
<g className="floorplan-registry-overlay">
|
|
1423
|
+
{entries.map((entry) => (
|
|
1424
|
+
<FloorplanRegistryEntry
|
|
1425
|
+
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
|
1426
|
+
annotationVisibility={annotationVisibility}
|
|
1427
|
+
floorplanMode={floorplanMode}
|
|
1428
|
+
floorplanVisible={floorplanVisible}
|
|
1429
|
+
geometryCacheRef={geometryCacheRef}
|
|
1430
|
+
hatchPatternId={renderCtx?.hatchPatternId}
|
|
1431
|
+
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
|
1432
|
+
interactiveElevators={interactiveElevators}
|
|
1433
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1434
|
+
key={`overlay-${entry.id}`}
|
|
1435
|
+
levelDataCacheRef={levelDataCacheRef}
|
|
1436
|
+
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
|
1437
|
+
moving={movingNode?.id === entry.id}
|
|
1438
|
+
node={entry.node}
|
|
1439
|
+
nodeId={entry.id}
|
|
1440
|
+
nodes={nodes}
|
|
1441
|
+
onClickStop={handleClickStop}
|
|
1442
|
+
onEntryPointerDown={handleEntryPointerDown}
|
|
1443
|
+
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
|
1444
|
+
onHandleHoverChange={setHoveredHandleId}
|
|
1445
|
+
onHandleDoubleClick={commitAffordanceAction}
|
|
1446
|
+
onHandlePointerDown={startAffordanceDrag}
|
|
1447
|
+
palette={palette}
|
|
1448
|
+
pass="overlay"
|
|
1449
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1450
|
+
setMovingNode={setMovingNode}
|
|
1451
|
+
setMovingNodeOrigin={setMovingNodeOrigin}
|
|
1452
|
+
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
|
1453
|
+
unit={unit}
|
|
1454
|
+
metricNotation={metricNotation}
|
|
1455
|
+
wallDimensionReference={effectiveWallDimensionReference}
|
|
1456
|
+
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
|
1457
|
+
ctxOverrides={entry.ctxOverrides}
|
|
1458
|
+
/>
|
|
1459
|
+
))}
|
|
1460
|
+
</g>
|
|
1461
|
+
<FloorplanAnnotationLayoutResolver active={floorplanVisible} />
|
|
1462
|
+
{/* Dashed group bbox — shows what a group drag carries along while a
|
|
1463
|
+
multi-selection exists, rides the live delta mid-drag, and doubles
|
|
1464
|
+
as the group's whole-area drag handle. */}
|
|
1465
|
+
<ScaleAwareFloorplanGroupSelectionBox
|
|
1466
|
+
onPointerDown={handleGroupBoxPointerDown}
|
|
1467
|
+
onRotatePointerDown={handleGroupBoxRotatePointerDown}
|
|
1468
|
+
palette={palette}
|
|
1469
|
+
/>
|
|
1470
|
+
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
|
1471
|
+
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
|
1472
|
+
{rotationOverlay && palette ? (
|
|
1473
|
+
<RotationAngleOverlay
|
|
1474
|
+
overlay={rotationOverlay}
|
|
1475
|
+
palette={palette}
|
|
1476
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1477
|
+
/>
|
|
1478
|
+
) : null}
|
|
1479
|
+
</g>
|
|
1480
|
+
)
|
|
1481
|
+
})
|
|
1482
|
+
|
|
1483
|
+
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
|
1484
|
+
const markerRef = useRef<SVGGElement>(null)
|
|
1485
|
+
const collisionLabelElementsRef = useRef<SVGGElement[]>([])
|
|
1486
|
+
const registryLabelElementsRef = useRef<SVGGElement[]>([])
|
|
1487
|
+
const registryOrientationElementsRef = useRef<SVGGElement[]>([])
|
|
1488
|
+
const appliedRotationDegRef = useRef<number | null>(null)
|
|
1489
|
+
const appliedLayoutInputsRef = useRef<object | null>(null)
|
|
1490
|
+
const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
|
|
1491
|
+
const sceneRotationDeg = useFloorplanSceneRotation()
|
|
1492
|
+
const [settledLayoutEpoch, setSettledLayoutEpoch] = useState(0)
|
|
1493
|
+
const drawingType = useDrawingView((state) => state.drawingType)
|
|
1494
|
+
const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
|
|
1495
|
+
const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
|
|
1496
|
+
const wallDimensionReference = useFloorplanAnnotationVisibility(
|
|
1497
|
+
(state) => state.wallDimensionReference,
|
|
1498
|
+
)
|
|
1499
|
+
const floorplanMode = useFloorplanMode((state) => state.mode)
|
|
1500
|
+
const layoutInputs = useMemo(
|
|
1501
|
+
() => ({
|
|
1502
|
+
annotationVisibility,
|
|
1503
|
+
annotationLayoutOverrides,
|
|
1504
|
+
drawingType,
|
|
1505
|
+
interactionIdle,
|
|
1506
|
+
settledLayoutEpoch,
|
|
1507
|
+
wallDimensionReference,
|
|
1508
|
+
floorplanMode,
|
|
1509
|
+
}),
|
|
1510
|
+
[
|
|
1511
|
+
annotationVisibility,
|
|
1512
|
+
annotationLayoutOverrides,
|
|
1513
|
+
drawingType,
|
|
1514
|
+
interactionIdle,
|
|
1515
|
+
settledLayoutEpoch,
|
|
1516
|
+
wallDimensionReference,
|
|
1517
|
+
floorplanMode,
|
|
1518
|
+
],
|
|
1519
|
+
)
|
|
1520
|
+
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
|
|
1521
|
+
const layoutEnabled = active && interactionIdle
|
|
1522
|
+
|
|
1523
|
+
useEffect(() => {
|
|
1524
|
+
if (!layoutEnabled) return
|
|
1525
|
+
let frame: number | null = null
|
|
1526
|
+
const invalidateLayout = () => {
|
|
1527
|
+
if (frame !== null) return
|
|
1528
|
+
frame = window.requestAnimationFrame(() => {
|
|
1529
|
+
frame = null
|
|
1530
|
+
setSettledLayoutEpoch((epoch) => epoch + 1)
|
|
1531
|
+
})
|
|
1532
|
+
}
|
|
1533
|
+
const unsubscribeScene = useScene.subscribe((state, previousState) => {
|
|
1534
|
+
if (
|
|
1535
|
+
state.nodes !== previousState.nodes ||
|
|
1536
|
+
state.installedPlugins !== previousState.installedPlugins
|
|
1537
|
+
) {
|
|
1538
|
+
invalidateLayout()
|
|
1539
|
+
}
|
|
1540
|
+
})
|
|
1541
|
+
const unsubscribeTransforms = useLiveTransforms.subscribe((state, previousState) => {
|
|
1542
|
+
if (state.transforms !== previousState.transforms) invalidateLayout()
|
|
1543
|
+
})
|
|
1544
|
+
const unsubscribeOverrides = useLiveNodeOverrides.subscribe((state, previousState) => {
|
|
1545
|
+
if (state.overrides !== previousState.overrides) invalidateLayout()
|
|
1546
|
+
})
|
|
1547
|
+
const unsubscribeInteractive = useInteractive.subscribe((state, previousState) => {
|
|
1548
|
+
if (state.elevators !== previousState.elevators) invalidateLayout()
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
return () => {
|
|
1552
|
+
unsubscribeScene()
|
|
1553
|
+
unsubscribeTransforms()
|
|
1554
|
+
unsubscribeOverrides()
|
|
1555
|
+
unsubscribeInteractive()
|
|
1556
|
+
if (frame !== null) window.cancelAnimationFrame(frame)
|
|
1557
|
+
}
|
|
1558
|
+
}, [layoutEnabled])
|
|
1559
|
+
|
|
1560
|
+
useLayoutEffect(() => {
|
|
1561
|
+
// Explicit layout inputs replace the former whole-subtree MutationObserver.
|
|
1562
|
+
if (!active) {
|
|
1563
|
+
appliedLayoutInputsRef.current = null
|
|
1564
|
+
return
|
|
1565
|
+
}
|
|
1566
|
+
if (!interactionIdle) return
|
|
1567
|
+
const registryLayer = markerRef.current?.parentElement
|
|
1568
|
+
if (!registryLayer) return
|
|
1569
|
+
const update = resolveFloorplanAnnotationUpdate({
|
|
1570
|
+
layoutInputsChanged: appliedLayoutInputsRef.current !== layoutInputs,
|
|
1571
|
+
nextRotationDeg: sceneRotationDeg,
|
|
1572
|
+
previousRotationDeg: appliedRotationDegRef.current,
|
|
1573
|
+
})
|
|
1574
|
+
if (update.resolveCollisions) {
|
|
1575
|
+
const svg = markerRef.current?.ownerSVGElement
|
|
1576
|
+
if (!svg) return
|
|
1577
|
+
collisionLabelElementsRef.current = Array.from(
|
|
1578
|
+
svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
|
|
1579
|
+
)
|
|
1580
|
+
registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) =>
|
|
1581
|
+
registryLayer.contains(label),
|
|
1582
|
+
)
|
|
1583
|
+
registryOrientationElementsRef.current = Array.from(
|
|
1584
|
+
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-angle-radians]'),
|
|
1585
|
+
)
|
|
1586
|
+
}
|
|
1587
|
+
if (update.updateLabelPresentation) {
|
|
1588
|
+
updateSvgFloorplanLabelOrientations(registryOrientationElementsRef.current, sceneRotationDeg)
|
|
1589
|
+
appliedRotationDegRef.current = sceneRotationDeg
|
|
1590
|
+
}
|
|
1591
|
+
if (!update.resolveCollisions) return
|
|
1592
|
+
const svg = markerRef.current?.ownerSVGElement
|
|
1593
|
+
if (!svg) return
|
|
1594
|
+
resolveSvgAnnotationCollisions(svg, {
|
|
1595
|
+
labels: collisionLabelElementsRef.current,
|
|
1596
|
+
layoutOverrides: annotationLayoutOverrides,
|
|
1597
|
+
})
|
|
1598
|
+
appliedLayoutInputsRef.current = layoutInputs
|
|
1599
|
+
|
|
1600
|
+
for (const [index, label] of registryLabelElementsRef.current.entries()) {
|
|
1601
|
+
const id = svgAnnotationLabelId(label, index)
|
|
1602
|
+
label.dataset.floorplanAnnotationId = id
|
|
1603
|
+
label.style.pointerEvents = 'all'
|
|
1604
|
+
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
|
|
1605
|
+
}
|
|
1606
|
+
}, [active, annotationLayoutOverrides, interactionIdle, layoutInputs, sceneRotationDeg])
|
|
1607
|
+
|
|
1608
|
+
useEffect(() => {
|
|
1609
|
+
if (!layoutEnabled) return
|
|
1610
|
+
const registryLayer = markerRef.current?.parentElement
|
|
1611
|
+
if (!registryLayer) return
|
|
1612
|
+
let cleanupPointerDrag: (() => void) | null = null
|
|
1613
|
+
let cancelActivePointerDrag: (() => void) | null = null
|
|
1614
|
+
|
|
1615
|
+
const findLabel = (target: EventTarget | null): SVGGElement | null => {
|
|
1616
|
+
if (!(target instanceof Element)) return null
|
|
1617
|
+
const label = target.closest<SVGGElement>('[data-floorplan-annotation-label]')
|
|
1618
|
+
return label && registryLayer.contains(label) ? label : null
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const labelId = (label: SVGGElement): string => {
|
|
1622
|
+
const labels = registryLabelElementsRef.current
|
|
1623
|
+
return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1627
|
+
const label = findLabel(event.target)
|
|
1628
|
+
if (!(label && event.button === 0)) return
|
|
1629
|
+
const matrix = label.getScreenCTM()
|
|
1630
|
+
if (!matrix) return
|
|
1631
|
+
event.preventDefault()
|
|
1632
|
+
event.stopPropagation()
|
|
1633
|
+
cancelActivePointerDrag?.()
|
|
1634
|
+
label.style.cursor = 'grabbing'
|
|
1635
|
+
const id = labelId(label)
|
|
1636
|
+
const start = { x: event.clientX, y: event.clientY }
|
|
1637
|
+
const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? {
|
|
1638
|
+
...readFloorplanAnnotationLayoutOffset(label),
|
|
1639
|
+
pinned: true,
|
|
1640
|
+
}
|
|
1641
|
+
const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true
|
|
1642
|
+
let latest = existing
|
|
1643
|
+
let moved = false
|
|
1644
|
+
|
|
1645
|
+
const onPointerMove = (moveEvent: PointerEvent) => {
|
|
1646
|
+
if (moveEvent.pointerId !== event.pointerId) return
|
|
1647
|
+
moved = true
|
|
1648
|
+
const local = screenVectorToFloorplanAnnotationLocal(
|
|
1649
|
+
matrix,
|
|
1650
|
+
moveEvent.clientX - start.x,
|
|
1651
|
+
moveEvent.clientY - start.y,
|
|
1652
|
+
)
|
|
1653
|
+
latest = {
|
|
1654
|
+
dx: existing.dx + local.x,
|
|
1655
|
+
dy: existing.dy + local.y,
|
|
1656
|
+
pinned: true,
|
|
704
1657
|
}
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
1658
|
+
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
|
|
1659
|
+
label.setAttribute(
|
|
1660
|
+
'transform',
|
|
1661
|
+
`${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(),
|
|
1662
|
+
)
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const finishPointerDrag = (endEvent: PointerEvent) => {
|
|
1666
|
+
if (endEvent.pointerId !== event.pointerId) return
|
|
1667
|
+
cleanupPointerDrag?.()
|
|
1668
|
+
label.style.cursor = moved || wasPinned ? 'grab' : 'move'
|
|
1669
|
+
if (moved) setAnnotationLayoutOverride(id, latest)
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const cancelPointerDrag = (cancelEvent: PointerEvent) => {
|
|
1673
|
+
if (cancelEvent.pointerId !== event.pointerId) return
|
|
1674
|
+
cancelActivePointerDrag?.()
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
cancelActivePointerDrag = () => {
|
|
1678
|
+
cleanupPointerDrag?.()
|
|
1679
|
+
label.style.cursor = wasPinned ? 'grab' : 'move'
|
|
1680
|
+
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
|
|
1681
|
+
label.setAttribute(
|
|
1682
|
+
'transform',
|
|
1683
|
+
`${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(),
|
|
1684
|
+
)
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
cleanupPointerDrag = () => {
|
|
1688
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
1689
|
+
window.removeEventListener('pointerup', finishPointerDrag)
|
|
1690
|
+
window.removeEventListener('pointercancel', cancelPointerDrag)
|
|
1691
|
+
cleanupPointerDrag = null
|
|
1692
|
+
cancelActivePointerDrag = null
|
|
1693
|
+
}
|
|
1694
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
1695
|
+
window.addEventListener('pointerup', finishPointerDrag)
|
|
1696
|
+
window.addEventListener('pointercancel', cancelPointerDrag)
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const onDoubleClick = (event: MouseEvent) => {
|
|
1700
|
+
const label = findLabel(event.target)
|
|
1701
|
+
if (!label) return
|
|
1702
|
+
event.preventDefault()
|
|
1703
|
+
event.stopPropagation()
|
|
1704
|
+
label.style.cursor = 'move'
|
|
1705
|
+
setAnnotationLayoutOverride(labelId(label), null)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
registryLayer.addEventListener('pointerdown', onPointerDown)
|
|
1709
|
+
registryLayer.addEventListener('dblclick', onDoubleClick)
|
|
1710
|
+
return () => {
|
|
1711
|
+
cancelActivePointerDrag?.()
|
|
1712
|
+
registryLayer.removeEventListener('pointerdown', onPointerDown)
|
|
1713
|
+
registryLayer.removeEventListener('dblclick', onDoubleClick)
|
|
1714
|
+
for (const label of registryLabelElementsRef.current) {
|
|
1715
|
+
label.style.pointerEvents = ''
|
|
1716
|
+
label.style.cursor = ''
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}, [layoutEnabled, setAnnotationLayoutOverride])
|
|
1720
|
+
return <g pointerEvents="none" ref={markerRef} />
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) {
|
|
1724
|
+
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
|
|
1725
|
+
if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
|
|
1726
|
+
return {
|
|
1727
|
+
x: (matrix.d * dx - matrix.c * dy) / determinant,
|
|
1728
|
+
y: (-matrix.b * dx + matrix.a * dy) / determinant,
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
|
|
1733
|
+
const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
|
|
1734
|
+
const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
|
|
1735
|
+
return {
|
|
1736
|
+
dx: Number.isFinite(dx) ? dx : 0,
|
|
1737
|
+
dy: Number.isFinite(dy) ? dy : 0,
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
type FloorplanRegistryEntryProps = {
|
|
1742
|
+
activeDragId: string | null
|
|
1743
|
+
annotationVisibility: FloorplanAnnotationVisibility
|
|
1744
|
+
floorplanMode: FloorplanMode
|
|
1745
|
+
ctxOverrides: FloorplanContextOverrides | undefined
|
|
1746
|
+
floorplanVisible: boolean
|
|
1747
|
+
geometryCacheRef: { current: Map<string, CacheEntry> }
|
|
1748
|
+
hatchPatternId: string | undefined
|
|
1749
|
+
hoveredHandleId: string | null
|
|
1750
|
+
interactiveElevators: unknown
|
|
1751
|
+
isMarqueeSelectionActive: boolean
|
|
1752
|
+
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
|
|
1753
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
|
1754
|
+
moving: boolean
|
|
1755
|
+
node: AnyNode
|
|
1756
|
+
nodeId: AnyNodeId
|
|
1757
|
+
nodes: Record<string, AnyNode>
|
|
1758
|
+
onClickStop: (event: React.MouseEvent<SVGGElement>) => void
|
|
1759
|
+
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
|
|
1760
|
+
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
|
|
1761
|
+
onHandleHoverChange: (id: string | null) => void
|
|
1762
|
+
onHandleDoubleClick: (
|
|
1763
|
+
nodeId: AnyNodeId,
|
|
1764
|
+
affordance: string,
|
|
1765
|
+
payload: unknown,
|
|
1766
|
+
event: ReactMouseEvent<SVGElement>,
|
|
1767
|
+
) => void
|
|
1768
|
+
onHandlePointerDown: (
|
|
1769
|
+
nodeId: AnyNodeId,
|
|
1770
|
+
handleId: string,
|
|
1771
|
+
affordance: string,
|
|
1772
|
+
payload: unknown,
|
|
1773
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
1774
|
+
rotationPivot?: FloorplanPoint,
|
|
1775
|
+
) => void
|
|
1776
|
+
palette: FloorplanPalette | undefined
|
|
1777
|
+
pass: FloorplanRenderPass
|
|
1778
|
+
sceneRotationDeg: number
|
|
1779
|
+
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
|
|
1780
|
+
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
|
|
1781
|
+
siblingEpoch: number
|
|
1782
|
+
unit: 'metric' | 'imperial'
|
|
1783
|
+
metricNotation: 'meters' | 'millimeters'
|
|
1784
|
+
wallDimensionReference: FloorplanWallDimensionReference
|
|
1785
|
+
visibilityRootId: AnyNodeId | undefined
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
1789
|
+
activeDragId,
|
|
1790
|
+
annotationVisibility,
|
|
1791
|
+
floorplanMode,
|
|
1792
|
+
ctxOverrides,
|
|
1793
|
+
floorplanVisible,
|
|
1794
|
+
geometryCacheRef,
|
|
1795
|
+
hatchPatternId,
|
|
1796
|
+
hoveredHandleId,
|
|
1797
|
+
interactiveElevators,
|
|
1798
|
+
isMarqueeSelectionActive,
|
|
1799
|
+
levelDataCacheRef,
|
|
1800
|
+
levelNodeIdsByType,
|
|
1801
|
+
moving,
|
|
1802
|
+
node,
|
|
1803
|
+
nodeId,
|
|
1804
|
+
nodes,
|
|
1805
|
+
onClickStop,
|
|
1806
|
+
onEntryPointerDown,
|
|
1807
|
+
onGroupMovePointerDown,
|
|
1808
|
+
onHandleHoverChange,
|
|
1809
|
+
onHandleDoubleClick,
|
|
1810
|
+
onHandlePointerDown,
|
|
1811
|
+
palette,
|
|
1812
|
+
pass,
|
|
1813
|
+
sceneRotationDeg,
|
|
1814
|
+
setMovingNode,
|
|
1815
|
+
setMovingNodeOrigin,
|
|
1816
|
+
siblingEpoch,
|
|
1817
|
+
unit,
|
|
1818
|
+
metricNotation,
|
|
1819
|
+
wallDimensionReference,
|
|
1820
|
+
visibilityRootId,
|
|
1821
|
+
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
|
1822
|
+
const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
1823
|
+
const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
|
|
1824
|
+
const suppressHandles = useViewer(
|
|
1825
|
+
(state) =>
|
|
1826
|
+
state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
|
|
1827
|
+
)
|
|
1828
|
+
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
1829
|
+
const selectionProxyId = resolveSelectionProxyId(
|
|
1830
|
+
node,
|
|
1831
|
+
nodes as Record<string, AnyNode | undefined>,
|
|
1832
|
+
)
|
|
1833
|
+
const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
|
|
1834
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
1835
|
+
const referencedAnnotationRole = useViewer((state) =>
|
|
1836
|
+
floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
|
|
1837
|
+
)
|
|
1838
|
+
const activeRotateNodeId = useDirectManipulationFeedback((state) =>
|
|
1839
|
+
state.activeRotateNodeId === nodeId ? nodeId : null,
|
|
1840
|
+
)
|
|
1841
|
+
const groupMoveCursor =
|
|
1842
|
+
suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
|
|
1843
|
+
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
|
|
1844
|
+
const liveOverride = useLiveNodeOverrides((s) =>
|
|
1845
|
+
floorplanVisible ? s.overrides.get(nodeId) : undefined,
|
|
1846
|
+
)
|
|
1847
|
+
const liveOverrides = floorplanVisible
|
|
1848
|
+
? useLiveNodeOverrides.getState().overrides
|
|
1849
|
+
: EMPTY_LIVE_OVERRIDES
|
|
1850
|
+
const presentationVisibility = resolveFloorplanAnnotationVisibility(
|
|
1851
|
+
floorplanMode,
|
|
1852
|
+
annotationVisibility,
|
|
1853
|
+
{
|
|
1854
|
+
referencedAnnotationRole,
|
|
1855
|
+
selected,
|
|
1856
|
+
target: 'editor',
|
|
1857
|
+
},
|
|
1858
|
+
)
|
|
1859
|
+
|
|
1860
|
+
const handlePointerDown = useCallback(
|
|
1861
|
+
(event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
|
|
1862
|
+
[nodeId, onEntryPointerDown],
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
// Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
|
|
1866
|
+
// the highlight halo in 3D as well as registry floor-plan hover strokes.
|
|
1867
|
+
const handlePointerEnter = useCallback(() => {
|
|
1868
|
+
const currentNode = useScene.getState().nodes[nodeId]
|
|
1869
|
+
setHoveredId(
|
|
1870
|
+
currentNode
|
|
1871
|
+
? resolveSelectionProxyId(
|
|
1872
|
+
currentNode,
|
|
1873
|
+
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1874
|
+
)
|
|
1875
|
+
: nodeId,
|
|
1876
|
+
)
|
|
1877
|
+
}, [nodeId, setHoveredId])
|
|
1878
|
+
|
|
1879
|
+
const handlePointerLeave = useCallback(() => {
|
|
1880
|
+
const node = useScene.getState().nodes[nodeId]
|
|
1881
|
+
const targetId = node
|
|
1882
|
+
? resolveSelectionProxyId(
|
|
1883
|
+
node,
|
|
1884
|
+
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
|
1885
|
+
)
|
|
1886
|
+
: nodeId
|
|
1887
|
+
if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
|
|
1888
|
+
}, [nodeId, setHoveredId])
|
|
1889
|
+
|
|
1890
|
+
const handleHandlePointerDown = useCallback(
|
|
1891
|
+
(
|
|
1892
|
+
affordance: string,
|
|
1893
|
+
payload: unknown,
|
|
1894
|
+
event: ReactPointerEvent<SVGGElement>,
|
|
1895
|
+
rotationPivot?: FloorplanPoint,
|
|
1896
|
+
) => {
|
|
1897
|
+
onHandlePointerDown(
|
|
1898
|
+
nodeId,
|
|
1899
|
+
makeHandleId(nodeId, payload),
|
|
1900
|
+
affordance,
|
|
1901
|
+
payload,
|
|
1902
|
+
event,
|
|
1903
|
+
rotationPivot,
|
|
1904
|
+
)
|
|
1905
|
+
},
|
|
1906
|
+
[nodeId, onHandlePointerDown],
|
|
1907
|
+
)
|
|
1908
|
+
|
|
1909
|
+
const handleHandleDoubleClick = useCallback(
|
|
1910
|
+
(affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
|
|
1911
|
+
onHandleDoubleClick(nodeId, affordance, payload, event)
|
|
1912
|
+
},
|
|
1913
|
+
[nodeId, onHandleDoubleClick],
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
const handleMoveHandlePointerDown = useCallback(
|
|
1917
|
+
(event: ReactPointerEvent<SVGGElement>) => {
|
|
1918
|
+
if (event.button !== 0) return
|
|
1919
|
+
const currentNode = useScene.getState().nodes[nodeId]
|
|
1920
|
+
if (!currentNode) return
|
|
1921
|
+
event.preventDefault()
|
|
1922
|
+
event.stopPropagation()
|
|
1923
|
+
suppressBoxSelectForPointer(event)
|
|
1924
|
+
// In a multi-selection the move dot drives the group session, matching
|
|
1925
|
+
// the body-drag gesture — the whole selection slides, not one member.
|
|
1926
|
+
if (onGroupMovePointerDown(nodeId, event)) return
|
|
1927
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
1928
|
+
setMovingNode(currentNode as never)
|
|
1929
|
+
// Claim 2D ownership of this move at the source. `setMovingNode`
|
|
1930
|
+
// resets the origin to null, so this must follow it.
|
|
1931
|
+
setMovingNodeOrigin('2d')
|
|
1932
|
+
},
|
|
1933
|
+
[nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
|
|
1934
|
+
)
|
|
1935
|
+
|
|
1936
|
+
const cacheEntry = buildFloorplanEntryGeometry({
|
|
1937
|
+
automaticDimensions: presentationVisibility.automaticDimensions,
|
|
1938
|
+
ctxOverrides,
|
|
1939
|
+
geometryCache: geometryCacheRef.current,
|
|
1940
|
+
highlighted,
|
|
1941
|
+
hovered,
|
|
1942
|
+
interactiveElevators,
|
|
1943
|
+
levelDataCache: levelDataCacheRef.current,
|
|
1944
|
+
levelNodeIdsByType,
|
|
1945
|
+
live,
|
|
1946
|
+
liveOverride,
|
|
1947
|
+
liveOverrides,
|
|
1948
|
+
moving,
|
|
1949
|
+
node,
|
|
1950
|
+
nodeId,
|
|
1951
|
+
nodes,
|
|
1952
|
+
palette,
|
|
1953
|
+
selected,
|
|
1954
|
+
siblingEpoch,
|
|
1955
|
+
unit,
|
|
1956
|
+
metricNotation,
|
|
1957
|
+
wallDimensionReference,
|
|
1958
|
+
visibilityRootId,
|
|
1959
|
+
})
|
|
1960
|
+
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
|
|
1961
|
+
const visibleGeometry = rawGeometry
|
|
1962
|
+
? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
|
|
1963
|
+
: null
|
|
1964
|
+
// Multi-selection shows highlight only: strip this member's edit handles /
|
|
1965
|
+
// dimension chrome (all of which live in the overlay pass) while keeping
|
|
1966
|
+
// its highlighted body geometry.
|
|
1967
|
+
const geometry =
|
|
1968
|
+
visibleGeometry && suppressHandles && pass === 'overlay'
|
|
1969
|
+
? stripHandleChrome(visibleGeometry)
|
|
1970
|
+
: visibleGeometry
|
|
1971
|
+
if (!geometry) return null
|
|
1972
|
+
|
|
1973
|
+
const entryClick = isMarqueeSelectionActive ? undefined : onClickStop
|
|
1974
|
+
const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown
|
|
1975
|
+
|
|
1976
|
+
return (
|
|
1977
|
+
<g
|
|
1978
|
+
className="floorplan-registry-entry"
|
|
1979
|
+
data-node-id={nodeId}
|
|
1980
|
+
onClick={entryClick}
|
|
1981
|
+
onPointerDown={entryPointerDown}
|
|
1982
|
+
onPointerEnter={handlePointerEnter}
|
|
1983
|
+
onPointerLeave={handlePointerLeave}
|
|
1984
|
+
style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
|
|
1985
|
+
>
|
|
1986
|
+
<InteractiveGeometry
|
|
1987
|
+
activeDragId={activeDragId}
|
|
1988
|
+
activeRotateNodeId={activeRotateNodeId}
|
|
1989
|
+
geometry={geometry}
|
|
1990
|
+
hatchPatternId={hatchPatternId}
|
|
1991
|
+
hoveredHandleId={hoveredHandleId}
|
|
1992
|
+
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
1993
|
+
nodeId={nodeId}
|
|
1994
|
+
onHandleDoubleClick={handleHandleDoubleClick}
|
|
1995
|
+
onHandleHoverChange={onHandleHoverChange}
|
|
1996
|
+
onHandlePointerDown={handleHandlePointerDown}
|
|
1997
|
+
onMoveHandlePointerDown={handleMoveHandlePointerDown}
|
|
1998
|
+
palette={palette}
|
|
1999
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
2000
|
+
/>
|
|
2001
|
+
</g>
|
|
2002
|
+
)
|
|
2003
|
+
}, shallowPropsAreEqual)
|
|
2004
|
+
|
|
2005
|
+
type BuildFloorplanEntryGeometryArgs = {
|
|
2006
|
+
automaticDimensions: boolean
|
|
2007
|
+
ctxOverrides: FloorplanContextOverrides | undefined
|
|
2008
|
+
geometryCache: Map<string, CacheEntry>
|
|
2009
|
+
highlighted: boolean
|
|
2010
|
+
hovered: boolean
|
|
2011
|
+
interactiveElevators: unknown
|
|
2012
|
+
levelDataCache: Map<string, LevelDataCacheEntry>
|
|
2013
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
|
2014
|
+
live: LiveTransform | undefined
|
|
2015
|
+
liveOverride: LiveNodeOverrides | undefined
|
|
2016
|
+
liveOverrides: Map<string, LiveNodeOverrides>
|
|
2017
|
+
moving: boolean
|
|
2018
|
+
node: AnyNode
|
|
2019
|
+
nodeId: AnyNodeId
|
|
2020
|
+
nodes: Record<string, AnyNode>
|
|
2021
|
+
palette: FloorplanPalette | undefined
|
|
2022
|
+
selected: boolean
|
|
2023
|
+
siblingEpoch: number
|
|
2024
|
+
unit: 'metric' | 'imperial'
|
|
2025
|
+
metricNotation: 'meters' | 'millimeters'
|
|
2026
|
+
wallDimensionReference: FloorplanWallDimensionReference
|
|
2027
|
+
visibilityRootId: AnyNodeId | undefined
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
export function collectFloorplanDependencyNodes(
|
|
2031
|
+
def: AnyNodeDefinition,
|
|
2032
|
+
node: AnyNode,
|
|
2033
|
+
nodes: Record<string, AnyNode>,
|
|
2034
|
+
liveOverrides?: Map<string, LiveNodeOverrides>,
|
|
2035
|
+
): AnyNode[] {
|
|
2036
|
+
return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
|
|
2037
|
+
const dependency = nodes[id]
|
|
2038
|
+
if (!dependency) return []
|
|
2039
|
+
const dependencyOverride = liveOverrides?.get(dependency.id)
|
|
2040
|
+
const effectiveDependency = dependencyOverride
|
|
2041
|
+
? ({ ...dependency, ...dependencyOverride } as AnyNode)
|
|
2042
|
+
: dependency
|
|
2043
|
+
const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
|
|
2044
|
+
if (!parent) return [effectiveDependency]
|
|
2045
|
+
const parentOverride = liveOverrides?.get(parent.id)
|
|
2046
|
+
const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
|
|
2047
|
+
return [effectiveDependency, effectiveParent]
|
|
2048
|
+
})
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function floorplanEntryReferencedAnnotationRole(
|
|
2052
|
+
node: AnyNode,
|
|
2053
|
+
selectedIds: ReadonlySet<string>,
|
|
2054
|
+
): FloorplanAnnotationRole | undefined {
|
|
2055
|
+
if (selectedIds.size === 0) return undefined
|
|
2056
|
+
const definition = nodeRegistry.get(node.type)
|
|
2057
|
+
const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
|
|
2058
|
+
if (!role) return undefined
|
|
2059
|
+
const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
|
|
2060
|
+
return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function buildFloorplanEntryGeometry({
|
|
2064
|
+
automaticDimensions,
|
|
2065
|
+
ctxOverrides,
|
|
2066
|
+
geometryCache,
|
|
2067
|
+
highlighted,
|
|
2068
|
+
hovered,
|
|
2069
|
+
interactiveElevators,
|
|
2070
|
+
levelDataCache,
|
|
2071
|
+
levelNodeIdsByType,
|
|
2072
|
+
live,
|
|
2073
|
+
liveOverride,
|
|
2074
|
+
liveOverrides,
|
|
2075
|
+
moving,
|
|
2076
|
+
node,
|
|
2077
|
+
nodeId,
|
|
2078
|
+
nodes,
|
|
2079
|
+
palette,
|
|
2080
|
+
selected,
|
|
2081
|
+
siblingEpoch,
|
|
2082
|
+
unit,
|
|
2083
|
+
metricNotation,
|
|
2084
|
+
wallDimensionReference,
|
|
2085
|
+
visibilityRootId,
|
|
2086
|
+
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
|
|
2087
|
+
const def = nodeRegistry.get(node.type)
|
|
2088
|
+
const builder = def?.floorplan
|
|
2089
|
+
if (!builder) return null
|
|
2090
|
+
|
|
2091
|
+
const visible = visibilityRootId
|
|
2092
|
+
? isFloorplanHierarchyVisible(node, nodes, liveOverrides, visibilityRootId)
|
|
2093
|
+
: isFloorplanNodeVisible(node, liveOverride)
|
|
2094
|
+
if (!visible) {
|
|
2095
|
+
geometryCache.delete(nodeId)
|
|
2096
|
+
return null
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const dependsOnSiblingInputs = !!(
|
|
2100
|
+
def.floorplanDependsOnSiblings ||
|
|
2101
|
+
def.floorplanSiblingOverrides ||
|
|
2102
|
+
def.floorplanAffectedIds
|
|
2103
|
+
)
|
|
2104
|
+
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
|
|
2105
|
+
const deps: NodeDeps = {
|
|
2106
|
+
automaticDimensions,
|
|
2107
|
+
node,
|
|
2108
|
+
live,
|
|
2109
|
+
unit,
|
|
2110
|
+
metricNotation,
|
|
2111
|
+
wallDimensionReference,
|
|
2112
|
+
selected,
|
|
2113
|
+
highlighted,
|
|
2114
|
+
hovered,
|
|
2115
|
+
moving,
|
|
2116
|
+
liveOverride,
|
|
2117
|
+
palette,
|
|
2118
|
+
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
|
|
2119
|
+
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
|
2120
|
+
// committed state via `ctx`, so committed sibling edits still invalidate.
|
|
2121
|
+
committedNodes: dependsOnSiblingInputs ? nodes : null,
|
|
2122
|
+
dependencyNodes,
|
|
2123
|
+
interactiveElevators,
|
|
2124
|
+
}
|
|
2125
|
+
const cached = geometryCache.get(nodeId)
|
|
2126
|
+
if (cached && nodeDepsEqual(cached.deps, deps)) return cached
|
|
2127
|
+
|
|
2128
|
+
const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
|
|
2129
|
+
if (!live) return sourceNode
|
|
2130
|
+
const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
|
|
2131
|
+
const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable
|
|
2132
|
+
?.parentFrame?.floorplanLiveTransform
|
|
2133
|
+
if (parentFrameProjection) {
|
|
2134
|
+
return parentFrameProjection({ node: sourceNode, live })
|
|
2135
|
+
}
|
|
2136
|
+
if (sourceNode.type === 'door' || sourceNode.type === 'window') {
|
|
2137
|
+
const r = (sourceNode as { rotation?: unknown }).rotation
|
|
2138
|
+
return {
|
|
2139
|
+
...sourceNode,
|
|
2140
|
+
position: live.position,
|
|
2141
|
+
rotation: Array.isArray(r)
|
|
2142
|
+
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
|
2143
|
+
: r,
|
|
2144
|
+
} as AnyNode
|
|
2145
|
+
}
|
|
2146
|
+
if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
|
|
2147
|
+
return applyPositionLiveTransform(sourceNode, live)
|
|
2148
|
+
}
|
|
2149
|
+
if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') {
|
|
2150
|
+
const dx = live.position[0]
|
|
2151
|
+
const dz = live.position[2]
|
|
2152
|
+
if (dx === 0 && dz === 0) return sourceNode
|
|
2153
|
+
const surface = sourceNode as {
|
|
2154
|
+
polygon: Array<[number, number]>
|
|
2155
|
+
holes?: Array<Array<[number, number]>>
|
|
2156
|
+
}
|
|
2157
|
+
return {
|
|
2158
|
+
...sourceNode,
|
|
2159
|
+
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
2160
|
+
holes: (surface.holes ?? []).map((h) =>
|
|
2161
|
+
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
2162
|
+
),
|
|
2163
|
+
} as AnyNode
|
|
2164
|
+
}
|
|
2165
|
+
return sourceNode
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const contextNodes = def.floorplanSiblingOverrides
|
|
2169
|
+
? def.floorplanSiblingOverrides({
|
|
2170
|
+
nodeId,
|
|
2171
|
+
nodes,
|
|
2172
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
2173
|
+
liveOverrides,
|
|
2174
|
+
})
|
|
2175
|
+
: nodes
|
|
2176
|
+
const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node
|
|
2177
|
+
const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode
|
|
2178
|
+
const effectiveNode = applyLiveTransform(overrideNode)
|
|
2179
|
+
const levelData = getFloorplanLevelData(
|
|
2180
|
+
node.type,
|
|
2181
|
+
nodes,
|
|
2182
|
+
liveOverrides,
|
|
2183
|
+
levelNodeIdsByType,
|
|
2184
|
+
levelDataCache,
|
|
2185
|
+
)
|
|
2186
|
+
const viewState = {
|
|
2187
|
+
automaticDimensions,
|
|
2188
|
+
selected,
|
|
2189
|
+
unit,
|
|
2190
|
+
metricNotation,
|
|
2191
|
+
wallDimensionReference,
|
|
2192
|
+
highlighted,
|
|
2193
|
+
hovered,
|
|
2194
|
+
moving,
|
|
2195
|
+
palette,
|
|
2196
|
+
}
|
|
2197
|
+
const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
|
|
2198
|
+
const contextNode = contextNodes[rid]
|
|
2199
|
+
if (!contextNode) return undefined
|
|
2200
|
+
const contextOverride = liveOverrides.get(contextNode.id)
|
|
2201
|
+
return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
|
|
2202
|
+
}
|
|
2203
|
+
const ctx: GeometryContext = ctxOverrides
|
|
2204
|
+
? {
|
|
2205
|
+
resolve: resolveContextNode,
|
|
2206
|
+
children: ctxOverrides.children,
|
|
2207
|
+
siblings: ctxOverrides.siblings,
|
|
2208
|
+
parent: ctxOverrides.parent,
|
|
2209
|
+
levelData,
|
|
2210
|
+
extensions: createFloorplanContextExtensions({
|
|
2211
|
+
automaticDimensions,
|
|
2212
|
+
metricNotation,
|
|
2213
|
+
purpose: 'edit',
|
|
2214
|
+
wallDimensionReference,
|
|
2215
|
+
}),
|
|
2216
|
+
viewState: palette
|
|
2217
|
+
? {
|
|
2218
|
+
selected,
|
|
2219
|
+
unit,
|
|
2220
|
+
highlighted,
|
|
2221
|
+
hovered,
|
|
2222
|
+
moving,
|
|
2223
|
+
palette,
|
|
2224
|
+
}
|
|
2225
|
+
: undefined,
|
|
708
2226
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}, [])
|
|
713
|
-
|
|
714
|
-
if (entries.length === 0) return null
|
|
715
|
-
|
|
716
|
-
const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
|
|
717
|
-
const palette = renderCtx?.palette
|
|
718
|
-
|
|
719
|
-
const renderEntry = (id: AnyNodeId, geometry: FloorplanGeometry, key: string) => (
|
|
720
|
-
<g
|
|
721
|
-
className="floorplan-registry-entry"
|
|
722
|
-
data-node-id={id}
|
|
723
|
-
key={key}
|
|
724
|
-
onClick={isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handleClickStop}
|
|
725
|
-
onPointerDown={
|
|
726
|
-
isOpeningPlacementActive || isMarqueeSelectionActive
|
|
727
|
-
? undefined
|
|
728
|
-
: (e) => handleSelect(id, e)
|
|
2227
|
+
: {
|
|
2228
|
+
...buildContext(effectiveNode, contextNodes, viewState, levelData),
|
|
2229
|
+
resolve: resolveContextNode,
|
|
729
2230
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
// pointer-enter and clearing on leave keeps the two views in
|
|
734
|
-
// sync. Without this the registry-driven kinds had hover
|
|
735
|
-
// visuals defined but never reached because the entry `<g>`
|
|
736
|
-
// never updated the store.
|
|
737
|
-
onPointerEnter={() => setHoveredId(id)}
|
|
738
|
-
onPointerLeave={() => {
|
|
739
|
-
// Only clear when this entry is the one we last set —
|
|
740
|
-
// avoids racing with sibling entries during fast-moving
|
|
741
|
-
// pointer scans.
|
|
742
|
-
if (useViewer.getState().hoveredId === id) setHoveredId(null)
|
|
743
|
-
}}
|
|
744
|
-
style={{ cursor: 'pointer' }}
|
|
745
|
-
>
|
|
746
|
-
<InteractiveGeometry
|
|
747
|
-
activeDragId={activeDragId}
|
|
748
|
-
geometry={geometry}
|
|
749
|
-
hatchPatternId={renderCtx?.hatchPatternId}
|
|
750
|
-
hoveredHandleId={hoveredHandleId}
|
|
751
|
-
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
|
752
|
-
nodeId={id}
|
|
753
|
-
onHandleHoverChange={setHoveredHandleId}
|
|
754
|
-
onHandlePointerDown={(affordance, payload, event, rotationPivot) =>
|
|
755
|
-
startAffordanceDrag(
|
|
756
|
-
id,
|
|
757
|
-
makeHandleId(id, payload),
|
|
758
|
-
affordance,
|
|
759
|
-
payload,
|
|
760
|
-
event,
|
|
761
|
-
rotationPivot,
|
|
762
|
-
)
|
|
763
|
-
}
|
|
764
|
-
onMoveHandlePointerDown={(event) => {
|
|
765
|
-
if (event.button !== 0) return
|
|
766
|
-
const node = useScene.getState().nodes[id]
|
|
767
|
-
if (!node) return
|
|
768
|
-
event.preventDefault()
|
|
769
|
-
event.stopPropagation()
|
|
770
|
-
sfxEmitter.emit('sfx:item-pick')
|
|
771
|
-
setMovingNode(node as never)
|
|
772
|
-
}}
|
|
773
|
-
palette={palette}
|
|
774
|
-
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
775
|
-
unitsPerPixel={unitsPerPixel}
|
|
776
|
-
/>
|
|
777
|
-
</g>
|
|
2231
|
+
const modelGeometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
2232
|
+
effectiveNode,
|
|
2233
|
+
ctx,
|
|
778
2234
|
)
|
|
2235
|
+
const contextualGeometry = selected
|
|
2236
|
+
? (getFloorplanNodeExtension(def)?.contextualDimensions?.(effectiveNode, ctx) ?? null)
|
|
2237
|
+
: null
|
|
2238
|
+
const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, {
|
|
2239
|
+
annotationRole: 'contextual-dimension',
|
|
2240
|
+
})
|
|
2241
|
+
const geometry =
|
|
2242
|
+
modelGeometry && taggedContextualGeometry
|
|
2243
|
+
? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] }
|
|
2244
|
+
: (modelGeometry ?? taggedContextualGeometry)
|
|
2245
|
+
const { base, overlay } = geometry
|
|
2246
|
+
? splitFloorplanOverlay(geometry)
|
|
2247
|
+
: { base: null, overlay: null }
|
|
2248
|
+
const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
|
|
2249
|
+
geometryCache.set(nodeId, entry)
|
|
2250
|
+
return entry
|
|
2251
|
+
}
|
|
779
2252
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
|
822
|
-
{rotationOverlay && palette ? (
|
|
823
|
-
<RotationAngleOverlay
|
|
824
|
-
overlay={rotationOverlay}
|
|
825
|
-
palette={palette}
|
|
826
|
-
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
|
827
|
-
unitsPerPixel={unitsPerPixel}
|
|
828
|
-
/>
|
|
829
|
-
) : null}
|
|
830
|
-
</g>
|
|
831
|
-
)
|
|
832
|
-
})
|
|
2253
|
+
export function getFloorplanLevelData(
|
|
2254
|
+
type: string,
|
|
2255
|
+
nodes: Record<string, AnyNode>,
|
|
2256
|
+
liveOverrides: Map<string, LiveNodeOverrides>,
|
|
2257
|
+
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>,
|
|
2258
|
+
levelDataCache: Map<string, LevelDataCacheEntry>,
|
|
2259
|
+
): unknown {
|
|
2260
|
+
const def = nodeRegistry.get(type)
|
|
2261
|
+
if (!def?.computeFloorplanLevelData) return undefined
|
|
2262
|
+
const ids = levelNodeIdsByType.get(type)
|
|
2263
|
+
const sampleId = ids?.[0]
|
|
2264
|
+
if (!ids || !sampleId) return undefined
|
|
2265
|
+
|
|
2266
|
+
const cached = levelDataCache.get(type)
|
|
2267
|
+
if (
|
|
2268
|
+
cached &&
|
|
2269
|
+
cached.nodes === nodes &&
|
|
2270
|
+
cached.liveOverrides === liveOverrides &&
|
|
2271
|
+
cached.ids === ids
|
|
2272
|
+
) {
|
|
2273
|
+
return cached.value
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
|
|
2277
|
+
const contextNodes = def.floorplanSiblingOverrides
|
|
2278
|
+
? def.floorplanSiblingOverrides({
|
|
2279
|
+
nodeId: sampleId,
|
|
2280
|
+
nodes,
|
|
2281
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
2282
|
+
liveOverrides,
|
|
2283
|
+
})
|
|
2284
|
+
: nodes
|
|
2285
|
+
const siblings: AnyNode[] = []
|
|
2286
|
+
for (const id of ids) {
|
|
2287
|
+
const sibling = contextNodes[id]
|
|
2288
|
+
if (sibling?.type === type) siblings.push(sibling)
|
|
2289
|
+
}
|
|
2290
|
+
const value = computeLevelData({ siblings, nodes: contextNodes })
|
|
2291
|
+
levelDataCache.set(type, { nodes, liveOverrides, ids, value })
|
|
2292
|
+
return value
|
|
2293
|
+
}
|
|
833
2294
|
|
|
834
2295
|
// ── Interactive geometry walker ──────────────────────────────────────
|
|
835
2296
|
|
|
836
|
-
|
|
837
|
-
geometry,
|
|
838
|
-
unitsPerPixel,
|
|
839
|
-
palette,
|
|
840
|
-
hatchPatternId,
|
|
841
|
-
hoveredHandleId,
|
|
842
|
-
activeDragId,
|
|
843
|
-
isMarqueeSelectionActive,
|
|
844
|
-
nodeId,
|
|
845
|
-
sceneRotationDeg,
|
|
846
|
-
onHandleHoverChange,
|
|
847
|
-
onHandlePointerDown,
|
|
848
|
-
onMoveHandlePointerDown,
|
|
849
|
-
}: {
|
|
2297
|
+
type InteractiveGeometryProps = {
|
|
850
2298
|
geometry: FloorplanGeometry
|
|
851
|
-
unitsPerPixel
|
|
2299
|
+
unitsPerPixel?: number
|
|
852
2300
|
palette: FloorplanPalette | undefined
|
|
853
2301
|
hatchPatternId: string | undefined
|
|
854
2302
|
hoveredHandleId: string | null
|
|
855
2303
|
activeDragId: string | null
|
|
2304
|
+
activeRotateNodeId: AnyNodeId | null
|
|
856
2305
|
isMarqueeSelectionActive: boolean
|
|
857
2306
|
nodeId: AnyNodeId
|
|
858
2307
|
sceneRotationDeg: number
|
|
859
2308
|
onHandleHoverChange: (id: string | null) => void
|
|
2309
|
+
onHandleDoubleClick: (
|
|
2310
|
+
affordance: string,
|
|
2311
|
+
payload: unknown,
|
|
2312
|
+
event: ReactMouseEvent<SVGElement>,
|
|
2313
|
+
) => void
|
|
860
2314
|
onHandlePointerDown: (
|
|
861
2315
|
affordance: string,
|
|
862
2316
|
payload: unknown,
|
|
@@ -866,7 +2320,30 @@ function InteractiveGeometry({
|
|
|
866
2320
|
rotationPivot?: FloorplanPoint,
|
|
867
2321
|
) => void
|
|
868
2322
|
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
|
|
869
|
-
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
export const InteractiveGeometry = memo(function InteractiveGeometry({
|
|
2326
|
+
geometry,
|
|
2327
|
+
unitsPerPixel: unitsPerPixelOverride,
|
|
2328
|
+
palette,
|
|
2329
|
+
hatchPatternId,
|
|
2330
|
+
hoveredHandleId,
|
|
2331
|
+
activeDragId,
|
|
2332
|
+
activeRotateNodeId,
|
|
2333
|
+
isMarqueeSelectionActive,
|
|
2334
|
+
nodeId,
|
|
2335
|
+
sceneRotationDeg,
|
|
2336
|
+
onHandleDoubleClick,
|
|
2337
|
+
onHandleHoverChange,
|
|
2338
|
+
onHandlePointerDown,
|
|
2339
|
+
onMoveHandlePointerDown,
|
|
2340
|
+
}: InteractiveGeometryProps): React.ReactElement {
|
|
2341
|
+
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
|
2342
|
+
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
|
|
2343
|
+
// Keep handles pixel-sized through normal navigation, then let them shrink
|
|
2344
|
+
// with the plan once zoom compensation would make them dominate the geometry.
|
|
2345
|
+
const handleUnitsPerPixel = resolveFloorplanHandleUnitsPerPixel(unitsPerPixel)
|
|
2346
|
+
|
|
870
2347
|
return renderInteractive(geometry, 0)
|
|
871
2348
|
|
|
872
2349
|
function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
|
|
@@ -874,7 +2351,14 @@ function InteractiveGeometry({
|
|
|
874
2351
|
case 'group': {
|
|
875
2352
|
const transform = formatGroupTransform(g.transform)
|
|
876
2353
|
return (
|
|
877
|
-
<g
|
|
2354
|
+
<g
|
|
2355
|
+
data-floorplan-annotation-obstacle={
|
|
2356
|
+
floorplanAnnotationObstacleMode(g) ??
|
|
2357
|
+
(isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined)
|
|
2358
|
+
}
|
|
2359
|
+
key={keyHint}
|
|
2360
|
+
transform={transform}
|
|
2361
|
+
>
|
|
878
2362
|
{g.children.map((child, i) => renderInteractive(child, i))}
|
|
879
2363
|
</g>
|
|
880
2364
|
)
|
|
@@ -911,6 +2395,7 @@ function InteractiveGeometry({
|
|
|
911
2395
|
case 'endpoint-handle': {
|
|
912
2396
|
if (!palette) return <></>
|
|
913
2397
|
const handleId = makeHandleId(nodeId, g.payload)
|
|
2398
|
+
const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
|
|
914
2399
|
const isHovered = hoveredHandleId === handleId
|
|
915
2400
|
const isActive = activeDragId === handleId
|
|
916
2401
|
// Variant picks the colour-set. Endpoint dots use the orange
|
|
@@ -934,10 +2419,10 @@ function InteractiveGeometry({
|
|
|
934
2419
|
: palette.endpointHandleFill
|
|
935
2420
|
const outerRadius =
|
|
936
2421
|
(isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
|
|
937
|
-
|
|
2422
|
+
handleUnitsPerPixel
|
|
938
2423
|
const dotRadius =
|
|
939
2424
|
(isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
|
|
940
|
-
|
|
2425
|
+
handleUnitsPerPixel
|
|
941
2426
|
return (
|
|
942
2427
|
<g
|
|
943
2428
|
key={keyHint}
|
|
@@ -953,7 +2438,7 @@ function InteractiveGeometry({
|
|
|
953
2438
|
r={outerRadius}
|
|
954
2439
|
stroke={hoverStroke}
|
|
955
2440
|
strokeOpacity={isActive ? 0.24 : 0.16}
|
|
956
|
-
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX *
|
|
2441
|
+
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
|
957
2442
|
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
958
2443
|
vectorEffect="non-scaling-stroke"
|
|
959
2444
|
/>
|
|
@@ -965,7 +2450,7 @@ function InteractiveGeometry({
|
|
|
965
2450
|
r={outerRadius}
|
|
966
2451
|
stroke={hoverStroke}
|
|
967
2452
|
strokeOpacity={isActive ? 0.72 : 0.52}
|
|
968
|
-
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX *
|
|
2453
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
|
969
2454
|
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
970
2455
|
vectorEffect="non-scaling-stroke"
|
|
971
2456
|
/>
|
|
@@ -992,6 +2477,15 @@ function InteractiveGeometry({
|
|
|
992
2477
|
cx={g.point[0]}
|
|
993
2478
|
cy={g.point[1]}
|
|
994
2479
|
fill="transparent"
|
|
2480
|
+
onDoubleClick={
|
|
2481
|
+
doubleClickAffordance
|
|
2482
|
+
? (event) => {
|
|
2483
|
+
event.preventDefault()
|
|
2484
|
+
event.stopPropagation()
|
|
2485
|
+
onHandleDoubleClick(doubleClickAffordance, g.payload, event)
|
|
2486
|
+
}
|
|
2487
|
+
: undefined
|
|
2488
|
+
}
|
|
995
2489
|
onPointerDown={(e) =>
|
|
996
2490
|
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
|
997
2491
|
}
|
|
@@ -1095,7 +2589,7 @@ function InteractiveGeometry({
|
|
|
1095
2589
|
// each end pointing tangentially in opposite directions —
|
|
1096
2590
|
// "rotate either way."
|
|
1097
2591
|
const handleId = makeHandleId(nodeId, g.payload)
|
|
1098
|
-
const isHovered = hoveredHandleId === handleId
|
|
2592
|
+
const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
|
|
1099
2593
|
// Arc geometry (all values precomputed for a 72° arc of
|
|
1100
2594
|
// radius 0.13 — comparable footprint to `move-arrow`).
|
|
1101
2595
|
const R = 0.13
|
|
@@ -1317,8 +2811,8 @@ function InteractiveGeometry({
|
|
|
1317
2811
|
// Slightly smaller than endpoint dots; hover-expanded.
|
|
1318
2812
|
const baseRadiusPx = 6
|
|
1319
2813
|
const hoverRadiusPx = 8
|
|
1320
|
-
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) *
|
|
1321
|
-
const plusHalf = 3 *
|
|
2814
|
+
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * handleUnitsPerPixel
|
|
2815
|
+
const plusHalf = 3 * handleUnitsPerPixel
|
|
1322
2816
|
return (
|
|
1323
2817
|
<g
|
|
1324
2818
|
key={keyHint}
|
|
@@ -1331,10 +2825,10 @@ function InteractiveGeometry({
|
|
|
1331
2825
|
cy={g.point[1]}
|
|
1332
2826
|
fill="none"
|
|
1333
2827
|
pointerEvents="none"
|
|
1334
|
-
r={radius + 2 *
|
|
2828
|
+
r={radius + 2 * handleUnitsPerPixel}
|
|
1335
2829
|
stroke={hoverStroke}
|
|
1336
2830
|
strokeOpacity={0.16}
|
|
1337
|
-
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX *
|
|
2831
|
+
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
|
1338
2832
|
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
|
1339
2833
|
vectorEffect="non-scaling-stroke"
|
|
1340
2834
|
/>
|
|
@@ -1402,35 +2896,106 @@ function InteractiveGeometry({
|
|
|
1402
2896
|
// and flip by 180° if it falls outside (-90, 90] — that keeps
|
|
1403
2897
|
// text reading left-to-right, top-to-bottom regardless of the
|
|
1404
2898
|
// building's orientation.
|
|
2899
|
+
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
|
|
2900
|
+
|
|
2901
|
+
const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
|
|
2902
|
+
const outlined = g.appearance === 'outlined'
|
|
2903
|
+
const padX = labelUnitsPerPixel * 6
|
|
2904
|
+
const padY = labelUnitsPerPixel * 3
|
|
2905
|
+
const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
|
|
2906
|
+
// Rough text width approximation — SVG can't measure text without
|
|
2907
|
+
// the DOM. 6.2px per char at 10px font keeps the plate visually
|
|
2908
|
+
// balanced for the short length strings ("3.24m", "1'2\"", etc.).
|
|
2909
|
+
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
|
|
2910
|
+
const plateW = textWidth + padX * 2
|
|
2911
|
+
const plateH = fontSize + padY * 2
|
|
2912
|
+
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
|
|
2913
|
+
return (
|
|
2914
|
+
<g
|
|
2915
|
+
data-floorplan-annotation-angle-radians={g.angle}
|
|
2916
|
+
data-floorplan-annotation-default-transform={labelTransform}
|
|
2917
|
+
data-floorplan-annotation-label=""
|
|
2918
|
+
data-floorplan-annotation-priority="20"
|
|
2919
|
+
data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
|
|
2920
|
+
data-floorplan-annotation-transform-after-rotation={`translate(0 ${
|
|
2921
|
+
-(g.offsetPx ?? 0) * labelUnitsPerPixel
|
|
2922
|
+
})`}
|
|
2923
|
+
data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
|
|
2924
|
+
key={keyHint}
|
|
2925
|
+
pointerEvents="none"
|
|
2926
|
+
transform={labelTransform}
|
|
2927
|
+
>
|
|
2928
|
+
{outlined ? null : (
|
|
2929
|
+
<rect
|
|
2930
|
+
fill={palette.measurementLabelBackground}
|
|
2931
|
+
height={plateH}
|
|
2932
|
+
opacity={0.92}
|
|
2933
|
+
rx={labelUnitsPerPixel * 3}
|
|
2934
|
+
ry={labelUnitsPerPixel * 3}
|
|
2935
|
+
stroke={palette.measurementStroke}
|
|
2936
|
+
strokeWidth={labelUnitsPerPixel * 0.5}
|
|
2937
|
+
vectorEffect="non-scaling-stroke"
|
|
2938
|
+
width={plateW}
|
|
2939
|
+
x={-plateW / 2}
|
|
2940
|
+
y={-plateH / 2}
|
|
2941
|
+
/>
|
|
2942
|
+
)}
|
|
2943
|
+
<text
|
|
2944
|
+
dominantBaseline="middle"
|
|
2945
|
+
fill={outlined ? '#ffffff' : palette.measurementLabelText}
|
|
2946
|
+
fontFamily={
|
|
2947
|
+
outlined
|
|
2948
|
+
? 'system-ui, -apple-system, sans-serif'
|
|
2949
|
+
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
|
|
2950
|
+
}
|
|
2951
|
+
fontSize={fontSize}
|
|
2952
|
+
fontWeight={outlined ? 500 : 600}
|
|
2953
|
+
paintOrder={outlined ? 'stroke' : undefined}
|
|
2954
|
+
stroke={outlined ? palette.measurementStroke : undefined}
|
|
2955
|
+
strokeLinecap={outlined ? 'round' : undefined}
|
|
2956
|
+
strokeLinejoin={outlined ? 'round' : undefined}
|
|
2957
|
+
strokeWidth={outlined ? fontSize * 0.35 : undefined}
|
|
2958
|
+
textAnchor="middle"
|
|
2959
|
+
x={0}
|
|
2960
|
+
y={0}
|
|
2961
|
+
>
|
|
2962
|
+
{g.text}
|
|
2963
|
+
</text>
|
|
2964
|
+
</g>
|
|
2965
|
+
)
|
|
2966
|
+
}
|
|
2967
|
+
case 'equal-spacing-badge': {
|
|
2968
|
+
// A distinct accent (Figma-style "=" rhythm) so equal spacing reads
|
|
2969
|
+
// apart from the orange placement dimensions. Same screen-upright flip
|
|
2970
|
+
// as the dimension-label case above.
|
|
2971
|
+
const accent = '#ec4899'
|
|
1405
2972
|
let degrees = (g.angle * 180) / Math.PI
|
|
1406
2973
|
let screenDegrees = degrees + sceneRotationDeg
|
|
1407
2974
|
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
|
1408
2975
|
if (screenDegrees > 90) degrees -= 180
|
|
1409
2976
|
else if (screenDegrees <= -90) degrees += 180
|
|
1410
2977
|
|
|
2978
|
+
const label = `= ${g.text}`
|
|
1411
2979
|
const padX = unitsPerPixel * 6
|
|
1412
2980
|
const padY = unitsPerPixel * 3
|
|
1413
2981
|
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
|
1414
|
-
|
|
1415
|
-
// the DOM. 6.2px per char at 10px font keeps the plate visually
|
|
1416
|
-
// balanced for the short length strings ("3.24m", "1'2\"", etc.).
|
|
1417
|
-
const textWidth = g.text.length * unitsPerPixel * 6.2
|
|
2982
|
+
const textWidth = label.length * unitsPerPixel * 6.2
|
|
1418
2983
|
const plateW = textWidth + padX * 2
|
|
1419
2984
|
const plateH = fontSize + padY * 2
|
|
1420
2985
|
return (
|
|
1421
2986
|
<g
|
|
1422
2987
|
key={keyHint}
|
|
1423
2988
|
pointerEvents="none"
|
|
1424
|
-
transform={`translate(${g.
|
|
2989
|
+
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
|
|
1425
2990
|
>
|
|
1426
2991
|
<rect
|
|
1427
|
-
fill=
|
|
2992
|
+
fill="#ffffff"
|
|
1428
2993
|
height={plateH}
|
|
1429
|
-
opacity={0.
|
|
2994
|
+
opacity={0.95}
|
|
1430
2995
|
rx={unitsPerPixel * 3}
|
|
1431
2996
|
ry={unitsPerPixel * 3}
|
|
1432
|
-
stroke={
|
|
1433
|
-
strokeWidth={unitsPerPixel * 0.
|
|
2997
|
+
stroke={accent}
|
|
2998
|
+
strokeWidth={unitsPerPixel * 0.75}
|
|
1434
2999
|
vectorEffect="non-scaling-stroke"
|
|
1435
3000
|
width={plateW}
|
|
1436
3001
|
x={-plateW / 2}
|
|
@@ -1438,162 +3003,28 @@ function InteractiveGeometry({
|
|
|
1438
3003
|
/>
|
|
1439
3004
|
<text
|
|
1440
3005
|
dominantBaseline="middle"
|
|
1441
|
-
fill={
|
|
3006
|
+
fill={accent}
|
|
1442
3007
|
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
1443
3008
|
fontSize={fontSize}
|
|
1444
|
-
fontWeight={
|
|
3009
|
+
fontWeight={700}
|
|
1445
3010
|
textAnchor="middle"
|
|
1446
3011
|
x={0}
|
|
1447
3012
|
y={0}
|
|
1448
3013
|
>
|
|
1449
|
-
{
|
|
3014
|
+
{label}
|
|
1450
3015
|
</text>
|
|
1451
3016
|
</g>
|
|
1452
3017
|
)
|
|
1453
3018
|
}
|
|
1454
3019
|
case 'dimension': {
|
|
1455
3020
|
if (!palette) return <></>
|
|
1456
|
-
const stroke = g.stroke ?? palette.measurementStroke
|
|
1457
|
-
// Offset endpoints along the outward normal — this is where the
|
|
1458
|
-
// dimension line sits, parallel to the edge.
|
|
1459
|
-
const ox = g.offsetNormal[0] * g.offsetDistance
|
|
1460
|
-
const oy = g.offsetNormal[1] * g.offsetDistance
|
|
1461
|
-
const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
|
|
1462
|
-
const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
|
|
1463
|
-
|
|
1464
|
-
// Extension line endpoints — extend past the dimension line by
|
|
1465
|
-
// `extensionOvershoot` so the tip clears the dimension stroke.
|
|
1466
|
-
const eOvershoot = g.extensionOvershoot
|
|
1467
|
-
const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
|
|
1468
|
-
const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
|
|
1469
|
-
const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
|
|
1470
|
-
const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
|
|
1471
|
-
|
|
1472
|
-
const dx = dEnd[0] - dStart[0]
|
|
1473
|
-
const dy = dEnd[1] - dStart[1]
|
|
1474
|
-
const length = Math.hypot(dx, dy)
|
|
1475
|
-
if (length < 1e-6) return <></>
|
|
1476
|
-
const dirX = dx / length
|
|
1477
|
-
const dirY = dy / length
|
|
1478
|
-
|
|
1479
|
-
// Plan-unit constants matching the legacy `floorplan-
|
|
1480
|
-
// measurements-layer.tsx`. `strokeWidth` is intentionally a
|
|
1481
|
-
// raw value (not multiplied by `unitsPerPixel`) because every
|
|
1482
|
-
// stroke here uses `vectorEffect: non-scaling-stroke` — the
|
|
1483
|
-
// browser interprets it as screen-pixel-stable. Multiplying
|
|
1484
|
-
// by `unitsPerPixel` would shrink the strokes by ~100× and
|
|
1485
|
-
// make them invisible. Tick length, dash pattern, font size,
|
|
1486
|
-
// and the label gap stay in plan units (they're geometry,
|
|
1487
|
-
// not stroke width).
|
|
1488
|
-
const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
|
|
1489
|
-
const perpX = -dirY * tickHalf
|
|
1490
|
-
const perpY = dirX * tickHalf
|
|
1491
|
-
|
|
1492
|
-
const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
|
|
1493
|
-
const labelGap = 0.5 // plan units — gap in the dimension line for the label
|
|
1494
|
-
const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
|
|
1495
|
-
|
|
1496
|
-
const midX = (dStart[0] + dEnd[0]) / 2
|
|
1497
|
-
const midY = (dStart[1] + dEnd[1]) / 2
|
|
1498
|
-
const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
|
|
1499
|
-
const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
|
|
1500
|
-
|
|
1501
|
-
// Keep the label parallel to the dimension line, but decide the
|
|
1502
|
-
// 180° flip from the on-SCREEN angle, not the local one. The parent
|
|
1503
|
-
// `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
|
|
1504
|
-
// plan), so a label kept upright in local coords still renders
|
|
1505
|
-
// upside down for half of the wall orientations. Same fix as the
|
|
1506
|
-
// `dimension-label` case above.
|
|
1507
|
-
let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
|
|
1508
|
-
let screenDeg = labelDeg + sceneRotationDeg
|
|
1509
|
-
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
|
1510
|
-
if (screenDeg > 90) labelDeg -= 180
|
|
1511
|
-
else if (screenDeg <= -90) labelDeg += 180
|
|
1512
|
-
|
|
1513
3021
|
return (
|
|
1514
|
-
<
|
|
1515
|
-
{
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
strokeOpacity={0.95}
|
|
1521
|
-
strokeWidth={1.35}
|
|
1522
|
-
vectorEffect="non-scaling-stroke"
|
|
1523
|
-
x1={g.start[0]}
|
|
1524
|
-
x2={eStartTip[0]}
|
|
1525
|
-
y1={g.start[1]}
|
|
1526
|
-
y2={eStartTip[1]}
|
|
1527
|
-
/>
|
|
1528
|
-
<line
|
|
1529
|
-
stroke={stroke}
|
|
1530
|
-
strokeDasharray="0.08 0.12"
|
|
1531
|
-
strokeLinecap="round"
|
|
1532
|
-
strokeOpacity={0.95}
|
|
1533
|
-
strokeWidth={1.35}
|
|
1534
|
-
vectorEffect="non-scaling-stroke"
|
|
1535
|
-
x1={g.end[0]}
|
|
1536
|
-
x2={eEndTip[0]}
|
|
1537
|
-
y1={g.end[1]}
|
|
1538
|
-
y2={eEndTip[1]}
|
|
1539
|
-
/>
|
|
1540
|
-
{/* Dimension line: two halves with the label in between. */}
|
|
1541
|
-
<line
|
|
1542
|
-
stroke={stroke}
|
|
1543
|
-
strokeLinecap="round"
|
|
1544
|
-
strokeWidth={1.35}
|
|
1545
|
-
vectorEffect="non-scaling-stroke"
|
|
1546
|
-
x1={dStart[0]}
|
|
1547
|
-
x2={gapStart[0]}
|
|
1548
|
-
y1={dStart[1]}
|
|
1549
|
-
y2={gapStart[1]}
|
|
1550
|
-
/>
|
|
1551
|
-
<line
|
|
1552
|
-
stroke={stroke}
|
|
1553
|
-
strokeLinecap="round"
|
|
1554
|
-
strokeWidth={1.35}
|
|
1555
|
-
vectorEffect="non-scaling-stroke"
|
|
1556
|
-
x1={gapEnd[0]}
|
|
1557
|
-
x2={dEnd[0]}
|
|
1558
|
-
y1={gapEnd[1]}
|
|
1559
|
-
y2={dEnd[1]}
|
|
1560
|
-
/>
|
|
1561
|
-
{/* End ticks. */}
|
|
1562
|
-
<line
|
|
1563
|
-
stroke={stroke}
|
|
1564
|
-
strokeLinecap="round"
|
|
1565
|
-
strokeWidth={1.35}
|
|
1566
|
-
vectorEffect="non-scaling-stroke"
|
|
1567
|
-
x1={dStart[0] - perpX}
|
|
1568
|
-
x2={dStart[0] + perpX}
|
|
1569
|
-
y1={dStart[1] - perpY}
|
|
1570
|
-
y2={dStart[1] + perpY}
|
|
1571
|
-
/>
|
|
1572
|
-
<line
|
|
1573
|
-
stroke={stroke}
|
|
1574
|
-
strokeLinecap="round"
|
|
1575
|
-
strokeWidth={1.35}
|
|
1576
|
-
vectorEffect="non-scaling-stroke"
|
|
1577
|
-
x1={dEnd[0] - perpX}
|
|
1578
|
-
x2={dEnd[0] + perpX}
|
|
1579
|
-
y1={dEnd[1] - perpY}
|
|
1580
|
-
y2={dEnd[1] + perpY}
|
|
1581
|
-
/>
|
|
1582
|
-
{/* Rotated label centered in the gap. */}
|
|
1583
|
-
<text
|
|
1584
|
-
dominantBaseline="central"
|
|
1585
|
-
fill={stroke}
|
|
1586
|
-
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
1587
|
-
fontSize={fontSize}
|
|
1588
|
-
fontWeight={600}
|
|
1589
|
-
textAnchor="middle"
|
|
1590
|
-
transform={`rotate(${labelDeg} ${midX} ${midY})`}
|
|
1591
|
-
x={midX}
|
|
1592
|
-
y={midY}
|
|
1593
|
-
>
|
|
1594
|
-
{g.text}
|
|
1595
|
-
</text>
|
|
1596
|
-
</g>
|
|
3022
|
+
<FloorplanDimensionRenderer
|
|
3023
|
+
geometry={g}
|
|
3024
|
+
key={keyHint}
|
|
3025
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
3026
|
+
stroke={g.stroke ?? palette.measurementStroke}
|
|
3027
|
+
/>
|
|
1597
3028
|
)
|
|
1598
3029
|
}
|
|
1599
3030
|
case 'text': {
|
|
@@ -1601,8 +3032,18 @@ function InteractiveGeometry({
|
|
|
1601
3032
|
// Counter-rotate by the scene rotation so the label reads
|
|
1602
3033
|
// horizontally on screen even when the floor-plan view is
|
|
1603
3034
|
// rotated (default `sceneRotationDeg` is 90°).
|
|
3035
|
+
const beforeRotation = `translate(${g.x} ${g.y})`
|
|
3036
|
+
const transform = `${beforeRotation} rotate(${-sceneRotationDeg})`
|
|
1604
3037
|
return (
|
|
1605
|
-
<g
|
|
3038
|
+
<g
|
|
3039
|
+
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
|
|
3040
|
+
data-floorplan-annotation-angle-radians={0}
|
|
3041
|
+
data-floorplan-annotation-default-transform={transform}
|
|
3042
|
+
data-floorplan-annotation-screen-upright="true"
|
|
3043
|
+
data-floorplan-annotation-transform-before-rotation={beforeRotation}
|
|
3044
|
+
key={keyHint}
|
|
3045
|
+
transform={transform}
|
|
3046
|
+
>
|
|
1606
3047
|
<text
|
|
1607
3048
|
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
|
1608
3049
|
fill={g.fill ?? '#171717'}
|
|
@@ -1630,14 +3071,30 @@ function InteractiveGeometry({
|
|
|
1630
3071
|
geometry={g}
|
|
1631
3072
|
key={keyHint}
|
|
1632
3073
|
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
|
|
3074
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1633
3075
|
/>
|
|
1634
3076
|
)
|
|
1635
3077
|
}
|
|
1636
3078
|
}
|
|
1637
|
-
}
|
|
3079
|
+
}, shallowPropsAreEqual)
|
|
1638
3080
|
|
|
1639
3081
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
1640
3082
|
|
|
3083
|
+
function shallowPropsAreEqual<T extends object>(a: T, b: T): boolean {
|
|
3084
|
+
const aKeys = Object.keys(a) as Array<keyof T>
|
|
3085
|
+
const bKeys = Object.keys(b) as Array<keyof T>
|
|
3086
|
+
if (aKeys.length !== bKeys.length) return false
|
|
3087
|
+
for (const key of aKeys) {
|
|
3088
|
+
if (!Object.is(a[key], b[key])) return false
|
|
3089
|
+
}
|
|
3090
|
+
return true
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
function handleIdForNode(handleId: string | null, nodeId: AnyNodeId): string | null {
|
|
3094
|
+
if (!handleId) return null
|
|
3095
|
+
return handleId === nodeId || handleId.startsWith(`${nodeId}:`) ? handleId : null
|
|
3096
|
+
}
|
|
3097
|
+
|
|
1641
3098
|
function applyPositionLiveTransform(
|
|
1642
3099
|
node: AnyNode,
|
|
1643
3100
|
live: { position: [number, number, number]; rotation: number },
|
|
@@ -1661,16 +3118,48 @@ function applyPositionLiveTransform(
|
|
|
1661
3118
|
} as AnyNode
|
|
1662
3119
|
}
|
|
1663
3120
|
|
|
1664
|
-
function
|
|
3121
|
+
export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
|
|
3122
|
+
const overrideVisible = liveOverride?.visible
|
|
3123
|
+
if (typeof overrideVisible === 'boolean') return overrideVisible
|
|
3124
|
+
return (node as { visible?: boolean }).visible !== false
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
function isFloorplanHierarchyVisible(
|
|
3128
|
+
node: AnyNode,
|
|
3129
|
+
nodes: Record<string, AnyNode>,
|
|
3130
|
+
liveOverrides: Map<string, LiveNodeOverrides>,
|
|
3131
|
+
rootId: AnyNodeId,
|
|
3132
|
+
): boolean {
|
|
3133
|
+
let current: AnyNode | undefined = node
|
|
3134
|
+
const seen = new Set<AnyNodeId>()
|
|
3135
|
+
while (current) {
|
|
3136
|
+
if (seen.has(current.id)) return true
|
|
3137
|
+
seen.add(current.id)
|
|
3138
|
+
if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
|
|
3139
|
+
if (current.id === rootId) return true
|
|
3140
|
+
const parentId = current.parentId as AnyNodeId | null
|
|
3141
|
+
if (!parentId) return true
|
|
3142
|
+
current = nodes[parentId]
|
|
3143
|
+
}
|
|
3144
|
+
return true
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
export function buildContext(
|
|
1665
3148
|
node: AnyNode,
|
|
1666
3149
|
nodes: Record<string, AnyNode>,
|
|
1667
3150
|
viewState: {
|
|
3151
|
+
automaticDimensions?: boolean
|
|
1668
3152
|
selected: boolean
|
|
3153
|
+
unit: 'metric' | 'imperial'
|
|
3154
|
+
metricNotation?: 'meters' | 'millimeters'
|
|
3155
|
+
purpose?: 'edit' | 'document'
|
|
3156
|
+
wallDimensionReference?: FloorplanWallDimensionReference
|
|
1669
3157
|
highlighted: boolean
|
|
1670
3158
|
hovered: boolean
|
|
1671
3159
|
moving: boolean
|
|
1672
3160
|
palette: FloorplanPalette | undefined
|
|
1673
3161
|
},
|
|
3162
|
+
levelData?: unknown,
|
|
1674
3163
|
): GeometryContext {
|
|
1675
3164
|
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
1676
3165
|
|
|
@@ -1703,9 +3192,17 @@ function buildContext(
|
|
|
1703
3192
|
children,
|
|
1704
3193
|
siblings,
|
|
1705
3194
|
parent,
|
|
3195
|
+
levelData,
|
|
3196
|
+
extensions: createFloorplanContextExtensions({
|
|
3197
|
+
automaticDimensions: viewState.automaticDimensions,
|
|
3198
|
+
metricNotation: viewState.metricNotation ?? 'meters',
|
|
3199
|
+
purpose: viewState.purpose ?? 'edit',
|
|
3200
|
+
wallDimensionReference: viewState.wallDimensionReference,
|
|
3201
|
+
}),
|
|
1706
3202
|
viewState: viewState.palette
|
|
1707
3203
|
? {
|
|
1708
3204
|
selected: viewState.selected,
|
|
3205
|
+
unit: viewState.unit,
|
|
1709
3206
|
highlighted: viewState.highlighted,
|
|
1710
3207
|
hovered: viewState.hovered,
|
|
1711
3208
|
moving: viewState.moving,
|
|
@@ -1715,6 +3212,29 @@ function buildContext(
|
|
|
1715
3212
|
}
|
|
1716
3213
|
}
|
|
1717
3214
|
|
|
3215
|
+
export function collectFloorplanLinkedLevelNodes(
|
|
3216
|
+
nodes: Record<string, AnyNode>,
|
|
3217
|
+
levelId: AnyNodeId,
|
|
3218
|
+
excludedIds: ReadonlySet<AnyNodeId> = new Set(),
|
|
3219
|
+
): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> {
|
|
3220
|
+
const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = []
|
|
3221
|
+
for (const [rawId, node] of Object.entries(nodes)) {
|
|
3222
|
+
if (!node) continue
|
|
3223
|
+
const definition = nodeRegistry.get(node.type)
|
|
3224
|
+
const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
|
|
3225
|
+
if (!definition?.floorplan || !linkedLevelIds) continue
|
|
3226
|
+
const id = rawId as AnyNodeId
|
|
3227
|
+
if (excludedIds.has(id)) continue
|
|
3228
|
+
if (!linkedLevelIds(node).includes(levelId)) continue
|
|
3229
|
+
const childIds = (node as { children?: AnyNodeId[] }).children
|
|
3230
|
+
const children = Array.isArray(childIds)
|
|
3231
|
+
? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
|
|
3232
|
+
: []
|
|
3233
|
+
linked.push({ id, node, children })
|
|
3234
|
+
}
|
|
3235
|
+
return linked
|
|
3236
|
+
}
|
|
3237
|
+
|
|
1718
3238
|
/**
|
|
1719
3239
|
* Stable id for a handle on a node, derived from the node id + opaque
|
|
1720
3240
|
* payload. Used to track hover / active visual state when multiple
|
|
@@ -1735,6 +3255,14 @@ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
|
|
|
1735
3255
|
return `${nodeId}:${String(payload)}`
|
|
1736
3256
|
}
|
|
1737
3257
|
|
|
3258
|
+
export function floorplanHandleDoubleClickAffordance(
|
|
3259
|
+
geometry: FloorplanGeometry,
|
|
3260
|
+
): 'delete-vertex' | null {
|
|
3261
|
+
return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
|
|
3262
|
+
? 'delete-vertex'
|
|
3263
|
+
: null
|
|
3264
|
+
}
|
|
3265
|
+
|
|
1738
3266
|
/**
|
|
1739
3267
|
* Geometry kinds that always render in the overlay pass — interactive
|
|
1740
3268
|
* handles and node labels. These need to sit above every kind's base
|
|
@@ -1753,7 +3281,9 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
|
1753
3281
|
'move-arrow',
|
|
1754
3282
|
'rotate-arrow',
|
|
1755
3283
|
'dimension',
|
|
3284
|
+
'dimension-string',
|
|
1756
3285
|
'dimension-label',
|
|
3286
|
+
'equal-spacing-badge',
|
|
1757
3287
|
])
|
|
1758
3288
|
|
|
1759
3289
|
/**
|
|
@@ -1766,10 +3296,16 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
|
1766
3296
|
* / translations apply in both passes. Empty groups collapse to `null`
|
|
1767
3297
|
* so the caller can skip emitting an `<g>` when there's nothing to draw.
|
|
1768
3298
|
*/
|
|
1769
|
-
function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
3299
|
+
export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
1770
3300
|
base: FloorplanGeometry | null
|
|
1771
3301
|
overlay: FloorplanGeometry | null
|
|
1772
3302
|
} {
|
|
3303
|
+
if (readFloorplanGeometryMetadata(g).renderPass === 'overlay') {
|
|
3304
|
+
return { base: null, overlay: g }
|
|
3305
|
+
}
|
|
3306
|
+
if (isFloorplanAnnotationObstacleGeometry(g)) {
|
|
3307
|
+
return { base: null, overlay: g }
|
|
3308
|
+
}
|
|
1773
3309
|
if (OVERLAY_KINDS.has(g.kind)) {
|
|
1774
3310
|
return { base: null, overlay: g }
|
|
1775
3311
|
}
|
|
@@ -1782,18 +3318,185 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
|
1782
3318
|
if (split.overlay) overlayChildren.push(split.overlay)
|
|
1783
3319
|
}
|
|
1784
3320
|
const base: FloorplanGeometry | null =
|
|
1785
|
-
baseChildren.length > 0
|
|
1786
|
-
? { kind: 'group', children: baseChildren, transform: g.transform }
|
|
1787
|
-
: null
|
|
3321
|
+
baseChildren.length > 0 ? { ...g, children: baseChildren, transform: g.transform } : null
|
|
1788
3322
|
const overlay: FloorplanGeometry | null =
|
|
1789
3323
|
overlayChildren.length > 0
|
|
1790
|
-
? {
|
|
3324
|
+
? { ...g, children: overlayChildren, transform: g.transform }
|
|
1791
3325
|
: null
|
|
1792
3326
|
return { base, overlay }
|
|
1793
3327
|
}
|
|
1794
3328
|
return { base: g, overlay: null }
|
|
1795
3329
|
}
|
|
1796
3330
|
|
|
3331
|
+
/**
|
|
3332
|
+
* Per-node edit chrome hidden while a multi-selection is active: the group is
|
|
3333
|
+
* manipulated as one rigid piece (drag to move, R/T to rotate), so individual
|
|
3334
|
+
* handles / dimension labels on each member would mislead. `text` stays —
|
|
3335
|
+
* zone names are identification, not editing chrome.
|
|
3336
|
+
*/
|
|
3337
|
+
const HANDLE_CHROME_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
3338
|
+
'endpoint-handle',
|
|
3339
|
+
'midpoint-handle',
|
|
3340
|
+
'edge-handle',
|
|
3341
|
+
'move-handle',
|
|
3342
|
+
'move-arrow',
|
|
3343
|
+
'rotate-arrow',
|
|
3344
|
+
'dimension',
|
|
3345
|
+
'dimension-label',
|
|
3346
|
+
'equal-spacing-badge',
|
|
3347
|
+
])
|
|
3348
|
+
|
|
3349
|
+
function stripHandleChrome(g: FloorplanGeometry): FloorplanGeometry | null {
|
|
3350
|
+
if (HANDLE_CHROME_KINDS.has(g.kind)) return null
|
|
3351
|
+
if (g.kind === 'group') {
|
|
3352
|
+
const children = g.children
|
|
3353
|
+
.map(stripHandleChrome)
|
|
3354
|
+
.filter((c): c is FloorplanGeometry => c !== null)
|
|
3355
|
+
if (children.length === 0) return null
|
|
3356
|
+
return { kind: 'group', children, transform: g.transform }
|
|
3357
|
+
}
|
|
3358
|
+
return g
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
// Stable string key for a wall endpoint, rounded to 1 mm so floating-point
|
|
3362
|
+
// drift collapses while distinct corners stay distinct.
|
|
3363
|
+
function endpointKey(x: number, y: number): string {
|
|
3364
|
+
return `${Math.round(x * 1000)},${Math.round(y * 1000)}`
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
// Given the sibling-dependent nodes with a live drag in flight, the set of
|
|
3368
|
+
// floor-plan geometries that must rebuild this frame. A node's geometry depends
|
|
3369
|
+
// on more than its own data:
|
|
3370
|
+
// - a wall's miters depend on the walls meeting at each of its endpoints, so a
|
|
3371
|
+
// dragged wall invalidates the walls at its old AND new junctions, plus its
|
|
3372
|
+
// own door/window children (their cuts are drawn into it);
|
|
3373
|
+
// - a door/window cut is drawn into its host wall, so it invalidates that wall;
|
|
3374
|
+
// - a gutter join depends on sibling gutters under the same roof.
|
|
3375
|
+
// Everything else stays cached, so dragging one wall/opening rebuilds a handful
|
|
3376
|
+
// of geometries rather than every wall + opening on the level.
|
|
3377
|
+
export function computeAffectedSiblingIds(
|
|
3378
|
+
liveFlaggedIds: readonly AnyNodeId[],
|
|
3379
|
+
nodes: Record<string, AnyNode>,
|
|
3380
|
+
liveOverrides: Map<string, Record<string, unknown>>,
|
|
3381
|
+
): Set<AnyNodeId> {
|
|
3382
|
+
const affected = new Set<AnyNodeId>()
|
|
3383
|
+
if (liveFlaggedIds.length === 0) return affected
|
|
3384
|
+
|
|
3385
|
+
// Junction map (committed wall endpoint → wall ids), built lazily on first use.
|
|
3386
|
+
let junctions: Map<string, AnyNodeId[]> | null = null
|
|
3387
|
+
const wallsAtPoint = (x: number, y: number): AnyNodeId[] => {
|
|
3388
|
+
if (!junctions) {
|
|
3389
|
+
junctions = new Map()
|
|
3390
|
+
for (const id in nodes) {
|
|
3391
|
+
const n = nodes[id]
|
|
3392
|
+
if (n?.type !== 'wall') continue
|
|
3393
|
+
const w = n as unknown as { start: [number, number]; end: [number, number] }
|
|
3394
|
+
for (const [px, py] of [w.start, w.end]) {
|
|
3395
|
+
const key = endpointKey(px, py)
|
|
3396
|
+
const arr = junctions.get(key)
|
|
3397
|
+
if (arr) arr.push(id as AnyNodeId)
|
|
3398
|
+
else junctions.set(key, [id as AnyNodeId])
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
return junctions.get(endpointKey(x, y)) ?? []
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
for (const id of liveFlaggedIds) {
|
|
3406
|
+
const node = nodes[id]
|
|
3407
|
+
if (!node) continue
|
|
3408
|
+
affected.add(id)
|
|
3409
|
+
const def = nodeRegistry.get(node.type)
|
|
3410
|
+
const extraAffectedIds = def?.floorplanAffectedIds?.({
|
|
3411
|
+
nodeId: id,
|
|
3412
|
+
node,
|
|
3413
|
+
nodes: nodes as Record<AnyNodeId, AnyNode>,
|
|
3414
|
+
liveTransforms: useLiveTransforms.getState().transforms,
|
|
3415
|
+
liveOverrides,
|
|
3416
|
+
})
|
|
3417
|
+
if (extraAffectedIds) {
|
|
3418
|
+
for (const extraId of extraAffectedIds) affected.add(extraId)
|
|
3419
|
+
}
|
|
3420
|
+
if (node.type === 'wall') {
|
|
3421
|
+
const w = node as unknown as {
|
|
3422
|
+
start: [number, number]
|
|
3423
|
+
end: [number, number]
|
|
3424
|
+
children?: AnyNodeId[]
|
|
3425
|
+
}
|
|
3426
|
+
// Use the live (override-merged) endpoints as well as the committed ones,
|
|
3427
|
+
// so walls at both the wall's old and new junctions get fresh miters.
|
|
3428
|
+
const ov = liveOverrides.get(id) as
|
|
3429
|
+
| { start?: [number, number]; end?: [number, number] }
|
|
3430
|
+
| undefined
|
|
3431
|
+
const points: [number, number][] = [w.start, w.end]
|
|
3432
|
+
if (ov?.start) points.push(ov.start)
|
|
3433
|
+
if (ov?.end) points.push(ov.end)
|
|
3434
|
+
for (const [px, py] of points) {
|
|
3435
|
+
for (const wid of wallsAtPoint(px, py)) affected.add(wid)
|
|
3436
|
+
}
|
|
3437
|
+
if (Array.isArray(w.children)) {
|
|
3438
|
+
for (const cid of w.children) {
|
|
3439
|
+
const child = nodes[cid]
|
|
3440
|
+
if (child?.type === 'door' || child?.type === 'window') affected.add(cid)
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
} else if (node.type === 'door' || node.type === 'window') {
|
|
3444
|
+
const hostId = (node as { parentId?: string }).parentId
|
|
3445
|
+
if (hostId) affected.add(hostId as AnyNodeId)
|
|
3446
|
+
const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
|
|
3447
|
+
if (liveHostId) affected.add(liveHostId as AnyNodeId)
|
|
3448
|
+
} else if (node.type === 'gutter') {
|
|
3449
|
+
const roofId = (node as { parentId?: string }).parentId
|
|
3450
|
+
if (roofId) {
|
|
3451
|
+
for (const sid in nodes) {
|
|
3452
|
+
const s = nodes[sid]
|
|
3453
|
+
if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) {
|
|
3454
|
+
affected.add(sid as AnyNodeId)
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
return affected
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
|
3464
|
+
const keys: Array<keyof NodeDeps> = [
|
|
3465
|
+
'automaticDimensions',
|
|
3466
|
+
'node',
|
|
3467
|
+
'live',
|
|
3468
|
+
'unit',
|
|
3469
|
+
'metricNotation',
|
|
3470
|
+
'wallDimensionReference',
|
|
3471
|
+
'selected',
|
|
3472
|
+
'highlighted',
|
|
3473
|
+
'hovered',
|
|
3474
|
+
'moving',
|
|
3475
|
+
'liveOverride',
|
|
3476
|
+
'palette',
|
|
3477
|
+
'siblingEpoch',
|
|
3478
|
+
'committedNodes',
|
|
3479
|
+
'dependencyNodes',
|
|
3480
|
+
'interactiveElevators',
|
|
3481
|
+
]
|
|
3482
|
+
for (const key of keys) {
|
|
3483
|
+
if (!depsValueEqual(a[key], b[key])) return false
|
|
3484
|
+
}
|
|
3485
|
+
return true
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
function depsValueEqual(a: unknown, b: unknown): boolean {
|
|
3489
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
3490
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
|
3491
|
+
if (a.length !== b.length) return false
|
|
3492
|
+
for (let i = 0; i < a.length; i++) {
|
|
3493
|
+
if (!Object.is(a[i], b[i])) return false
|
|
3494
|
+
}
|
|
3495
|
+
return true
|
|
3496
|
+
}
|
|
3497
|
+
return Object.is(a, b)
|
|
3498
|
+
}
|
|
3499
|
+
|
|
1797
3500
|
/**
|
|
1798
3501
|
* Z-order bucket for floor-plan rendering. Lower rank = painted first =
|
|
1799
3502
|
* sits under everything with a higher rank. SVG renders in document
|
|
@@ -1809,7 +3512,7 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|
|
1809
3512
|
* Sort is stable in modern JS engines, so siblings within the same
|
|
1810
3513
|
* bucket keep their DFS order (= scene tree order).
|
|
1811
3514
|
*/
|
|
1812
|
-
function floorplanLayerRank(type: string): number {
|
|
3515
|
+
export function floorplanLayerRank(type: string): number {
|
|
1813
3516
|
switch (type) {
|
|
1814
3517
|
case 'zone':
|
|
1815
3518
|
return 0
|
|
@@ -1855,17 +3558,22 @@ const ROTATION_WEDGE_SEGMENTS = 48
|
|
|
1855
3558
|
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
|
|
1856
3559
|
* horizontally regardless of the building's on-screen orientation.
|
|
1857
3560
|
*/
|
|
1858
|
-
function RotationAngleOverlay({
|
|
3561
|
+
export function RotationAngleOverlay({
|
|
1859
3562
|
overlay,
|
|
1860
3563
|
palette,
|
|
1861
|
-
unitsPerPixel,
|
|
3564
|
+
unitsPerPixel: unitsPerPixelOverride,
|
|
1862
3565
|
sceneRotationDeg,
|
|
1863
3566
|
}: {
|
|
1864
3567
|
overlay: RotationOverlayState
|
|
1865
|
-
palette:
|
|
1866
|
-
|
|
3568
|
+
palette: Pick<
|
|
3569
|
+
FloorplanPalette,
|
|
3570
|
+
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
|
|
3571
|
+
>
|
|
3572
|
+
unitsPerPixel?: number
|
|
1867
3573
|
sceneRotationDeg: number
|
|
1868
3574
|
}): React.ReactElement {
|
|
3575
|
+
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
|
3576
|
+
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
|
|
1869
3577
|
const { pivot, startAngle, endAngle, radius, sweep } = overlay
|
|
1870
3578
|
const span = endAngle - startAngle
|
|
1871
3579
|
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
|
|
@@ -1944,20 +3652,14 @@ function formatGroupTransform(t?: {
|
|
|
1944
3652
|
return parts.length > 0 ? parts.join(' ') : undefined
|
|
1945
3653
|
}
|
|
1946
3654
|
|
|
1947
|
-
function
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
point.x = clientX
|
|
1958
|
-
point.y = clientY
|
|
1959
|
-
const transformed = point.matrixTransform(ctm.inverse())
|
|
1960
|
-
// The floor-plan `<g>` maps plan X/Z directly to SVG x/y (Z stored as
|
|
1961
|
-
// the Y axis on screen — same convention as `toSvgPlanPoint`).
|
|
1962
|
-
return [transformed.x, transformed.y]
|
|
3655
|
+
function swallowNextClick(timeoutMs = 0) {
|
|
3656
|
+
const swallowClick = (event: MouseEvent) => {
|
|
3657
|
+
event.stopPropagation()
|
|
3658
|
+
event.preventDefault()
|
|
3659
|
+
window.removeEventListener('click', swallowClick, true)
|
|
3660
|
+
}
|
|
3661
|
+
window.addEventListener('click', swallowClick, true)
|
|
3662
|
+
setTimeout(() => {
|
|
3663
|
+
window.removeEventListener('click', swallowClick, true)
|
|
3664
|
+
}, timeoutMs)
|
|
1963
3665
|
}
|