@pascal-app/editor 0.8.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +7 -7
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +332 -0
- package/src/lib/scene.ts +40 -8
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +39 -0
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
closestMeasurementFeatureBinding,
|
|
7
|
+
collectAlignmentAnchors,
|
|
8
|
+
emitter,
|
|
9
|
+
type GeometryContext,
|
|
10
|
+
type MeasurementFeatureAnchor,
|
|
11
|
+
type MeasurementSnapKind,
|
|
12
|
+
measurementAngle,
|
|
13
|
+
measurementArea,
|
|
14
|
+
measurementDistance,
|
|
15
|
+
measurementFeatureLength,
|
|
16
|
+
measurementPerimeter,
|
|
17
|
+
measurementPrismVolume,
|
|
18
|
+
nodeRegistry,
|
|
19
|
+
useScene,
|
|
20
|
+
} from '@pascal-app/core'
|
|
21
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
22
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
23
|
+
import { measurementPolygonLabelAnchor } from '../../lib/measurement-label'
|
|
24
|
+
import {
|
|
25
|
+
buildMeasurementAngleArcPoints,
|
|
26
|
+
formatAreaLabel,
|
|
27
|
+
formatLinearMeasurement,
|
|
28
|
+
formatVolumeLabel,
|
|
29
|
+
getLinearUnitLabel,
|
|
30
|
+
linearUnitToMeters,
|
|
31
|
+
MEASUREMENT_ACTIVE_COLOR,
|
|
32
|
+
metersToLinearUnit,
|
|
33
|
+
} from '../../lib/measurements'
|
|
34
|
+
import {
|
|
35
|
+
clearSurfacePlanSnapFeedback,
|
|
36
|
+
resolveSurfacePlanPointSnap,
|
|
37
|
+
} from '../../lib/surface-plan-snap'
|
|
38
|
+
import useEditor from '../../store/use-editor'
|
|
39
|
+
import {
|
|
40
|
+
commitMeasurementDraft,
|
|
41
|
+
type MeasurementAxisGuide,
|
|
42
|
+
type MeasurementPoint,
|
|
43
|
+
measurementPolygonMidpoints,
|
|
44
|
+
useMeasurementDraft,
|
|
45
|
+
} from '../../store/use-measurement-draft'
|
|
46
|
+
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
|
47
|
+
import { FloorplanQuickMeasureLayer } from './floorplan-quick-measure-layer'
|
|
48
|
+
import { useFloorplanRender } from './floorplan-render-context'
|
|
49
|
+
import { resolveFloorplanLabelAngle } from './renderers/floorplan-label-angle'
|
|
50
|
+
|
|
51
|
+
const DRAFT_COLOR = MEASUREMENT_ACTIVE_COLOR
|
|
52
|
+
const X_AXIS_COLOR = '#ef4444'
|
|
53
|
+
const Z_AXIS_COLOR = '#3b82f6'
|
|
54
|
+
const RETICLE_COLOR = MEASUREMENT_ACTIVE_COLOR
|
|
55
|
+
const AXIS_SNAP_DISTANCE_PX = 16
|
|
56
|
+
const AXIS_SNAP_RELEASE_DISTANCE_PX = 24
|
|
57
|
+
const PROXIMITY_GUIDE_DISTANCE_PX = 40
|
|
58
|
+
const VERTEX_HANDLE_DISTANCE_PX = 12
|
|
59
|
+
const MIDPOINT_HANDLE_DISTANCE_PX = 10
|
|
60
|
+
const VERTEX_DRAG_ACTIVATION_PX = 4
|
|
61
|
+
const AXIS_GUIDE_HALF_LENGTH_PX = 1_200
|
|
62
|
+
|
|
63
|
+
type PlanPoint = { x: number; z: number }
|
|
64
|
+
type ProjectedSnapPoint = PlanPoint & { nodeId: string }
|
|
65
|
+
type ProjectedSnapSegment = {
|
|
66
|
+
start: ProjectedSnapPoint
|
|
67
|
+
end: ProjectedSnapPoint
|
|
68
|
+
nodeId: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ProjectedFloorplanSnap = {
|
|
72
|
+
kind: 'vertex' | 'edge'
|
|
73
|
+
nodeId: string
|
|
74
|
+
point: PlanPoint
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MAX_REGISTERED_SNAP_ELEMENTS = 512
|
|
78
|
+
const MAX_REGISTERED_SNAP_SEGMENTS = 4096
|
|
79
|
+
const MAX_SAMPLED_PATH_SEGMENTS = 48
|
|
80
|
+
const VERTEX_SNAP_DISTANCE_PX = 16
|
|
81
|
+
const EDGE_SNAP_DISTANCE_PX = 10
|
|
82
|
+
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
|
|
83
|
+
// Alt-bypass association mirrors the 3D tool's surface-verify tolerance: a
|
|
84
|
+
// feature binds only when the point already sits on it, never by attraction.
|
|
85
|
+
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
|
|
86
|
+
|
|
87
|
+
function measurementGeometryContext(
|
|
88
|
+
node: AnyNode,
|
|
89
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
90
|
+
): GeometryContext {
|
|
91
|
+
const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
|
|
92
|
+
nodes[id] as N | undefined
|
|
93
|
+
const childIds =
|
|
94
|
+
'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
|
|
95
|
+
const children = childIds
|
|
96
|
+
.map((id) => nodes[id])
|
|
97
|
+
.filter((child): child is AnyNode => child !== undefined)
|
|
98
|
+
const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
|
|
99
|
+
const siblings =
|
|
100
|
+
parent && 'children' in parent && Array.isArray(parent.children)
|
|
101
|
+
? (parent.children as AnyNodeId[])
|
|
102
|
+
.map((id) => nodes[id])
|
|
103
|
+
.filter(
|
|
104
|
+
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
|
|
105
|
+
)
|
|
106
|
+
: []
|
|
107
|
+
return { resolve, children, parent, siblings }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function associatePlanPoint(
|
|
111
|
+
point: MeasurementPoint,
|
|
112
|
+
targetNodeId: string | null,
|
|
113
|
+
maxDistance = SEMANTIC_FEATURE_SNAP_DISTANCE,
|
|
114
|
+
): {
|
|
115
|
+
point: MeasurementPoint
|
|
116
|
+
anchor?: MeasurementFeatureAnchor
|
|
117
|
+
semantic?: { label: string; length: number | null; snapKind: MeasurementSnapKind }
|
|
118
|
+
} {
|
|
119
|
+
if (!targetNodeId) return { point }
|
|
120
|
+
const nodes = useScene.getState().nodes
|
|
121
|
+
const node = nodes[targetNodeId as AnyNodeId]
|
|
122
|
+
const contribution = node ? nodeRegistry.get(node.type)?.measurement : undefined
|
|
123
|
+
if (!(node && contribution)) return { point }
|
|
124
|
+
const context = measurementGeometryContext(node, nodes)
|
|
125
|
+
const features = contribution.features(node, context)
|
|
126
|
+
const match =
|
|
127
|
+
contribution.match?.(node, context, point, maxDistance) ??
|
|
128
|
+
closestMeasurementFeatureBinding(features, point, maxDistance)
|
|
129
|
+
if (!match) return { point }
|
|
130
|
+
const reference = {
|
|
131
|
+
nodeId: node.id,
|
|
132
|
+
featureId: match.featureId,
|
|
133
|
+
parameters: match.parameters,
|
|
134
|
+
}
|
|
135
|
+
const feature =
|
|
136
|
+
contribution.resolve?.(node, context, reference) ??
|
|
137
|
+
features.find((candidate) => candidate.id === match.featureId) ??
|
|
138
|
+
null
|
|
139
|
+
return {
|
|
140
|
+
point: match.point,
|
|
141
|
+
anchor: {
|
|
142
|
+
kind: 'feature',
|
|
143
|
+
reference,
|
|
144
|
+
fallback: match.point,
|
|
145
|
+
},
|
|
146
|
+
semantic: feature
|
|
147
|
+
? {
|
|
148
|
+
label: feature.label,
|
|
149
|
+
length: measurementFeatureLength(feature),
|
|
150
|
+
snapKind: feature.snapKind,
|
|
151
|
+
}
|
|
152
|
+
: undefined,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function closestPointOnScreenSegment(
|
|
157
|
+
point: PlanPoint,
|
|
158
|
+
start: PlanPoint,
|
|
159
|
+
end: PlanPoint,
|
|
160
|
+
): PlanPoint {
|
|
161
|
+
const dx = end.x - start.x
|
|
162
|
+
const dz = end.z - start.z
|
|
163
|
+
const lengthSquared = dx * dx + dz * dz
|
|
164
|
+
if (lengthSquared < 1e-9) return { ...start }
|
|
165
|
+
const t = Math.max(
|
|
166
|
+
0,
|
|
167
|
+
Math.min(1, ((point.x - start.x) * dx + (point.z - start.z) * dz) / lengthSquared),
|
|
168
|
+
)
|
|
169
|
+
return { x: start.x + dx * t, z: start.z + dz * t }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resolveProjectedFloorplanSnap(
|
|
173
|
+
pointer: PlanPoint,
|
|
174
|
+
vertices: readonly ProjectedSnapPoint[],
|
|
175
|
+
segments: readonly ProjectedSnapSegment[],
|
|
176
|
+
vertexThreshold = VERTEX_SNAP_DISTANCE_PX,
|
|
177
|
+
edgeThreshold = EDGE_SNAP_DISTANCE_PX,
|
|
178
|
+
): ProjectedFloorplanSnap | null {
|
|
179
|
+
let nearestVertex: { candidate: ProjectedSnapPoint; distance: number } | null = null
|
|
180
|
+
for (const candidate of vertices) {
|
|
181
|
+
const distance = Math.hypot(pointer.x - candidate.x, pointer.z - candidate.z)
|
|
182
|
+
if (distance <= vertexThreshold && (!nearestVertex || distance < nearestVertex.distance)) {
|
|
183
|
+
nearestVertex = { candidate, distance }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (nearestVertex) {
|
|
187
|
+
return {
|
|
188
|
+
kind: 'vertex',
|
|
189
|
+
nodeId: nearestVertex.candidate.nodeId,
|
|
190
|
+
point: { x: nearestVertex.candidate.x, z: nearestVertex.candidate.z },
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let nearestEdge: { nodeId: string; point: PlanPoint; distance: number } | null = null
|
|
195
|
+
for (const segment of segments) {
|
|
196
|
+
const projected = closestPointOnScreenSegment(pointer, segment.start, segment.end)
|
|
197
|
+
const distance = Math.hypot(pointer.x - projected.x, pointer.z - projected.z)
|
|
198
|
+
if (distance <= edgeThreshold && (!nearestEdge || distance < nearestEdge.distance)) {
|
|
199
|
+
nearestEdge = { nodeId: segment.nodeId, point: projected, distance }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return nearestEdge ? { kind: 'edge', nodeId: nearestEdge.nodeId, point: nearestEdge.point } : null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function isProjectedFloorplanAxisPointVerified(
|
|
206
|
+
candidate: PlanPoint,
|
|
207
|
+
nodeId: string,
|
|
208
|
+
vertices: readonly ProjectedSnapPoint[],
|
|
209
|
+
segments: readonly ProjectedSnapSegment[],
|
|
210
|
+
tolerance = 1.5,
|
|
211
|
+
): boolean {
|
|
212
|
+
const resolved = resolveProjectedFloorplanSnap(
|
|
213
|
+
candidate,
|
|
214
|
+
vertices.filter((vertex) => vertex.nodeId === nodeId),
|
|
215
|
+
segments.filter((segment) => segment.nodeId === nodeId),
|
|
216
|
+
tolerance,
|
|
217
|
+
tolerance,
|
|
218
|
+
)
|
|
219
|
+
return Boolean(
|
|
220
|
+
resolved &&
|
|
221
|
+
resolved.nodeId === nodeId &&
|
|
222
|
+
Math.hypot(candidate.x - resolved.point.x, candidate.z - resolved.point.z) <= tolerance,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function screenPointForElement(
|
|
227
|
+
element: SVGGeometryElement,
|
|
228
|
+
nodeId: string,
|
|
229
|
+
x: number,
|
|
230
|
+
z: number,
|
|
231
|
+
): ProjectedSnapPoint | null {
|
|
232
|
+
const matrix = element.getScreenCTM()
|
|
233
|
+
if (!matrix) return null
|
|
234
|
+
const point = new DOMPoint(x, z).matrixTransform(matrix)
|
|
235
|
+
return { x: point.x, z: point.y, nodeId }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function collectRegisteredFloorplanSnapGeometry(svg: SVGSVGElement): {
|
|
239
|
+
vertices: ProjectedSnapPoint[]
|
|
240
|
+
segments: ProjectedSnapSegment[]
|
|
241
|
+
} {
|
|
242
|
+
const vertices: ProjectedSnapPoint[] = []
|
|
243
|
+
const segments: ProjectedSnapSegment[] = []
|
|
244
|
+
const nodes = useScene.getState().nodes as Record<
|
|
245
|
+
string,
|
|
246
|
+
{ type: string; visible?: boolean } | undefined
|
|
247
|
+
>
|
|
248
|
+
const entries = svg.querySelectorAll<SVGGElement>(
|
|
249
|
+
'.floorplan-registry-base .floorplan-registry-entry[data-node-id]',
|
|
250
|
+
)
|
|
251
|
+
let elementCount = 0
|
|
252
|
+
|
|
253
|
+
const addSegment = (start: ProjectedSnapPoint | null, end: ProjectedSnapPoint | null) => {
|
|
254
|
+
if (!(start && end) || segments.length >= MAX_REGISTERED_SNAP_SEGMENTS) return
|
|
255
|
+
segments.push({ start, end, nodeId: start.nodeId })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const nodeId = entry.dataset.nodeId
|
|
260
|
+
const node = nodeId ? nodes[nodeId] : null
|
|
261
|
+
if (
|
|
262
|
+
!nodeId ||
|
|
263
|
+
!node ||
|
|
264
|
+
node.visible === false ||
|
|
265
|
+
node.type === 'measurement' ||
|
|
266
|
+
node.type === 'guide' ||
|
|
267
|
+
node.type === 'scan'
|
|
268
|
+
) {
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const geometry = entry.querySelectorAll<SVGGeometryElement>(
|
|
273
|
+
'line, polyline, polygon, rect, path, circle, ellipse',
|
|
274
|
+
)
|
|
275
|
+
for (const element of geometry) {
|
|
276
|
+
if (elementCount >= MAX_REGISTERED_SNAP_ELEMENTS) break
|
|
277
|
+
elementCount += 1
|
|
278
|
+
const tag = element.tagName.toLowerCase()
|
|
279
|
+
|
|
280
|
+
if (tag === 'line') {
|
|
281
|
+
const line = element as SVGLineElement
|
|
282
|
+
const start = screenPointForElement(
|
|
283
|
+
element,
|
|
284
|
+
nodeId,
|
|
285
|
+
line.x1.baseVal.value,
|
|
286
|
+
line.y1.baseVal.value,
|
|
287
|
+
)
|
|
288
|
+
const end = screenPointForElement(
|
|
289
|
+
element,
|
|
290
|
+
nodeId,
|
|
291
|
+
line.x2.baseVal.value,
|
|
292
|
+
line.y2.baseVal.value,
|
|
293
|
+
)
|
|
294
|
+
if (start) vertices.push(start)
|
|
295
|
+
if (end) vertices.push(end)
|
|
296
|
+
addSegment(start, end)
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (tag === 'polyline' || tag === 'polygon') {
|
|
301
|
+
const poly = element as SVGPolylineElement | SVGPolygonElement
|
|
302
|
+
const points: ProjectedSnapPoint[] = []
|
|
303
|
+
for (let index = 0; index < poly.points.numberOfItems; index += 1) {
|
|
304
|
+
const item = poly.points.getItem(index)
|
|
305
|
+
const point = screenPointForElement(element, nodeId, item.x, item.y)
|
|
306
|
+
if (point) points.push(point)
|
|
307
|
+
}
|
|
308
|
+
vertices.push(...points)
|
|
309
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
310
|
+
addSegment(points[index - 1]!, points[index]!)
|
|
311
|
+
}
|
|
312
|
+
if (tag === 'polygon' && points.length >= 3) addSegment(points.at(-1)!, points[0]!)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (tag === 'rect') {
|
|
317
|
+
const rect = element as SVGRectElement
|
|
318
|
+
const x = rect.x.baseVal.value
|
|
319
|
+
const z = rect.y.baseVal.value
|
|
320
|
+
const width = rect.width.baseVal.value
|
|
321
|
+
const height = rect.height.baseVal.value
|
|
322
|
+
const points = [
|
|
323
|
+
screenPointForElement(element, nodeId, x, z),
|
|
324
|
+
screenPointForElement(element, nodeId, x + width, z),
|
|
325
|
+
screenPointForElement(element, nodeId, x + width, z + height),
|
|
326
|
+
screenPointForElement(element, nodeId, x, z + height),
|
|
327
|
+
].filter((point): point is ProjectedSnapPoint => point !== null)
|
|
328
|
+
vertices.push(...points)
|
|
329
|
+
if (points.length === 4) {
|
|
330
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
331
|
+
addSegment(points[index]!, points[(index + 1) % points.length]!)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let length = 0
|
|
338
|
+
try {
|
|
339
|
+
length = element.getTotalLength()
|
|
340
|
+
} catch {
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (!(Number.isFinite(length) && length > 0)) continue
|
|
344
|
+
const segmentCount = Math.min(
|
|
345
|
+
MAX_SAMPLED_PATH_SEGMENTS,
|
|
346
|
+
Math.max(8, Math.ceil(length / 0.25)),
|
|
347
|
+
)
|
|
348
|
+
let previous: ProjectedSnapPoint | null = null
|
|
349
|
+
for (let index = 0; index <= segmentCount; index += 1) {
|
|
350
|
+
const sample = element.getPointAtLength((length * index) / segmentCount)
|
|
351
|
+
const point = screenPointForElement(element, nodeId, sample.x, sample.y)
|
|
352
|
+
if (previous && point) addSegment(previous, point)
|
|
353
|
+
if (tag === 'path' && (index === 0 || index === segmentCount) && point) {
|
|
354
|
+
vertices.push(point)
|
|
355
|
+
}
|
|
356
|
+
previous = point
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (elementCount >= MAX_REGISTERED_SNAP_ELEMENTS) break
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { vertices, segments }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function resolveFloorplanMeasurementAxisSnap(
|
|
366
|
+
raw: MeasurementPoint,
|
|
367
|
+
anchor: MeasurementPoint,
|
|
368
|
+
xAxisScreenDistance: number,
|
|
369
|
+
zAxisScreenDistance: number,
|
|
370
|
+
threshold = AXIS_SNAP_DISTANCE_PX,
|
|
371
|
+
lockedAxis: 'x' | 'z' | null = null,
|
|
372
|
+
releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
|
|
373
|
+
proximity = false,
|
|
374
|
+
): { point: MeasurementPoint; guide: MeasurementAxisGuide } {
|
|
375
|
+
const candidates = [
|
|
376
|
+
{
|
|
377
|
+
axis: 'x' as const,
|
|
378
|
+
screenDistance: xAxisScreenDistance,
|
|
379
|
+
to: [raw[0], anchor[1], anchor[2]] as MeasurementPoint,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
axis: 'z' as const,
|
|
383
|
+
screenDistance: zAxisScreenDistance,
|
|
384
|
+
to: [anchor[0], anchor[1], raw[2]] as MeasurementPoint,
|
|
385
|
+
},
|
|
386
|
+
]
|
|
387
|
+
const locked = lockedAxis
|
|
388
|
+
? candidates.find(
|
|
389
|
+
(candidate) =>
|
|
390
|
+
candidate.axis === lockedAxis && candidate.screenDistance <= releaseThreshold,
|
|
391
|
+
)
|
|
392
|
+
: null
|
|
393
|
+
const closest = candidates.reduce((best, candidate) =>
|
|
394
|
+
candidate.screenDistance < best.screenDistance ? candidate : best,
|
|
395
|
+
)
|
|
396
|
+
const selected = locked ?? (closest.screenDistance <= threshold ? closest : null)
|
|
397
|
+
const guideCandidate = selected ?? closest
|
|
398
|
+
return {
|
|
399
|
+
point: selected ? [...selected.to] : [...raw],
|
|
400
|
+
guide: {
|
|
401
|
+
axis: guideCandidate.axis,
|
|
402
|
+
from: [...anchor],
|
|
403
|
+
to: [...guideCandidate.to],
|
|
404
|
+
snapped: selected !== null,
|
|
405
|
+
...(proximity ? { proximity: true } : {}),
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isMeasurementKind(
|
|
411
|
+
value: unknown,
|
|
412
|
+
): value is 'distance' | 'angle' | 'area' | 'perimeter' | 'volume' {
|
|
413
|
+
return (
|
|
414
|
+
value === 'distance' ||
|
|
415
|
+
value === 'angle' ||
|
|
416
|
+
value === 'area' ||
|
|
417
|
+
value === 'perimeter' ||
|
|
418
|
+
value === 'volume'
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null {
|
|
423
|
+
const matrix = group.getScreenCTM()
|
|
424
|
+
if (!matrix) return null
|
|
425
|
+
const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
|
|
426
|
+
return { x: local.x, z: local.y }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function planPointToClient(group: SVGGElement, point: MeasurementPoint): DOMPoint | null {
|
|
430
|
+
const matrix = group.getScreenCTM()
|
|
431
|
+
if (!matrix) return null
|
|
432
|
+
return new DOMPoint(point[0], point[2]).matrixTransform(matrix)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function screenDistanceToPlanPoint(
|
|
436
|
+
group: SVGGElement,
|
|
437
|
+
point: MeasurementPoint,
|
|
438
|
+
clientX: number,
|
|
439
|
+
clientY: number,
|
|
440
|
+
): number {
|
|
441
|
+
const screen = planPointToClient(group, point)
|
|
442
|
+
return screen ? Math.hypot(clientX - screen.x, clientY - screen.y) : Number.POSITIVE_INFINITY
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function snapPlanPoint(
|
|
446
|
+
group: SVGGElement,
|
|
447
|
+
raw: MeasurementPoint,
|
|
448
|
+
anchors: readonly MeasurementPoint[],
|
|
449
|
+
clientX: number,
|
|
450
|
+
clientY: number,
|
|
451
|
+
allowAxisSnap = true,
|
|
452
|
+
lockedGuide: MeasurementAxisGuide | null = null,
|
|
453
|
+
proximityAnchors: readonly MeasurementPoint[] = [],
|
|
454
|
+
): { point: MeasurementPoint; guide: MeasurementAxisGuide | null } {
|
|
455
|
+
if (anchors.length === 0 && proximityAnchors.length === 0) return { point: raw, guide: null }
|
|
456
|
+
const candidates = [
|
|
457
|
+
...anchors.map((anchor) => ({ anchor, proximity: false })),
|
|
458
|
+
...proximityAnchors.map((anchor) => ({ anchor, proximity: true })),
|
|
459
|
+
].flatMap(({ anchor, proximity }) => {
|
|
460
|
+
const xPoint: MeasurementPoint = [raw[0], anchor[1], anchor[2]]
|
|
461
|
+
const zPoint: MeasurementPoint = [anchor[0], anchor[1], raw[2]]
|
|
462
|
+
return [
|
|
463
|
+
{
|
|
464
|
+
anchor,
|
|
465
|
+
axis: 'x' as const,
|
|
466
|
+
point: xPoint,
|
|
467
|
+
proximity,
|
|
468
|
+
screenDistance: screenDistanceToPlanPoint(group, xPoint, clientX, clientY),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
anchor,
|
|
472
|
+
axis: 'z' as const,
|
|
473
|
+
point: zPoint,
|
|
474
|
+
proximity,
|
|
475
|
+
screenDistance: screenDistanceToPlanPoint(group, zPoint, clientX, clientY),
|
|
476
|
+
},
|
|
477
|
+
]
|
|
478
|
+
})
|
|
479
|
+
const threshold = allowAxisSnap ? AXIS_SNAP_DISTANCE_PX : -1
|
|
480
|
+
const releaseThreshold = allowAxisSnap ? AXIS_SNAP_RELEASE_DISTANCE_PX : -1
|
|
481
|
+
const locked = lockedGuide
|
|
482
|
+
? candidates.reduce<(typeof candidates)[number] | null>((best, candidate) => {
|
|
483
|
+
if (
|
|
484
|
+
candidate.axis !== lockedGuide.axis ||
|
|
485
|
+
candidate.proximity !== Boolean(lockedGuide.proximity) ||
|
|
486
|
+
candidate.anchor.some(
|
|
487
|
+
(value, index) => Math.abs(value - lockedGuide.from[index]!) > 1e-9,
|
|
488
|
+
) ||
|
|
489
|
+
candidate.screenDistance > releaseThreshold
|
|
490
|
+
)
|
|
491
|
+
return best
|
|
492
|
+
return !best || candidate.screenDistance < best.screenDistance ? candidate : best
|
|
493
|
+
}, null)
|
|
494
|
+
: null
|
|
495
|
+
const closest = candidates
|
|
496
|
+
.filter(
|
|
497
|
+
(candidate) =>
|
|
498
|
+
!candidate.proximity || candidate.screenDistance <= PROXIMITY_GUIDE_DISTANCE_PX,
|
|
499
|
+
)
|
|
500
|
+
.reduce<(typeof candidates)[number] | null>(
|
|
501
|
+
(best, candidate) =>
|
|
502
|
+
!best || candidate.screenDistance < best.screenDistance ? candidate : best,
|
|
503
|
+
null,
|
|
504
|
+
)
|
|
505
|
+
if (!closest) return { point: raw, guide: null }
|
|
506
|
+
const selected = locked ?? (closest.screenDistance <= threshold ? closest : null)
|
|
507
|
+
const guideCandidate = selected ?? closest
|
|
508
|
+
return {
|
|
509
|
+
point: selected ? [...selected.point] : [...raw],
|
|
510
|
+
guide: {
|
|
511
|
+
axis: guideCandidate.axis,
|
|
512
|
+
from: [...guideCandidate.anchor],
|
|
513
|
+
to: [...guideCandidate.point],
|
|
514
|
+
snapped: selected !== null,
|
|
515
|
+
...(guideCandidate.proximity ? { proximity: true } : {}),
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function measurementVertexSnapAnchors(
|
|
521
|
+
points: readonly MeasurementPoint[],
|
|
522
|
+
index: number,
|
|
523
|
+
polygon: boolean,
|
|
524
|
+
): MeasurementPoint[] {
|
|
525
|
+
if (!Number.isInteger(index) || index < 0 || index >= points.length || points.length < 2)
|
|
526
|
+
return []
|
|
527
|
+
const neighborIndices =
|
|
528
|
+
polygon && points.length >= 3
|
|
529
|
+
? [(index - 1 + points.length) % points.length, (index + 1) % points.length]
|
|
530
|
+
: [index - 1, index + 1]
|
|
531
|
+
return Array.from(new Set(neighborIndices))
|
|
532
|
+
.filter(
|
|
533
|
+
(neighborIndex) =>
|
|
534
|
+
neighborIndex >= 0 && neighborIndex < points.length && neighborIndex !== index,
|
|
535
|
+
)
|
|
536
|
+
.map((neighborIndex) => [...points[neighborIndex]!] as MeasurementPoint)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function closeBaseAndMaybeCommit(): boolean {
|
|
540
|
+
const draft = useMeasurementDraft.getState()
|
|
541
|
+
if (draft.kind === 'distance' || draft.kind === 'angle') return false
|
|
542
|
+
if (!draft.closeBase('2d', [0, 1, 0])) return false
|
|
543
|
+
if (draft.kind === 'area' || draft.kind === 'perimeter') commitMeasurementDraft('2d')
|
|
544
|
+
return true
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isExtrusionControlTarget(target: EventTarget | null): boolean {
|
|
548
|
+
return (
|
|
549
|
+
target instanceof Element && target.closest('[data-measurement-extrusion-control]') !== null
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function FloorplanDraftLabel({
|
|
554
|
+
angle = 0,
|
|
555
|
+
appearance = 'plate',
|
|
556
|
+
background,
|
|
557
|
+
border,
|
|
558
|
+
offsetPx = 0,
|
|
559
|
+
point,
|
|
560
|
+
sceneRotationDeg,
|
|
561
|
+
screenUpright = false,
|
|
562
|
+
text,
|
|
563
|
+
textColor,
|
|
564
|
+
unitsPerPixel,
|
|
565
|
+
}: {
|
|
566
|
+
angle?: number
|
|
567
|
+
appearance?: 'plate' | 'outlined'
|
|
568
|
+
background: string
|
|
569
|
+
border: string
|
|
570
|
+
offsetPx?: number
|
|
571
|
+
point: MeasurementPoint
|
|
572
|
+
sceneRotationDeg: number
|
|
573
|
+
screenUpright?: boolean
|
|
574
|
+
text: string
|
|
575
|
+
textColor: string
|
|
576
|
+
unitsPerPixel: number
|
|
577
|
+
}) {
|
|
578
|
+
const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
|
|
579
|
+
const fontSize = labelUnitsPerPixel * (appearance === 'outlined' ? 12 : 10)
|
|
580
|
+
const padX = labelUnitsPerPixel * 6
|
|
581
|
+
const padY = labelUnitsPerPixel * 3
|
|
582
|
+
const plateWidth = text.length * labelUnitsPerPixel * 6.2 + padX * 2
|
|
583
|
+
const plateHeight = fontSize + padY * 2
|
|
584
|
+
const localAngleDeg = resolveFloorplanLabelAngle(angle, sceneRotationDeg, screenUpright)
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<g
|
|
588
|
+
pointerEvents="none"
|
|
589
|
+
transform={`translate(${point[0]} ${point[2]}) rotate(${localAngleDeg}) translate(0 ${-offsetPx * labelUnitsPerPixel})`}
|
|
590
|
+
>
|
|
591
|
+
{appearance === 'outlined' ? null : (
|
|
592
|
+
<rect
|
|
593
|
+
fill={background}
|
|
594
|
+
height={plateHeight}
|
|
595
|
+
opacity={0.94}
|
|
596
|
+
rx={labelUnitsPerPixel * 4}
|
|
597
|
+
ry={labelUnitsPerPixel * 4}
|
|
598
|
+
stroke={border}
|
|
599
|
+
strokeWidth={labelUnitsPerPixel * 0.7}
|
|
600
|
+
vectorEffect="non-scaling-stroke"
|
|
601
|
+
width={plateWidth}
|
|
602
|
+
x={-plateWidth / 2}
|
|
603
|
+
y={-plateHeight / 2}
|
|
604
|
+
/>
|
|
605
|
+
)}
|
|
606
|
+
<text
|
|
607
|
+
dominantBaseline="middle"
|
|
608
|
+
fill={appearance === 'outlined' ? '#ffffff' : textColor}
|
|
609
|
+
fontFamily={
|
|
610
|
+
appearance === 'outlined'
|
|
611
|
+
? 'system-ui, -apple-system, sans-serif'
|
|
612
|
+
: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
|
613
|
+
}
|
|
614
|
+
fontSize={fontSize}
|
|
615
|
+
fontWeight={appearance === 'outlined' ? '500' : '600'}
|
|
616
|
+
paintOrder={appearance === 'outlined' ? 'stroke' : undefined}
|
|
617
|
+
stroke={appearance === 'outlined' ? border : undefined}
|
|
618
|
+
strokeLinecap={appearance === 'outlined' ? 'round' : undefined}
|
|
619
|
+
strokeLinejoin={appearance === 'outlined' ? 'round' : undefined}
|
|
620
|
+
strokeWidth={appearance === 'outlined' ? fontSize * 0.35 : undefined}
|
|
621
|
+
textAnchor="middle"
|
|
622
|
+
>
|
|
623
|
+
{text}
|
|
624
|
+
</text>
|
|
625
|
+
</g>
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function FloorplanExtrusionControl({
|
|
630
|
+
center,
|
|
631
|
+
sceneRotationDeg,
|
|
632
|
+
unitsPerPixel,
|
|
633
|
+
}: {
|
|
634
|
+
center: MeasurementPoint
|
|
635
|
+
sceneRotationDeg: number
|
|
636
|
+
unitsPerPixel: number
|
|
637
|
+
}) {
|
|
638
|
+
const unit = useViewer((state) => state.unit)
|
|
639
|
+
const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight)
|
|
640
|
+
const points = useMeasurementDraft((state) => state.points)
|
|
641
|
+
const baseNormal = useMeasurementDraft((state) => state.baseNormal)
|
|
642
|
+
const [value, setValue] = useState(() =>
|
|
643
|
+
extrusionHeight === 0 ? '' : String(metersToLinearUnit(extrusionHeight, unit)),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
const height = useMeasurementDraft.getState().extrusionHeight
|
|
648
|
+
setValue(height === 0 ? '' : String(Number(metersToLinearUnit(height, unit).toFixed(3))))
|
|
649
|
+
}, [unit])
|
|
650
|
+
|
|
651
|
+
const width = 260 * unitsPerPixel
|
|
652
|
+
const height = 72 * unitsPerPixel
|
|
653
|
+
const x = center[0] - width / 2
|
|
654
|
+
const y = center[2] - height / 2
|
|
655
|
+
|
|
656
|
+
const commit = () => {
|
|
657
|
+
const numericValue = Number.parseFloat(value)
|
|
658
|
+
if (!Number.isFinite(numericValue)) {
|
|
659
|
+
useMeasurementDraft.getState().setExtrusionHeight('2d', 0)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
useMeasurementDraft.getState().setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit))
|
|
663
|
+
const draft = useMeasurementDraft.getState()
|
|
664
|
+
if (draft.finishExtrusion('2d')) commitMeasurementDraft('2d')
|
|
665
|
+
}
|
|
666
|
+
const extrusion: MeasurementPoint = baseNormal
|
|
667
|
+
? [
|
|
668
|
+
baseNormal[0] * extrusionHeight,
|
|
669
|
+
baseNormal[1] * extrusionHeight,
|
|
670
|
+
baseNormal[2] * extrusionHeight,
|
|
671
|
+
]
|
|
672
|
+
: [0, 0, 0]
|
|
673
|
+
const volumeLabel = formatVolumeLabel(measurementPrismVolume(points, extrusion), unit)
|
|
674
|
+
|
|
675
|
+
return (
|
|
676
|
+
<g transform={`rotate(${-sceneRotationDeg} ${center[0]} ${center[2]})`}>
|
|
677
|
+
<foreignObject
|
|
678
|
+
data-measurement-extrusion-control
|
|
679
|
+
height={height}
|
|
680
|
+
overflow="visible"
|
|
681
|
+
pointerEvents="auto"
|
|
682
|
+
width={width}
|
|
683
|
+
x={x}
|
|
684
|
+
y={y}
|
|
685
|
+
>
|
|
686
|
+
<div
|
|
687
|
+
className="flex h-full w-full items-center gap-1.5 rounded-md border border-border/70 bg-background/95 p-1.5 text-foreground shadow-lg"
|
|
688
|
+
data-measurement-extrusion-control
|
|
689
|
+
onClick={(event) => {
|
|
690
|
+
event.stopPropagation()
|
|
691
|
+
event.nativeEvent.stopImmediatePropagation()
|
|
692
|
+
}}
|
|
693
|
+
onPointerDown={(event) => {
|
|
694
|
+
event.stopPropagation()
|
|
695
|
+
event.nativeEvent.stopImmediatePropagation()
|
|
696
|
+
}}
|
|
697
|
+
>
|
|
698
|
+
<label className="sr-only" htmlFor="measurement-extrusion-height">
|
|
699
|
+
Extrusion height
|
|
700
|
+
</label>
|
|
701
|
+
<div className="relative min-w-0 flex-1">
|
|
702
|
+
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center font-medium text-muted-foreground text-xs">
|
|
703
|
+
H
|
|
704
|
+
</span>
|
|
705
|
+
<input
|
|
706
|
+
aria-label="Extrusion height"
|
|
707
|
+
className="h-8 w-full rounded-md border border-border bg-background pr-7 pl-6 text-sm outline-none focus:border-cyan-400"
|
|
708
|
+
id="measurement-extrusion-height"
|
|
709
|
+
inputMode="decimal"
|
|
710
|
+
onChange={(event) => {
|
|
711
|
+
const next = event.target.value
|
|
712
|
+
setValue(next)
|
|
713
|
+
const numericValue = Number.parseFloat(next)
|
|
714
|
+
if (Number.isFinite(numericValue)) {
|
|
715
|
+
useMeasurementDraft
|
|
716
|
+
.getState()
|
|
717
|
+
.setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit))
|
|
718
|
+
} else {
|
|
719
|
+
useMeasurementDraft.getState().setExtrusionHeight('2d', 0)
|
|
720
|
+
}
|
|
721
|
+
}}
|
|
722
|
+
onKeyDown={(event) => {
|
|
723
|
+
event.stopPropagation()
|
|
724
|
+
if (event.key === 'Enter') {
|
|
725
|
+
event.preventDefault()
|
|
726
|
+
commit()
|
|
727
|
+
}
|
|
728
|
+
}}
|
|
729
|
+
placeholder="0"
|
|
730
|
+
step="0.1"
|
|
731
|
+
type="number"
|
|
732
|
+
value={value}
|
|
733
|
+
/>
|
|
734
|
+
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-muted-foreground text-xs">
|
|
735
|
+
{getLinearUnitLabel(unit)}
|
|
736
|
+
</span>
|
|
737
|
+
</div>
|
|
738
|
+
<span className="shrink-0 font-mono font-semibold text-[11px] tabular-nums text-foreground">
|
|
739
|
+
V {volumeLabel}
|
|
740
|
+
</span>
|
|
741
|
+
<button
|
|
742
|
+
className="h-8 shrink-0 rounded-full bg-cyan-500 px-3 font-medium text-black text-xs disabled:cursor-not-allowed disabled:opacity-40"
|
|
743
|
+
disabled={Math.abs(extrusionHeight) < 0.001}
|
|
744
|
+
onClick={commit}
|
|
745
|
+
type="button"
|
|
746
|
+
>
|
|
747
|
+
Create
|
|
748
|
+
</button>
|
|
749
|
+
</div>
|
|
750
|
+
</foreignObject>
|
|
751
|
+
</g>
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function FloorplanMeasurementToolLayer() {
|
|
756
|
+
const groupRef = useRef<SVGGElement>(null)
|
|
757
|
+
const vertexGesture = useRef<{
|
|
758
|
+
pointerId: number
|
|
759
|
+
source: 'vertex' | 'midpoint'
|
|
760
|
+
index: number
|
|
761
|
+
startX: number
|
|
762
|
+
startY: number
|
|
763
|
+
engaged: boolean
|
|
764
|
+
previousInputDragging: boolean
|
|
765
|
+
previousCursor: string
|
|
766
|
+
} | null>(null)
|
|
767
|
+
const suppressNextClick = useRef(false)
|
|
768
|
+
const cancelVertexGesture = useRef<() => void>(() => {})
|
|
769
|
+
const mode = useEditor((state) => state.mode)
|
|
770
|
+
const tool = useEditor((state) => state.tool)
|
|
771
|
+
const storedKind = useEditor((state) => state.toolDefaults.measurement?.kind)
|
|
772
|
+
const kind = isMeasurementKind(storedKind) ? storedKind : 'distance'
|
|
773
|
+
const smartActive = mode === 'build' && tool === 'measurement' && storedKind === 'smart'
|
|
774
|
+
const active = mode === 'build' && tool === 'measurement' && isMeasurementKind(storedKind)
|
|
775
|
+
const renderContext = useFloorplanRender()
|
|
776
|
+
const points = useMeasurementDraft((state) => state.points)
|
|
777
|
+
const hover = useMeasurementDraft((state) => state.hover)
|
|
778
|
+
const hoverOwner = useMeasurementDraft((state) => state.hoverOwner)
|
|
779
|
+
const owner = useMeasurementDraft((state) => state.owner)
|
|
780
|
+
const stage = useMeasurementDraft((state) => state.stage)
|
|
781
|
+
const axisGuide = useMeasurementDraft((state) => state.axisGuide)
|
|
782
|
+
const vertexDrag = useMeasurementDraft((state) => state.vertexDrag)
|
|
783
|
+
const baseNormal = useMeasurementDraft((state) => state.baseNormal)
|
|
784
|
+
const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight)
|
|
785
|
+
const error = useMeasurementDraft((state) => state.error)
|
|
786
|
+
const draftLevelId = useMeasurementDraft((state) => state.levelId)
|
|
787
|
+
const activeLevelId = useViewer((state) => state.selection.levelId)
|
|
788
|
+
const unit = useViewer((state) => state.unit)
|
|
789
|
+
|
|
790
|
+
useEffect(() => {
|
|
791
|
+
if (active) {
|
|
792
|
+
cancelVertexGesture.current()
|
|
793
|
+
useMeasurementDraft.getState().setKind(kind)
|
|
794
|
+
}
|
|
795
|
+
}, [active, kind])
|
|
796
|
+
|
|
797
|
+
useEffect(() => {
|
|
798
|
+
const draft = useMeasurementDraft.getState()
|
|
799
|
+
if (draft.levelId && draft.levelId !== activeLevelId) {
|
|
800
|
+
cancelVertexGesture.current()
|
|
801
|
+
draft.reset()
|
|
802
|
+
}
|
|
803
|
+
}, [activeLevelId])
|
|
804
|
+
|
|
805
|
+
useEffect(() => {
|
|
806
|
+
const group = groupRef.current
|
|
807
|
+
const svg = group?.ownerSVGElement
|
|
808
|
+
if (!(active && group && svg) || owner === '3d') return
|
|
809
|
+
let projectedGeometry:
|
|
810
|
+
| (ReturnType<typeof collectRegisteredFloorplanSnapGeometry> & {
|
|
811
|
+
proximityAnchors: MeasurementPoint[]
|
|
812
|
+
})
|
|
813
|
+
| null = null
|
|
814
|
+
let projectedGeometryTimestamp = Number.NEGATIVE_INFINITY
|
|
815
|
+
|
|
816
|
+
const getProjectedGeometry = () => {
|
|
817
|
+
const now = performance.now()
|
|
818
|
+
if (!projectedGeometry || now - projectedGeometryTimestamp > 120) {
|
|
819
|
+
const geometry = collectRegisteredFloorplanSnapGeometry(svg)
|
|
820
|
+
const seen = new Set<string>()
|
|
821
|
+
const sceneAnchors = collectAlignmentAnchors(
|
|
822
|
+
useScene.getState().nodes,
|
|
823
|
+
'',
|
|
824
|
+
activeLevelId,
|
|
825
|
+
).map(({ x, z }) => [x, 0, z] as MeasurementPoint)
|
|
826
|
+
const proximityAnchors = [
|
|
827
|
+
...sceneAnchors,
|
|
828
|
+
...geometry.vertices.flatMap((vertex) => {
|
|
829
|
+
const plan = clientToPlanPoint(group, vertex.x, vertex.z)
|
|
830
|
+
if (!plan) return []
|
|
831
|
+
return [[plan.x, 0, plan.z] as MeasurementPoint]
|
|
832
|
+
}),
|
|
833
|
+
].filter((anchor) => {
|
|
834
|
+
const key = `${Math.round(anchor[0] * 1_000)}:${Math.round(anchor[2] * 1_000)}`
|
|
835
|
+
if (seen.has(key)) return false
|
|
836
|
+
seen.add(key)
|
|
837
|
+
return true
|
|
838
|
+
})
|
|
839
|
+
projectedGeometry = { ...geometry, proximityAnchors }
|
|
840
|
+
projectedGeometryTimestamp = now
|
|
841
|
+
}
|
|
842
|
+
return projectedGeometry
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const consume = (event: MouseEvent | PointerEvent) => {
|
|
846
|
+
event.preventDefault()
|
|
847
|
+
event.stopPropagation()
|
|
848
|
+
event.stopImmediatePropagation()
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const closestVertexIndex = (
|
|
852
|
+
event: MouseEvent | PointerEvent,
|
|
853
|
+
points: readonly MeasurementPoint[],
|
|
854
|
+
threshold = VERTEX_HANDLE_DISTANCE_PX,
|
|
855
|
+
) => {
|
|
856
|
+
let closestIndex: number | null = null
|
|
857
|
+
let closestDistance = threshold
|
|
858
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
859
|
+
const distance = screenDistanceToPlanPoint(
|
|
860
|
+
group,
|
|
861
|
+
points[index]!,
|
|
862
|
+
event.clientX,
|
|
863
|
+
event.clientY,
|
|
864
|
+
)
|
|
865
|
+
if (distance > closestDistance) continue
|
|
866
|
+
closestDistance = distance
|
|
867
|
+
closestIndex = index
|
|
868
|
+
}
|
|
869
|
+
return closestIndex
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const resolveEventPoint = (
|
|
873
|
+
event: MouseEvent | PointerEvent,
|
|
874
|
+
anchorsOverride?: readonly MeasurementPoint[],
|
|
875
|
+
) => {
|
|
876
|
+
const plan = clientToPlanPoint(group, event.clientX, event.clientY)
|
|
877
|
+
if (!plan) return null
|
|
878
|
+
const draft = useMeasurementDraft.getState()
|
|
879
|
+
const projectedGeometry = getProjectedGeometry()
|
|
880
|
+
const projectedSnap = resolveProjectedFloorplanSnap(
|
|
881
|
+
{ x: event.clientX, z: event.clientY },
|
|
882
|
+
projectedGeometry.vertices,
|
|
883
|
+
projectedGeometry.segments,
|
|
884
|
+
)
|
|
885
|
+
const projectedPlan = projectedSnap
|
|
886
|
+
? clientToPlanPoint(group, projectedSnap.point.x, projectedSnap.point.z)
|
|
887
|
+
: null
|
|
888
|
+
// Measurement anchors always bind to real geometry — the construction
|
|
889
|
+
// snapping-mode chip doesn't govern this analysis tool. Alt bypasses,
|
|
890
|
+
// including the projected registry-geometry pull.
|
|
891
|
+
const surfaceSnap = resolveSurfacePlanPointSnap({
|
|
892
|
+
rawPoint: [plan.x, plan.z],
|
|
893
|
+
fallbackPoint:
|
|
894
|
+
projectedPlan && !event.altKey ? [projectedPlan.x, projectedPlan.z] : [plan.x, plan.z],
|
|
895
|
+
levelId: activeLevelId,
|
|
896
|
+
align: false,
|
|
897
|
+
magnetic: !event.altKey,
|
|
898
|
+
})
|
|
899
|
+
// A discrete wall snap (corner / midpoint / crossing) is the strongest
|
|
900
|
+
// signal; a locked axis must not pull the point off it.
|
|
901
|
+
const discreteWallSnap =
|
|
902
|
+
surfaceSnap.wallSnap === 'endpoint' ||
|
|
903
|
+
surfaceSnap.wallSnap === 'midpoint' ||
|
|
904
|
+
surfaceSnap.wallSnap === 'intersection'
|
|
905
|
+
const raw: MeasurementPoint = [surfaceSnap.point[0], 0, surfaceSnap.point[1]]
|
|
906
|
+
const targetNodeId = surfaceSnap.wallIds[0] ?? projectedSnap?.nodeId ?? null
|
|
907
|
+
const lastPoint = draft.points.at(-1)
|
|
908
|
+
const resolved = snapPlanPoint(
|
|
909
|
+
group,
|
|
910
|
+
raw,
|
|
911
|
+
anchorsOverride ?? (lastPoint ? [lastPoint] : []),
|
|
912
|
+
event.clientX,
|
|
913
|
+
event.clientY,
|
|
914
|
+
!discreteWallSnap && !event.altKey,
|
|
915
|
+
!event.altKey &&
|
|
916
|
+
!discreteWallSnap &&
|
|
917
|
+
draft.axisGuide?.snapped &&
|
|
918
|
+
draft.axisGuide.axis !== 'y'
|
|
919
|
+
? draft.axisGuide
|
|
920
|
+
: null,
|
|
921
|
+
projectedGeometry.proximityAnchors,
|
|
922
|
+
)
|
|
923
|
+
if (targetNodeId && resolved.guide?.snapped) {
|
|
924
|
+
const candidate = planPointToClient(group, resolved.point)
|
|
925
|
+
if (
|
|
926
|
+
!candidate ||
|
|
927
|
+
!isProjectedFloorplanAxisPointVerified(
|
|
928
|
+
{ x: candidate.x, z: candidate.y },
|
|
929
|
+
targetNodeId,
|
|
930
|
+
projectedGeometry.vertices,
|
|
931
|
+
projectedGeometry.segments,
|
|
932
|
+
)
|
|
933
|
+
) {
|
|
934
|
+
const surfaceOnly = snapPlanPoint(
|
|
935
|
+
group,
|
|
936
|
+
raw,
|
|
937
|
+
anchorsOverride ?? (lastPoint ? [lastPoint] : []),
|
|
938
|
+
event.clientX,
|
|
939
|
+
event.clientY,
|
|
940
|
+
false,
|
|
941
|
+
null,
|
|
942
|
+
projectedGeometry.proximityAnchors,
|
|
943
|
+
)
|
|
944
|
+
return { ...surfaceOnly, targetNodeId }
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return { ...resolved, targetNodeId }
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const clearVertexGesture = (finish: boolean) => {
|
|
951
|
+
const gesture = vertexGesture.current
|
|
952
|
+
if (!gesture) return
|
|
953
|
+
if (gesture.engaged) {
|
|
954
|
+
const draft = useMeasurementDraft.getState()
|
|
955
|
+
if (finish) draft.finishVertexDrag('2d')
|
|
956
|
+
else draft.cancelVertexDrag('2d')
|
|
957
|
+
useViewer.getState().setInputDragging(gesture.previousInputDragging)
|
|
958
|
+
}
|
|
959
|
+
document.body.style.cursor = gesture.previousCursor
|
|
960
|
+
if (svg.hasPointerCapture?.(gesture.pointerId)) svg.releasePointerCapture(gesture.pointerId)
|
|
961
|
+
vertexGesture.current = null
|
|
962
|
+
}
|
|
963
|
+
cancelVertexGesture.current = () => clearVertexGesture(false)
|
|
964
|
+
|
|
965
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
966
|
+
const draft = useMeasurementDraft.getState()
|
|
967
|
+
if (event.button !== 0 || (draft.owner && draft.owner !== '2d')) return
|
|
968
|
+
if (isExtrusionControlTarget(event.target)) return
|
|
969
|
+
consume(event)
|
|
970
|
+
if (draft.owner !== '2d' || draft.stage !== 'collecting' || draft.vertexDrag) return
|
|
971
|
+
if (draft.kind === 'distance') return
|
|
972
|
+
const polygon = draft.kind === 'area' || draft.kind === 'perimeter' || draft.kind === 'volume'
|
|
973
|
+
const threshold = event.pointerType === 'touch' ? 22 : VERTEX_HANDLE_DISTANCE_PX
|
|
974
|
+
const vertexIndex = closestVertexIndex(event, draft.points, threshold)
|
|
975
|
+
const midpointIndex =
|
|
976
|
+
vertexIndex === null && polygon
|
|
977
|
+
? closestVertexIndex(
|
|
978
|
+
event,
|
|
979
|
+
measurementPolygonMidpoints(draft.points).map((midpoint) => midpoint.point),
|
|
980
|
+
event.pointerType === 'touch' ? 18 : MIDPOINT_HANDLE_DISTANCE_PX,
|
|
981
|
+
)
|
|
982
|
+
: null
|
|
983
|
+
if (vertexIndex === null && midpointIndex === null) return
|
|
984
|
+
svg.setPointerCapture?.(event.pointerId)
|
|
985
|
+
vertexGesture.current = {
|
|
986
|
+
pointerId: event.pointerId,
|
|
987
|
+
source: vertexIndex !== null ? 'vertex' : 'midpoint',
|
|
988
|
+
index: vertexIndex ?? midpointIndex!,
|
|
989
|
+
startX: event.clientX,
|
|
990
|
+
startY: event.clientY,
|
|
991
|
+
engaged: false,
|
|
992
|
+
previousInputDragging: useViewer.getState().inputDragging,
|
|
993
|
+
previousCursor: document.body.style.cursor,
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
998
|
+
const draft = useMeasurementDraft.getState()
|
|
999
|
+
if (draft.owner && draft.owner !== '2d') return
|
|
1000
|
+
|
|
1001
|
+
const gesture = vertexGesture.current
|
|
1002
|
+
if (gesture?.pointerId === event.pointerId) {
|
|
1003
|
+
consume(event)
|
|
1004
|
+
if (!gesture.engaged) {
|
|
1005
|
+
if (
|
|
1006
|
+
Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <
|
|
1007
|
+
VERTEX_DRAG_ACTIVATION_PX
|
|
1008
|
+
) {
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
const began =
|
|
1012
|
+
gesture.source === 'vertex'
|
|
1013
|
+
? draft.beginVertexDrag('2d', gesture.index)
|
|
1014
|
+
: draft.beginMidpointVertexDrag('2d', gesture.index)
|
|
1015
|
+
if (!began) {
|
|
1016
|
+
clearVertexGesture(false)
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
if (gesture.source === 'midpoint') gesture.index += 1
|
|
1020
|
+
gesture.engaged = true
|
|
1021
|
+
useViewer.getState().setInputDragging(true)
|
|
1022
|
+
document.body.style.cursor = 'grabbing'
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const activeDraft = useMeasurementDraft.getState()
|
|
1026
|
+
const anchors = measurementVertexSnapAnchors(
|
|
1027
|
+
activeDraft.points,
|
|
1028
|
+
gesture.index,
|
|
1029
|
+
activeDraft.kind === 'area' ||
|
|
1030
|
+
activeDraft.kind === 'perimeter' ||
|
|
1031
|
+
activeDraft.kind === 'volume',
|
|
1032
|
+
)
|
|
1033
|
+
const resolved = resolveEventPoint(event, anchors)
|
|
1034
|
+
if (resolved) {
|
|
1035
|
+
const associated = associatePlanPoint(
|
|
1036
|
+
resolved.point,
|
|
1037
|
+
resolved.targetNodeId,
|
|
1038
|
+
event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
|
|
1039
|
+
)
|
|
1040
|
+
activeDraft.updateDraggedVertex(
|
|
1041
|
+
'2d',
|
|
1042
|
+
{
|
|
1043
|
+
point: associated.point,
|
|
1044
|
+
normal: [0, 1, 0],
|
|
1045
|
+
targetNodeId: resolved.targetNodeId,
|
|
1046
|
+
anchor: associated.anchor,
|
|
1047
|
+
semantic: associated.semantic,
|
|
1048
|
+
},
|
|
1049
|
+
resolved.guide,
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (draft.stage !== 'collecting') return
|
|
1056
|
+
const resolved = resolveEventPoint(event)
|
|
1057
|
+
const associated = resolved
|
|
1058
|
+
? associatePlanPoint(
|
|
1059
|
+
resolved.point,
|
|
1060
|
+
resolved.targetNodeId,
|
|
1061
|
+
event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
|
|
1062
|
+
)
|
|
1063
|
+
: null
|
|
1064
|
+
draft.setHover(
|
|
1065
|
+
'2d',
|
|
1066
|
+
resolved && associated
|
|
1067
|
+
? {
|
|
1068
|
+
point: associated.point,
|
|
1069
|
+
normal: [0, 1, 0],
|
|
1070
|
+
targetNodeId: resolved.targetNodeId,
|
|
1071
|
+
anchor: associated.anchor,
|
|
1072
|
+
semantic: associated.semantic,
|
|
1073
|
+
}
|
|
1074
|
+
: null,
|
|
1075
|
+
resolved?.guide ?? null,
|
|
1076
|
+
)
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const onPointerLeave = () => {
|
|
1080
|
+
if (vertexGesture.current) return
|
|
1081
|
+
clearSurfacePlanSnapFeedback()
|
|
1082
|
+
const draft = useMeasurementDraft.getState()
|
|
1083
|
+
if (!draft.owner || draft.owner === '2d') draft.setHover('2d', null)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
1087
|
+
const gesture = vertexGesture.current
|
|
1088
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return
|
|
1089
|
+
consume(event)
|
|
1090
|
+
const wasEngaged = gesture.engaged
|
|
1091
|
+
const source = gesture.source
|
|
1092
|
+
const vertexIndex = gesture.index
|
|
1093
|
+
clearVertexGesture(true)
|
|
1094
|
+
if (!wasEngaged && source === 'vertex') {
|
|
1095
|
+
const draft = useMeasurementDraft.getState()
|
|
1096
|
+
if (vertexIndex === 0 && draft.points.length >= 3 && draft.kind !== 'angle') {
|
|
1097
|
+
closeBaseAndMaybeCommit()
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
suppressNextClick.current = true
|
|
1101
|
+
setTimeout(() => {
|
|
1102
|
+
suppressNextClick.current = false
|
|
1103
|
+
}, 300)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const onPointerCancel = (event: PointerEvent) => {
|
|
1107
|
+
const gesture = vertexGesture.current
|
|
1108
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return
|
|
1109
|
+
consume(event)
|
|
1110
|
+
clearVertexGesture(false)
|
|
1111
|
+
clearSurfacePlanSnapFeedback()
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const onClick = (event: MouseEvent) => {
|
|
1115
|
+
const draft = useMeasurementDraft.getState()
|
|
1116
|
+
if (event.button !== 0 || (draft.owner && draft.owner !== '2d')) return
|
|
1117
|
+
if (isExtrusionControlTarget(event.target)) return
|
|
1118
|
+
consume(event)
|
|
1119
|
+
if (suppressNextClick.current) {
|
|
1120
|
+
suppressNextClick.current = false
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
if (event.detail > 1 || draft.stage !== 'collecting') return
|
|
1124
|
+
|
|
1125
|
+
const vertexIndex = draft.kind === 'distance' ? null : closestVertexIndex(event, draft.points)
|
|
1126
|
+
if (vertexIndex !== null) {
|
|
1127
|
+
if (vertexIndex === 0 && draft.points.length >= 3 && draft.kind !== 'angle') {
|
|
1128
|
+
closeBaseAndMaybeCommit()
|
|
1129
|
+
}
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
const resolved = resolveEventPoint(event)
|
|
1133
|
+
if (!resolved) return
|
|
1134
|
+
const associated = associatePlanPoint(
|
|
1135
|
+
resolved.point,
|
|
1136
|
+
resolved.targetNodeId,
|
|
1137
|
+
event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
|
|
1138
|
+
)
|
|
1139
|
+
if (!draft.addPoint('2d', associated.point, associated.anchor)) return
|
|
1140
|
+
if (useMeasurementDraft.getState().stage === 'ready') commitMeasurementDraft('2d')
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const onDoubleClick = (event: MouseEvent) => {
|
|
1144
|
+
const draft = useMeasurementDraft.getState()
|
|
1145
|
+
if (draft.owner !== '2d' || draft.stage !== 'collecting') return
|
|
1146
|
+
if (draft.kind === 'distance' || draft.kind === 'angle') return
|
|
1147
|
+
if (isExtrusionControlTarget(event.target)) return
|
|
1148
|
+
consume(event)
|
|
1149
|
+
if (suppressNextClick.current) {
|
|
1150
|
+
suppressNextClick.current = false
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
closeBaseAndMaybeCommit()
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const onBlur = () => {
|
|
1157
|
+
clearVertexGesture(false)
|
|
1158
|
+
clearSurfacePlanSnapFeedback()
|
|
1159
|
+
}
|
|
1160
|
+
const onToolCancel = () => {
|
|
1161
|
+
clearVertexGesture(false)
|
|
1162
|
+
clearSurfacePlanSnapFeedback()
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
emitter.on('tool:cancel', onToolCancel)
|
|
1166
|
+
svg.addEventListener('pointerdown', onPointerDown, true)
|
|
1167
|
+
svg.addEventListener('pointermove', onPointerMove, true)
|
|
1168
|
+
svg.addEventListener('pointerup', onPointerUp, true)
|
|
1169
|
+
svg.addEventListener('pointercancel', onPointerCancel, true)
|
|
1170
|
+
svg.addEventListener('pointerleave', onPointerLeave, true)
|
|
1171
|
+
svg.addEventListener('click', onClick, true)
|
|
1172
|
+
svg.addEventListener('dblclick', onDoubleClick, true)
|
|
1173
|
+
window.addEventListener('blur', onBlur)
|
|
1174
|
+
return () => {
|
|
1175
|
+
cancelVertexGesture.current = () => {}
|
|
1176
|
+
clearVertexGesture(false)
|
|
1177
|
+
clearSurfacePlanSnapFeedback()
|
|
1178
|
+
emitter.off('tool:cancel', onToolCancel)
|
|
1179
|
+
svg.removeEventListener('pointerdown', onPointerDown, true)
|
|
1180
|
+
svg.removeEventListener('pointermove', onPointerMove, true)
|
|
1181
|
+
svg.removeEventListener('pointerup', onPointerUp, true)
|
|
1182
|
+
svg.removeEventListener('pointercancel', onPointerCancel, true)
|
|
1183
|
+
svg.removeEventListener('pointerleave', onPointerLeave, true)
|
|
1184
|
+
svg.removeEventListener('click', onClick, true)
|
|
1185
|
+
svg.removeEventListener('dblclick', onDoubleClick, true)
|
|
1186
|
+
window.removeEventListener('blur', onBlur)
|
|
1187
|
+
}
|
|
1188
|
+
}, [active, activeLevelId, owner])
|
|
1189
|
+
|
|
1190
|
+
const preview = useMemo(() => {
|
|
1191
|
+
const livePoints =
|
|
1192
|
+
stage === 'collecting' && hover && !vertexDrag ? [...points, hover.point] : points
|
|
1193
|
+
const planPoints = livePoints.map((point) => ({ x: point[0], y: point[2] }))
|
|
1194
|
+
const polygonPoints = planPoints.map((point) => `${point.x},${point.y}`).join(' ')
|
|
1195
|
+
const angleArc =
|
|
1196
|
+
kind === 'angle' && livePoints.length >= 3
|
|
1197
|
+
? buildMeasurementAngleArcPoints(livePoints[0]!, livePoints[1]!, livePoints[2]!)
|
|
1198
|
+
: []
|
|
1199
|
+
const angleArcPoints = angleArc.map((point) => `${point[0]},${point[2]}`).join(' ')
|
|
1200
|
+
const first = planPoints[0]
|
|
1201
|
+
const last = planPoints[planPoints.length - 1]
|
|
1202
|
+
const center = measurementPolygonLabelAnchor(points)
|
|
1203
|
+
const polygon = kind === 'area' || kind === 'perimeter' || kind === 'volume'
|
|
1204
|
+
const midpointHandles =
|
|
1205
|
+
polygon && stage === 'collecting' && !vertexDrag ? measurementPolygonMidpoints(points) : []
|
|
1206
|
+
const activeEdges: Array<[MeasurementPoint, MeasurementPoint]> = vertexDrag
|
|
1207
|
+
? measurementVertexSnapAnchors(points, vertexDrag.index, polygon).map((neighbor) => [
|
|
1208
|
+
neighbor,
|
|
1209
|
+
points[vertexDrag.index]!,
|
|
1210
|
+
])
|
|
1211
|
+
: []
|
|
1212
|
+
let label: {
|
|
1213
|
+
angle: number
|
|
1214
|
+
point: MeasurementPoint
|
|
1215
|
+
screenUpright: boolean
|
|
1216
|
+
text: string
|
|
1217
|
+
} | null = null
|
|
1218
|
+
let segmentLabel: { angle: number; point: MeasurementPoint; text: string } | null = null
|
|
1219
|
+
|
|
1220
|
+
if (kind === 'distance' && livePoints.length >= 2) {
|
|
1221
|
+
const start = livePoints[0]!
|
|
1222
|
+
const end = livePoints[livePoints.length - 1]!
|
|
1223
|
+
label = {
|
|
1224
|
+
angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
|
|
1225
|
+
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
|
|
1226
|
+
screenUpright: false,
|
|
1227
|
+
text: formatLinearMeasurement(measurementDistance(start, end), unit),
|
|
1228
|
+
}
|
|
1229
|
+
} else if (kind === 'angle' && livePoints.length >= 3) {
|
|
1230
|
+
const anglePoints = livePoints.slice(0, 3) as [
|
|
1231
|
+
MeasurementPoint,
|
|
1232
|
+
MeasurementPoint,
|
|
1233
|
+
MeasurementPoint,
|
|
1234
|
+
]
|
|
1235
|
+
label = {
|
|
1236
|
+
angle: 0,
|
|
1237
|
+
point: angleArc[Math.floor(angleArc.length / 2)] ?? anglePoints[1],
|
|
1238
|
+
screenUpright: true,
|
|
1239
|
+
text: formatAngleRadians(measurementAngle(...anglePoints)),
|
|
1240
|
+
}
|
|
1241
|
+
} else if ((kind === 'area' || kind === 'perimeter') && livePoints.length >= 3) {
|
|
1242
|
+
const liveCenter = measurementPolygonLabelAnchor(livePoints)
|
|
1243
|
+
if (liveCenter) {
|
|
1244
|
+
label = {
|
|
1245
|
+
angle: 0,
|
|
1246
|
+
point: liveCenter,
|
|
1247
|
+
screenUpright: true,
|
|
1248
|
+
text:
|
|
1249
|
+
kind === 'area'
|
|
1250
|
+
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
|
|
1251
|
+
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
} else if (kind === 'volume' && center && baseNormal) {
|
|
1255
|
+
const extrusion: MeasurementPoint = [
|
|
1256
|
+
baseNormal[0] * extrusionHeight,
|
|
1257
|
+
baseNormal[1] * extrusionHeight,
|
|
1258
|
+
baseNormal[2] * extrusionHeight,
|
|
1259
|
+
]
|
|
1260
|
+
if (stage !== 'extruding') {
|
|
1261
|
+
label = {
|
|
1262
|
+
angle: 0,
|
|
1263
|
+
point: center,
|
|
1264
|
+
screenUpright: true,
|
|
1265
|
+
text: `V ${formatVolumeLabel(measurementPrismVolume(points, extrusion), unit)}`,
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (kind !== 'distance' && stage === 'collecting') {
|
|
1271
|
+
const segmentEnd = vertexDrag ? points[vertexDrag.index] : hover?.point
|
|
1272
|
+
const segmentStart = vertexDrag
|
|
1273
|
+
? (axisGuide?.from ?? points[(vertexDrag.index - 1 + points.length) % points.length])
|
|
1274
|
+
: points.at(-1)
|
|
1275
|
+
if (segmentStart && segmentEnd && measurementDistance(segmentStart, segmentEnd) > 1e-6) {
|
|
1276
|
+
segmentLabel = {
|
|
1277
|
+
angle: Math.atan2(segmentEnd[2] - segmentStart[2], segmentEnd[0] - segmentStart[0]),
|
|
1278
|
+
point: [
|
|
1279
|
+
(segmentStart[0] + segmentEnd[0]) / 2,
|
|
1280
|
+
(segmentStart[1] + segmentEnd[1]) / 2,
|
|
1281
|
+
(segmentStart[2] + segmentEnd[2]) / 2,
|
|
1282
|
+
],
|
|
1283
|
+
text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return {
|
|
1289
|
+
activeEdges,
|
|
1290
|
+
angleArcPoints,
|
|
1291
|
+
center,
|
|
1292
|
+
first,
|
|
1293
|
+
label,
|
|
1294
|
+
last,
|
|
1295
|
+
livePoints,
|
|
1296
|
+
midpointHandles,
|
|
1297
|
+
planPoints,
|
|
1298
|
+
polygonPoints,
|
|
1299
|
+
segmentLabel,
|
|
1300
|
+
}
|
|
1301
|
+
}, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
|
|
1302
|
+
|
|
1303
|
+
if (smartActive) return <FloorplanQuickMeasureLayer />
|
|
1304
|
+
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
|
|
1305
|
+
const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
|
|
1306
|
+
const sceneRotationDeg = renderContext?.sceneRotationDeg ?? 0
|
|
1307
|
+
const anchorRadius = 4.5 * unitsPerPixel
|
|
1308
|
+
const labelFontSize = 12 * unitsPerPixel
|
|
1309
|
+
const reticleRadius = 16 * unitsPerPixel
|
|
1310
|
+
const reticleInnerRadius = 9 * unitsPerPixel
|
|
1311
|
+
const cornerSnap = hover?.semantic?.snapKind === 'endpoint'
|
|
1312
|
+
const reticleColor = axisGuide?.snapped
|
|
1313
|
+
? axisGuide.axis === 'x'
|
|
1314
|
+
? X_AXIS_COLOR
|
|
1315
|
+
: axisGuide.axis === 'z'
|
|
1316
|
+
? Z_AXIS_COLOR
|
|
1317
|
+
: RETICLE_COLOR
|
|
1318
|
+
: hover?.semantic
|
|
1319
|
+
? '#22c55e'
|
|
1320
|
+
: RETICLE_COLOR
|
|
1321
|
+
const distanceStrokeColor =
|
|
1322
|
+
kind === 'distance' && axisGuide?.snapped
|
|
1323
|
+
? axisGuide.axis === 'x'
|
|
1324
|
+
? X_AXIS_COLOR
|
|
1325
|
+
: Z_AXIS_COLOR
|
|
1326
|
+
: DRAFT_COLOR
|
|
1327
|
+
const guideAnchor = vertexDrag && axisGuide ? axisGuide.from : points.at(-1)
|
|
1328
|
+
const guideHalfLength = AXIS_GUIDE_HALF_LENGTH_PX * unitsPerPixel
|
|
1329
|
+
const labelBackground = renderContext?.palette.measurementLabelBackground ?? '#0f172a'
|
|
1330
|
+
const labelText = renderContext?.palette.measurementLabelText ?? '#f8fafc'
|
|
1331
|
+
|
|
1332
|
+
return (
|
|
1333
|
+
<g ref={groupRef}>
|
|
1334
|
+
{guideAnchor ? (
|
|
1335
|
+
<g pointerEvents="none">
|
|
1336
|
+
<line
|
|
1337
|
+
stroke={X_AXIS_COLOR}
|
|
1338
|
+
strokeDasharray={axisGuide?.axis === 'x' && axisGuide.snapped ? undefined : '6 5'}
|
|
1339
|
+
strokeOpacity={axisGuide?.axis === 'x' ? (axisGuide.snapped ? 1 : 0.8) : 0.28}
|
|
1340
|
+
strokeWidth={axisGuide?.axis === 'x' ? (axisGuide.snapped ? 3.5 : 2) : 1}
|
|
1341
|
+
vectorEffect="non-scaling-stroke"
|
|
1342
|
+
x1={guideAnchor[0] - guideHalfLength}
|
|
1343
|
+
x2={guideAnchor[0] + guideHalfLength}
|
|
1344
|
+
y1={guideAnchor[2]}
|
|
1345
|
+
y2={guideAnchor[2]}
|
|
1346
|
+
/>
|
|
1347
|
+
<line
|
|
1348
|
+
stroke={Z_AXIS_COLOR}
|
|
1349
|
+
strokeDasharray={axisGuide?.axis === 'z' && axisGuide.snapped ? undefined : '6 5'}
|
|
1350
|
+
strokeOpacity={axisGuide?.axis === 'z' ? (axisGuide.snapped ? 1 : 0.8) : 0.28}
|
|
1351
|
+
strokeWidth={axisGuide?.axis === 'z' ? (axisGuide.snapped ? 3.5 : 2) : 1}
|
|
1352
|
+
vectorEffect="non-scaling-stroke"
|
|
1353
|
+
x1={guideAnchor[0]}
|
|
1354
|
+
x2={guideAnchor[0]}
|
|
1355
|
+
y1={guideAnchor[2] - guideHalfLength}
|
|
1356
|
+
y2={guideAnchor[2] + guideHalfLength}
|
|
1357
|
+
/>
|
|
1358
|
+
</g>
|
|
1359
|
+
) : null}
|
|
1360
|
+
{kind === 'distance' && preview.planPoints.length >= 2 ? (
|
|
1361
|
+
<line
|
|
1362
|
+
stroke={distanceStrokeColor}
|
|
1363
|
+
strokeLinecap="round"
|
|
1364
|
+
strokeWidth={2}
|
|
1365
|
+
vectorEffect="non-scaling-stroke"
|
|
1366
|
+
x1={preview.planPoints[0]!.x}
|
|
1367
|
+
x2={preview.planPoints[preview.planPoints.length - 1]!.x}
|
|
1368
|
+
y1={preview.planPoints[0]!.y}
|
|
1369
|
+
y2={preview.planPoints[preview.planPoints.length - 1]!.y}
|
|
1370
|
+
/>
|
|
1371
|
+
) : null}
|
|
1372
|
+
{kind === 'angle' && preview.planPoints.length >= 2 ? (
|
|
1373
|
+
<>
|
|
1374
|
+
<polyline
|
|
1375
|
+
fill="none"
|
|
1376
|
+
points={preview.polygonPoints}
|
|
1377
|
+
stroke={DRAFT_COLOR}
|
|
1378
|
+
strokeLinecap="round"
|
|
1379
|
+
strokeLinejoin="round"
|
|
1380
|
+
strokeWidth={2}
|
|
1381
|
+
vectorEffect="non-scaling-stroke"
|
|
1382
|
+
/>
|
|
1383
|
+
{preview.angleArcPoints ? (
|
|
1384
|
+
<polyline
|
|
1385
|
+
fill="none"
|
|
1386
|
+
points={preview.angleArcPoints}
|
|
1387
|
+
stroke={DRAFT_COLOR}
|
|
1388
|
+
strokeLinecap="round"
|
|
1389
|
+
strokeLinejoin="round"
|
|
1390
|
+
strokeWidth={3}
|
|
1391
|
+
vectorEffect="non-scaling-stroke"
|
|
1392
|
+
/>
|
|
1393
|
+
) : null}
|
|
1394
|
+
</>
|
|
1395
|
+
) : null}
|
|
1396
|
+
{(kind === 'area' || kind === 'perimeter' || kind === 'volume') &&
|
|
1397
|
+
preview.planPoints.length >= 2 ? (
|
|
1398
|
+
<>
|
|
1399
|
+
{kind !== 'perimeter' && preview.planPoints.length >= 3 ? (
|
|
1400
|
+
<polygon fill={DRAFT_COLOR} fillOpacity={0.12} points={preview.polygonPoints} />
|
|
1401
|
+
) : null}
|
|
1402
|
+
<polyline
|
|
1403
|
+
fill="none"
|
|
1404
|
+
points={preview.polygonPoints}
|
|
1405
|
+
stroke={DRAFT_COLOR}
|
|
1406
|
+
strokeLinecap="round"
|
|
1407
|
+
strokeLinejoin="round"
|
|
1408
|
+
strokeWidth={2}
|
|
1409
|
+
vectorEffect="non-scaling-stroke"
|
|
1410
|
+
/>
|
|
1411
|
+
{preview.first && preview.last ? (
|
|
1412
|
+
<line
|
|
1413
|
+
stroke={DRAFT_COLOR}
|
|
1414
|
+
strokeDasharray="5 4"
|
|
1415
|
+
strokeOpacity={0.65}
|
|
1416
|
+
strokeWidth={1.5}
|
|
1417
|
+
vectorEffect="non-scaling-stroke"
|
|
1418
|
+
x1={preview.last.x}
|
|
1419
|
+
x2={preview.first.x}
|
|
1420
|
+
y1={preview.last.y}
|
|
1421
|
+
y2={preview.first.y}
|
|
1422
|
+
/>
|
|
1423
|
+
) : null}
|
|
1424
|
+
</>
|
|
1425
|
+
) : null}
|
|
1426
|
+
{preview.activeEdges.map(([start, end], index) => (
|
|
1427
|
+
<line
|
|
1428
|
+
key={`measurement-active-edge-${index}`}
|
|
1429
|
+
stroke="#f8fafc"
|
|
1430
|
+
strokeLinecap="round"
|
|
1431
|
+
strokeOpacity={0.95}
|
|
1432
|
+
strokeWidth={3}
|
|
1433
|
+
vectorEffect="non-scaling-stroke"
|
|
1434
|
+
x1={start[0]}
|
|
1435
|
+
x2={end[0]}
|
|
1436
|
+
y1={start[2]}
|
|
1437
|
+
y2={end[2]}
|
|
1438
|
+
/>
|
|
1439
|
+
))}
|
|
1440
|
+
{axisGuide ? (
|
|
1441
|
+
<g pointerEvents="none">
|
|
1442
|
+
<line
|
|
1443
|
+
stroke="#f8fafc"
|
|
1444
|
+
strokeOpacity={axisGuide.snapped ? 0.8 : 0.35}
|
|
1445
|
+
strokeWidth={4}
|
|
1446
|
+
vectorEffect="non-scaling-stroke"
|
|
1447
|
+
x1={axisGuide.from[0]}
|
|
1448
|
+
x2={axisGuide.to[0]}
|
|
1449
|
+
y1={axisGuide.from[2]}
|
|
1450
|
+
y2={axisGuide.to[2]}
|
|
1451
|
+
/>
|
|
1452
|
+
<line
|
|
1453
|
+
stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
|
|
1454
|
+
strokeDasharray={axisGuide.snapped ? undefined : '5 4'}
|
|
1455
|
+
strokeOpacity={axisGuide.snapped ? 1 : 0.72}
|
|
1456
|
+
strokeWidth={axisGuide.snapped ? 2.5 : 1.75}
|
|
1457
|
+
vectorEffect="non-scaling-stroke"
|
|
1458
|
+
x1={axisGuide.from[0]}
|
|
1459
|
+
x2={axisGuide.to[0]}
|
|
1460
|
+
y1={axisGuide.from[2]}
|
|
1461
|
+
y2={axisGuide.to[2]}
|
|
1462
|
+
/>
|
|
1463
|
+
<text
|
|
1464
|
+
dominantBaseline="central"
|
|
1465
|
+
fill={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
|
|
1466
|
+
fontSize={labelFontSize}
|
|
1467
|
+
fontWeight="700"
|
|
1468
|
+
textAnchor="middle"
|
|
1469
|
+
transform={`rotate(${-sceneRotationDeg} ${axisGuide.to[0]} ${axisGuide.to[2]})`}
|
|
1470
|
+
x={axisGuide.to[0]}
|
|
1471
|
+
y={axisGuide.to[2] - 24 * unitsPerPixel}
|
|
1472
|
+
>
|
|
1473
|
+
{axisGuide.proximity ? 'ALIGN ' : ''}
|
|
1474
|
+
{axisGuide.axis.toUpperCase()}
|
|
1475
|
+
</text>
|
|
1476
|
+
{axisGuide.snapped ? (
|
|
1477
|
+
<>
|
|
1478
|
+
<circle
|
|
1479
|
+
cx={axisGuide.to[0]}
|
|
1480
|
+
cy={axisGuide.to[2]}
|
|
1481
|
+
fill={labelBackground}
|
|
1482
|
+
r={6 * unitsPerPixel}
|
|
1483
|
+
stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
|
|
1484
|
+
strokeWidth={3}
|
|
1485
|
+
vectorEffect="non-scaling-stroke"
|
|
1486
|
+
/>
|
|
1487
|
+
<circle
|
|
1488
|
+
cx={axisGuide.to[0]}
|
|
1489
|
+
cy={axisGuide.to[2]}
|
|
1490
|
+
fill={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
|
|
1491
|
+
r={2 * unitsPerPixel}
|
|
1492
|
+
/>
|
|
1493
|
+
</>
|
|
1494
|
+
) : null}
|
|
1495
|
+
{axisGuide.proximity && axisGuide.snapped ? (
|
|
1496
|
+
<circle
|
|
1497
|
+
cx={axisGuide.from[0]}
|
|
1498
|
+
cy={axisGuide.from[2]}
|
|
1499
|
+
fill={labelBackground}
|
|
1500
|
+
r={5 * unitsPerPixel}
|
|
1501
|
+
stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
|
|
1502
|
+
strokeWidth={2}
|
|
1503
|
+
vectorEffect="non-scaling-stroke"
|
|
1504
|
+
/>
|
|
1505
|
+
) : null}
|
|
1506
|
+
</g>
|
|
1507
|
+
) : null}
|
|
1508
|
+
{points.map((point, index) => (
|
|
1509
|
+
<circle
|
|
1510
|
+
cx={point[0]}
|
|
1511
|
+
cy={point[2]}
|
|
1512
|
+
fill={vertexDrag?.index === index ? '#f8fafc' : DRAFT_COLOR}
|
|
1513
|
+
key={`measurement-draft-point-${index}`}
|
|
1514
|
+
pointerEvents="none"
|
|
1515
|
+
r={vertexDrag?.index === index ? anchorRadius * 1.2 : anchorRadius}
|
|
1516
|
+
stroke="white"
|
|
1517
|
+
strokeWidth={1}
|
|
1518
|
+
vectorEffect="non-scaling-stroke"
|
|
1519
|
+
/>
|
|
1520
|
+
))}
|
|
1521
|
+
{preview.midpointHandles.map(({ edgeIndex, point }) => (
|
|
1522
|
+
<circle
|
|
1523
|
+
cx={point[0]}
|
|
1524
|
+
cy={point[2]}
|
|
1525
|
+
fill="#a3e635"
|
|
1526
|
+
key={`measurement-draft-midpoint-${edgeIndex}`}
|
|
1527
|
+
pointerEvents="none"
|
|
1528
|
+
r={3 * unitsPerPixel}
|
|
1529
|
+
stroke="white"
|
|
1530
|
+
strokeWidth={1}
|
|
1531
|
+
vectorEffect="non-scaling-stroke"
|
|
1532
|
+
/>
|
|
1533
|
+
))}
|
|
1534
|
+
{hover && hoverOwner === '2d' ? (
|
|
1535
|
+
<g pointerEvents="none">
|
|
1536
|
+
{cornerSnap ? (
|
|
1537
|
+
<circle
|
|
1538
|
+
cx={hover.point[0]}
|
|
1539
|
+
cy={hover.point[2]}
|
|
1540
|
+
fill="none"
|
|
1541
|
+
r={reticleRadius + 6 * unitsPerPixel}
|
|
1542
|
+
stroke="#a3e635"
|
|
1543
|
+
strokeOpacity={0.9}
|
|
1544
|
+
strokeWidth={3}
|
|
1545
|
+
vectorEffect="non-scaling-stroke"
|
|
1546
|
+
/>
|
|
1547
|
+
) : null}
|
|
1548
|
+
<circle
|
|
1549
|
+
cx={hover.point[0]}
|
|
1550
|
+
cy={hover.point[2]}
|
|
1551
|
+
fill="none"
|
|
1552
|
+
r={reticleRadius}
|
|
1553
|
+
stroke={reticleColor}
|
|
1554
|
+
strokeWidth={2}
|
|
1555
|
+
vectorEffect="non-scaling-stroke"
|
|
1556
|
+
/>
|
|
1557
|
+
<circle
|
|
1558
|
+
cx={hover.point[0]}
|
|
1559
|
+
cy={hover.point[2]}
|
|
1560
|
+
fill="none"
|
|
1561
|
+
r={reticleInnerRadius}
|
|
1562
|
+
stroke={reticleColor}
|
|
1563
|
+
strokeOpacity={0.72}
|
|
1564
|
+
strokeWidth={1.5}
|
|
1565
|
+
vectorEffect="non-scaling-stroke"
|
|
1566
|
+
/>
|
|
1567
|
+
<line
|
|
1568
|
+
stroke={X_AXIS_COLOR}
|
|
1569
|
+
strokeWidth={2}
|
|
1570
|
+
vectorEffect="non-scaling-stroke"
|
|
1571
|
+
x1={hover.point[0] - reticleInnerRadius}
|
|
1572
|
+
x2={hover.point[0] + reticleInnerRadius}
|
|
1573
|
+
y1={hover.point[2]}
|
|
1574
|
+
y2={hover.point[2]}
|
|
1575
|
+
/>
|
|
1576
|
+
<line
|
|
1577
|
+
stroke={Z_AXIS_COLOR}
|
|
1578
|
+
strokeWidth={2}
|
|
1579
|
+
vectorEffect="non-scaling-stroke"
|
|
1580
|
+
x1={hover.point[0]}
|
|
1581
|
+
x2={hover.point[0]}
|
|
1582
|
+
y1={hover.point[2] - reticleInnerRadius}
|
|
1583
|
+
y2={hover.point[2] + reticleInnerRadius}
|
|
1584
|
+
/>
|
|
1585
|
+
</g>
|
|
1586
|
+
) : null}
|
|
1587
|
+
{hover && hoverOwner === '2d' && hover.semantic ? (
|
|
1588
|
+
<FloorplanDraftLabel
|
|
1589
|
+
background={labelBackground}
|
|
1590
|
+
border="#22c55e"
|
|
1591
|
+
offsetPx={30}
|
|
1592
|
+
point={hover.point}
|
|
1593
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1594
|
+
screenUpright
|
|
1595
|
+
text={`${hover.semantic.label}${
|
|
1596
|
+
hover.semantic.length === null
|
|
1597
|
+
? ''
|
|
1598
|
+
: ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
|
|
1599
|
+
}`}
|
|
1600
|
+
textColor={labelText}
|
|
1601
|
+
unitsPerPixel={unitsPerPixel}
|
|
1602
|
+
/>
|
|
1603
|
+
) : null}
|
|
1604
|
+
{preview.label ? (
|
|
1605
|
+
<FloorplanDraftLabel
|
|
1606
|
+
angle={preview.label.angle}
|
|
1607
|
+
appearance="outlined"
|
|
1608
|
+
background={labelBackground}
|
|
1609
|
+
border={DRAFT_COLOR}
|
|
1610
|
+
offsetPx={preview.label.screenUpright ? 0 : 14}
|
|
1611
|
+
point={preview.label.point}
|
|
1612
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1613
|
+
screenUpright={preview.label.screenUpright}
|
|
1614
|
+
text={preview.label.text}
|
|
1615
|
+
textColor={labelText}
|
|
1616
|
+
unitsPerPixel={unitsPerPixel}
|
|
1617
|
+
/>
|
|
1618
|
+
) : null}
|
|
1619
|
+
{preview.segmentLabel ? (
|
|
1620
|
+
<FloorplanDraftLabel
|
|
1621
|
+
angle={preview.segmentLabel.angle}
|
|
1622
|
+
background={labelBackground}
|
|
1623
|
+
border={DRAFT_COLOR}
|
|
1624
|
+
offsetPx={12}
|
|
1625
|
+
point={preview.segmentLabel.point}
|
|
1626
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1627
|
+
text={preview.segmentLabel.text}
|
|
1628
|
+
textColor={labelText}
|
|
1629
|
+
unitsPerPixel={unitsPerPixel}
|
|
1630
|
+
/>
|
|
1631
|
+
) : null}
|
|
1632
|
+
{error && preview.last ? (
|
|
1633
|
+
<FloorplanDraftLabel
|
|
1634
|
+
background={labelBackground}
|
|
1635
|
+
border="var(--destructive)"
|
|
1636
|
+
offsetPx={-16}
|
|
1637
|
+
point={[preview.last.x, 0, preview.last.y]}
|
|
1638
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1639
|
+
screenUpright
|
|
1640
|
+
text={error}
|
|
1641
|
+
textColor="var(--destructive)"
|
|
1642
|
+
unitsPerPixel={unitsPerPixel}
|
|
1643
|
+
/>
|
|
1644
|
+
) : null}
|
|
1645
|
+
{owner === '2d' && stage === 'extruding' && preview.center ? (
|
|
1646
|
+
<FloorplanExtrusionControl
|
|
1647
|
+
center={preview.center}
|
|
1648
|
+
sceneRotationDeg={sceneRotationDeg}
|
|
1649
|
+
unitsPerPixel={unitsPerPixel}
|
|
1650
|
+
/>
|
|
1651
|
+
) : null}
|
|
1652
|
+
</g>
|
|
1653
|
+
)
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
export default FloorplanMeasurementToolLayer
|