@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,1646 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type ArcResizeHandle,
|
|
7
|
+
type Cursor,
|
|
8
|
+
createSceneApi,
|
|
9
|
+
DEFAULT_ANGLE_STEP,
|
|
10
|
+
type HandleDescriptor,
|
|
11
|
+
type HandlePortal,
|
|
12
|
+
type LatchHandle,
|
|
13
|
+
type LinearResizeHandle,
|
|
14
|
+
nodeRegistry,
|
|
15
|
+
type RadialResizeHandle,
|
|
16
|
+
sceneRegistry,
|
|
17
|
+
snapScalar,
|
|
18
|
+
type TapActionHandle,
|
|
19
|
+
useLiveNodeOverrides,
|
|
20
|
+
useScene,
|
|
21
|
+
} from '@pascal-app/core'
|
|
22
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
23
|
+
import { Html } from '@react-three/drei'
|
|
24
|
+
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
|
25
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
26
|
+
import {
|
|
27
|
+
BufferGeometry,
|
|
28
|
+
Color,
|
|
29
|
+
CylinderGeometry,
|
|
30
|
+
DoubleSide,
|
|
31
|
+
Float32BufferAttribute,
|
|
32
|
+
type Group,
|
|
33
|
+
Matrix4,
|
|
34
|
+
type Object3D,
|
|
35
|
+
OrthographicCamera,
|
|
36
|
+
Plane,
|
|
37
|
+
Quaternion,
|
|
38
|
+
Ray,
|
|
39
|
+
RingGeometry,
|
|
40
|
+
Vector3,
|
|
41
|
+
} from 'three'
|
|
42
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
43
|
+
|
|
44
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
45
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
46
|
+
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
47
|
+
import { resolveDirectManipulationNode } from '../../lib/direct-manipulation'
|
|
48
|
+
import { createEditorApi } from '../../lib/editor-api'
|
|
49
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
50
|
+
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
|
51
|
+
import useEditor from '../../store/use-editor'
|
|
52
|
+
import useInteractionScope, {
|
|
53
|
+
useEndpointReshape,
|
|
54
|
+
useIsCurveReshape,
|
|
55
|
+
useMovingNode,
|
|
56
|
+
} from '../../store/use-interaction-scope'
|
|
57
|
+
import useOpeningGuides from '../../store/use-opening-guides'
|
|
58
|
+
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
|
59
|
+
import {
|
|
60
|
+
ARROW_COLOR,
|
|
61
|
+
ARROW_HOVER_COLOR,
|
|
62
|
+
ARROW_SCALE,
|
|
63
|
+
CORNER_HEX_RADIUS,
|
|
64
|
+
HandleArrow,
|
|
65
|
+
NO_RAYCAST,
|
|
66
|
+
} from './handles/handle-arrow'
|
|
67
|
+
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
|
|
68
|
+
|
|
69
|
+
// Pooled scratch for the handle rig's world-relative pose mapping.
|
|
70
|
+
const _rigRelative = new Matrix4()
|
|
71
|
+
const _rigScratchScale = new Vector3()
|
|
72
|
+
const _resizeAxisW = new Vector3()
|
|
73
|
+
const _resizeScale = new Vector3()
|
|
74
|
+
const _resizeQuaternion = new Quaternion()
|
|
75
|
+
const _resizeOriginW = new Vector3()
|
|
76
|
+
const _resizePositionW = new Vector3()
|
|
77
|
+
const _resizeRay = new Ray()
|
|
78
|
+
const _resizeRayW = new Vector3()
|
|
79
|
+
const MEASUREMENT_SURFACE_EXCLUDE_USER_DATA = { measurementSurface: false }
|
|
80
|
+
|
|
81
|
+
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
|
|
82
|
+
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
|
|
83
|
+
const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0]
|
|
84
|
+
|
|
85
|
+
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
|
|
86
|
+
target.set(0, 0, 0)
|
|
87
|
+
if (axis === 'x') target.x = 1
|
|
88
|
+
else if (axis === 'y') target.y = 1
|
|
89
|
+
else target.z = 1
|
|
90
|
+
return target
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function axisScale(axis: 'x' | 'y' | 'z', scale: Vector3) {
|
|
94
|
+
return axis === 'x' ? scale.x : axis === 'y' ? scale.y : scale.z
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function closestAxisParameterToRay(axisOrigin: Vector3, axisDirection: Vector3, ray: Ray) {
|
|
98
|
+
_resizeRayW.subVectors(axisOrigin, ray.origin)
|
|
99
|
+
const b = axisDirection.dot(ray.direction)
|
|
100
|
+
const d = axisDirection.dot(_resizeRayW)
|
|
101
|
+
const e = ray.direction.dot(_resizeRayW)
|
|
102
|
+
const denominator = 1 - b * b
|
|
103
|
+
if (Math.abs(denominator) < 1e-6) {
|
|
104
|
+
return -d
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const axisParameter = (b * e - d) / denominator
|
|
108
|
+
const rayParameter = e + b * axisParameter
|
|
109
|
+
if (rayParameter < 0) {
|
|
110
|
+
return -d
|
|
111
|
+
}
|
|
112
|
+
return axisParameter
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
ARROW_COLOR,
|
|
117
|
+
ARROW_HOVER_COLOR,
|
|
118
|
+
ARROW_SCALE,
|
|
119
|
+
createArrowHandleGeometry,
|
|
120
|
+
createArrowHitAreaGeometry,
|
|
121
|
+
createEndpointHitAreaGeometry,
|
|
122
|
+
createMoveCrossHandleGeometry,
|
|
123
|
+
createRotateArrowHandleGeometry,
|
|
124
|
+
createRotateArrowHitAreaGeometry,
|
|
125
|
+
HandleArrow,
|
|
126
|
+
type HandleArrowInputShape,
|
|
127
|
+
type HandleArrowPlacement,
|
|
128
|
+
type HandleArrowProps,
|
|
129
|
+
HIT_AREA_MARGIN,
|
|
130
|
+
InvisibleHandleHitArea,
|
|
131
|
+
NO_RAYCAST,
|
|
132
|
+
useArrowMaterial,
|
|
133
|
+
useInvisibleHitAreaMaterial,
|
|
134
|
+
} from './handles/handle-arrow'
|
|
135
|
+
export { swallowNextClick } from './handles/use-handle-drag'
|
|
136
|
+
|
|
137
|
+
// How far a DOWNWARD tracker's dashed leader pokes past its cube so the
|
|
138
|
+
// dashes visibly thread through it (the cube sits ON the line, not at
|
|
139
|
+
// its end). Upward trackers — wall / chimney height — stop at the cube.
|
|
140
|
+
const TRACKER_THROUGH = 0.12
|
|
141
|
+
|
|
142
|
+
type SceneApiForHandles = ReturnType<typeof createSceneApi>
|
|
143
|
+
type CenteredGuideDecoration = {
|
|
144
|
+
center?: (node: AnyNode, sceneApi: SceneApiForHandles) => readonly [number, number, number]
|
|
145
|
+
radius: (node: AnyNode, sceneApi: SceneApiForHandles) => number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function guideDecorationCenter(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) {
|
|
149
|
+
return (decoration as CenteredGuideDecoration | undefined)?.center?.(node, sceneApi)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function guideDecorationRadius(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) {
|
|
153
|
+
return (decoration as CenteredGuideDecoration).radius(node, sceneApi)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Mirrors the formatter used by wall / fence measurement labels so all
|
|
157
|
+
// in-world dimension chips read consistently.
|
|
158
|
+
function formatDimension(value: number, unit: 'metric' | 'imperial'): string {
|
|
159
|
+
if (unit === 'imperial') {
|
|
160
|
+
const feet = value * 3.280_84
|
|
161
|
+
const wholeFeet = Math.floor(feet)
|
|
162
|
+
const inches = Math.round((feet - wholeFeet) * 12)
|
|
163
|
+
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
164
|
+
return `${wholeFeet}'${inches}"`
|
|
165
|
+
}
|
|
166
|
+
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// In-world dimension chip rendered next to a resize arrow during hover
|
|
170
|
+
// or drag. Uses the same screen-space `<Html>` recipe + text-shadow
|
|
171
|
+
// halo as the wall measurement label so it reads at every camera angle.
|
|
172
|
+
function DimensionLabel({
|
|
173
|
+
position,
|
|
174
|
+
text,
|
|
175
|
+
}: {
|
|
176
|
+
position: readonly [number, number, number]
|
|
177
|
+
text: string
|
|
178
|
+
}) {
|
|
179
|
+
return (
|
|
180
|
+
<Html
|
|
181
|
+
center
|
|
182
|
+
position={position as unknown as [number, number, number]}
|
|
183
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
184
|
+
zIndexRange={[25, 0]}
|
|
185
|
+
>
|
|
186
|
+
<div
|
|
187
|
+
className="whitespace-nowrap font-bold font-mono text-[13px]"
|
|
188
|
+
style={{
|
|
189
|
+
color: '#fafafa',
|
|
190
|
+
textShadow:
|
|
191
|
+
'-1.5px -1.5px 0 #0b0b0b, 1.5px -1.5px 0 #0b0b0b, -1.5px 1.5px 0 #0b0b0b, 1.5px 1.5px 0 #0b0b0b, 0 0 4px #0b0b0b',
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
{text}
|
|
195
|
+
</div>
|
|
196
|
+
</Html>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function NodeArrowHandles() {
|
|
201
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
202
|
+
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
|
|
203
|
+
const mode = useEditor((state) => state.mode)
|
|
204
|
+
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
205
|
+
const movingNode = useMovingNode()
|
|
206
|
+
// Endpoint / curve drags reshape the selected wall or fence; hide its
|
|
207
|
+
// resize arrows for the duration so they don't clutter (or get blocked
|
|
208
|
+
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
|
|
209
|
+
// on the legacy wall handles (`WallMoveSideHandles`).
|
|
210
|
+
const endpointReshape = useEndpointReshape()
|
|
211
|
+
const isCurveReshape = useIsCurveReshape()
|
|
212
|
+
|
|
213
|
+
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
|
|
214
|
+
const rawNode = useScene((state) => {
|
|
215
|
+
if (!selectedId) return null
|
|
216
|
+
const selectedNode = state.nodes[selectedId as AnyNodeId]
|
|
217
|
+
return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Merge any live drag override so the arrows themselves (positions,
|
|
221
|
+
// ring decorations) track the in-flight drag instead of freezing at
|
|
222
|
+
// pre-drag values. Subscribe to just this node's entry so unrelated
|
|
223
|
+
// override writes don't re-render the handle stack.
|
|
224
|
+
const liveOverride = useLiveNodeOverrides((s) =>
|
|
225
|
+
rawNode ? s.overrides.get(rawNode.id) : undefined,
|
|
226
|
+
)
|
|
227
|
+
const node = useMemo<AnyNode | null>(
|
|
228
|
+
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
|
229
|
+
[rawNode, liveOverride],
|
|
230
|
+
)
|
|
231
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
232
|
+
const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
233
|
+
const descriptors = useMemo(() => {
|
|
234
|
+
if (!(node && def?.handles)) return null
|
|
235
|
+
const all =
|
|
236
|
+
typeof def.handles === 'function'
|
|
237
|
+
? def.handles(node as never)
|
|
238
|
+
: (def.handles as HandleDescriptor[])
|
|
239
|
+
// The whole-node move-cross gizmo is gone: moving is now click-to-move on
|
|
240
|
+
// the selected node body (see selection-manager). Drop both flavours — the
|
|
241
|
+
// `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
|
|
242
|
+
// `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
|
|
243
|
+
return all.filter(
|
|
244
|
+
(d) =>
|
|
245
|
+
d.kind !== 'translate' &&
|
|
246
|
+
!('shape' in d && d.shape === 'move-cross') &&
|
|
247
|
+
(d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false),
|
|
248
|
+
)
|
|
249
|
+
}, [node, def, descriptorSceneApi])
|
|
250
|
+
|
|
251
|
+
const shouldRender =
|
|
252
|
+
Boolean(node && descriptors?.length) &&
|
|
253
|
+
!isFloorplanHovered &&
|
|
254
|
+
mode !== 'delete' &&
|
|
255
|
+
// Any whole-node move (placement or press-drag) hides the rig: the item is
|
|
256
|
+
// following the cursor, so its rotate/resize handles would only clutter and
|
|
257
|
+
// draw stray selection rays. The active handle-drag scope (resize/rotate)
|
|
258
|
+
// sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
|
|
259
|
+
!movingNode &&
|
|
260
|
+
!endpointReshape &&
|
|
261
|
+
!isCurveReshape
|
|
262
|
+
|
|
263
|
+
if (!shouldRender || !node || !descriptors) return null
|
|
264
|
+
// Key by the selected node id so switching selection REMOUNTS the rig.
|
|
265
|
+
// The portal target + ride-mesh refs are seeded from the scene registry
|
|
266
|
+
// in `useState` initializers; without a remount they'd persist from the
|
|
267
|
+
// previous selection and the arrows would ride the old node's world pose
|
|
268
|
+
// (right local placements, wrong frame) until the resolve effect happened
|
|
269
|
+
// to catch up. Remounting re-resolves both refs synchronously for the new
|
|
270
|
+
// node, so the arrows land in the right place the instant it's selected.
|
|
271
|
+
return <NodeArrowHandlesForNode descriptors={descriptors} key={node.id} node={node} />
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Resolves the portal target + ride mesh chain. Descriptor-level `portal`
|
|
275
|
+
// toggles between two layout patterns; descriptor placement is *always* in
|
|
276
|
+
// the selected node's local frame regardless of mode.
|
|
277
|
+
//
|
|
278
|
+
// - 'parent' (default): mount inside the selected node's parent mesh.
|
|
279
|
+
// The wrapper mirrors the node's own local pose, so handles live in
|
|
280
|
+
// node-local coords directly. No inner group. Used by columns / walls
|
|
281
|
+
// / anything where the node IS the thing the user selected and whose
|
|
282
|
+
// rotation should drive the handle frame.
|
|
283
|
+
// - 'grandparent': mount inside the grandparent mesh (to escape the
|
|
284
|
+
// parent's selection-outline traversal). The wrapper mirrors the
|
|
285
|
+
// parent mesh's local pose; a nested inner group mirrors the node's
|
|
286
|
+
// own local pose. Handles end up in node-local coords. Used by doors /
|
|
287
|
+
// windows — handles need to ride the wall's rotation but not be
|
|
288
|
+
// children of the wall mesh.
|
|
289
|
+
function NodeArrowHandlesForNode({
|
|
290
|
+
node,
|
|
291
|
+
descriptors,
|
|
292
|
+
}: {
|
|
293
|
+
node: AnyNode
|
|
294
|
+
descriptors: HandleDescriptor[]
|
|
295
|
+
}) {
|
|
296
|
+
const parentId = node.parentId ?? null
|
|
297
|
+
const grandparentId = useScene((state) => {
|
|
298
|
+
if (!parentId) return null
|
|
299
|
+
const parent = state.nodes[parentId as AnyNodeId]
|
|
300
|
+
return parent?.parentId ?? null
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
|
|
304
|
+
? 'grandparent'
|
|
305
|
+
: 'parent'
|
|
306
|
+
|
|
307
|
+
// Portal target: the mesh we createPortal into.
|
|
308
|
+
const portalTargetId = portalMode === 'grandparent' ? grandparentId : parentId
|
|
309
|
+
// Outer wrapper mirrors this mesh's local pose. For 'parent' mode the
|
|
310
|
+
// outer IS the node (so handles + drag math both live in node-local).
|
|
311
|
+
// For 'grandparent' the outer rides the parent and an inner group adds
|
|
312
|
+
// the node's own pose.
|
|
313
|
+
const outerRideId = portalMode === 'grandparent' ? parentId : (node.id as AnyNodeId)
|
|
314
|
+
const innerRideId = portalMode === 'grandparent' ? (node.id as AnyNodeId) : null
|
|
315
|
+
|
|
316
|
+
const [portalObject, setPortalObject] = useState<Object3D | null>(() =>
|
|
317
|
+
portalTargetId ? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null) : null,
|
|
318
|
+
)
|
|
319
|
+
const [outerRide, setOuterRide] = useState<Object3D | null>(() =>
|
|
320
|
+
outerRideId ? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null) : null,
|
|
321
|
+
)
|
|
322
|
+
const [innerRide, setInnerRide] = useState<Object3D | null>(() =>
|
|
323
|
+
innerRideId ? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null) : null,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
let frameId = 0
|
|
328
|
+
const resolve = () => {
|
|
329
|
+
const nextPortal = portalTargetId
|
|
330
|
+
? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null)
|
|
331
|
+
: null
|
|
332
|
+
const nextOuter = outerRideId
|
|
333
|
+
? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null)
|
|
334
|
+
: null
|
|
335
|
+
const nextInner = innerRideId
|
|
336
|
+
? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null)
|
|
337
|
+
: null
|
|
338
|
+
setPortalObject((cur) => (cur === nextPortal ? cur : nextPortal))
|
|
339
|
+
setOuterRide((cur) => (cur === nextOuter ? cur : nextOuter))
|
|
340
|
+
setInnerRide((cur) => (cur === nextInner ? cur : nextInner))
|
|
341
|
+
// Inner ride is optional ('parent' mode skips it).
|
|
342
|
+
const needInner = innerRideId !== null
|
|
343
|
+
if (!nextPortal || !nextOuter || (needInner && !nextInner)) {
|
|
344
|
+
frameId = window.requestAnimationFrame(resolve)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
resolve()
|
|
348
|
+
return () => {
|
|
349
|
+
if (frameId) window.cancelAnimationFrame(frameId)
|
|
350
|
+
}
|
|
351
|
+
}, [portalTargetId, outerRideId, innerRideId])
|
|
352
|
+
|
|
353
|
+
const outerRef = useRef<Group>(null)
|
|
354
|
+
const innerRef = useRef<Group>(null)
|
|
355
|
+
|
|
356
|
+
// Keep arrow objects on SCENE_LAYER so the post-processing scenePass
|
|
357
|
+
// captures them in the depth/normal MRT — that's what feeds the ink-edge
|
|
358
|
+
// shader, and it's the reason the wall height arrow (which also stays on
|
|
359
|
+
// SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting
|
|
360
|
+
// them on EDITOR_LAYER hides them from scenePass and the chevron renders
|
|
361
|
+
// flat. Arrows are only mounted while a node is selected, so thumbnail
|
|
362
|
+
// captures (which never have selection) don't need the layer-based
|
|
363
|
+
// exclusion the wall arrow also goes without.
|
|
364
|
+
|
|
365
|
+
useFrame(() => {
|
|
366
|
+
if (innerRef.current && innerRide && portalObject) {
|
|
367
|
+
// Grandparent mode: pose the rig by mapping the node's WORLD pose
|
|
368
|
+
// into the portal target's frame. Copying the parent + node
|
|
369
|
+
// registry poses (the previous approach) assumed the node mesh is
|
|
370
|
+
// a DIRECT child of the parent's registered object — roof-hosted
|
|
371
|
+
// openings break that with an intermediate face-frame group, which
|
|
372
|
+
// the world-relative mapping absorbs for free. For wall children
|
|
373
|
+
// the result is identical (portal⁻¹ ∘ node = wall.local ∘ node.local).
|
|
374
|
+
if (outerRef.current) {
|
|
375
|
+
outerRef.current.position.set(0, 0, 0)
|
|
376
|
+
outerRef.current.quaternion.identity()
|
|
377
|
+
}
|
|
378
|
+
portalObject.updateWorldMatrix(true, false)
|
|
379
|
+
innerRide.updateWorldMatrix(true, false)
|
|
380
|
+
_rigRelative.copy(portalObject.matrixWorld).invert().multiply(innerRide.matrixWorld)
|
|
381
|
+
_rigRelative.decompose(
|
|
382
|
+
innerRef.current.position,
|
|
383
|
+
innerRef.current.quaternion,
|
|
384
|
+
_rigScratchScale,
|
|
385
|
+
)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
if (outerRef.current && outerRide) {
|
|
389
|
+
outerRef.current.position.copy(outerRide.position)
|
|
390
|
+
outerRef.current.quaternion.copy(outerRide.quaternion)
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
// Active-drag tracking. When a handle starts dragging, it claims its
|
|
395
|
+
// descriptor index here and snapshots the store node at drag-start.
|
|
396
|
+
// Non-active arrows re-render against the snapshot + a freeze offset
|
|
397
|
+
// that undoes the mesh's `position` drift in node-local frame — so
|
|
398
|
+
// asymmetric resize (width L/R, length L/R) doesn't visually slide the
|
|
399
|
+
// depth / height / rotate chevrons. They stay anchored at their
|
|
400
|
+
// pre-drag world positions for the duration of the drag.
|
|
401
|
+
//
|
|
402
|
+
// Hooks must sit ABOVE the early-return guard below — the registry-
|
|
403
|
+
// resolve `useEffect` flips `portalObject` from null → object after
|
|
404
|
+
// the first frame, so a guard between two hooks would change the
|
|
405
|
+
// hook count between renders and trip React's rules-of-hooks check.
|
|
406
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
|
407
|
+
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
|
|
408
|
+
// Latch groups currently toggled open. A `latch` cube descriptor flips its
|
|
409
|
+
// group here on click; arrows tagged with a `latchGroup` only render while
|
|
410
|
+
// their group is in this set. Local to this mount, so it resets on deselect
|
|
411
|
+
// (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode).
|
|
412
|
+
const [openLatchGroups, setOpenLatchGroups] = useState<ReadonlySet<string>>(() => new Set())
|
|
413
|
+
const toggleLatchGroup = useMemo(
|
|
414
|
+
() => (group: string) =>
|
|
415
|
+
setOpenLatchGroups((prev) => {
|
|
416
|
+
const next = new Set(prev)
|
|
417
|
+
if (next.has(group)) next.delete(group)
|
|
418
|
+
else next.add(group)
|
|
419
|
+
return next
|
|
420
|
+
}),
|
|
421
|
+
[],
|
|
422
|
+
)
|
|
423
|
+
const dragControls = useMemo<HandleDragControls>(
|
|
424
|
+
() => ({
|
|
425
|
+
onStart: (index: number, snapshot: AnyNode) => {
|
|
426
|
+
setActiveIndex(index)
|
|
427
|
+
setPreDragNode(snapshot)
|
|
428
|
+
},
|
|
429
|
+
onEnd: () => {
|
|
430
|
+
setActiveIndex(null)
|
|
431
|
+
setPreDragNode(null)
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
[],
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
if (!portalObject || !outerRide || (innerRideId !== null && !innerRide)) return null
|
|
438
|
+
|
|
439
|
+
// `arrowFrame` is the Object3D used as the spatial reference for the
|
|
440
|
+
// per-arrow drag math — its world matrix maps node-local coords to
|
|
441
|
+
// world. In 'parent' mode that's the outer ride (= the node mesh
|
|
442
|
+
// itself). In 'grandparent' mode it's the inner ride (= the node mesh)
|
|
443
|
+
// because the inner group mirrors the node's local pose under the
|
|
444
|
+
// wall-riding outer wrapper.
|
|
445
|
+
const arrowFrame = innerRide ?? outerRide
|
|
446
|
+
|
|
447
|
+
// A translate drag moves `position`, so the whole handle rig should travel
|
|
448
|
+
// with the mesh — the freeze-at-pre-drag mechanism (built for asymmetric
|
|
449
|
+
// resize that re-centres the mesh) must NOT fire for the non-active arrows
|
|
450
|
+
// here, or they'd lag behind the moving item.
|
|
451
|
+
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
|
|
452
|
+
// While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
|
|
453
|
+
// can't move and rotate at once, so it only clutters the rotation.
|
|
454
|
+
const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
|
|
455
|
+
const activeIsRotate =
|
|
456
|
+
!!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
|
|
457
|
+
|
|
458
|
+
const arrows = descriptors.map((descriptor, index) => {
|
|
459
|
+
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
|
|
460
|
+
// A `latch` cube toggles its group's visibility; render it always.
|
|
461
|
+
if (descriptor.kind === 'latch') {
|
|
462
|
+
return (
|
|
463
|
+
<LatchCube
|
|
464
|
+
descriptor={descriptor}
|
|
465
|
+
key={index}
|
|
466
|
+
node={node}
|
|
467
|
+
onToggle={toggleLatchGroup}
|
|
468
|
+
open={openLatchGroups.has(descriptor.group)}
|
|
469
|
+
/>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
// Arrows tagged with a latch group stay hidden until that group is open.
|
|
473
|
+
const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
|
|
474
|
+
if (latchGroup && !openLatchGroups.has(latchGroup)) return null
|
|
475
|
+
return (
|
|
476
|
+
<ArrowHandle
|
|
477
|
+
activeIndex={activeIndex}
|
|
478
|
+
descriptor={descriptor}
|
|
479
|
+
dragControls={dragControls}
|
|
480
|
+
handleIndex={index}
|
|
481
|
+
// Descriptors come from a per-node-kind static list, so index is a
|
|
482
|
+
// stable identity within this node's selection cycle.
|
|
483
|
+
key={index}
|
|
484
|
+
liveNode={node}
|
|
485
|
+
preDragNode={preDragNode}
|
|
486
|
+
rideObject={arrowFrame}
|
|
487
|
+
suppressFreeze={activeIsTranslate}
|
|
488
|
+
/>
|
|
489
|
+
)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
return createPortal(
|
|
493
|
+
<group ref={outerRef} userData={MEASUREMENT_SURFACE_EXCLUDE_USER_DATA}>
|
|
494
|
+
{innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
|
|
495
|
+
</group>,
|
|
496
|
+
portalObject,
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Offset, in node-local frame, that compensates for `position` drift on
|
|
501
|
+
// the mesh during an asymmetric resize. Width/length L+R recompute
|
|
502
|
+
// `position` so the anchored edge stays world-fixed — the renderer
|
|
503
|
+
// follows that override, the ride object moves, and every arrow under
|
|
504
|
+
// it would drift along with the mesh center. Subtracting this offset
|
|
505
|
+
// from a non-active arrow's local placement undoes that drift so it
|
|
506
|
+
// stays at its pre-drag world position.
|
|
507
|
+
//
|
|
508
|
+
// Rotation drags don't change `position`, so the offset collapses to
|
|
509
|
+
// zero and non-active arrows naturally rotate with the mesh — which is
|
|
510
|
+
// the desired behaviour (the whole rig rotates as a unit).
|
|
511
|
+
function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] {
|
|
512
|
+
// Not every node in the union carries a `position` field (sites are the
|
|
513
|
+
// notable holdout — they don't have handles anyway, but TypeScript still
|
|
514
|
+
// requires us to discriminate). Guarded access keeps the freeze logic
|
|
515
|
+
// safe for the few node kinds that lack the field.
|
|
516
|
+
const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
|
|
517
|
+
const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
|
|
518
|
+
0, 0, 0,
|
|
519
|
+
]
|
|
520
|
+
const deltaWorldX = liveP[0] - preP[0]
|
|
521
|
+
const deltaWorldY = liveP[1] - preP[1]
|
|
522
|
+
const deltaWorldZ = liveP[2] - preP[2]
|
|
523
|
+
const rotY = (preDragNode as { rotation?: number }).rotation ?? 0
|
|
524
|
+
// World → node-local for Y-axis rotation by rotY (THREE.Object3D
|
|
525
|
+
// rotation-y convention): inverse is rotation by -rotY around +Y.
|
|
526
|
+
const cosR = Math.cos(rotY)
|
|
527
|
+
const sinR = Math.sin(rotY)
|
|
528
|
+
const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
|
|
529
|
+
const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
|
|
530
|
+
return [deltaLocalX, deltaWorldY, deltaLocalZ]
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function ArrowHandle({
|
|
534
|
+
descriptor,
|
|
535
|
+
liveNode,
|
|
536
|
+
preDragNode,
|
|
537
|
+
activeIndex,
|
|
538
|
+
handleIndex,
|
|
539
|
+
dragControls,
|
|
540
|
+
rideObject,
|
|
541
|
+
suppressFreeze,
|
|
542
|
+
}: {
|
|
543
|
+
descriptor: HandleDescriptor
|
|
544
|
+
liveNode: AnyNode
|
|
545
|
+
preDragNode: AnyNode | null
|
|
546
|
+
activeIndex: number | null
|
|
547
|
+
handleIndex: number
|
|
548
|
+
dragControls: HandleDragControls
|
|
549
|
+
rideObject: Object3D
|
|
550
|
+
/** When the active drag is a translate, non-active arrows ride the moving
|
|
551
|
+
* mesh instead of freezing at their pre-drag world position. */
|
|
552
|
+
suppressFreeze?: boolean
|
|
553
|
+
}) {
|
|
554
|
+
// During a drag, non-active arrows render against the pre-drag store
|
|
555
|
+
// snapshot. The active arrow always uses the live (override-merged)
|
|
556
|
+
// node so it tracks the cursor.
|
|
557
|
+
const isOtherActive = activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null
|
|
558
|
+
const placementNode = isOtherActive ? (preDragNode as AnyNode) : liveNode
|
|
559
|
+
const freezeOffset =
|
|
560
|
+
isOtherActive && preDragNode && !suppressFreeze
|
|
561
|
+
? computeFreezeOffset(liveNode, preDragNode)
|
|
562
|
+
: null
|
|
563
|
+
|
|
564
|
+
if (descriptor.kind === 'linear-resize' || descriptor.kind === 'radial-resize') {
|
|
565
|
+
return (
|
|
566
|
+
<LinearArrow
|
|
567
|
+
descriptor={descriptor}
|
|
568
|
+
dragControls={dragControls}
|
|
569
|
+
freezeOffset={freezeOffset}
|
|
570
|
+
handleIndex={handleIndex}
|
|
571
|
+
liveNode={liveNode}
|
|
572
|
+
node={placementNode}
|
|
573
|
+
rideObject={rideObject}
|
|
574
|
+
/>
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
if (descriptor.kind === 'arc-resize') {
|
|
578
|
+
return (
|
|
579
|
+
<ArcArrow
|
|
580
|
+
descriptor={descriptor}
|
|
581
|
+
dragControls={dragControls}
|
|
582
|
+
freezeOffset={freezeOffset}
|
|
583
|
+
handleIndex={handleIndex}
|
|
584
|
+
liveNode={liveNode}
|
|
585
|
+
node={placementNode}
|
|
586
|
+
rideObject={rideObject}
|
|
587
|
+
/>
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
if (descriptor.kind === 'tap-action') {
|
|
591
|
+
// Tap-action handles (fence side-move arrows, corner pickers) aren't
|
|
592
|
+
// resize handles, so the freeze-at-pre-drag mechanism — which only
|
|
593
|
+
// exists to stop arrows sliding when an asymmetric width/length resize
|
|
594
|
+
// re-centers the mesh — doesn't apply to them. Track the live node so
|
|
595
|
+
// their height-dependent placement (side arrows ride the top, corner
|
|
596
|
+
// leaders span the full height) follows a height drag in real time.
|
|
597
|
+
return <TapActionArrow descriptor={descriptor} node={liveNode} />
|
|
598
|
+
}
|
|
599
|
+
// endpoint-move not yet implemented.
|
|
600
|
+
return null
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>): Cursor {
|
|
604
|
+
if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor
|
|
605
|
+
return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize'
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function resolveBound(
|
|
609
|
+
bound:
|
|
610
|
+
| number
|
|
611
|
+
| ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number)
|
|
612
|
+
| undefined,
|
|
613
|
+
fallback: number,
|
|
614
|
+
node: AnyNode,
|
|
615
|
+
sceneApi: ReturnType<typeof createSceneApi>,
|
|
616
|
+
): number {
|
|
617
|
+
if (bound === undefined) return fallback
|
|
618
|
+
return typeof bound === 'function' ? bound(node, sceneApi) : bound
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function LinearArrow({
|
|
622
|
+
descriptor,
|
|
623
|
+
node,
|
|
624
|
+
liveNode,
|
|
625
|
+
freezeOffset,
|
|
626
|
+
handleIndex,
|
|
627
|
+
dragControls,
|
|
628
|
+
rideObject,
|
|
629
|
+
}: {
|
|
630
|
+
descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>
|
|
631
|
+
/** Effective node for placement (preDrag snapshot when another arrow is active). */
|
|
632
|
+
node: AnyNode
|
|
633
|
+
/** Always the live (override-merged) node — used inside drag handlers. */
|
|
634
|
+
liveNode: AnyNode
|
|
635
|
+
/** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
|
|
636
|
+
freezeOffset: [number, number, number] | null
|
|
637
|
+
handleIndex: number
|
|
638
|
+
dragControls: HandleDragControls
|
|
639
|
+
rideObject: Object3D
|
|
640
|
+
}) {
|
|
641
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
642
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
643
|
+
const { camera } = useThree()
|
|
644
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
645
|
+
const baseScale = zoom * ARROW_SCALE
|
|
646
|
+
const unit = useViewer((s) => s.unit)
|
|
647
|
+
|
|
648
|
+
// Suppress "declared but unused" for `liveNode` — LinearArrow's apply
|
|
649
|
+
// operates on `initialNode` (snapshot inside activate) and reads value
|
|
650
|
+
// updates back via `useLiveNodeOverrides`. The prop is required for
|
|
651
|
+
// uniformity with ArrowHandle's variant dispatch but isn't consumed in
|
|
652
|
+
// this variant's render path.
|
|
653
|
+
void liveNode
|
|
654
|
+
|
|
655
|
+
const cursor = pickCursor(descriptor)
|
|
656
|
+
// When a handle declares `measureLabel`, its readout is routed to the
|
|
657
|
+
// floating dimension pill (via `activeHandleDrag`) and its own in-world
|
|
658
|
+
// chip is suppressed — matches the wall height handle.
|
|
659
|
+
const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
|
|
660
|
+
// Optional per-tick feedback hook (doors/windows publish proximity/sill guides
|
|
661
|
+
// for the edge being resized); cleared when the drag ends.
|
|
662
|
+
const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
|
|
663
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
664
|
+
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
|
665
|
+
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
|
|
666
|
+
// drift while another arrow is being dragged — `basePosition` is
|
|
667
|
+
// computed against the pre-drag snapshot, then we subtract the offset
|
|
668
|
+
// so the arrow's WORLD location matches its pre-drag world location.
|
|
669
|
+
// Active arrows + idle state have `freezeOffset === null`, so the
|
|
670
|
+
// position passes through unchanged.
|
|
671
|
+
const position: [number, number, number] = freezeOffset
|
|
672
|
+
? [
|
|
673
|
+
basePosition[0] - freezeOffset[0],
|
|
674
|
+
basePosition[1] - freezeOffset[1],
|
|
675
|
+
basePosition[2] - freezeOffset[2],
|
|
676
|
+
]
|
|
677
|
+
: [basePosition[0], basePosition[1], basePosition[2]]
|
|
678
|
+
const baseRotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
679
|
+
// Default chevron points +X. Rotate around Y to face the chosen axis.
|
|
680
|
+
const axisRotationY = descriptor.axis === 'z' ? -Math.PI / 2 : 0
|
|
681
|
+
// For axis === 'y' we orient the chevron up. Z-rotation by π/2 then
|
|
682
|
+
// Y-rotation chains via the parent <group> below.
|
|
683
|
+
const rotationY = baseRotationY + axisRotationY
|
|
684
|
+
|
|
685
|
+
const activate = useHandleDrag({
|
|
686
|
+
kind: 'drag',
|
|
687
|
+
cursor,
|
|
688
|
+
dragControls,
|
|
689
|
+
handleIndex,
|
|
690
|
+
node,
|
|
691
|
+
rideObject,
|
|
692
|
+
setIsDragging,
|
|
693
|
+
onStart: ({
|
|
694
|
+
event,
|
|
695
|
+
getPointerRay,
|
|
696
|
+
initialNode,
|
|
697
|
+
nodeId,
|
|
698
|
+
rideObject: dragRideObject,
|
|
699
|
+
sceneApi,
|
|
700
|
+
}) => {
|
|
701
|
+
dragRideObject.matrixWorld.decompose(_resizePositionW, _resizeQuaternion, _resizeScale)
|
|
702
|
+
_resizeOriginW.set(...position).applyMatrix4(dragRideObject.matrixWorld)
|
|
703
|
+
axisVector(descriptor.axis, _resizeAxisW).applyQuaternion(_resizeQuaternion).normalize()
|
|
704
|
+
const localToWorldScale = axisScale(descriptor.axis, _resizeScale)
|
|
705
|
+
if (Math.abs(localToWorldScale) < 1e-6 || _resizeAxisW.lengthSq() === 0) return null
|
|
706
|
+
|
|
707
|
+
const initialPointer =
|
|
708
|
+
closestAxisParameterToRay(
|
|
709
|
+
_resizeOriginW,
|
|
710
|
+
_resizeAxisW,
|
|
711
|
+
getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
|
|
712
|
+
) / localToWorldScale
|
|
713
|
+
|
|
714
|
+
const overrideId =
|
|
715
|
+
(descriptor.kind === 'linear-resize'
|
|
716
|
+
? descriptor.overrideTarget?.(initialNode as never, sceneApi)
|
|
717
|
+
: undefined) ?? nodeId
|
|
718
|
+
const initialValue = descriptor.currentValue(initialNode)
|
|
719
|
+
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
|
|
720
|
+
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
|
|
721
|
+
const gridSnapStep =
|
|
722
|
+
descriptor.kind === 'linear-resize' && descriptor.gridSnap
|
|
723
|
+
? useEditor.getState().gridSnapStep
|
|
724
|
+
: null
|
|
725
|
+
const factor =
|
|
726
|
+
descriptor.kind === 'radial-resize'
|
|
727
|
+
? 1
|
|
728
|
+
: descriptor.anchor === 'center'
|
|
729
|
+
? 2
|
|
730
|
+
: descriptor.anchor === 'min'
|
|
731
|
+
? 1
|
|
732
|
+
: -1
|
|
733
|
+
|
|
734
|
+
// Last value an emitted resize tick fired at — a new tick fires only
|
|
735
|
+
// when the (snapped + clamped) value actually changes, so the cue
|
|
736
|
+
// tracks real size steps instead of every sub-pixel pointer jitter.
|
|
737
|
+
let lastTickValue = initialValue
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
overrideId,
|
|
741
|
+
commit:
|
|
742
|
+
descriptor.kind === 'linear-resize' && descriptor.commit
|
|
743
|
+
? (patch) => descriptor.commit?.(initialNode, patch, sceneApi)
|
|
744
|
+
: undefined,
|
|
745
|
+
onBegin: () => {
|
|
746
|
+
// Always claim the handle-drag scope so the HUD knows a resize is the
|
|
747
|
+
// active interaction (keeps the idle select hints off-screen). The
|
|
748
|
+
// dimension-pill handles carry their `measureLabel`; plain resize
|
|
749
|
+
// arrows use the generic label.
|
|
750
|
+
useInteractionScope.getState().begin({
|
|
751
|
+
kind: 'handle-drag',
|
|
752
|
+
nodeId,
|
|
753
|
+
handle: measureLabel ?? RESIZE_HANDLE_DRAG_LABEL,
|
|
754
|
+
})
|
|
755
|
+
},
|
|
756
|
+
onEnd: () => {
|
|
757
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
758
|
+
if (onDrag) useOpeningGuides.getState().clear()
|
|
759
|
+
},
|
|
760
|
+
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
|
|
761
|
+
const currentPointer =
|
|
762
|
+
closestAxisParameterToRay(
|
|
763
|
+
_resizeOriginW,
|
|
764
|
+
_resizeAxisW,
|
|
765
|
+
getMovePointerRay(moveEvent.clientX, moveEvent.clientY, _resizeRay),
|
|
766
|
+
) / localToWorldScale
|
|
767
|
+
const delta = currentPointer - initialPointer
|
|
768
|
+
const rawNext = initialValue + delta * factor
|
|
769
|
+
const snappedNext =
|
|
770
|
+
!moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0
|
|
771
|
+
? snapScalar(rawNext, gridSnapStep)
|
|
772
|
+
: rawNext
|
|
773
|
+
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
|
774
|
+
if (next !== lastTickValue) {
|
|
775
|
+
lastTickValue = next
|
|
776
|
+
sfxEmitter.emit('sfx:resize')
|
|
777
|
+
}
|
|
778
|
+
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
|
779
|
+
// Let the kind publish live guides for the edge being resized.
|
|
780
|
+
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
|
|
781
|
+
return patch
|
|
782
|
+
},
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
// For axis === 'y' (vertical handles), tilt the chevron up via local
|
|
788
|
+
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
|
|
789
|
+
// sits below the node (placement Y < 0, e.g. window bottom arrow),
|
|
790
|
+
// flip the Z rotation so the chevron points outward (downward).
|
|
791
|
+
//
|
|
792
|
+
// For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
|
|
793
|
+
// roll the blade 90° about its own pointing (X) axis so it stands up from
|
|
794
|
+
// the horizontal XZ plane into the node's facing plane (XY = the wall
|
|
795
|
+
// face) — otherwise the blade is seen edge-on from the front.
|
|
796
|
+
const faceNormalX =
|
|
797
|
+
descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
|
|
798
|
+
const innerRotation: [number, number, number] =
|
|
799
|
+
descriptor.axis === 'y'
|
|
800
|
+
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
|
|
801
|
+
: faceNormalX
|
|
802
|
+
? [Math.PI / 2, 0, 0]
|
|
803
|
+
: [0, 0, 0]
|
|
804
|
+
|
|
805
|
+
// Optional guide decoration — linear handles use it for curved-stair
|
|
806
|
+
// width / inner-radius rings; radial handles use it for the column's
|
|
807
|
+
// round footprint ring.
|
|
808
|
+
const decoration = descriptor.decoration
|
|
809
|
+
const showDecoration = Boolean(decoration) && (isHovered || isDragging)
|
|
810
|
+
|
|
811
|
+
// Dimension chip — shows the live value the drag is steering. `node`
|
|
812
|
+
// is already the effective (override-merged) node, so currentValue
|
|
813
|
+
// returns the in-flight value during a drag and the label tracks
|
|
814
|
+
// smoothly without an extra subscription.
|
|
815
|
+
// `measureLabel` handles route their readout to the floating dimension
|
|
816
|
+
// pill, so suppress the inline chip here to avoid showing it twice.
|
|
817
|
+
const showLabel = (isHovered || isDragging) && !measureLabel
|
|
818
|
+
const labelText = showLabel ? formatDimension(descriptor.currentValue(node), unit) : ''
|
|
819
|
+
|
|
820
|
+
// `tracker` shape on a linear-resize handle: render a dashed vertical
|
|
821
|
+
// leader from the floor up to a small cube at `placement.position`. The
|
|
822
|
+
// cube is the drag target and reuses the same `activate` pointer handler
|
|
823
|
+
// as the chevron path, so all the override/commit plumbing is unchanged.
|
|
824
|
+
// Only valid for axis='y' resize handles — the leader is rendered at
|
|
825
|
+
// (0,0,0)→(0,position.y,0) in the same group as the cube, so for x/z
|
|
826
|
+
// axes the leader would still climb vertically and look wrong.
|
|
827
|
+
const shape =
|
|
828
|
+
descriptor.kind === 'linear-resize' && descriptor.shape === 'tracker' ? 'tracker' : 'arrow'
|
|
829
|
+
|
|
830
|
+
if (shape === 'tracker') {
|
|
831
|
+
// Descriptors can pin the leader's bottom Y above the floor — e.g.
|
|
832
|
+
// chimney body height starts at the deck plane, not at y=0, so the
|
|
833
|
+
// dashed leader spans only the body's visible extent.
|
|
834
|
+
const trackerDescriptor = descriptor as LinearResizeHandle<AnyNode>
|
|
835
|
+
const baseY = trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0
|
|
836
|
+
// Leader spans base ↔ cube either direction. Upward (cube above base:
|
|
837
|
+
// wall / chimney height) it stops at the cube as before. Downward
|
|
838
|
+
// (cube below base: a downspout's length cube under the gutter
|
|
839
|
+
// outlet) it pokes `TRACKER_THROUGH` past the cube so the dashes
|
|
840
|
+
// thread through it instead of the leader collapsing to nothing.
|
|
841
|
+
const cubeY = position[1]
|
|
842
|
+
const cubeBelowBase = cubeY < baseY
|
|
843
|
+
const leaderBottomY = Math.min(baseY, cubeY) - (cubeBelowBase ? TRACKER_THROUGH : 0)
|
|
844
|
+
const leaderHeight = Math.max(Math.max(baseY, cubeY) - leaderBottomY, 0)
|
|
845
|
+
return (
|
|
846
|
+
<>
|
|
847
|
+
{showDecoration && decoration ? (
|
|
848
|
+
<GuideRing
|
|
849
|
+
center={guideDecorationCenter(decoration, node, placementSceneApi)}
|
|
850
|
+
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
|
|
851
|
+
y={decoration.y?.(node as never) ?? 0}
|
|
852
|
+
/>
|
|
853
|
+
) : null}
|
|
854
|
+
{showLabel ? (
|
|
855
|
+
<DimensionLabel
|
|
856
|
+
position={[position[0], position[1] + 0.22, position[2]]}
|
|
857
|
+
text={labelText}
|
|
858
|
+
/>
|
|
859
|
+
) : null}
|
|
860
|
+
<TrackerShape
|
|
861
|
+
basePosition={[position[0], leaderBottomY, position[2]]}
|
|
862
|
+
baseScale={zoom}
|
|
863
|
+
cubePosition={position}
|
|
864
|
+
cursor={cursor}
|
|
865
|
+
hover={isHovered}
|
|
866
|
+
leaderHeight={leaderHeight}
|
|
867
|
+
onHoverChange={setIsHovered}
|
|
868
|
+
onPointerDown={activate}
|
|
869
|
+
/>
|
|
870
|
+
</>
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return (
|
|
875
|
+
<>
|
|
876
|
+
{showDecoration && decoration ? (
|
|
877
|
+
<GuideRing
|
|
878
|
+
center={guideDecorationCenter(decoration, node, placementSceneApi)}
|
|
879
|
+
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
|
|
880
|
+
y={decoration.y?.(node as never) ?? 0}
|
|
881
|
+
/>
|
|
882
|
+
) : null}
|
|
883
|
+
<HandleArrow
|
|
884
|
+
cursor={cursor}
|
|
885
|
+
hover={isHovered}
|
|
886
|
+
indicatorRotation={innerRotation}
|
|
887
|
+
onHoverChange={setIsHovered}
|
|
888
|
+
onPointerDown={activate}
|
|
889
|
+
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
|
890
|
+
shape="chevron"
|
|
891
|
+
thin
|
|
892
|
+
>
|
|
893
|
+
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
|
|
894
|
+
</HandleArrow>
|
|
895
|
+
</>
|
|
896
|
+
)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Thin horizontal ring used as a visual guide alongside a resize arrow —
|
|
900
|
+
// e.g. the curved-stair width arrow traces the outer rim, the inner-radius
|
|
901
|
+
// arrow traces the central pillar. Floats at node-local `y`, lies in the
|
|
902
|
+
// XZ plane.
|
|
903
|
+
export function GuideRing({
|
|
904
|
+
center,
|
|
905
|
+
radius,
|
|
906
|
+
y,
|
|
907
|
+
}: {
|
|
908
|
+
center?: readonly [number, number, number]
|
|
909
|
+
radius: number
|
|
910
|
+
y: number
|
|
911
|
+
}) {
|
|
912
|
+
const safeRadius = Math.max(radius, 0.01)
|
|
913
|
+
const ringGeometry = useMemo(() => {
|
|
914
|
+
const inner = Math.max(safeRadius - 0.015, 0.001)
|
|
915
|
+
const outer = safeRadius + 0.015
|
|
916
|
+
return new RingGeometry(inner, outer, 96)
|
|
917
|
+
}, [safeRadius])
|
|
918
|
+
const ringMaterial = useMemo(
|
|
919
|
+
() =>
|
|
920
|
+
new MeshBasicNodeMaterial({
|
|
921
|
+
color: new Color(ARROW_COLOR),
|
|
922
|
+
side: DoubleSide,
|
|
923
|
+
transparent: true,
|
|
924
|
+
opacity: 0.95,
|
|
925
|
+
depthTest: false,
|
|
926
|
+
depthWrite: false,
|
|
927
|
+
}),
|
|
928
|
+
[],
|
|
929
|
+
)
|
|
930
|
+
useEffect(() => () => ringGeometry.dispose(), [ringGeometry])
|
|
931
|
+
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
|
|
932
|
+
|
|
933
|
+
return (
|
|
934
|
+
<mesh
|
|
935
|
+
frustumCulled={false}
|
|
936
|
+
geometry={ringGeometry}
|
|
937
|
+
material={ringMaterial}
|
|
938
|
+
position={center ? [center[0], y, center[2]] : [0, y, 0]}
|
|
939
|
+
renderOrder={1009}
|
|
940
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
941
|
+
/>
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const ROTATION_GUIDE_COLOR = ARROW_COLOR
|
|
946
|
+
const ROTATION_GUIDE_SEGMENTS = 48
|
|
947
|
+
|
|
948
|
+
// Live rotation readout shown while a whole-node rotate gizmo is dragged.
|
|
949
|
+
// Mirrors the wall-draft angle arc: a filled wedge + outline swept from the
|
|
950
|
+
// pointer's bearing at grab (`startAngle`) to its current bearing
|
|
951
|
+
// (`endAngle`) around the rotation pivot, plus a degree chip at the wedge's
|
|
952
|
+
// midpoint. All coordinates are world-space — the guide is portalled to the
|
|
953
|
+
// scene root so it stays fixed while the node mesh rotates underneath it.
|
|
954
|
+
export type RotationGuideData = {
|
|
955
|
+
center: [number, number, number]
|
|
956
|
+
startAngle: number
|
|
957
|
+
endAngle: number
|
|
958
|
+
radius: number
|
|
959
|
+
labelPos: [number, number, number]
|
|
960
|
+
/** Swept magnitude in radians, for the degree chip. */
|
|
961
|
+
sweep: number
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export function RotationGuide({ data }: { data: RotationGuideData }) {
|
|
965
|
+
const { center, startAngle, endAngle, radius, labelPos, sweep } = data
|
|
966
|
+
const { outline, fill } = useMemo(() => {
|
|
967
|
+
const span = endAngle - startAngle
|
|
968
|
+
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
|
|
969
|
+
const arc = Array.from({ length: count + 1 }, (_, index) => {
|
|
970
|
+
const angle = startAngle + (span * index) / count
|
|
971
|
+
return new Vector3(
|
|
972
|
+
center[0] + Math.cos(angle) * radius,
|
|
973
|
+
center[1],
|
|
974
|
+
center[2] + Math.sin(angle) * radius,
|
|
975
|
+
)
|
|
976
|
+
})
|
|
977
|
+
const centerV = new Vector3(center[0], center[1], center[2])
|
|
978
|
+
const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
|
|
979
|
+
const positions: number[] = []
|
|
980
|
+
for (let i = 0; i < arc.length - 1; i++) {
|
|
981
|
+
const a = arc[i]
|
|
982
|
+
const b = arc[i + 1]
|
|
983
|
+
if (!a || !b) continue
|
|
984
|
+
positions.push(centerV.x, centerV.y, centerV.z, a.x, a.y, a.z, b.x, b.y, b.z)
|
|
985
|
+
}
|
|
986
|
+
const fillGeo = new BufferGeometry()
|
|
987
|
+
fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
988
|
+
return { outline: outlineGeo, fill: fillGeo }
|
|
989
|
+
}, [center, startAngle, endAngle, radius])
|
|
990
|
+
useEffect(() => () => outline.dispose(), [outline])
|
|
991
|
+
useEffect(() => () => fill.dispose(), [fill])
|
|
992
|
+
|
|
993
|
+
return (
|
|
994
|
+
<>
|
|
995
|
+
<mesh
|
|
996
|
+
frustumCulled={false}
|
|
997
|
+
geometry={fill}
|
|
998
|
+
layers={EDITOR_LAYER}
|
|
999
|
+
raycast={NO_RAYCAST}
|
|
1000
|
+
renderOrder={1008}
|
|
1001
|
+
>
|
|
1002
|
+
<meshBasicMaterial
|
|
1003
|
+
color={ROTATION_GUIDE_COLOR}
|
|
1004
|
+
depthTest={false}
|
|
1005
|
+
depthWrite={false}
|
|
1006
|
+
opacity={0.18}
|
|
1007
|
+
side={DoubleSide}
|
|
1008
|
+
transparent
|
|
1009
|
+
/>
|
|
1010
|
+
</mesh>
|
|
1011
|
+
<RotationGuideOutline geometry={outline} />
|
|
1012
|
+
<DimensionLabel position={labelPos} text={formatAngleRadians(sweep)} />
|
|
1013
|
+
</>
|
|
1014
|
+
)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
|
|
1018
|
+
return (
|
|
1019
|
+
// @ts-expect-error - R3F accepts Three line primitives, matching the wall draft arc.
|
|
1020
|
+
<line frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={1009}>
|
|
1021
|
+
<lineBasicNodeMaterial
|
|
1022
|
+
color={ROTATION_GUIDE_COLOR}
|
|
1023
|
+
depthTest={false}
|
|
1024
|
+
depthWrite={false}
|
|
1025
|
+
linewidth={2}
|
|
1026
|
+
opacity={0.95}
|
|
1027
|
+
transparent
|
|
1028
|
+
/>
|
|
1029
|
+
</line>
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Live whole-node rotation readout for a single-node rotate gizmo, rendered as
|
|
1034
|
+
// a CHILD of the node frame (sibling of its guide ring). Because it lives in
|
|
1035
|
+
// the node's own frame, it is automatically concentric and coplanar with the
|
|
1036
|
+
// ring — on flat ground the frame's XZ is world-horizontal, on a pitched roof
|
|
1037
|
+
// it follows the slope, with no world-space basis math.
|
|
1038
|
+
//
|
|
1039
|
+
// The wedge fills the node-local XZ plane at the ring's height `y`. Its leading
|
|
1040
|
+
// edge sits at the rotate handle's bearing `handleAngle` (which is fixed in the
|
|
1041
|
+
// node frame, so it tracks the orbiting handle), and it opens BACKWARD by the
|
|
1042
|
+
// swept `delta` — so the trailing edge stays pinned to the grab direction in
|
|
1043
|
+
// world while the node spins. `orbitRadius` is the handle's in-plane distance
|
|
1044
|
+
// from the pivot; the fill is pulled inside it so it reads as the handle
|
|
1045
|
+
// swinging around rather than overlapping the icon.
|
|
1046
|
+
function RotationWedge({
|
|
1047
|
+
center,
|
|
1048
|
+
delta,
|
|
1049
|
+
handleAngle,
|
|
1050
|
+
orbitRadius,
|
|
1051
|
+
y,
|
|
1052
|
+
}: {
|
|
1053
|
+
center?: readonly [number, number, number]
|
|
1054
|
+
delta: number
|
|
1055
|
+
handleAngle: number
|
|
1056
|
+
orbitRadius: number
|
|
1057
|
+
y: number
|
|
1058
|
+
}) {
|
|
1059
|
+
const radius = Math.min(Math.max(orbitRadius * 0.72, 0.3), 1.6)
|
|
1060
|
+
const { outline, fill } = useMemo(() => {
|
|
1061
|
+
const start = handleAngle - delta
|
|
1062
|
+
const span = delta
|
|
1063
|
+
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
|
|
1064
|
+
const arc = Array.from({ length: count + 1 }, (_, index) => {
|
|
1065
|
+
const angle = start + (span * index) / count
|
|
1066
|
+
return new Vector3(Math.cos(angle) * radius, 0, Math.sin(angle) * radius)
|
|
1067
|
+
})
|
|
1068
|
+
const centerV = new Vector3(0, 0, 0)
|
|
1069
|
+
const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
|
|
1070
|
+
const positions: number[] = []
|
|
1071
|
+
for (let i = 0; i < arc.length - 1; i++) {
|
|
1072
|
+
const a = arc[i]
|
|
1073
|
+
const b = arc[i + 1]
|
|
1074
|
+
if (!a || !b) continue
|
|
1075
|
+
positions.push(0, 0, 0, a.x, a.y, a.z, b.x, b.y, b.z)
|
|
1076
|
+
}
|
|
1077
|
+
const fillGeo = new BufferGeometry()
|
|
1078
|
+
fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
1079
|
+
return { outline: outlineGeo, fill: fillGeo }
|
|
1080
|
+
}, [delta, handleAngle, radius])
|
|
1081
|
+
useEffect(() => () => outline.dispose(), [outline])
|
|
1082
|
+
useEffect(() => () => fill.dispose(), [fill])
|
|
1083
|
+
|
|
1084
|
+
const labelRadius = radius + 0.22
|
|
1085
|
+
const midAngle = handleAngle - delta / 2
|
|
1086
|
+
const labelPos: [number, number, number] = [
|
|
1087
|
+
Math.cos(midAngle) * labelRadius,
|
|
1088
|
+
0,
|
|
1089
|
+
Math.sin(midAngle) * labelRadius,
|
|
1090
|
+
]
|
|
1091
|
+
|
|
1092
|
+
return (
|
|
1093
|
+
<group position={center ? [center[0], y, center[2]] : [0, y, 0]}>
|
|
1094
|
+
<mesh
|
|
1095
|
+
frustumCulled={false}
|
|
1096
|
+
geometry={fill}
|
|
1097
|
+
layers={EDITOR_LAYER}
|
|
1098
|
+
raycast={NO_RAYCAST}
|
|
1099
|
+
renderOrder={1008}
|
|
1100
|
+
>
|
|
1101
|
+
<meshBasicMaterial
|
|
1102
|
+
color={ROTATION_GUIDE_COLOR}
|
|
1103
|
+
depthTest={false}
|
|
1104
|
+
depthWrite={false}
|
|
1105
|
+
opacity={0.18}
|
|
1106
|
+
side={DoubleSide}
|
|
1107
|
+
transparent
|
|
1108
|
+
/>
|
|
1109
|
+
</mesh>
|
|
1110
|
+
<RotationGuideOutline geometry={outline} />
|
|
1111
|
+
<DimensionLabel position={labelPos} text={formatAngleRadians(Math.abs(delta))} />
|
|
1112
|
+
</group>
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Angular drag: project pointer to a horizontal plane at the arrow's Y
|
|
1117
|
+
// and measure the signed angle around the node's local origin (in world
|
|
1118
|
+
// XZ). Pass the normalised delta to `apply` — the descriptor owns the
|
|
1119
|
+
// per-field math (sweep handles write `sweepAngle` AND `rotation` from
|
|
1120
|
+
// the same delta to keep the opposite edge world-fixed).
|
|
1121
|
+
function ArcArrow({
|
|
1122
|
+
descriptor,
|
|
1123
|
+
node,
|
|
1124
|
+
liveNode,
|
|
1125
|
+
freezeOffset,
|
|
1126
|
+
handleIndex,
|
|
1127
|
+
dragControls,
|
|
1128
|
+
rideObject,
|
|
1129
|
+
}: {
|
|
1130
|
+
descriptor: ArcResizeHandle<AnyNode>
|
|
1131
|
+
/** Effective node for placement (preDrag snapshot when another arrow is active). */
|
|
1132
|
+
node: AnyNode
|
|
1133
|
+
/** Always the live (override-merged) node — used inside drag handlers. */
|
|
1134
|
+
liveNode: AnyNode
|
|
1135
|
+
/** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
|
|
1136
|
+
freezeOffset: [number, number, number] | null
|
|
1137
|
+
handleIndex: number
|
|
1138
|
+
dragControls: HandleDragControls
|
|
1139
|
+
rideObject: Object3D
|
|
1140
|
+
}) {
|
|
1141
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
1142
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
1143
|
+
// 'rotate' descriptors (whole-node rotation handles like the elevator
|
|
1144
|
+
// corner) render a two-headed curved arrow; everything else (stair
|
|
1145
|
+
// sweep, etc.) keeps the chevron.
|
|
1146
|
+
const isRotateShape = descriptor.shape === 'rotate'
|
|
1147
|
+
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
|
|
1148
|
+
const isDirectRotating = isRotateShape && activeRotateNodeId === liveNode.id
|
|
1149
|
+
// 'node-normal' spins the node about its local +Z (a wall item flat against
|
|
1150
|
+
// its wall) instead of yaw about world-Y. The drag plane and the icon both
|
|
1151
|
+
// tilt into that plane, and the horizontal-only wedge/ring readout is
|
|
1152
|
+
// suppressed.
|
|
1153
|
+
const isNodeNormalRot = descriptor.rotationPlane === 'node-normal'
|
|
1154
|
+
const { camera } = useThree()
|
|
1155
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
1156
|
+
// The rotate icon is denser than the chevron; pump scale a touch so the
|
|
1157
|
+
// ribbon reads at the same on-screen size as the other handles.
|
|
1158
|
+
const arrowScale = isRotateShape ? ARROW_SCALE * 1.05 : ARROW_SCALE
|
|
1159
|
+
const baseScale = zoom * arrowScale
|
|
1160
|
+
// Live rotation amount (radians swept since grab) — non-null only while a
|
|
1161
|
+
// `shape: 'rotate'` gizmo is mid-drag. Drives the in-frame wedge readout.
|
|
1162
|
+
const [rotationDelta, setRotationDelta] = useState<number | null>(null)
|
|
1163
|
+
|
|
1164
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
1165
|
+
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
|
1166
|
+
// See the LinearArrow note on freezeOffset — for rotation drags the
|
|
1167
|
+
// delta collapses to zero (position doesn't change), so the rotate
|
|
1168
|
+
// gizmo naturally rotates with the mesh while another arrow is being
|
|
1169
|
+
// dragged. The offset only kicks in for asymmetric resize drags that
|
|
1170
|
+
// recompute `position` to anchor the opposite edge.
|
|
1171
|
+
const position: [number, number, number] = freezeOffset
|
|
1172
|
+
? [
|
|
1173
|
+
basePosition[0] - freezeOffset[0],
|
|
1174
|
+
basePosition[1] - freezeOffset[1],
|
|
1175
|
+
basePosition[2] - freezeOffset[2],
|
|
1176
|
+
]
|
|
1177
|
+
: [basePosition[0], basePosition[1], basePosition[2]]
|
|
1178
|
+
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
1179
|
+
// Rotation gizmo: hover signals "grabbable", active drag signals
|
|
1180
|
+
// "grabbed". `ew-resize` was wrong — it implies linear width drag.
|
|
1181
|
+
const hoverCursor: Cursor = 'grab'
|
|
1182
|
+
const dragCursor: Cursor = 'grabbing'
|
|
1183
|
+
|
|
1184
|
+
// Optional guide ring (elevator rotation circle) shown while the arc
|
|
1185
|
+
// arrow is hovered or dragging. Same recipe as the linear / radial
|
|
1186
|
+
// decoration path.
|
|
1187
|
+
const decoration = descriptor.decoration
|
|
1188
|
+
const decorationCenter = guideDecorationCenter(decoration, node, placementSceneApi)
|
|
1189
|
+
const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
|
|
1190
|
+
|
|
1191
|
+
const activate = useHandleDrag({
|
|
1192
|
+
kind: 'drag',
|
|
1193
|
+
cursor: dragCursor,
|
|
1194
|
+
dragControls,
|
|
1195
|
+
handleIndex,
|
|
1196
|
+
node,
|
|
1197
|
+
rideObject,
|
|
1198
|
+
setIsDragging,
|
|
1199
|
+
onStart: ({ event, initialNode, intersectPlane, rideObject: dragRideObject, sceneApi }) => {
|
|
1200
|
+
const centerWorld =
|
|
1201
|
+
descriptor.rotationCenter !== undefined
|
|
1202
|
+
? new Vector3(...descriptor.rotationCenter(node as never, sceneApi)).applyMatrix4(
|
|
1203
|
+
dragRideObject.matrixWorld,
|
|
1204
|
+
)
|
|
1205
|
+
: new Vector3().setFromMatrixPosition(dragRideObject.matrixWorld)
|
|
1206
|
+
const arrowWorld = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld)
|
|
1207
|
+
const planeY = arrowWorld.y
|
|
1208
|
+
const axis = isNodeNormalRot
|
|
1209
|
+
? new Vector3().setFromMatrixColumn(dragRideObject.matrixWorld, 2).normalize()
|
|
1210
|
+
: new Vector3(0, 1, 0)
|
|
1211
|
+
const plane = isNodeNormalRot
|
|
1212
|
+
? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld)
|
|
1213
|
+
: new Plane(new Vector3(0, 1, 0), -planeY)
|
|
1214
|
+
|
|
1215
|
+
let basisU: Vector3
|
|
1216
|
+
if (isNodeNormalRot) {
|
|
1217
|
+
const up = new Vector3(0, 1, 0)
|
|
1218
|
+
basisU = up.clone().addScaledVector(axis, -up.dot(axis))
|
|
1219
|
+
if (basisU.lengthSq() < 1e-6) {
|
|
1220
|
+
const x = new Vector3(1, 0, 0)
|
|
1221
|
+
basisU = x.addScaledVector(axis, -x.dot(axis))
|
|
1222
|
+
}
|
|
1223
|
+
basisU.normalize()
|
|
1224
|
+
} else {
|
|
1225
|
+
basisU = new Vector3(1, 0, 0)
|
|
1226
|
+
}
|
|
1227
|
+
const basisV = isNodeNormalRot
|
|
1228
|
+
? new Vector3().crossVectors(axis, basisU).normalize()
|
|
1229
|
+
: new Vector3(0, 0, 1)
|
|
1230
|
+
const angleOf = (p: Vector3) => {
|
|
1231
|
+
const d = new Vector3().subVectors(p, centerWorld)
|
|
1232
|
+
return Math.atan2(d.dot(basisV), d.dot(basisU))
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const hitWorld = new Vector3()
|
|
1236
|
+
if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
|
|
1237
|
+
return null
|
|
1238
|
+
}
|
|
1239
|
+
const initialAngle = angleOf(hitWorld)
|
|
1240
|
+
|
|
1241
|
+
// Advertise the rotate interaction so the contextual HUD can surface the
|
|
1242
|
+
// Shift = free-rotation toggle (the angle-step bypass below). Resize
|
|
1243
|
+
// handles route a measurement label here; rotate gets a sentinel label so
|
|
1244
|
+
// the HUD shows the rotate hint, not a dimension pill.
|
|
1245
|
+
if (isRotateShape) {
|
|
1246
|
+
useInteractionScope
|
|
1247
|
+
.getState()
|
|
1248
|
+
.begin({ kind: 'handle-drag', nodeId: node.id, handle: ROTATE_HANDLE_DRAG_LABEL })
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
return {
|
|
1252
|
+
onEnd: () => {
|
|
1253
|
+
setRotationDelta(null)
|
|
1254
|
+
if (isRotateShape) {
|
|
1255
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
|
1259
|
+
const hit = new Vector3()
|
|
1260
|
+
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
|
1261
|
+
const currentAngle = angleOf(hit)
|
|
1262
|
+
let delta = currentAngle - initialAngle
|
|
1263
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
1264
|
+
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
1265
|
+
|
|
1266
|
+
if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
|
|
1267
|
+
delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (isRotateShape && !isNodeNormalRot) {
|
|
1271
|
+
setRotationDelta(Math.abs(delta) < 0.0087 ? null : delta)
|
|
1272
|
+
}
|
|
1273
|
+
return descriptor.apply(initialNode as never, delta, sceneApi) as Partial<AnyNode>
|
|
1274
|
+
},
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
return (
|
|
1280
|
+
<>
|
|
1281
|
+
{showDecoration && decoration ? (
|
|
1282
|
+
<GuideRing
|
|
1283
|
+
center={guideDecorationCenter(decoration, node, placementSceneApi)}
|
|
1284
|
+
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
|
|
1285
|
+
y={decoration.y?.(node as never) ?? 0}
|
|
1286
|
+
/>
|
|
1287
|
+
) : null}
|
|
1288
|
+
{/* Live rotation readout. Rendered HERE (a child of the node frame, the
|
|
1289
|
+
same frame the guide ring lives in) rather than portalled to world
|
|
1290
|
+
space, so the wedge is automatically concentric and coplanar with the
|
|
1291
|
+
ring on any surface — flat ground or a pitched roof. */}
|
|
1292
|
+
{rotationDelta !== null ? (
|
|
1293
|
+
<RotationWedge
|
|
1294
|
+
center={decorationCenter}
|
|
1295
|
+
delta={rotationDelta}
|
|
1296
|
+
handleAngle={Math.atan2(
|
|
1297
|
+
position[2] - (decorationCenter?.[2] ?? 0),
|
|
1298
|
+
position[0] - (decorationCenter?.[0] ?? 0),
|
|
1299
|
+
)}
|
|
1300
|
+
orbitRadius={Math.hypot(
|
|
1301
|
+
position[0] - (decorationCenter?.[0] ?? 0),
|
|
1302
|
+
position[2] - (decorationCenter?.[2] ?? 0),
|
|
1303
|
+
)}
|
|
1304
|
+
y={decoration?.y?.(node as never) ?? 0}
|
|
1305
|
+
/>
|
|
1306
|
+
) : null}
|
|
1307
|
+
<HandleArrow
|
|
1308
|
+
activeCursor={dragCursor}
|
|
1309
|
+
cursor={hoverCursor}
|
|
1310
|
+
hover={isHovered || isDirectRotating}
|
|
1311
|
+
onHoverChange={setIsHovered}
|
|
1312
|
+
onPointerDown={activate}
|
|
1313
|
+
placement={{
|
|
1314
|
+
position,
|
|
1315
|
+
// The curved arrow is built flat in XZ. For a wall-normal spin, tilt
|
|
1316
|
+
// it up about X so it lies in the item-local XY plane (the wall face).
|
|
1317
|
+
rotation: isNodeNormalRot ? [Math.PI / 2, 0, rotationY] : [0, rotationY, 0],
|
|
1318
|
+
baseScale,
|
|
1319
|
+
}}
|
|
1320
|
+
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
|
|
1321
|
+
thin
|
|
1322
|
+
/>
|
|
1323
|
+
</>
|
|
1324
|
+
)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Click-to-engage affordance — no drag plumbing, just a click target. The
|
|
1328
|
+
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
|
|
1329
|
+
// move tools, endpoint drags, or any other editor-state transition without
|
|
1330
|
+
// importing editor internals from the node-def layer.
|
|
1331
|
+
function TapActionArrow({
|
|
1332
|
+
descriptor,
|
|
1333
|
+
node,
|
|
1334
|
+
}: {
|
|
1335
|
+
descriptor: TapActionHandle<AnyNode>
|
|
1336
|
+
node: AnyNode
|
|
1337
|
+
}) {
|
|
1338
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
1339
|
+
const { camera } = useThree()
|
|
1340
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
1341
|
+
|
|
1342
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
1343
|
+
const position = descriptor.placement.position(node, placementSceneApi)
|
|
1344
|
+
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
1345
|
+
const shape = descriptor.shape ?? 'arrow'
|
|
1346
|
+
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
|
1347
|
+
const round = descriptor.round ?? false
|
|
1348
|
+
|
|
1349
|
+
const onActivate = useHandleDrag({
|
|
1350
|
+
kind: 'tap',
|
|
1351
|
+
onTap: () => {
|
|
1352
|
+
setIsHovered(false)
|
|
1353
|
+
descriptor.onActivate(node as never, createSceneApi(useScene), createEditorApi())
|
|
1354
|
+
},
|
|
1355
|
+
})
|
|
1356
|
+
|
|
1357
|
+
if (shape === 'corner-picker') {
|
|
1358
|
+
const height = descriptor.nodeHeight?.(node) ?? 1
|
|
1359
|
+
return (
|
|
1360
|
+
<CornerPickerShape
|
|
1361
|
+
baseScale={zoom}
|
|
1362
|
+
cursor={cursor}
|
|
1363
|
+
height={height}
|
|
1364
|
+
hover={isHovered}
|
|
1365
|
+
onHoverChange={setIsHovered}
|
|
1366
|
+
onPointerDown={onActivate}
|
|
1367
|
+
position={position}
|
|
1368
|
+
round={round}
|
|
1369
|
+
/>
|
|
1370
|
+
)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const baseScale = zoom * ARROW_SCALE
|
|
1374
|
+
// A `move-cross` with `plane: 'node-normal'` stands up into the node's facing
|
|
1375
|
+
// plane (a wall face) like the door / window / wall-item move grips; other
|
|
1376
|
+
// tap-actions keep their in-plane `rotationY`.
|
|
1377
|
+
const rotation: [number, number, number] =
|
|
1378
|
+
descriptor.plane === 'node-normal' ? NODE_NORMAL_TILT : [0, rotationY, 0]
|
|
1379
|
+
return (
|
|
1380
|
+
<HandleArrow
|
|
1381
|
+
cursor={cursor}
|
|
1382
|
+
hover={isHovered}
|
|
1383
|
+
onHoverChange={setIsHovered}
|
|
1384
|
+
onPointerDown={onActivate}
|
|
1385
|
+
placement={{ position, rotation, baseScale }}
|
|
1386
|
+
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
|
|
1387
|
+
thin
|
|
1388
|
+
/>
|
|
1389
|
+
)
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles
|
|
1393
|
+
// the visibility of every arrow tagged with its `latchGroup` on click. Sized
|
|
1394
|
+
// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE)
|
|
1395
|
+
// so every latch grip reads the same across the app. Stays highlighted while
|
|
1396
|
+
// its group is open so the user can tell it's engaged.
|
|
1397
|
+
function LatchCube({
|
|
1398
|
+
descriptor,
|
|
1399
|
+
node,
|
|
1400
|
+
open,
|
|
1401
|
+
onToggle,
|
|
1402
|
+
}: {
|
|
1403
|
+
descriptor: LatchHandle<AnyNode>
|
|
1404
|
+
node: AnyNode
|
|
1405
|
+
open: boolean
|
|
1406
|
+
onToggle: (group: string) => void
|
|
1407
|
+
}) {
|
|
1408
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
1409
|
+
const { camera } = useThree()
|
|
1410
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
1411
|
+
const baseScale = zoom
|
|
1412
|
+
|
|
1413
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
1414
|
+
const position = descriptor.placement.position(node, placementSceneApi)
|
|
1415
|
+
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
1416
|
+
|
|
1417
|
+
// Route through the shared tap path so the cube click is swallowed before it
|
|
1418
|
+
// reaches the select tool — stops R3F propagation, suppresses box-select, and
|
|
1419
|
+
// eats the trailing DOM click that would otherwise select the host node.
|
|
1420
|
+
const onPointerDown = useHandleDrag({
|
|
1421
|
+
kind: 'tap',
|
|
1422
|
+
onTap: () => {
|
|
1423
|
+
setIsHovered(false)
|
|
1424
|
+
onToggle(descriptor.group)
|
|
1425
|
+
},
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
return (
|
|
1429
|
+
<HandleArrow
|
|
1430
|
+
cursor="grab"
|
|
1431
|
+
hover={isHovered || open}
|
|
1432
|
+
hoverScale={1.15}
|
|
1433
|
+
onHoverChange={setIsHovered}
|
|
1434
|
+
onPointerDown={onPointerDown}
|
|
1435
|
+
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
|
1436
|
+
shape="tracker"
|
|
1437
|
+
/>
|
|
1438
|
+
)
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Wall corner-picker visual: dashed vertical leader from floor up to
|
|
1442
|
+
// `height` + billboarded hex disc (the click target) + outer ring. The
|
|
1443
|
+
// hex disc is the only mesh with a pointer-down handler; the dashes and
|
|
1444
|
+
// ring are decorative.
|
|
1445
|
+
const CORNER_DASH_SIZE = 0.1
|
|
1446
|
+
const CORNER_GAP_SIZE = 0.07
|
|
1447
|
+
const CORNER_DASH_THICKNESS = 0.006
|
|
1448
|
+
const CORNER_FLOOR_OFFSET = 0.01
|
|
1449
|
+
|
|
1450
|
+
function buildDashedVerticalGeometry(height: number) {
|
|
1451
|
+
const dashes: BufferGeometry[] = []
|
|
1452
|
+
let y = 0
|
|
1453
|
+
while (y < height) {
|
|
1454
|
+
const end = Math.min(y + CORNER_DASH_SIZE, height)
|
|
1455
|
+
const length = end - y
|
|
1456
|
+
const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
|
|
1457
|
+
cylinder.translate(0, y + length / 2, 0)
|
|
1458
|
+
dashes.push(cylinder)
|
|
1459
|
+
y = end + CORNER_GAP_SIZE
|
|
1460
|
+
}
|
|
1461
|
+
const merged = mergeGeometries(dashes, false) ?? dashes[0]
|
|
1462
|
+
for (const dash of dashes) dash.dispose()
|
|
1463
|
+
return merged
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function CornerPickerShape({
|
|
1467
|
+
position,
|
|
1468
|
+
height,
|
|
1469
|
+
baseScale,
|
|
1470
|
+
cursor,
|
|
1471
|
+
hover,
|
|
1472
|
+
onHoverChange,
|
|
1473
|
+
onPointerDown,
|
|
1474
|
+
round = false,
|
|
1475
|
+
}: {
|
|
1476
|
+
position: readonly [number, number, number]
|
|
1477
|
+
height: number
|
|
1478
|
+
baseScale: number
|
|
1479
|
+
cursor: Cursor
|
|
1480
|
+
hover: boolean
|
|
1481
|
+
onHoverChange: (hovered: boolean) => void
|
|
1482
|
+
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
|
1483
|
+
round?: boolean
|
|
1484
|
+
}) {
|
|
1485
|
+
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
|
|
1486
|
+
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
|
1487
|
+
|
|
1488
|
+
const dashMaterial = useMemo(
|
|
1489
|
+
() =>
|
|
1490
|
+
new MeshBasicNodeMaterial({
|
|
1491
|
+
color: new Color(ARROW_COLOR),
|
|
1492
|
+
transparent: true,
|
|
1493
|
+
opacity: 0.85,
|
|
1494
|
+
depthTest: false,
|
|
1495
|
+
depthWrite: false,
|
|
1496
|
+
}),
|
|
1497
|
+
[],
|
|
1498
|
+
)
|
|
1499
|
+
const ringMaterial = useMemo(
|
|
1500
|
+
() =>
|
|
1501
|
+
new MeshBasicNodeMaterial({
|
|
1502
|
+
color: new Color(ARROW_COLOR),
|
|
1503
|
+
side: DoubleSide,
|
|
1504
|
+
transparent: true,
|
|
1505
|
+
opacity: 1,
|
|
1506
|
+
depthTest: false,
|
|
1507
|
+
depthWrite: false,
|
|
1508
|
+
}),
|
|
1509
|
+
[],
|
|
1510
|
+
)
|
|
1511
|
+
useEffect(() => {
|
|
1512
|
+
const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR
|
|
1513
|
+
dashMaterial.color.set(next)
|
|
1514
|
+
ringMaterial.color.set(next)
|
|
1515
|
+
}, [dashMaterial, ringMaterial, hover])
|
|
1516
|
+
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
|
|
1517
|
+
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
|
|
1518
|
+
|
|
1519
|
+
const billboardRef = useRef<Group>(null)
|
|
1520
|
+
const { camera } = useThree()
|
|
1521
|
+
// Billboard the disc to the camera so the picker remains readable at any
|
|
1522
|
+
// viewing angle. The disc lives under the building-rotated tool group
|
|
1523
|
+
// (and possibly a rotated level), so copying `camera.quaternion` onto
|
|
1524
|
+
// the local quaternion no longer yields camera-aligned WORLD rotation
|
|
1525
|
+
// when the parent has a rotation of its own — compute the local
|
|
1526
|
+
// quaternion that, composed with the parent's world rotation, equals
|
|
1527
|
+
// the camera's world rotation.
|
|
1528
|
+
const parentWorldQuat = useMemo(() => new Quaternion(), [])
|
|
1529
|
+
const invParentWorldQuat = useMemo(() => new Quaternion(), [])
|
|
1530
|
+
useFrame(() => {
|
|
1531
|
+
const group = billboardRef.current
|
|
1532
|
+
if (!group) return
|
|
1533
|
+
if (group.parent) {
|
|
1534
|
+
group.parent.getWorldQuaternion(parentWorldQuat)
|
|
1535
|
+
invParentWorldQuat.copy(parentWorldQuat).invert()
|
|
1536
|
+
group.quaternion.copy(invParentWorldQuat).multiply(camera.quaternion)
|
|
1537
|
+
} else {
|
|
1538
|
+
group.quaternion.copy(camera.quaternion)
|
|
1539
|
+
}
|
|
1540
|
+
})
|
|
1541
|
+
|
|
1542
|
+
const scale = (hover ? 1.25 : 1) * baseScale
|
|
1543
|
+
|
|
1544
|
+
return (
|
|
1545
|
+
<>
|
|
1546
|
+
<mesh
|
|
1547
|
+
frustumCulled={false}
|
|
1548
|
+
geometry={dashedGeometry}
|
|
1549
|
+
material={dashMaterial}
|
|
1550
|
+
position={position}
|
|
1551
|
+
renderOrder={1001}
|
|
1552
|
+
/>
|
|
1553
|
+
<group position={[position[0], CORNER_FLOOR_OFFSET, position[2]]} ref={billboardRef}>
|
|
1554
|
+
<HandleArrow
|
|
1555
|
+
cursor={cursor}
|
|
1556
|
+
hover={hover}
|
|
1557
|
+
hoverScale={1.25}
|
|
1558
|
+
onHoverChange={onHoverChange}
|
|
1559
|
+
onPointerDown={onPointerDown}
|
|
1560
|
+
placement={{ position: [0, 0, 0], baseScale }}
|
|
1561
|
+
round={round}
|
|
1562
|
+
shape="corner-picker"
|
|
1563
|
+
/>
|
|
1564
|
+
<mesh material={ringMaterial} renderOrder={1002} scale={scale}>
|
|
1565
|
+
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, round ? 32 : 6]} />
|
|
1566
|
+
</mesh>
|
|
1567
|
+
</group>
|
|
1568
|
+
</>
|
|
1569
|
+
)
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Tracker visual for the `linear-resize` handle's `shape: 'tracker'` option.
|
|
1573
|
+
// Mirrors the corner picker (dashed vertical leader from the floor) but caps
|
|
1574
|
+
// the leader with a small draggable cube instead of a hex disc, and the cube
|
|
1575
|
+
// sits at the TOP of the leader rather than the floor — the visual reads as
|
|
1576
|
+
// "this cube is the wall top; drag it to raise/lower." All interactivity
|
|
1577
|
+
// (pointer-down → linear-resize drag) is wired by the parent `LinearArrow`.
|
|
1578
|
+
function TrackerShape({
|
|
1579
|
+
basePosition,
|
|
1580
|
+
baseScale,
|
|
1581
|
+
cubePosition,
|
|
1582
|
+
cursor,
|
|
1583
|
+
hover,
|
|
1584
|
+
leaderHeight,
|
|
1585
|
+
onHoverChange,
|
|
1586
|
+
onPointerDown,
|
|
1587
|
+
}: {
|
|
1588
|
+
basePosition: readonly [number, number, number]
|
|
1589
|
+
baseScale: number
|
|
1590
|
+
cubePosition: readonly [number, number, number]
|
|
1591
|
+
cursor: Cursor
|
|
1592
|
+
hover: boolean
|
|
1593
|
+
leaderHeight: number
|
|
1594
|
+
onHoverChange: (hovered: boolean) => void
|
|
1595
|
+
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
|
1596
|
+
}) {
|
|
1597
|
+
// `leaderHeight === 0` (wallHeight collapsed to floor) would make the
|
|
1598
|
+
// dashed builder return an empty geometry — skip the mesh entirely in
|
|
1599
|
+
// that case so the cube still renders by itself.
|
|
1600
|
+
const hasLeader = leaderHeight > 0.0001
|
|
1601
|
+
const dashedGeometry = useMemo(
|
|
1602
|
+
() => (hasLeader ? buildDashedVerticalGeometry(leaderHeight) : null),
|
|
1603
|
+
[hasLeader, leaderHeight],
|
|
1604
|
+
)
|
|
1605
|
+
useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry])
|
|
1606
|
+
|
|
1607
|
+
const dashMaterial = useMemo(
|
|
1608
|
+
() =>
|
|
1609
|
+
new MeshBasicNodeMaterial({
|
|
1610
|
+
color: new Color(ARROW_COLOR),
|
|
1611
|
+
transparent: true,
|
|
1612
|
+
opacity: 0.85,
|
|
1613
|
+
depthTest: false,
|
|
1614
|
+
depthWrite: false,
|
|
1615
|
+
}),
|
|
1616
|
+
[],
|
|
1617
|
+
)
|
|
1618
|
+
useEffect(() => {
|
|
1619
|
+
const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR
|
|
1620
|
+
dashMaterial.color.set(next)
|
|
1621
|
+
}, [dashMaterial, hover])
|
|
1622
|
+
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
|
|
1623
|
+
|
|
1624
|
+
return (
|
|
1625
|
+
<>
|
|
1626
|
+
{dashedGeometry ? (
|
|
1627
|
+
<mesh
|
|
1628
|
+
frustumCulled={false}
|
|
1629
|
+
geometry={dashedGeometry}
|
|
1630
|
+
material={dashMaterial}
|
|
1631
|
+
position={basePosition}
|
|
1632
|
+
renderOrder={1001}
|
|
1633
|
+
/>
|
|
1634
|
+
) : null}
|
|
1635
|
+
<HandleArrow
|
|
1636
|
+
cursor={cursor}
|
|
1637
|
+
hover={hover}
|
|
1638
|
+
hoverScale={1.25}
|
|
1639
|
+
onHoverChange={onHoverChange}
|
|
1640
|
+
onPointerDown={onPointerDown}
|
|
1641
|
+
placement={{ position: cubePosition, baseScale }}
|
|
1642
|
+
shape="tracker"
|
|
1643
|
+
/>
|
|
1644
|
+
</>
|
|
1645
|
+
)
|
|
1646
|
+
}
|