@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,870 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
DEFAULT_WALL_HEIGHT,
|
|
6
|
+
type FenceNode,
|
|
7
|
+
getWallCurveFrameAt,
|
|
8
|
+
getWallThickness,
|
|
9
|
+
isCurvedWall,
|
|
10
|
+
sceneRegistry,
|
|
11
|
+
useLiveNodeOverrides,
|
|
12
|
+
useScene,
|
|
13
|
+
type WallNode,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
16
|
+
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
18
|
+
import {
|
|
19
|
+
BufferGeometry,
|
|
20
|
+
Color,
|
|
21
|
+
CylinderGeometry,
|
|
22
|
+
DoubleSide,
|
|
23
|
+
ExtrudeGeometry,
|
|
24
|
+
type Group,
|
|
25
|
+
type Object3D,
|
|
26
|
+
OrthographicCamera,
|
|
27
|
+
Plane,
|
|
28
|
+
Quaternion,
|
|
29
|
+
Shape,
|
|
30
|
+
Vector2,
|
|
31
|
+
Vector3,
|
|
32
|
+
} from 'three'
|
|
33
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
34
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
35
|
+
import { isHistoryShortcut } from '../../lib/history'
|
|
36
|
+
import { endpointReshapeScope } from '../../lib/interaction/scope'
|
|
37
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
38
|
+
import useEditor from '../../store/use-editor'
|
|
39
|
+
import useInteractionScope, {
|
|
40
|
+
useEndpointReshape,
|
|
41
|
+
useIsCurveReshape,
|
|
42
|
+
useMovingNode,
|
|
43
|
+
} from '../../store/use-interaction-scope'
|
|
44
|
+
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
45
|
+
import {
|
|
46
|
+
createArrowHitAreaGeometry,
|
|
47
|
+
createEndpointHitAreaGeometry,
|
|
48
|
+
InvisibleHandleHitArea,
|
|
49
|
+
NO_RAYCAST,
|
|
50
|
+
useInvisibleHitAreaMaterial,
|
|
51
|
+
} from './node-arrow-handles'
|
|
52
|
+
|
|
53
|
+
const HANDLE_OFFSET = 0.27
|
|
54
|
+
const HANDLE_MIN_OFFSET = 0.33
|
|
55
|
+
const HANDLE_MIN_HEIGHT = 0.4
|
|
56
|
+
const HANDLE_TOP_INSET = 0.08
|
|
57
|
+
const HEIGHT_HANDLE_OFFSET = 0.26
|
|
58
|
+
const MIN_WALL_HEIGHT = 0.5
|
|
59
|
+
const ARROW_COLOR = '#8381ed'
|
|
60
|
+
const ARROW_HOVER_COLOR = '#a5b4fc'
|
|
61
|
+
// Match the door arrows: scale the rendered chevron down to ~two-thirds
|
|
62
|
+
// so the in-world handles read as a single UI family.
|
|
63
|
+
const ARROW_SCALE = 0.65
|
|
64
|
+
const CORNER_HEX_RADIUS = 0.11
|
|
65
|
+
const CORNER_DASH_SIZE = 0.1
|
|
66
|
+
const CORNER_GAP_SIZE = 0.07
|
|
67
|
+
const CORNER_DASH_THICKNESS = 0.006
|
|
68
|
+
const CORNER_FLOOR_OFFSET = 0.01
|
|
69
|
+
|
|
70
|
+
type WallMoveHandle = {
|
|
71
|
+
key: string
|
|
72
|
+
position: [number, number, number]
|
|
73
|
+
rotationY: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Pre-empt the synthetic `click` the browser fires immediately after a
|
|
77
|
+
// drag's pointerup. Without this, PointerMissedHandler treats the click
|
|
78
|
+
// as "missed" and deselects the wall when the height arrow drag commits.
|
|
79
|
+
function swallowNextClick() {
|
|
80
|
+
const swallow = (clickEvent: Event) => {
|
|
81
|
+
clickEvent.stopPropagation()
|
|
82
|
+
clickEvent.preventDefault()
|
|
83
|
+
}
|
|
84
|
+
window.addEventListener('click', swallow, { capture: true, once: true })
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
window.removeEventListener('click', swallow, { capture: true })
|
|
87
|
+
}, 300)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createArrowHandleGeometry() {
|
|
91
|
+
// Classic arrow silhouette — chevron head + rectangular shaft — extruded
|
|
92
|
+
// slightly so the handle reads as a 3D plate but stays visually light.
|
|
93
|
+
const shape = new Shape()
|
|
94
|
+
shape.moveTo(0.22, 0)
|
|
95
|
+
shape.lineTo(-0.04, 0.12)
|
|
96
|
+
shape.lineTo(-0.04, 0.035)
|
|
97
|
+
shape.lineTo(-0.2, 0.035)
|
|
98
|
+
shape.lineTo(-0.2, -0.035)
|
|
99
|
+
shape.lineTo(-0.04, -0.035)
|
|
100
|
+
shape.lineTo(-0.04, -0.12)
|
|
101
|
+
shape.lineTo(0.22, 0)
|
|
102
|
+
|
|
103
|
+
const geometry = new ExtrudeGeometry(shape, {
|
|
104
|
+
depth: 0.045,
|
|
105
|
+
bevelEnabled: true,
|
|
106
|
+
bevelThickness: 0.018,
|
|
107
|
+
bevelSize: 0.02,
|
|
108
|
+
bevelOffset: 0,
|
|
109
|
+
bevelSegments: 8,
|
|
110
|
+
curveSegments: 16,
|
|
111
|
+
steps: 1,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Centre the extruded plate around y=0 and re-orient it so the depth
|
|
115
|
+
// axis points up: the chevron lies flat in the XZ plane, tip along +X,
|
|
116
|
+
// wings spread across ±Z.
|
|
117
|
+
geometry.translate(0, 0, -0.0225)
|
|
118
|
+
geometry.rotateX(-Math.PI / 2)
|
|
119
|
+
geometry.computeVertexNormals()
|
|
120
|
+
geometry.computeBoundingSphere()
|
|
121
|
+
return geometry
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function WallMoveSideHandles() {
|
|
125
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
126
|
+
const mode = useEditor((state) => state.mode)
|
|
127
|
+
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
128
|
+
const movingNode = useMovingNode()
|
|
129
|
+
const endpointReshape = useEndpointReshape()
|
|
130
|
+
const isCurveReshape = useIsCurveReshape()
|
|
131
|
+
|
|
132
|
+
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
133
|
+
// Fence side-move / height / corner-pickers now flow through the
|
|
134
|
+
// registry handle path (see packages/nodes/src/fence/definition.ts).
|
|
135
|
+
// Only walls still need the legacy renderer here — the registry path
|
|
136
|
+
// didn't render correctly for walls specifically and was reverted in
|
|
137
|
+
// commit 0e207a7f; revisit once that's diagnosed.
|
|
138
|
+
const selectedNode = useScene((state) => {
|
|
139
|
+
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
|
|
140
|
+
return node?.type === 'wall' ? node : null
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const shouldRender =
|
|
144
|
+
Boolean(selectedNode) &&
|
|
145
|
+
!isFloorplanHovered &&
|
|
146
|
+
mode !== 'delete' &&
|
|
147
|
+
!movingNode &&
|
|
148
|
+
!endpointReshape &&
|
|
149
|
+
!isCurveReshape
|
|
150
|
+
|
|
151
|
+
if (!shouldRender || !selectedNode) return null
|
|
152
|
+
|
|
153
|
+
return <WallMoveSideHandlesForWall wall={selectedNode} />
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
|
|
157
|
+
// Merge the in-flight drag override so every handle (side-move arrows,
|
|
158
|
+
// height arrow, corner leaders) tracks the live height in real time
|
|
159
|
+
// during a height drag — the scene store stays at the pre-drag value
|
|
160
|
+
// until commit, so reading `wall` alone would freeze them. Same pattern
|
|
161
|
+
// as node-arrow-handles.
|
|
162
|
+
const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id))
|
|
163
|
+
const effectiveWall = useMemo(
|
|
164
|
+
() => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall),
|
|
165
|
+
[wall, liveOverride],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
|
|
169
|
+
wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
let frameId = 0
|
|
174
|
+
|
|
175
|
+
const resolveLevelObject = () => {
|
|
176
|
+
const nextLevelObject = wall.parentId
|
|
177
|
+
? (sceneRegistry.nodes.get(wall.parentId) ?? null)
|
|
178
|
+
: null
|
|
179
|
+
setLevelObject((currentLevelObject) => {
|
|
180
|
+
if (currentLevelObject === nextLevelObject) {
|
|
181
|
+
return currentLevelObject
|
|
182
|
+
}
|
|
183
|
+
return nextLevelObject
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
if (!nextLevelObject) {
|
|
187
|
+
frameId = window.requestAnimationFrame(resolveLevelObject)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
resolveLevelObject()
|
|
192
|
+
|
|
193
|
+
return () => {
|
|
194
|
+
if (frameId) {
|
|
195
|
+
window.cancelAnimationFrame(frameId)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}, [wall.parentId])
|
|
199
|
+
|
|
200
|
+
const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall])
|
|
201
|
+
|
|
202
|
+
if (!levelObject || handles.length === 0) return null
|
|
203
|
+
|
|
204
|
+
return createPortal(
|
|
205
|
+
<group>
|
|
206
|
+
{handles.map((handle) => (
|
|
207
|
+
<WallMoveArrowHandle handle={handle} key={handle.key} wall={effectiveWall} />
|
|
208
|
+
))}
|
|
209
|
+
<WallHeightArrowHandle wall={effectiveWall} />
|
|
210
|
+
<WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
|
|
211
|
+
<WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
|
|
212
|
+
</group>,
|
|
213
|
+
levelObject,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildDashedVerticalGeometry(height: number) {
|
|
218
|
+
// Build each dash as a thin cylinder section so thickness is
|
|
219
|
+
// controllable — native `lineSegments` lock to 1px on WebGL/WebGPU.
|
|
220
|
+
const dashes: BufferGeometry[] = []
|
|
221
|
+
let y = 0
|
|
222
|
+
while (y < height) {
|
|
223
|
+
const end = Math.min(y + CORNER_DASH_SIZE, height)
|
|
224
|
+
const length = end - y
|
|
225
|
+
const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
|
|
226
|
+
cylinder.translate(0, y + length / 2, 0)
|
|
227
|
+
dashes.push(cylinder)
|
|
228
|
+
y = end + CORNER_GAP_SIZE
|
|
229
|
+
}
|
|
230
|
+
const merged = mergeGeometries(dashes, false) ?? new BufferGeometry()
|
|
231
|
+
for (const dash of dashes) dash.dispose()
|
|
232
|
+
return merged
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) {
|
|
236
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
237
|
+
const { camera } = useThree()
|
|
238
|
+
const billboardRef = useRef<Group>(null)
|
|
239
|
+
const parentWorldQuaternionRef = useRef(new Quaternion())
|
|
240
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
241
|
+
const baseScale = zoom
|
|
242
|
+
const visualScale = isHovered ? 1.25 : 1
|
|
243
|
+
|
|
244
|
+
const corner = endpoint === 'start' ? wall.start : wall.end
|
|
245
|
+
const x = corner[0]
|
|
246
|
+
const z = corner[1]
|
|
247
|
+
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
|
248
|
+
|
|
249
|
+
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
|
|
250
|
+
const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
|
|
251
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
252
|
+
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
|
253
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
254
|
+
|
|
255
|
+
// Node materials matched to the rest of the file — mixing plain
|
|
256
|
+
// `meshBasicMaterial` with WebGPU node materials trips
|
|
257
|
+
// "Color target has no corresponding fragment stage output".
|
|
258
|
+
const dashMaterial = useMemo(
|
|
259
|
+
() =>
|
|
260
|
+
new MeshBasicNodeMaterial({
|
|
261
|
+
color: new Color(ARROW_COLOR),
|
|
262
|
+
transparent: true,
|
|
263
|
+
opacity: 0.85,
|
|
264
|
+
depthTest: false,
|
|
265
|
+
depthWrite: false,
|
|
266
|
+
}),
|
|
267
|
+
[],
|
|
268
|
+
)
|
|
269
|
+
const hexMaterial = useMemo(
|
|
270
|
+
() =>
|
|
271
|
+
new MeshBasicNodeMaterial({
|
|
272
|
+
color: new Color(ARROW_COLOR),
|
|
273
|
+
side: DoubleSide,
|
|
274
|
+
transparent: true,
|
|
275
|
+
opacity: 0.95,
|
|
276
|
+
depthTest: false,
|
|
277
|
+
depthWrite: false,
|
|
278
|
+
}),
|
|
279
|
+
[],
|
|
280
|
+
)
|
|
281
|
+
const ringMaterial = useMemo(
|
|
282
|
+
() =>
|
|
283
|
+
new MeshBasicNodeMaterial({
|
|
284
|
+
color: new Color(ARROW_COLOR),
|
|
285
|
+
side: DoubleSide,
|
|
286
|
+
transparent: true,
|
|
287
|
+
opacity: 1,
|
|
288
|
+
depthTest: false,
|
|
289
|
+
depthWrite: false,
|
|
290
|
+
}),
|
|
291
|
+
[],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR
|
|
296
|
+
dashMaterial.color.set(next)
|
|
297
|
+
hexMaterial.color.set(next)
|
|
298
|
+
ringMaterial.color.set(next)
|
|
299
|
+
}, [dashMaterial, hexMaterial, ringMaterial, isHovered])
|
|
300
|
+
|
|
301
|
+
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
|
|
302
|
+
useEffect(() => () => hexMaterial.dispose(), [hexMaterial])
|
|
303
|
+
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
|
|
304
|
+
|
|
305
|
+
// Billboard the hex disc to the camera so the picker is always
|
|
306
|
+
// recognisable regardless of viewing angle.
|
|
307
|
+
//
|
|
308
|
+
// Why parent-aware: the disc lives under a `createPortal` into the
|
|
309
|
+
// level object, which itself sits under a building. Both can have
|
|
310
|
+
// non-identity world rotations. `quaternion.copy(camera.quaternion)`
|
|
311
|
+
// alone sets the LOCAL quaternion, so any ancestor rotation rotates
|
|
312
|
+
// the disc away from the camera. We instead solve for a local
|
|
313
|
+
// quaternion whose composition with the parent world quaternion
|
|
314
|
+
// equals the camera's: `local = parentWorld⁻¹ · cameraWorld`.
|
|
315
|
+
useFrame(() => {
|
|
316
|
+
const billboard = billboardRef.current
|
|
317
|
+
if (!billboard) return
|
|
318
|
+
billboard.quaternion.copy(camera.quaternion)
|
|
319
|
+
const parent = billboard.parent
|
|
320
|
+
if (parent) {
|
|
321
|
+
parent.getWorldQuaternion(parentWorldQuaternionRef.current)
|
|
322
|
+
billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert())
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
return () => {
|
|
328
|
+
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
|
|
329
|
+
document.body.style.cursor = ''
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}, [])
|
|
333
|
+
|
|
334
|
+
const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
|
|
335
|
+
if (event.button !== 0) return
|
|
336
|
+
event.stopPropagation()
|
|
337
|
+
suppressBoxSelectForPointer(event)
|
|
338
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
339
|
+
document.body.style.cursor = 'grabbing'
|
|
340
|
+
useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<>
|
|
345
|
+
<mesh
|
|
346
|
+
frustumCulled={false}
|
|
347
|
+
geometry={dashedGeometry}
|
|
348
|
+
material={dashMaterial}
|
|
349
|
+
position={[x, 0, z]}
|
|
350
|
+
renderOrder={1001}
|
|
351
|
+
/>
|
|
352
|
+
<group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={baseScale}>
|
|
353
|
+
<InvisibleHandleHitArea
|
|
354
|
+
geometry={hitGeometry}
|
|
355
|
+
material={hitMaterial}
|
|
356
|
+
onPointerDown={activateEndpointMove}
|
|
357
|
+
onPointerEnter={(event) => {
|
|
358
|
+
event.stopPropagation()
|
|
359
|
+
setIsHovered(true)
|
|
360
|
+
document.body.style.cursor = 'grab'
|
|
361
|
+
}}
|
|
362
|
+
onPointerLeave={(event) => {
|
|
363
|
+
event.stopPropagation()
|
|
364
|
+
setIsHovered(false)
|
|
365
|
+
if (document.body.style.cursor === 'grab') {
|
|
366
|
+
document.body.style.cursor = ''
|
|
367
|
+
}
|
|
368
|
+
}}
|
|
369
|
+
scale={1}
|
|
370
|
+
/>
|
|
371
|
+
<group scale={visualScale}>
|
|
372
|
+
<mesh material={hexMaterial} raycast={NO_RAYCAST} renderOrder={1003}>
|
|
373
|
+
<circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
|
|
374
|
+
</mesh>
|
|
375
|
+
<mesh material={ringMaterial} raycast={NO_RAYCAST} renderOrder={1002}>
|
|
376
|
+
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
|
377
|
+
</mesh>
|
|
378
|
+
</group>
|
|
379
|
+
</group>
|
|
380
|
+
</>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|
385
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
386
|
+
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
|
387
|
+
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
|
388
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
389
|
+
const arrowMaterial = useMemo(
|
|
390
|
+
() =>
|
|
391
|
+
new MeshBasicNodeMaterial({
|
|
392
|
+
color: new Color(ARROW_COLOR),
|
|
393
|
+
side: DoubleSide,
|
|
394
|
+
depthTest: false,
|
|
395
|
+
depthWrite: false,
|
|
396
|
+
transparent: true,
|
|
397
|
+
opacity: 1,
|
|
398
|
+
}),
|
|
399
|
+
[],
|
|
400
|
+
)
|
|
401
|
+
const { camera, raycaster, gl } = useThree()
|
|
402
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
403
|
+
const baseScale = zoom * ARROW_SCALE
|
|
404
|
+
const scale = (isHovered ? 1.12 : 1) * baseScale
|
|
405
|
+
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
406
|
+
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
|
409
|
+
}, [arrowMaterial, isHovered])
|
|
410
|
+
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
return () => {
|
|
413
|
+
if (document.body.style.cursor === 'ns-resize') {
|
|
414
|
+
document.body.style.cursor = ''
|
|
415
|
+
}
|
|
416
|
+
dragCleanupRef.current?.()
|
|
417
|
+
}
|
|
418
|
+
}, [])
|
|
419
|
+
|
|
420
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
421
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
422
|
+
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
|
423
|
+
|
|
424
|
+
// Sit on the visual centre of the wall — for curved walls that's the
|
|
425
|
+
// arc apex at t=0.5, not the chord midpoint. Use the curve tangent for
|
|
426
|
+
// the yaw so the arrow's local frame matches the wall direction at the
|
|
427
|
+
// apex, consistent with `getWallMoveHandles`.
|
|
428
|
+
const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
|
|
429
|
+
const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2
|
|
430
|
+
const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2
|
|
431
|
+
const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0]
|
|
432
|
+
const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1]
|
|
433
|
+
const wallAngle = Math.atan2(-dirZ, dirX)
|
|
434
|
+
// `wall` is the override-merged effective wall (see
|
|
435
|
+
// WallMoveSideHandlesForWall), so this height is already live during a drag.
|
|
436
|
+
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
|
437
|
+
const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
|
|
438
|
+
|
|
439
|
+
const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
|
|
440
|
+
if (event.button !== 0) return
|
|
441
|
+
event.stopPropagation()
|
|
442
|
+
suppressBoxSelectForPointer(event)
|
|
443
|
+
const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
|
|
444
|
+
if (!levelObject) return
|
|
445
|
+
|
|
446
|
+
// Vertical plane through the wall midpoint whose normal points toward
|
|
447
|
+
// the camera (projected to horizontal). Raycasting against it converts
|
|
448
|
+
// pointer movement into a world-space Y value.
|
|
449
|
+
const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld)
|
|
450
|
+
const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
|
|
451
|
+
if (planeNormal.lengthSq() === 0) return
|
|
452
|
+
planeNormal.normalize()
|
|
453
|
+
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
|
|
454
|
+
|
|
455
|
+
const ndc = new Vector2()
|
|
456
|
+
const setNDC = (clientX: number, clientY: number) => {
|
|
457
|
+
const rect = gl.domElement.getBoundingClientRect()
|
|
458
|
+
ndc.set(
|
|
459
|
+
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
460
|
+
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
|
|
465
|
+
raycaster.setFromCamera(ndc, camera)
|
|
466
|
+
const hit = new Vector3()
|
|
467
|
+
if (!raycaster.ray.intersectPlane(plane, hit)) return
|
|
468
|
+
|
|
469
|
+
const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
|
470
|
+
const initialY = hit.y
|
|
471
|
+
const wallId = wall.id as AnyNodeId
|
|
472
|
+
let pendingHeight = initialHeight
|
|
473
|
+
|
|
474
|
+
document.body.style.cursor = 'ns-resize'
|
|
475
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
476
|
+
useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' })
|
|
477
|
+
// Suppress R3F node pointer events until pointerup completes so the
|
|
478
|
+
// synthesized click doesn't reroute selection to whatever mesh sits
|
|
479
|
+
// under the cursor at release.
|
|
480
|
+
useViewer.getState().setInputDragging(true)
|
|
481
|
+
useScene.temporal.getState().pause()
|
|
482
|
+
|
|
483
|
+
// Drag publishes `{ height }` to `useLiveNodeOverrides` and marks
|
|
484
|
+
// the wall dirty so `WallSystem.updateWallGeometry` rebuilds against
|
|
485
|
+
// the override-merged value (via `getEffectiveWall`). Zustand stays
|
|
486
|
+
// at the pre-drag height until pointerup commits one tracked write.
|
|
487
|
+
const onMove = (e: PointerEvent) => {
|
|
488
|
+
setNDC(e.clientX, e.clientY)
|
|
489
|
+
raycaster.setFromCamera(ndc, camera)
|
|
490
|
+
const intersection = new Vector3()
|
|
491
|
+
if (!raycaster.ray.intersectPlane(plane, intersection)) return
|
|
492
|
+
const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY))
|
|
493
|
+
pendingHeight = newHeight
|
|
494
|
+
useLiveNodeOverrides.getState().set(wallId, { height: newHeight })
|
|
495
|
+
useScene.getState().markDirty(wallId)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const cleanup = () => {
|
|
499
|
+
window.removeEventListener('pointermove', onMove)
|
|
500
|
+
window.removeEventListener('pointerup', onUp)
|
|
501
|
+
window.removeEventListener('pointercancel', onCancel)
|
|
502
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
503
|
+
if (document.body.style.cursor === 'ns-resize') {
|
|
504
|
+
document.body.style.cursor = ''
|
|
505
|
+
}
|
|
506
|
+
useScene.temporal.getState().resume()
|
|
507
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
508
|
+
useViewer.getState().setInputDragging(false)
|
|
509
|
+
dragCleanupRef.current = null
|
|
510
|
+
}
|
|
511
|
+
const onUp = () => {
|
|
512
|
+
swallowNextClick()
|
|
513
|
+
sfxEmitter.emit('sfx:item-place')
|
|
514
|
+
// Commit: write the final override-merged value to zustand once
|
|
515
|
+
// (tracked, undoable), then drop the override so the renderer
|
|
516
|
+
// falls back to the scene store.
|
|
517
|
+
if (pendingHeight !== initialHeight) {
|
|
518
|
+
useScene.getState().updateNode(wallId, { height: pendingHeight })
|
|
519
|
+
}
|
|
520
|
+
useLiveNodeOverrides.getState().clear(wallId)
|
|
521
|
+
useScene.getState().markDirty(wallId)
|
|
522
|
+
cleanup()
|
|
523
|
+
}
|
|
524
|
+
const onCancel = () => {
|
|
525
|
+
// Revert: drop the override, mark dirty so the geometry rebuilds
|
|
526
|
+
// against the original scene height.
|
|
527
|
+
useLiveNodeOverrides.getState().clear(wallId)
|
|
528
|
+
useScene.getState().markDirty(wallId)
|
|
529
|
+
cleanup()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Escape / ⌘Z abort the drag — capture phase so they win over the global
|
|
533
|
+
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
|
534
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
535
|
+
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
536
|
+
e.preventDefault()
|
|
537
|
+
e.stopPropagation()
|
|
538
|
+
swallowNextClick()
|
|
539
|
+
onCancel()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
dragCleanupRef.current = cleanup
|
|
543
|
+
window.addEventListener('pointermove', onMove)
|
|
544
|
+
window.addEventListener('pointerup', onUp)
|
|
545
|
+
window.addEventListener('pointercancel', onCancel)
|
|
546
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return (
|
|
550
|
+
<group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
|
|
551
|
+
<group rotation={[0, Math.PI / 2, Math.PI / 2]}>
|
|
552
|
+
<InvisibleHandleHitArea
|
|
553
|
+
geometry={hitGeometry}
|
|
554
|
+
material={hitMaterial}
|
|
555
|
+
onPointerDown={activateHeightResize}
|
|
556
|
+
onPointerEnter={(event) => {
|
|
557
|
+
event.stopPropagation()
|
|
558
|
+
setIsHovered(true)
|
|
559
|
+
document.body.style.cursor = 'ns-resize'
|
|
560
|
+
}}
|
|
561
|
+
onPointerLeave={(event) => {
|
|
562
|
+
event.stopPropagation()
|
|
563
|
+
setIsHovered(false)
|
|
564
|
+
if (document.body.style.cursor === 'ns-resize') {
|
|
565
|
+
document.body.style.cursor = ''
|
|
566
|
+
}
|
|
567
|
+
}}
|
|
568
|
+
scale={baseScale}
|
|
569
|
+
/>
|
|
570
|
+
<mesh
|
|
571
|
+
// Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
|
|
572
|
+
frustumCulled={false}
|
|
573
|
+
geometry={arrowGeometry}
|
|
574
|
+
material={arrowMaterial}
|
|
575
|
+
raycast={NO_RAYCAST}
|
|
576
|
+
renderOrder={1002}
|
|
577
|
+
scale={scale}
|
|
578
|
+
/>
|
|
579
|
+
</group>
|
|
580
|
+
</group>
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
|
|
585
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
586
|
+
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
|
587
|
+
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
|
588
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
589
|
+
const arrowMaterial = useMemo(
|
|
590
|
+
() =>
|
|
591
|
+
new MeshBasicNodeMaterial({
|
|
592
|
+
color: new Color(ARROW_COLOR),
|
|
593
|
+
side: DoubleSide,
|
|
594
|
+
depthTest: false,
|
|
595
|
+
depthWrite: false,
|
|
596
|
+
transparent: true,
|
|
597
|
+
opacity: 1,
|
|
598
|
+
}),
|
|
599
|
+
[],
|
|
600
|
+
)
|
|
601
|
+
const { camera } = useThree()
|
|
602
|
+
|
|
603
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
604
|
+
|
|
605
|
+
const baseScale = zoom * ARROW_SCALE
|
|
606
|
+
const scale = (isHovered ? 1.12 : 1) * baseScale
|
|
607
|
+
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
|
610
|
+
}, [arrowMaterial, isHovered])
|
|
611
|
+
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
return () => {
|
|
614
|
+
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
|
|
615
|
+
document.body.style.cursor = ''
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}, [])
|
|
619
|
+
|
|
620
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
621
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
622
|
+
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
|
623
|
+
|
|
624
|
+
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
|
625
|
+
if (event.button !== 0) return
|
|
626
|
+
event.stopPropagation()
|
|
627
|
+
suppressBoxSelectForPointer(event)
|
|
628
|
+
document.body.style.cursor = 'grabbing'
|
|
629
|
+
|
|
630
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
631
|
+
useEditor.getState().setMovingNode(wall)
|
|
632
|
+
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
|
|
633
|
+
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
|
|
634
|
+
// Keep the wall selected so it stays the active item once the move
|
|
635
|
+
// commits; the `!movingNode` guard on the handles hides them mid-drag.
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<group position={handle.position} rotation={[0, handle.rotationY, 0]}>
|
|
640
|
+
<InvisibleHandleHitArea
|
|
641
|
+
geometry={hitGeometry}
|
|
642
|
+
material={hitMaterial}
|
|
643
|
+
onPointerDown={activateWallMove}
|
|
644
|
+
onPointerEnter={(event) => {
|
|
645
|
+
event.stopPropagation()
|
|
646
|
+
setIsHovered(true)
|
|
647
|
+
document.body.style.cursor = 'grab'
|
|
648
|
+
}}
|
|
649
|
+
onPointerLeave={(event) => {
|
|
650
|
+
event.stopPropagation()
|
|
651
|
+
setIsHovered(false)
|
|
652
|
+
if (document.body.style.cursor === 'grab') {
|
|
653
|
+
document.body.style.cursor = ''
|
|
654
|
+
}
|
|
655
|
+
}}
|
|
656
|
+
scale={baseScale}
|
|
657
|
+
/>
|
|
658
|
+
<mesh
|
|
659
|
+
// Pass geometry as a prop (not `<primitive attach="geometry">`)
|
|
660
|
+
// so the mesh is never rendered with R3F's default empty
|
|
661
|
+
// `BufferGeometry`. Combined with `frustumCulled={false}`, the
|
|
662
|
+
// primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first
|
|
663
|
+
// frame and WebGPU flags "Vertex buffer slot 0 ... was not set".
|
|
664
|
+
frustumCulled={false}
|
|
665
|
+
geometry={arrowGeometry}
|
|
666
|
+
material={arrowMaterial}
|
|
667
|
+
raycast={NO_RAYCAST}
|
|
668
|
+
renderOrder={1002}
|
|
669
|
+
scale={scale}
|
|
670
|
+
/>
|
|
671
|
+
</group>
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
|
|
676
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
677
|
+
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
|
678
|
+
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
|
|
679
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
680
|
+
const arrowMaterial = useMemo(
|
|
681
|
+
() =>
|
|
682
|
+
new MeshBasicNodeMaterial({
|
|
683
|
+
color: new Color(ARROW_COLOR),
|
|
684
|
+
side: DoubleSide,
|
|
685
|
+
depthTest: false,
|
|
686
|
+
depthWrite: false,
|
|
687
|
+
transparent: true,
|
|
688
|
+
opacity: 1,
|
|
689
|
+
}),
|
|
690
|
+
[],
|
|
691
|
+
)
|
|
692
|
+
const { camera } = useThree()
|
|
693
|
+
|
|
694
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
695
|
+
const baseScale = zoom * ARROW_SCALE
|
|
696
|
+
const scale = (isHovered ? 1.12 : 1) * baseScale
|
|
697
|
+
|
|
698
|
+
useEffect(() => {
|
|
699
|
+
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
|
700
|
+
}, [arrowMaterial, isHovered])
|
|
701
|
+
|
|
702
|
+
useEffect(() => {
|
|
703
|
+
return () => {
|
|
704
|
+
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
|
|
705
|
+
document.body.style.cursor = ''
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}, [])
|
|
709
|
+
|
|
710
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
711
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
712
|
+
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
|
713
|
+
|
|
714
|
+
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
|
|
715
|
+
if (event.button !== 0) return
|
|
716
|
+
event.stopPropagation()
|
|
717
|
+
suppressBoxSelectForPointer(event)
|
|
718
|
+
document.body.style.cursor = 'grabbing'
|
|
719
|
+
|
|
720
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
721
|
+
useEditor.getState().setMovingNode(fence)
|
|
722
|
+
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
|
|
723
|
+
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
|
|
724
|
+
// Keep the fence selected so it stays active once the move commits.
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return (
|
|
728
|
+
<group position={handle.position} rotation={[0, handle.rotationY, 0]}>
|
|
729
|
+
<InvisibleHandleHitArea
|
|
730
|
+
geometry={hitGeometry}
|
|
731
|
+
material={hitMaterial}
|
|
732
|
+
onPointerDown={activateFenceMove}
|
|
733
|
+
onPointerEnter={(event) => {
|
|
734
|
+
event.stopPropagation()
|
|
735
|
+
setIsHovered(true)
|
|
736
|
+
document.body.style.cursor = 'grab'
|
|
737
|
+
}}
|
|
738
|
+
onPointerLeave={(event) => {
|
|
739
|
+
event.stopPropagation()
|
|
740
|
+
setIsHovered(false)
|
|
741
|
+
if (document.body.style.cursor === 'grab') {
|
|
742
|
+
document.body.style.cursor = ''
|
|
743
|
+
}
|
|
744
|
+
}}
|
|
745
|
+
scale={baseScale}
|
|
746
|
+
/>
|
|
747
|
+
<mesh
|
|
748
|
+
// Pass geometry as a prop — see WallMoveArrowHandle for the
|
|
749
|
+
// WebGPU "Vertex buffer slot 0 ... was not set" rationale.
|
|
750
|
+
frustumCulled={false}
|
|
751
|
+
geometry={arrowGeometry}
|
|
752
|
+
material={arrowMaterial}
|
|
753
|
+
raycast={NO_RAYCAST}
|
|
754
|
+
renderOrder={1002}
|
|
755
|
+
scale={scale}
|
|
756
|
+
/>
|
|
757
|
+
</group>
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
|
|
762
|
+
const dx = wall.end[0] - wall.start[0]
|
|
763
|
+
const dz = wall.end[1] - wall.start[1]
|
|
764
|
+
const length = Math.hypot(dx, dz)
|
|
765
|
+
|
|
766
|
+
if (length < 1e-6) {
|
|
767
|
+
return []
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
|
|
771
|
+
const normal: [number, number] = frame
|
|
772
|
+
? [frame.normal.x, frame.normal.y]
|
|
773
|
+
: [-dz / length, dx / length]
|
|
774
|
+
const midpoint: [number, number] = frame
|
|
775
|
+
? [frame.point.x, frame.point.y]
|
|
776
|
+
: [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
|
|
777
|
+
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
|
778
|
+
const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
|
|
779
|
+
const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
|
|
780
|
+
|
|
781
|
+
return [
|
|
782
|
+
buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
|
|
783
|
+
buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
|
|
784
|
+
]
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) {
|
|
788
|
+
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
|
|
789
|
+
fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
useEffect(() => {
|
|
793
|
+
let frameId = 0
|
|
794
|
+
|
|
795
|
+
const resolveLevelObject = () => {
|
|
796
|
+
const nextLevelObject = fence.parentId
|
|
797
|
+
? (sceneRegistry.nodes.get(fence.parentId) ?? null)
|
|
798
|
+
: null
|
|
799
|
+
setLevelObject((currentLevelObject) => {
|
|
800
|
+
if (currentLevelObject === nextLevelObject) {
|
|
801
|
+
return currentLevelObject
|
|
802
|
+
}
|
|
803
|
+
return nextLevelObject
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
if (!nextLevelObject) {
|
|
807
|
+
frameId = window.requestAnimationFrame(resolveLevelObject)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
resolveLevelObject()
|
|
812
|
+
|
|
813
|
+
return () => {
|
|
814
|
+
if (frameId) {
|
|
815
|
+
window.cancelAnimationFrame(frameId)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}, [fence.parentId])
|
|
819
|
+
|
|
820
|
+
const handles = useMemo(() => getFenceMoveHandles(fence), [fence])
|
|
821
|
+
|
|
822
|
+
if (!levelObject || handles.length === 0) return null
|
|
823
|
+
|
|
824
|
+
return createPortal(
|
|
825
|
+
<group>
|
|
826
|
+
{handles.map((handle) => (
|
|
827
|
+
<FenceMoveArrowHandle fence={fence} handle={handle} key={handle.key} />
|
|
828
|
+
))}
|
|
829
|
+
</group>,
|
|
830
|
+
levelObject,
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] {
|
|
835
|
+
const dx = fence.end[0] - fence.start[0]
|
|
836
|
+
const dz = fence.end[1] - fence.start[1]
|
|
837
|
+
const length = Math.hypot(dx, dz)
|
|
838
|
+
|
|
839
|
+
if (length < 1e-6) {
|
|
840
|
+
return []
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const midpoint: [number, number] = [
|
|
844
|
+
(fence.start[0] + fence.end[0]) / 2,
|
|
845
|
+
(fence.start[1] + fence.end[1]) / 2,
|
|
846
|
+
]
|
|
847
|
+
const normal: [number, number] = [-dz / length, dx / length]
|
|
848
|
+
const fenceHeight = fence.height ?? 1.8
|
|
849
|
+
const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
|
|
850
|
+
const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
|
|
851
|
+
|
|
852
|
+
return [
|
|
853
|
+
buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
|
|
854
|
+
buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
|
|
855
|
+
]
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function buildWallMoveHandle(
|
|
859
|
+
key: string,
|
|
860
|
+
midpoint: [number, number],
|
|
861
|
+
direction: [number, number],
|
|
862
|
+
offset: number,
|
|
863
|
+
height: number,
|
|
864
|
+
): WallMoveHandle {
|
|
865
|
+
return {
|
|
866
|
+
key,
|
|
867
|
+
position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
|
|
868
|
+
rotationY: Math.atan2(-direction[1], direction[0]),
|
|
869
|
+
}
|
|
870
|
+
}
|