@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
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import type { AssetInput } from '@pascal-app/core'
|
|
1
|
+
import type { AssetInput, ItemNode } from '@pascal-app/core'
|
|
2
2
|
import {
|
|
3
|
+
type AlignmentAnchor,
|
|
4
|
+
type AnyNode,
|
|
3
5
|
type AnyNodeId,
|
|
4
6
|
type CeilingEvent,
|
|
7
|
+
collectAlignmentAnchors,
|
|
5
8
|
emitter,
|
|
6
9
|
type GridEvent,
|
|
7
10
|
getScaledDimensions,
|
|
8
11
|
type ItemEvent,
|
|
12
|
+
movingFootprintAnchors,
|
|
13
|
+
type RoofEvent,
|
|
9
14
|
resolveLevelId,
|
|
15
|
+
type ShelfEvent,
|
|
10
16
|
sceneRegistry,
|
|
11
|
-
spatialGridManager,
|
|
12
17
|
useLiveTransforms,
|
|
13
18
|
useScene,
|
|
14
19
|
useSpatialQuery,
|
|
@@ -17,30 +22,61 @@ import {
|
|
|
17
22
|
} from '@pascal-app/core'
|
|
18
23
|
import { useViewer } from '@pascal-app/viewer'
|
|
19
24
|
import { Html } from '@react-three/drei'
|
|
20
|
-
import { useFrame } from '@react-three/fiber'
|
|
21
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
25
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
26
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
22
27
|
import {
|
|
23
|
-
|
|
28
|
+
Box3,
|
|
24
29
|
Euler,
|
|
25
|
-
Float32BufferAttribute,
|
|
26
30
|
type Group,
|
|
27
31
|
type LineSegments,
|
|
32
|
+
Matrix4,
|
|
28
33
|
type Mesh,
|
|
34
|
+
type Object3D,
|
|
29
35
|
PlaneGeometry,
|
|
30
36
|
Quaternion,
|
|
37
|
+
Ray,
|
|
31
38
|
Vector3,
|
|
32
39
|
} from 'three'
|
|
33
40
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
34
41
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
42
|
+
import {
|
|
43
|
+
clearPlacementSurface,
|
|
44
|
+
publishPlacementSurface,
|
|
45
|
+
} from '../../../lib/active-placement-surface'
|
|
35
46
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
47
|
+
import { formatLinearMeasurement } from '../../../lib/measurements'
|
|
36
48
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
37
|
-
|
|
38
|
-
import {
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
|
52
|
+
resolveAlignmentForActiveBuilding,
|
|
53
|
+
} from '../../../lib/world-grid-snap'
|
|
54
|
+
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
|
55
|
+
import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
|
|
56
|
+
|
|
57
|
+
import useFacingPose from '../../../store/use-facing-pose'
|
|
58
|
+
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
59
|
+
import {
|
|
60
|
+
createLineGeometry,
|
|
61
|
+
getBoxEdgePoints,
|
|
62
|
+
type PreviewBounds,
|
|
63
|
+
updateLineGeometry,
|
|
64
|
+
} from '../shared/placement-box-geometry'
|
|
65
|
+
import {
|
|
66
|
+
getDetachedAttachmentPreviewLift,
|
|
67
|
+
getGridAlignedDimensions,
|
|
68
|
+
snapToGrid,
|
|
69
|
+
snapToHalf,
|
|
70
|
+
snapUpToGridStep,
|
|
71
|
+
steppedRotation,
|
|
72
|
+
} from './placement-math'
|
|
39
73
|
import {
|
|
40
74
|
ceilingStrategy,
|
|
41
75
|
checkCanPlace,
|
|
42
76
|
floorStrategy,
|
|
43
77
|
itemSurfaceStrategy,
|
|
78
|
+
roofWallStrategy,
|
|
79
|
+
shelfSurfaceStrategy,
|
|
44
80
|
wallStrategy,
|
|
45
81
|
} from './placement-strategies'
|
|
46
82
|
import type { PlacementState, TransitionResult } from './placement-types'
|
|
@@ -48,30 +84,24 @@ import type { DraftNodeHandle } from './use-draft-node'
|
|
|
48
84
|
|
|
49
85
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
50
86
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const wholeFeet = Math.floor(feet)
|
|
55
|
-
const inches = Math.round((feet - wholeFeet) * 12)
|
|
56
|
-
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
57
|
-
return `${wholeFeet}'${inches}"`
|
|
58
|
-
}
|
|
59
|
-
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
60
|
-
}
|
|
87
|
+
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
|
88
|
+
* floor-plan overlay and the 3D registry move tool. */
|
|
89
|
+
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
61
90
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
/** Right-click cancels an active placement — but the right button also orbits
|
|
92
|
+
* the camera (CameraControls ROTATE). Only a quick, near-stationary right
|
|
93
|
+
* press/release counts as a cancel; anything that moves past the pixel
|
|
94
|
+
* threshold or is held longer is treated as a camera orbit and left alone. */
|
|
95
|
+
const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
|
|
96
|
+
const RIGHT_CLICK_CANCEL_MAX_MS = 200
|
|
68
97
|
|
|
69
98
|
/**
|
|
70
99
|
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
|
71
100
|
* The wireframe stays centered on the original bounds centre on each axis we
|
|
72
101
|
* expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
|
|
73
|
-
* `
|
|
74
|
-
*
|
|
102
|
+
* `min.z = 0` (the mounted face flush with the wall plane) and extend into the
|
|
103
|
+
* room along +Z — matching the body and the 2D footprint; the bottom (`min.y`)
|
|
104
|
+
* is preserved so the box still sits on the floor / attachment plane.
|
|
75
105
|
*
|
|
76
106
|
* Floor / ceiling / item-surface: X and Z expand; Y stays exact.
|
|
77
107
|
* Wall / wall-side: X and Y expand; Z stays exact.
|
|
@@ -97,9 +127,9 @@ function expandBoundsToGrid(
|
|
|
97
127
|
let maxZ: number
|
|
98
128
|
let newCz: number
|
|
99
129
|
if (attachTo === 'wall-side') {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
newCz =
|
|
130
|
+
minZ = 0
|
|
131
|
+
maxZ = expandedD
|
|
132
|
+
newCz = expandedD / 2
|
|
103
133
|
} else {
|
|
104
134
|
minZ = cz - expandedD / 2
|
|
105
135
|
maxZ = cz + expandedD / 2
|
|
@@ -116,124 +146,32 @@ function expandBoundsToGrid(
|
|
|
116
146
|
|
|
117
147
|
function getFallbackPreviewBounds(
|
|
118
148
|
item: import('@pascal-app/core').ItemNode | null,
|
|
119
|
-
asset: AssetInput,
|
|
120
|
-
attachTo: AssetInput['attachTo'],
|
|
149
|
+
asset: AssetInput | null | undefined,
|
|
150
|
+
attachTo: AssetInput['attachTo'] | null | undefined,
|
|
121
151
|
): PreviewBounds {
|
|
122
|
-
const dims = item ? getScaledDimensions(item) : (asset
|
|
152
|
+
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
123
153
|
return {
|
|
124
|
-
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ?
|
|
125
|
-
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ?
|
|
154
|
+
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
|
|
155
|
+
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
|
|
126
156
|
dimensions: dims,
|
|
127
|
-
center: [0, dims[1] / 2, attachTo === 'wall-side' ?
|
|
157
|
+
center: [0, dims[1] / 2, attachTo === 'wall-side' ? dims[2] / 2 : 0],
|
|
128
158
|
}
|
|
129
159
|
}
|
|
130
160
|
|
|
131
|
-
function
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
function getBoxEdgePoints(bounds: PreviewBounds): number[] {
|
|
138
|
-
const [width, height, depth] = bounds.dimensions
|
|
139
|
-
const [centerX, centerY, centerZ] = bounds.center
|
|
140
|
-
const minX = centerX - width / 2
|
|
141
|
-
const maxX = centerX + width / 2
|
|
142
|
-
const minY = centerY - height / 2
|
|
143
|
-
const maxY = centerY + height / 2
|
|
144
|
-
const minZ = centerZ - depth / 2
|
|
145
|
-
const maxZ = centerZ + depth / 2
|
|
146
|
-
|
|
147
|
-
return [
|
|
148
|
-
minX,
|
|
149
|
-
minY,
|
|
150
|
-
minZ,
|
|
151
|
-
maxX,
|
|
152
|
-
minY,
|
|
153
|
-
minZ,
|
|
154
|
-
maxX,
|
|
155
|
-
minY,
|
|
156
|
-
minZ,
|
|
157
|
-
maxX,
|
|
158
|
-
minY,
|
|
159
|
-
maxZ,
|
|
160
|
-
maxX,
|
|
161
|
-
minY,
|
|
162
|
-
maxZ,
|
|
163
|
-
minX,
|
|
164
|
-
minY,
|
|
165
|
-
maxZ,
|
|
166
|
-
minX,
|
|
167
|
-
minY,
|
|
168
|
-
maxZ,
|
|
169
|
-
minX,
|
|
170
|
-
minY,
|
|
171
|
-
minZ,
|
|
172
|
-
|
|
173
|
-
minX,
|
|
174
|
-
maxY,
|
|
175
|
-
minZ,
|
|
176
|
-
maxX,
|
|
177
|
-
maxY,
|
|
178
|
-
minZ,
|
|
179
|
-
maxX,
|
|
180
|
-
maxY,
|
|
181
|
-
minZ,
|
|
182
|
-
maxX,
|
|
183
|
-
maxY,
|
|
184
|
-
maxZ,
|
|
185
|
-
maxX,
|
|
186
|
-
maxY,
|
|
187
|
-
maxZ,
|
|
188
|
-
minX,
|
|
189
|
-
maxY,
|
|
190
|
-
maxZ,
|
|
191
|
-
minX,
|
|
192
|
-
maxY,
|
|
193
|
-
maxZ,
|
|
194
|
-
minX,
|
|
195
|
-
maxY,
|
|
196
|
-
minZ,
|
|
197
|
-
|
|
198
|
-
minX,
|
|
199
|
-
minY,
|
|
200
|
-
minZ,
|
|
201
|
-
minX,
|
|
202
|
-
maxY,
|
|
203
|
-
minZ,
|
|
204
|
-
maxX,
|
|
205
|
-
minY,
|
|
206
|
-
minZ,
|
|
207
|
-
maxX,
|
|
208
|
-
maxY,
|
|
209
|
-
minZ,
|
|
210
|
-
maxX,
|
|
211
|
-
minY,
|
|
212
|
-
maxZ,
|
|
213
|
-
maxX,
|
|
214
|
-
maxY,
|
|
215
|
-
maxZ,
|
|
216
|
-
minX,
|
|
217
|
-
minY,
|
|
218
|
-
maxZ,
|
|
219
|
-
minX,
|
|
220
|
-
maxY,
|
|
221
|
-
maxZ,
|
|
222
|
-
]
|
|
223
|
-
}
|
|
161
|
+
function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
|
|
162
|
+
const scaled = getScaledDimensions(item)
|
|
163
|
+
const aligned = getGridAlignedDimensions(scaled, item.asset.attachTo)
|
|
164
|
+
if (scaled[0] === aligned[0] && scaled[1] === aligned[1] && scaled[2] === aligned[2]) {
|
|
165
|
+
return item
|
|
166
|
+
}
|
|
224
167
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (!geometry) return
|
|
168
|
+
const scaleAxis = (axis: 0 | 1 | 2) =>
|
|
169
|
+
scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis])
|
|
228
170
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
} else {
|
|
233
|
-
attribute.set(points)
|
|
234
|
-
attribute.needsUpdate = true
|
|
171
|
+
return {
|
|
172
|
+
...item,
|
|
173
|
+
scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number],
|
|
235
174
|
}
|
|
236
|
-
geometry.computeBoundingSphere()
|
|
237
175
|
}
|
|
238
176
|
|
|
239
177
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
|
@@ -274,6 +212,13 @@ export interface PlacementCoordinatorConfig {
|
|
|
274
212
|
initialState?: PlacementState
|
|
275
213
|
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
|
276
214
|
defaultScale?: [number, number, number]
|
|
215
|
+
/** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling
|
|
216
|
+
* duplicates) so the duplicate keeps its materials. */
|
|
217
|
+
slots?: ItemNode['slots']
|
|
218
|
+
/** Move-mode sessions keep the grabbed item offset from the first surface hit
|
|
219
|
+
* (floor / wall / ceiling / item-surface / shelf) instead of snapping the
|
|
220
|
+
* item's origin under the cursor. */
|
|
221
|
+
preserveDragOffset?: boolean
|
|
277
222
|
}
|
|
278
223
|
|
|
279
224
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
|
@@ -285,13 +230,53 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
285
230
|
const basePlaneRef = useRef<Mesh>(null!)
|
|
286
231
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
|
287
232
|
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
|
233
|
+
const lastWallDirtyAtRef = useRef(new Map<string, number>())
|
|
288
234
|
const placementState = useRef<PlacementState>(
|
|
289
|
-
config.initialState ?? {
|
|
235
|
+
config.initialState ?? {
|
|
236
|
+
surface: 'floor',
|
|
237
|
+
wallId: null,
|
|
238
|
+
roofSegmentId: null,
|
|
239
|
+
ceilingId: null,
|
|
240
|
+
surfaceItemId: null,
|
|
241
|
+
shelfId: null,
|
|
242
|
+
},
|
|
290
243
|
)
|
|
291
|
-
const
|
|
244
|
+
const altFreeRef = useRef(false)
|
|
292
245
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
|
246
|
+
// Footprint shape (depth along local +Z and [x, z] centre) of the current
|
|
247
|
+
// preview box, mirrored from the rendered dimension bounds so the per-frame
|
|
248
|
+
// surface publisher can position the forward-facing triangle without reading
|
|
249
|
+
// React state. Updated in the render body below.
|
|
250
|
+
const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
|
|
251
|
+
depth: 0,
|
|
252
|
+
center: [0, 0],
|
|
253
|
+
})
|
|
254
|
+
// Goes true the first time a 3D pointer event drives this coordinator.
|
|
255
|
+
// The per-frame mesh-position lerp below is only useful for that path;
|
|
256
|
+
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
|
|
257
|
+
// writing scene.position directly), the lerp fights React's render and
|
|
258
|
+
// pulls the rendered item back toward its pre-move position. Gating
|
|
259
|
+
// the lerp on this flag keeps 3D placement smooth without hijacking
|
|
260
|
+
// 2D drags that share the same draft.
|
|
261
|
+
const has3DPointerDrivenMoveRef = useRef(false)
|
|
262
|
+
// The draft mesh's raycast is disabled while placing so the cursor ray
|
|
263
|
+
// passes through it to the surface beneath (grid / item / shelf). Without
|
|
264
|
+
// this, the ray hits the moving draft first and the surface strategy keeps
|
|
265
|
+
// re-deriving the host point from the draft's own (just-moved) geometry —
|
|
266
|
+
// on a multi-row shelf this oscillates the chosen row, jittering the item.
|
|
267
|
+
// Mirrors MoveRegistryNodeTool. Reconciled per-frame (the draft mesh can be
|
|
268
|
+
// recreated mid-session) and restored on unmount.
|
|
269
|
+
const raycastDisabledMeshRef = useRef<Object3D | null>(null)
|
|
270
|
+
const restoreRaycastsRef = useRef<Array<() => void>>([])
|
|
271
|
+
const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
|
|
293
272
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
294
273
|
|
|
274
|
+
// Live camera ref — the shelf-stickiness test reconstructs the cursor world
|
|
275
|
+
// ray (camera → grid hit) to check it still points at the shelf volume.
|
|
276
|
+
const camera = useThree((s) => s.camera)
|
|
277
|
+
const cameraRef = useRef(camera)
|
|
278
|
+
cameraRef.current = camera
|
|
279
|
+
|
|
295
280
|
// Store config callbacks in refs to avoid re-running effect when they change
|
|
296
281
|
const configRef = useRef(config)
|
|
297
282
|
configRef.current = config
|
|
@@ -300,7 +285,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
300
285
|
const { asset, draftNode } = config
|
|
301
286
|
const unit = useViewer((state) => state.unit)
|
|
302
287
|
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
|
303
|
-
const updatePreviewGeometry = (bounds: PreviewBounds) => {
|
|
288
|
+
const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
|
|
304
289
|
const [width, height, depth] = bounds.dimensions
|
|
305
290
|
const [centerX, centerY, centerZ] = bounds.center
|
|
306
291
|
const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
|
|
@@ -317,9 +302,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
317
302
|
const oldBasePlaneGeometry = basePlaneRef.current.geometry
|
|
318
303
|
basePlaneRef.current.geometry = nextBasePlaneGeometry
|
|
319
304
|
oldBasePlaneGeometry.dispose()
|
|
320
|
-
}
|
|
305
|
+
}, [])
|
|
321
306
|
|
|
322
|
-
const updateDimensionGuides = (bounds: PreviewBounds) => {
|
|
307
|
+
const updateDimensionGuides = useCallback((bounds: PreviewBounds) => {
|
|
323
308
|
setDimensionBounds((current) => {
|
|
324
309
|
if (
|
|
325
310
|
current &&
|
|
@@ -421,7 +406,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
421
406
|
applyPoints(measurementWidthRef, widthPoints)
|
|
422
407
|
applyPoints(measurementDepthRef, depthPoints)
|
|
423
408
|
applyPoints(measurementHeightRef, heightPoints)
|
|
424
|
-
}
|
|
409
|
+
}, [])
|
|
410
|
+
|
|
411
|
+
const getFloorVisualPosition = useCallback(
|
|
412
|
+
(
|
|
413
|
+
position: [number, number, number],
|
|
414
|
+
nodeUpdate?: Partial<ItemNode>,
|
|
415
|
+
): [number, number, number] => {
|
|
416
|
+
const draft = draftNode.current
|
|
417
|
+
if (!(draft && !asset?.attachTo)) return position
|
|
418
|
+
const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode)
|
|
419
|
+
return getFloorStackPreviewPosition({
|
|
420
|
+
node: previewNode,
|
|
421
|
+
position,
|
|
422
|
+
rotation: previewNode.rotation,
|
|
423
|
+
})
|
|
424
|
+
},
|
|
425
|
+
[asset?.attachTo, draftNode],
|
|
426
|
+
)
|
|
425
427
|
|
|
426
428
|
useEffect(() => {
|
|
427
429
|
if (!asset) return
|
|
@@ -429,16 +431,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
429
431
|
|
|
430
432
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
431
433
|
|
|
434
|
+
// Lazily-gathered alignment candidates — the corner anchors of every
|
|
435
|
+
// OTHER floor-placed node, excluding the draft. Computed on the first
|
|
436
|
+
// floor move (once the draft id exists) and reused for the rest of the
|
|
437
|
+
// drag; the scene graph is stable during placement. Coords are
|
|
438
|
+
// building-local, matching the draft's grid position and the guide
|
|
439
|
+
// layer's frame.
|
|
440
|
+
let alignmentCandidates: AlignmentAnchor[] | null = null
|
|
441
|
+
let floorDragAnchor: [number, number] | null = null
|
|
442
|
+
|
|
432
443
|
// Reset placement state
|
|
433
444
|
placementState.current = configRef.current.initialState ?? {
|
|
434
445
|
surface: 'floor',
|
|
435
446
|
wallId: null,
|
|
447
|
+
roofSegmentId: null,
|
|
436
448
|
ceilingId: null,
|
|
437
449
|
surfaceItemId: null,
|
|
450
|
+
shelfId: null,
|
|
438
451
|
}
|
|
439
452
|
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
|
440
453
|
gridPosition.current.y = 0
|
|
441
|
-
cursorGroupRef.current
|
|
454
|
+
if (cursorGroupRef.current) {
|
|
455
|
+
cursorGroupRef.current.position.y = 0
|
|
456
|
+
}
|
|
442
457
|
}
|
|
443
458
|
|
|
444
459
|
// ---- Helpers ----
|
|
@@ -449,11 +464,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
449
464
|
draftItem: draftNode.current,
|
|
450
465
|
gridPosition: gridPosition.current,
|
|
451
466
|
state: { ...placementState.current },
|
|
452
|
-
currentCursorRotationY:
|
|
467
|
+
currentCursorRotationY:
|
|
468
|
+
cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
|
|
453
469
|
})
|
|
454
470
|
|
|
455
471
|
const getActiveValidators = () =>
|
|
456
|
-
|
|
472
|
+
altFreeRef.current
|
|
457
473
|
? {
|
|
458
474
|
canPlaceOnFloor: () => ({ valid: true }),
|
|
459
475
|
canPlaceOnWall: () => ({ valid: true }),
|
|
@@ -461,8 +477,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
461
477
|
}
|
|
462
478
|
: validators
|
|
463
479
|
|
|
480
|
+
const finishCommittedPlacement = (
|
|
481
|
+
committedId: string | null,
|
|
482
|
+
wasAdopted: boolean,
|
|
483
|
+
repeat: () => void,
|
|
484
|
+
) => {
|
|
485
|
+
if (configRef.current.onCommitted()) {
|
|
486
|
+
repeat()
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
useAlignmentGuides.getState().clear()
|
|
491
|
+
useScene.temporal.getState().resume()
|
|
492
|
+
if (committedId) {
|
|
493
|
+
useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
|
|
494
|
+
}
|
|
495
|
+
if (!wasAdopted) {
|
|
496
|
+
useEditor.getState().setTool(null)
|
|
497
|
+
}
|
|
498
|
+
// A non-repeating placement is finished: return to select mode so the user
|
|
499
|
+
// lands on the just-placed (now selected) node instead of a tool-less build
|
|
500
|
+
// limbo. Repeat placements took the early return above and stay armed.
|
|
501
|
+
useEditor.getState().setMode('select')
|
|
502
|
+
}
|
|
503
|
+
|
|
464
504
|
const revalidate = (): boolean => {
|
|
465
|
-
const placeable =
|
|
505
|
+
const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
|
|
466
506
|
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
|
467
507
|
edgeMaterial.color.setHex(color)
|
|
468
508
|
basePlaneMaterial.color.setHex(color)
|
|
@@ -478,13 +518,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
478
518
|
return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
479
519
|
}
|
|
480
520
|
|
|
521
|
+
const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
|
|
522
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
523
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
524
|
+
return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
525
|
+
}
|
|
526
|
+
|
|
481
527
|
const applyTransition = (result: TransitionResult) => {
|
|
528
|
+
// Alignment guides are floor-only; clear them when the cursor moves
|
|
529
|
+
// onto a wall / ceiling / item surface (only those paths call this).
|
|
530
|
+
useAlignmentGuides.getState().clear()
|
|
531
|
+
// Roof faces carry no grab anchor, but landing on one still counts as
|
|
532
|
+
// anchoring elsewhere — a later return to the grabbed wall must center
|
|
533
|
+
// under the cursor, not restore the stale grab offset.
|
|
534
|
+
if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
|
|
482
535
|
Object.assign(placementState.current, result.stateUpdate)
|
|
483
536
|
gridPosition.current.set(...result.gridPosition)
|
|
484
537
|
|
|
485
538
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
486
|
-
cursorGroupRef.current
|
|
487
|
-
|
|
539
|
+
if (cursorGroupRef.current) {
|
|
540
|
+
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
541
|
+
if (result.cursorRotation) {
|
|
542
|
+
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
|
543
|
+
} else {
|
|
544
|
+
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
488
547
|
|
|
489
548
|
const draft = draftNode.current
|
|
490
549
|
if (draft) {
|
|
@@ -497,14 +556,27 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
497
556
|
const ensureDraft = (result: TransitionResult) => {
|
|
498
557
|
gridPosition.current.set(...result.gridPosition)
|
|
499
558
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
|
500
|
-
cursorGroupRef.current
|
|
501
|
-
|
|
559
|
+
if (cursorGroupRef.current) {
|
|
560
|
+
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
561
|
+
if (result.cursorRotation) {
|
|
562
|
+
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
|
563
|
+
} else {
|
|
564
|
+
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const initRotation: [number, number, number] = result.cursorRotation ?? [
|
|
569
|
+
0,
|
|
570
|
+
result.cursorRotationY,
|
|
571
|
+
0,
|
|
572
|
+
]
|
|
502
573
|
|
|
503
574
|
draftNode.create(
|
|
504
575
|
gridPosition.current,
|
|
505
576
|
asset,
|
|
506
|
-
|
|
577
|
+
initRotation,
|
|
507
578
|
configRef.current.defaultScale,
|
|
579
|
+
configRef.current.slots,
|
|
508
580
|
)
|
|
509
581
|
|
|
510
582
|
const draft = draftNode.current
|
|
@@ -514,6 +586,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
514
586
|
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
|
515
587
|
}
|
|
516
588
|
|
|
589
|
+
const previewBounds = expandBoundsToGrid(
|
|
590
|
+
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
|
591
|
+
asset.attachTo,
|
|
592
|
+
gridSnapStep,
|
|
593
|
+
)
|
|
594
|
+
updatePreviewGeometry(previewBounds)
|
|
595
|
+
updateDimensionGuides(previewBounds)
|
|
596
|
+
|
|
517
597
|
if (!revalidate()) {
|
|
518
598
|
draftNode.destroy()
|
|
519
599
|
}
|
|
@@ -521,6 +601,134 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
521
601
|
|
|
522
602
|
// ---- Init draft ----
|
|
523
603
|
configRef.current.initDraft(gridPosition.current)
|
|
604
|
+
const preserveDragOffset = configRef.current.preserveDragOffset === true
|
|
605
|
+
// The host the item was grabbed from + its pre-drag host-local position.
|
|
606
|
+
// Each surface's grab anchor preserves the grab offset only on THAT host,
|
|
607
|
+
// and only until the item anchors on ANY other host (another wall/ceiling/
|
|
608
|
+
// shelf, a roof face, or the floor after a host visit) — `grabForgotten`
|
|
609
|
+
// then latches and every host, the original included, centers the item
|
|
610
|
+
// under the cursor. Merely passing through empty space (wall/ceiling items
|
|
611
|
+
// hide between surfaces) does NOT forget the grab.
|
|
612
|
+
const grabWallId =
|
|
613
|
+
placementState.current.surface === 'wall' ? placementState.current.wallId : null
|
|
614
|
+
const grabCeilingId =
|
|
615
|
+
placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null
|
|
616
|
+
const grabSurfaceHostId =
|
|
617
|
+
placementState.current.surface === 'item-surface'
|
|
618
|
+
? placementState.current.surfaceItemId
|
|
619
|
+
: placementState.current.surface === 'shelf-surface'
|
|
620
|
+
? placementState.current.shelfId
|
|
621
|
+
: null
|
|
622
|
+
const grabStartPosition: [number, number, number] | null = draftNode.current
|
|
623
|
+
? [
|
|
624
|
+
draftNode.current.position[0],
|
|
625
|
+
draftNode.current.position[1],
|
|
626
|
+
draftNode.current.position[2],
|
|
627
|
+
]
|
|
628
|
+
: null
|
|
629
|
+
let grabForgotten = grabStartPosition === null
|
|
630
|
+
// Anchoring on `hostId`: returns whether the grab offset still applies
|
|
631
|
+
// there, and latches `grabForgotten` the first time it doesn't.
|
|
632
|
+
const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => {
|
|
633
|
+
const preserve = !grabForgotten && hostId !== null && hostId === grabHostId
|
|
634
|
+
if (!preserve) grabForgotten = true
|
|
635
|
+
return preserve
|
|
636
|
+
}
|
|
637
|
+
// Nulled when the item returns to the floor FROM a host (see
|
|
638
|
+
// `detachItemSurfaceToFloor`): the floor grab offset only survives until
|
|
639
|
+
// the item anchors elsewhere, like every other surface's grab anchor.
|
|
640
|
+
let relativeFloorStart =
|
|
641
|
+
preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
|
|
642
|
+
? gridPosition.current.clone()
|
|
643
|
+
: null
|
|
644
|
+
|
|
645
|
+
// Grab anchors for the non-floor surfaces. Each captures the cursor's
|
|
646
|
+
// surface-local position and the item's stored position on the first move
|
|
647
|
+
// for a given host, then offsets every later move by
|
|
648
|
+
// `start + (raw - anchor)` — mirroring the floor path and the door/window
|
|
649
|
+
// move tools so the item tracks the grabbed point instead of teleporting
|
|
650
|
+
// its origin under the cursor. Reset on host change (re-seeded from the
|
|
651
|
+
// item's then-current position) by the surface leave handlers.
|
|
652
|
+
let wallDragAnchor: {
|
|
653
|
+
wallId: string
|
|
654
|
+
rawX: number
|
|
655
|
+
rawY: number
|
|
656
|
+
startX: number
|
|
657
|
+
startY: number
|
|
658
|
+
} | null = null
|
|
659
|
+
let ceilingDragAnchor: {
|
|
660
|
+
ceilingId: string
|
|
661
|
+
rawX: number
|
|
662
|
+
rawZ: number
|
|
663
|
+
startX: number
|
|
664
|
+
startZ: number
|
|
665
|
+
} | null = null
|
|
666
|
+
let hostSurfaceDragAnchor: {
|
|
667
|
+
hostId: string
|
|
668
|
+
rawX: number
|
|
669
|
+
rawZ: number
|
|
670
|
+
startX: number
|
|
671
|
+
startZ: number
|
|
672
|
+
} | null = null
|
|
673
|
+
|
|
674
|
+
// Item-surface / shelf moves snap from a WORLD cursor hit projected into the
|
|
675
|
+
// host's local frame. Re-project the offset-corrected local point back to
|
|
676
|
+
// world so the strategy (which re-derives both the stored position and the
|
|
677
|
+
// visual cursor from `event.position`) stays self-consistent.
|
|
678
|
+
const resolveHostSurfaceWorld = (
|
|
679
|
+
hostId: string,
|
|
680
|
+
worldPos: readonly [number, number, number],
|
|
681
|
+
): [number, number, number] | null => {
|
|
682
|
+
const draft = draftNode.current
|
|
683
|
+
const hostMesh = sceneRegistry.nodes.get(hostId)
|
|
684
|
+
if (!(preserveDragOffset && draft && hostMesh)) return null
|
|
685
|
+
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
|
|
686
|
+
if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
|
|
687
|
+
// Grab offset survives only on the host the item was grabbed from and
|
|
688
|
+
// only until it anchors elsewhere — any other shelf/table centers the
|
|
689
|
+
// item under the cursor (same rule as the wall grab anchor).
|
|
690
|
+
const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId)
|
|
691
|
+
hostSurfaceDragAnchor = {
|
|
692
|
+
hostId,
|
|
693
|
+
rawX: rawLocal.x,
|
|
694
|
+
rawZ: rawLocal.z,
|
|
695
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
|
|
696
|
+
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
|
|
700
|
+
const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
|
|
701
|
+
const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
|
|
702
|
+
return [world.x, world.y, world.z]
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Floor grab-offset: the item tracks the grabbed point instead of snapping
|
|
706
|
+
// its origin under the cursor. The offset is computed in building-local space
|
|
707
|
+
// (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
|
|
708
|
+
// (`event.position`), so the corrected local point is re-projected to a
|
|
709
|
+
// corrected world point and both frames carry the offset to stay consistent.
|
|
710
|
+
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
|
|
711
|
+
if (relativeFloorStart === null) return event
|
|
712
|
+
const rawX = event.localPosition[0]
|
|
713
|
+
const rawZ = event.localPosition[2]
|
|
714
|
+
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
|
715
|
+
floorDragAnchor = anchor
|
|
716
|
+
const correctedLocal: [number, number, number] = [
|
|
717
|
+
relativeFloorStart.x + (rawX - anchor[0]),
|
|
718
|
+
event.localPosition[1],
|
|
719
|
+
relativeFloorStart.z + (rawZ - anchor[1]),
|
|
720
|
+
]
|
|
721
|
+
const correctedWorld = buildingLocalToWorld(
|
|
722
|
+
correctedLocal[0],
|
|
723
|
+
correctedLocal[1],
|
|
724
|
+
correctedLocal[2],
|
|
725
|
+
)
|
|
726
|
+
return {
|
|
727
|
+
...event,
|
|
728
|
+
position: [correctedWorld.x, event.position[1], correctedWorld.z],
|
|
729
|
+
localPosition: correctedLocal,
|
|
730
|
+
}
|
|
731
|
+
}
|
|
524
732
|
|
|
525
733
|
// Sync cursor to the draft mesh's world position and rotation
|
|
526
734
|
if (draftNode.current) {
|
|
@@ -529,12 +737,28 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
529
737
|
const worldPos = new Vector3()
|
|
530
738
|
mesh.getWorldPosition(worldPos)
|
|
531
739
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
|
532
|
-
cursorGroupRef.current
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
740
|
+
if (cursorGroupRef.current) {
|
|
741
|
+
cursorGroupRef.current.position.copy(localPos)
|
|
742
|
+
if (
|
|
743
|
+
draftNode.current.asset.attachTo ||
|
|
744
|
+
placementState.current?.surface === 'item-surface'
|
|
745
|
+
) {
|
|
746
|
+
// Wall/ceiling items AND items hosted on another item: the mesh is parented
|
|
747
|
+
// to a rotated host, so the box's building-local yaw must come from the mesh's
|
|
748
|
+
// world rotation, not the node's host-local `rotation[1]` (which would leave the
|
|
749
|
+
// box rotated by the host's yaw relative to the item).
|
|
750
|
+
const q = new Quaternion()
|
|
751
|
+
mesh.getWorldQuaternion(q)
|
|
752
|
+
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
|
753
|
+
} else {
|
|
754
|
+
// Floor items: the cursor group lives in building-local space, so use the
|
|
755
|
+
// node's local Y rotation — the same value onGridMove applies. The world
|
|
756
|
+
// quaternion would double-count any building rotation, leaving the initial
|
|
757
|
+
// box mis-rotated until the first cursor move.
|
|
758
|
+
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
} else if (cursorGroupRef.current) {
|
|
538
762
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
|
539
763
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
|
540
764
|
}
|
|
@@ -542,44 +766,185 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
542
766
|
|
|
543
767
|
revalidate()
|
|
544
768
|
|
|
769
|
+
// ---- Press-drag commit-on-release ----
|
|
770
|
+
// When the move was engaged by the press-drag move cross (vs. click-to-
|
|
771
|
+
// place), commit on pointer-up instead of waiting for a click. Each surface
|
|
772
|
+
// move handler records how to commit at the current cursor; the release
|
|
773
|
+
// replays it. Captured once at setup — a fresh coordinator mounts per move.
|
|
774
|
+
const dragMode = useEditor.getState().placementDragMode
|
|
775
|
+
let releaseCommit: (() => void) | null = null
|
|
776
|
+
// Eat the click the browser fires after pointer-up so the surface
|
|
777
|
+
// `:click` handlers don't commit a second time.
|
|
778
|
+
const swallowNextClick = () => {
|
|
779
|
+
const swallow = (e: Event) => {
|
|
780
|
+
e.stopPropagation()
|
|
781
|
+
e.preventDefault()
|
|
782
|
+
}
|
|
783
|
+
window.addEventListener('click', swallow, { capture: true, once: true })
|
|
784
|
+
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
|
|
785
|
+
}
|
|
786
|
+
const onReleaseCommit = () => {
|
|
787
|
+
if (!releaseCommit) return
|
|
788
|
+
const commit = releaseCommit
|
|
789
|
+
releaseCommit = null
|
|
790
|
+
swallowNextClick()
|
|
791
|
+
commit()
|
|
792
|
+
}
|
|
793
|
+
|
|
545
794
|
// ---- Floor Handlers ----
|
|
546
795
|
|
|
547
796
|
let previousGridPos: [number, number, number] | null = null
|
|
548
797
|
|
|
798
|
+
// Scratch objects reused by the stickiness test (runs per grid:move).
|
|
799
|
+
const stickyRay = new Ray()
|
|
800
|
+
const stickyBox = new Box3()
|
|
801
|
+
const stickyMat = new Matrix4()
|
|
802
|
+
const stickyCamPos = new Vector3()
|
|
803
|
+
|
|
804
|
+
// True while the cursor ray still points at the active shelf's volume.
|
|
805
|
+
// Used to keep an item hosted on a shelf "sticky": from an angled camera
|
|
806
|
+
// the cursor ray slips off the shelf's thin boards / through its gaps and
|
|
807
|
+
// lands on the floor *behind* the shelf, which would otherwise thrash the
|
|
808
|
+
// placement between the shelf row and the floor on every micro-move. We
|
|
809
|
+
// reconstruct the world ray (camera → grid hit point) and test it against
|
|
810
|
+
// the shelf's bounding box — so a ray that passes *through* the shelf but
|
|
811
|
+
// lands behind it still counts as "on the shelf". Only a ray that misses
|
|
812
|
+
// the shelf box entirely means the user genuinely moved off it. A simple
|
|
813
|
+
// footprint test on the floor hit point can't distinguish those.
|
|
814
|
+
const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
|
|
815
|
+
const shelfId = placementState.current.shelfId
|
|
816
|
+
if (!shelfId) return false
|
|
817
|
+
const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
|
|
818
|
+
const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
|
|
819
|
+
| { width?: number; depth?: number; height?: number }
|
|
820
|
+
| undefined
|
|
821
|
+
if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
|
|
822
|
+
|
|
823
|
+
cameraRef.current.getWorldPosition(stickyCamPos)
|
|
824
|
+
stickyRay.origin.copy(stickyCamPos)
|
|
825
|
+
stickyRay.direction
|
|
826
|
+
.set(
|
|
827
|
+
gridWorldPoint[0] - stickyCamPos.x,
|
|
828
|
+
gridWorldPoint[1] - stickyCamPos.y,
|
|
829
|
+
gridWorldPoint[2] - stickyCamPos.z,
|
|
830
|
+
)
|
|
831
|
+
.normalize()
|
|
832
|
+
|
|
833
|
+
// Into shelf-local space, then test the shelf's local AABB (origin at the
|
|
834
|
+
// base: y ∈ [0, height]) with a small margin.
|
|
835
|
+
stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
|
|
836
|
+
const m = 0.08
|
|
837
|
+
stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
|
|
838
|
+
stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
|
|
839
|
+
return stickyRay.intersectsBox(stickyBox)
|
|
840
|
+
}
|
|
841
|
+
|
|
549
842
|
const onGridMove = (event: GridEvent) => {
|
|
843
|
+
releaseCommit = () => onGridClick(event)
|
|
550
844
|
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
|
551
845
|
if (draftNode.current === null && asset.attachTo === undefined) {
|
|
552
846
|
configRef.current.initDraft(gridPosition.current)
|
|
553
847
|
}
|
|
554
848
|
|
|
555
|
-
|
|
556
|
-
|
|
849
|
+
has3DPointerDrivenMoveRef.current = true
|
|
850
|
+
|
|
851
|
+
// Shelf stickiness: while hosting on a shelf, ignore floor events while
|
|
852
|
+
// the cursor ray still points at the shelf volume (the ray merely slipped
|
|
853
|
+
// off a board / through a gap and hit the floor behind). Detach to the
|
|
854
|
+
// floor only once the ray misses the shelf entirely — without this the
|
|
855
|
+
// item oscillates between the shelf row and the floor on every micro-move.
|
|
856
|
+
if (placementState.current.surface === 'shelf-surface') {
|
|
857
|
+
if (cursorRayIntersectsActiveShelf(event.position)) return
|
|
858
|
+
detachItemSurfaceToFloor(event as unknown as ItemEvent)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const floorEvent = applyFloorGrabOffset(event)
|
|
862
|
+
|
|
863
|
+
lastRawPos.current.set(
|
|
864
|
+
floorEvent.localPosition[0],
|
|
865
|
+
floorEvent.localPosition[1],
|
|
866
|
+
floorEvent.localPosition[2],
|
|
867
|
+
)
|
|
868
|
+
if (!cursorGroupRef.current) return
|
|
869
|
+
const result = floorStrategy.move(getContext(), floorEvent)
|
|
557
870
|
if (!result) return
|
|
558
871
|
|
|
872
|
+
// Figma-style alignment snap layered on top of the floor strategy's
|
|
873
|
+
// grid snap: when the draft's edge lines up (on X or Z) with another
|
|
874
|
+
// item's edge, snap and publish a guide. The guide connects to the
|
|
875
|
+
// nearest real corner of the candidate (resolver tie-break), so the dot
|
|
876
|
+
// always sits on an actual point. The delta is applied to BOTH the grid
|
|
877
|
+
// and cursor positions below. Alt is force-place only (it does NOT bypass
|
|
878
|
+
// snapping — 'off' mode is the no-snap bypass); the active snapping mode
|
|
879
|
+
// governs whether alignment runs at all ('off' / 'angles' disable
|
|
880
|
+
// magnetic alignment, 'lines' enables it, matching the wall/fence flow).
|
|
881
|
+
const draft = draftNode.current
|
|
882
|
+
let alignX = 0
|
|
883
|
+
let alignZ = 0
|
|
884
|
+
// Alignment "lines" are DISPLAYED in every snapping mode except Off
|
|
885
|
+
// (isAlignmentGuideActive); the magnetic pull toward them is applied only
|
|
886
|
+
// in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
|
|
887
|
+
// bypass.
|
|
888
|
+
if (isAlignmentGuideActive() && draft) {
|
|
889
|
+
alignmentCandidates ??= collectAlignmentAnchors(
|
|
890
|
+
useScene.getState().nodes,
|
|
891
|
+
draft.id,
|
|
892
|
+
useViewer.getState().selection.levelId,
|
|
893
|
+
)
|
|
894
|
+
const ar = resolveAlignmentForActiveBuilding({
|
|
895
|
+
moving: movingFootprintAnchors(
|
|
896
|
+
draft as unknown as AnyNode,
|
|
897
|
+
result.gridPosition[0],
|
|
898
|
+
result.gridPosition[2],
|
|
899
|
+
cursorGroupRef.current.rotation.y,
|
|
900
|
+
),
|
|
901
|
+
candidates: alignmentCandidates,
|
|
902
|
+
threshold: ALIGNMENT_THRESHOLD_M,
|
|
903
|
+
})
|
|
904
|
+
if (ar.snap && isMagneticSnapActive()) {
|
|
905
|
+
alignX = ar.snap.dx
|
|
906
|
+
alignZ = ar.snap.dz
|
|
907
|
+
}
|
|
908
|
+
useAlignmentGuides
|
|
909
|
+
.getState()
|
|
910
|
+
.set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
|
|
911
|
+
} else {
|
|
912
|
+
useAlignmentGuides.getState().clear()
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const gridPos: [number, number, number] = [
|
|
916
|
+
result.gridPosition[0] + alignX,
|
|
917
|
+
result.gridPosition[1],
|
|
918
|
+
result.gridPosition[2] + alignZ,
|
|
919
|
+
]
|
|
920
|
+
|
|
559
921
|
// Play snap sound when grid position changes
|
|
560
922
|
if (
|
|
561
923
|
previousGridPos &&
|
|
562
|
-
(
|
|
563
|
-
result.gridPosition[2] !== previousGridPos[2])
|
|
924
|
+
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
|
|
564
925
|
) {
|
|
565
926
|
sfxEmitter.emit('sfx:grid-snap')
|
|
566
927
|
}
|
|
567
928
|
|
|
568
|
-
previousGridPos = [...
|
|
569
|
-
gridPosition.current.set(...
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
)
|
|
929
|
+
previousGridPos = [...gridPos]
|
|
930
|
+
gridPosition.current.set(...gridPos)
|
|
931
|
+
const cursorPosition = getFloorVisualPosition(gridPos)
|
|
932
|
+
if (!draft && asset.attachTo) {
|
|
933
|
+
cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
|
|
934
|
+
}
|
|
935
|
+
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
|
|
936
|
+
// Floor items only rotate on Y; keep the preview box (and the live
|
|
937
|
+
// transform the 2D floorplan mirrors) aligned with the draft's
|
|
938
|
+
// rotation. Without this the box stays at its seed rotation until a
|
|
939
|
+
// manual R/T, so a moved already-rotated item shows an axis-aligned box.
|
|
940
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
575
941
|
|
|
576
|
-
|
|
577
|
-
if (draft) draft.position = result.gridPosition
|
|
942
|
+
if (draft) draft.position = gridPos
|
|
578
943
|
|
|
579
944
|
// Publish live transform for 2D floorplan
|
|
580
945
|
if (draft) {
|
|
581
946
|
useLiveTransforms.getState().set(draft.id, {
|
|
582
|
-
position:
|
|
947
|
+
position: gridPos,
|
|
583
948
|
rotation: cursorGroupRef.current.rotation.y,
|
|
584
949
|
})
|
|
585
950
|
}
|
|
@@ -588,27 +953,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
588
953
|
}
|
|
589
954
|
|
|
590
955
|
const onGridClick = (event: GridEvent) => {
|
|
956
|
+
// Drop alignment guides on click — the move commits (guides done) or
|
|
957
|
+
// placement re-arms (the next move republishes them).
|
|
958
|
+
useAlignmentGuides.getState().clear()
|
|
591
959
|
const result = floorStrategy.click(getContext(), event, getActiveValidators())
|
|
592
960
|
if (!result) return
|
|
593
961
|
|
|
594
962
|
// Preserve cursor rotation for the next draft
|
|
595
|
-
const currentRotation: [number, number, number] = [
|
|
963
|
+
const currentRotation: [number, number, number] = [
|
|
964
|
+
0,
|
|
965
|
+
cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
|
|
966
|
+
0,
|
|
967
|
+
]
|
|
596
968
|
|
|
597
969
|
// Clear live transform before commit
|
|
598
970
|
if (draftNode.current) {
|
|
599
971
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
600
972
|
}
|
|
601
973
|
|
|
602
|
-
draftNode.
|
|
603
|
-
|
|
604
|
-
|
|
974
|
+
const committedId = draftNode.current?.id ?? null
|
|
975
|
+
const wasAdopted = draftNode.isAdopted
|
|
976
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
977
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
978
|
+
draftNode.create(
|
|
979
|
+
gridPosition.current,
|
|
980
|
+
asset,
|
|
981
|
+
currentRotation,
|
|
982
|
+
configRef.current.defaultScale,
|
|
983
|
+
configRef.current.slots,
|
|
984
|
+
)
|
|
985
|
+
const previewBounds = expandBoundsToGrid(
|
|
986
|
+
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
|
987
|
+
asset.attachTo,
|
|
988
|
+
gridSnapStep,
|
|
989
|
+
)
|
|
990
|
+
updatePreviewGeometry(previewBounds)
|
|
991
|
+
updateDimensionGuides(previewBounds)
|
|
605
992
|
revalidate()
|
|
606
|
-
}
|
|
993
|
+
})
|
|
607
994
|
}
|
|
608
995
|
|
|
609
996
|
// ---- Wall Handlers ----
|
|
610
997
|
|
|
611
998
|
const onWallEnter = (event: WallEvent) => {
|
|
999
|
+
has3DPointerDrivenMoveRef.current = true
|
|
612
1000
|
const nodes = useScene.getState().nodes
|
|
613
1001
|
const result = wallStrategy.enter(
|
|
614
1002
|
getContext(),
|
|
@@ -634,6 +1022,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
634
1022
|
}
|
|
635
1023
|
|
|
636
1024
|
const onWallMove = (event: WallEvent) => {
|
|
1025
|
+
releaseCommit = () => onWallClick(event)
|
|
1026
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1027
|
+
if (!cursorGroupRef.current) return
|
|
637
1028
|
const ctx = getContext()
|
|
638
1029
|
|
|
639
1030
|
if (ctx.state.surface !== 'wall') {
|
|
@@ -674,7 +1065,46 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
674
1065
|
return
|
|
675
1066
|
}
|
|
676
1067
|
|
|
677
|
-
|
|
1068
|
+
let wallMoveEvent = event
|
|
1069
|
+
if (preserveDragOffset && draftNode.current) {
|
|
1070
|
+
const rawX = event.localPosition[0]
|
|
1071
|
+
const rawY = event.localPosition[1]
|
|
1072
|
+
if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
|
|
1073
|
+
// Preserve the grab offset only on the wall the item was grabbed
|
|
1074
|
+
// from (no teleport under the pointer at grab time). Any OTHER host
|
|
1075
|
+
// centers the item under the cursor — a host change is already a
|
|
1076
|
+
// jump, so tracking the pointer exactly is the expected feel, while
|
|
1077
|
+
// re-seeding from the carried-over position (the old behavior)
|
|
1078
|
+
// baked an arbitrary along-wall offset into the whole stay on that
|
|
1079
|
+
// wall. Once anchored elsewhere the grab is forgotten for good, so
|
|
1080
|
+
// re-entering the original wall centers under the cursor too.
|
|
1081
|
+
const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
|
|
1082
|
+
wallDragAnchor = {
|
|
1083
|
+
wallId: event.node.id,
|
|
1084
|
+
rawX,
|
|
1085
|
+
rawY,
|
|
1086
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
|
1087
|
+
startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
|
|
1091
|
+
const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
|
|
1092
|
+
const wallMesh = sceneRegistry.nodes.get(event.node.id)
|
|
1093
|
+
// Derive the world cursor from the corrected wall-local point so the
|
|
1094
|
+
// visual cursor (world) and the stored position (wall-local) agree; if
|
|
1095
|
+
// the wall mesh is somehow absent, keep the raw world hit unchanged.
|
|
1096
|
+
const correctedWorld = wallMesh
|
|
1097
|
+
? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
|
|
1098
|
+
: null
|
|
1099
|
+
wallMoveEvent = {
|
|
1100
|
+
...event,
|
|
1101
|
+
localPosition: [correctedX, correctedY, event.localPosition[2]],
|
|
1102
|
+
position: correctedWorld
|
|
1103
|
+
? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
|
|
1104
|
+
: event.position,
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
|
|
678
1108
|
if (!result) return
|
|
679
1109
|
|
|
680
1110
|
event.stopPropagation()
|
|
@@ -722,13 +1152,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
722
1152
|
}
|
|
723
1153
|
// Mark parent wall dirty so it rebuilds geometry — only when position changed
|
|
724
1154
|
if (result.dirtyNodeId && posChanged) {
|
|
725
|
-
|
|
1155
|
+
const now = globalThis.performance?.now?.() ?? Date.now()
|
|
1156
|
+
const last = lastWallDirtyAtRef.current.get(result.dirtyNodeId) ?? 0
|
|
1157
|
+
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
|
1158
|
+
if (now - last > 120) {
|
|
1159
|
+
lastWallDirtyAtRef.current.set(result.dirtyNodeId, now)
|
|
1160
|
+
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
1161
|
+
}
|
|
726
1162
|
}
|
|
727
1163
|
|
|
728
|
-
// Publish live transform for 2D floorplan
|
|
1164
|
+
// Publish live transform for the 2D floorplan. The floorplan resolves a
|
|
1165
|
+
// wall item's footprint (and its wall-side depth offset) from this
|
|
1166
|
+
// rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
|
|
1167
|
+
// yaw, which is π off from the plan rotation on a wall face — feeding it
|
|
1168
|
+
// raw flips the footprint to the far side of the wall during placement.
|
|
1169
|
+
// Publish the plan rotation (wall angle + the item's wall-local yaw) so
|
|
1170
|
+
// the preview matches what the committed node resolves to.
|
|
1171
|
+
let liveRotation = result.cursorRotationY
|
|
1172
|
+
const liveWallId = placementState.current.wallId
|
|
1173
|
+
const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
|
|
1174
|
+
if (liveWall?.type === 'wall') {
|
|
1175
|
+
const w = liveWall as WallNode
|
|
1176
|
+
const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
|
|
1177
|
+
liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
|
|
1178
|
+
}
|
|
729
1179
|
useLiveTransforms.getState().set(draft.id, {
|
|
730
1180
|
position: result.cursorPosition,
|
|
731
|
-
rotation:
|
|
1181
|
+
rotation: liveRotation,
|
|
732
1182
|
})
|
|
733
1183
|
}
|
|
734
1184
|
}
|
|
@@ -742,12 +1192,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
742
1192
|
if (draftNode.current) {
|
|
743
1193
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
744
1194
|
}
|
|
745
|
-
draftNode.
|
|
1195
|
+
const committedId = draftNode.current?.id ?? null
|
|
1196
|
+
const wasAdopted = draftNode.isAdopted
|
|
1197
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
746
1198
|
if (result.dirtyNodeId) {
|
|
747
1199
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
748
1200
|
}
|
|
749
1201
|
|
|
750
|
-
|
|
1202
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
751
1203
|
const nodes = useScene.getState().nodes
|
|
752
1204
|
const enterResult = wallStrategy.enter(
|
|
753
1205
|
getContext(),
|
|
@@ -761,10 +1213,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
761
1213
|
} else {
|
|
762
1214
|
revalidate()
|
|
763
1215
|
}
|
|
764
|
-
}
|
|
1216
|
+
})
|
|
765
1217
|
}
|
|
766
1218
|
|
|
767
1219
|
const onWallLeave = (event: WallEvent) => {
|
|
1220
|
+
wallDragAnchor = null
|
|
768
1221
|
const result = wallStrategy.leave(getContext())
|
|
769
1222
|
if (!result) return
|
|
770
1223
|
|
|
@@ -794,28 +1247,215 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
794
1247
|
}
|
|
795
1248
|
}
|
|
796
1249
|
|
|
1250
|
+
// ---- Roof Wall Handlers ----
|
|
1251
|
+
// Wall-attach items also host on the vertical wall faces a roof
|
|
1252
|
+
// segment generates (base walls + coplanar gable ends). Unlike walls,
|
|
1253
|
+
// crossing between segments inside ONE roof never re-fires
|
|
1254
|
+
// `roof:enter` (events come from the roof group), so the move handler
|
|
1255
|
+
// re-enters whenever the strategy reports a segment change.
|
|
1256
|
+
|
|
1257
|
+
const enterRoofWall = (event: RoofEvent): boolean => {
|
|
1258
|
+
const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
|
1259
|
+
if (!result) return false
|
|
1260
|
+
|
|
1261
|
+
event.stopPropagation()
|
|
1262
|
+
applyTransition(result)
|
|
1263
|
+
|
|
1264
|
+
if (!draftNode.current) {
|
|
1265
|
+
ensureDraft(result)
|
|
1266
|
+
} else if (result.nodeUpdate.parentId) {
|
|
1267
|
+
// Existing draft (move mode): reparent to the segment
|
|
1268
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
1269
|
+
}
|
|
1270
|
+
return true
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const onRoofWallEnter = (event: RoofEvent) => {
|
|
1274
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1275
|
+
enterRoofWall(event)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const onRoofWallMove = (event: RoofEvent) => {
|
|
1279
|
+
releaseCommit = () => onRoofWallClick(event)
|
|
1280
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1281
|
+
if (!cursorGroupRef.current) return
|
|
1282
|
+
const ctx = getContext()
|
|
1283
|
+
|
|
1284
|
+
if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
|
|
1285
|
+
enterRoofWall(event)
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
|
|
1290
|
+
if (!result) {
|
|
1291
|
+
// Different segment under the pointer (or no placeable face) —
|
|
1292
|
+
// try a fresh enter; a null resolve leaves the draft where it is.
|
|
1293
|
+
enterRoofWall(event)
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
event.stopPropagation()
|
|
1298
|
+
|
|
1299
|
+
const posChanged =
|
|
1300
|
+
gridPosition.current.x !== result.gridPosition[0] ||
|
|
1301
|
+
gridPosition.current.y !== result.gridPosition[1] ||
|
|
1302
|
+
gridPosition.current.z !== result.gridPosition[2]
|
|
1303
|
+
|
|
1304
|
+
if (posChanged) {
|
|
1305
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
gridPosition.current.set(...result.gridPosition)
|
|
1309
|
+
const wc = worldToBuildingLocal(...result.cursorPosition)
|
|
1310
|
+
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
|
|
1311
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
1312
|
+
|
|
1313
|
+
const draft = draftNode.current
|
|
1314
|
+
if (draft && result.nodeUpdate) {
|
|
1315
|
+
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
|
|
1316
|
+
if ('rotation' in result.nodeUpdate)
|
|
1317
|
+
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const placeable = revalidate()
|
|
1321
|
+
|
|
1322
|
+
if (draft && placeable) {
|
|
1323
|
+
draft.position = result.gridPosition
|
|
1324
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1325
|
+
if (mesh) {
|
|
1326
|
+
mesh.position.copy(gridPosition.current)
|
|
1327
|
+
// Wall-side items sit on the outer surface: mirror ItemSystem's
|
|
1328
|
+
// push (z = thickness/2 off the face frame's mid-plane) so the
|
|
1329
|
+
// drag preview doesn't sink into the wall until commit.
|
|
1330
|
+
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
|
|
1331
|
+
const segment =
|
|
1332
|
+
useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
|
|
1333
|
+
if (segment?.type === 'roof-segment') {
|
|
1334
|
+
mesh.position.z = (segment.wallThickness ?? 0.1) / 2
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
const rot = result.nodeUpdate?.rotation
|
|
1338
|
+
if (rot) mesh.rotation.y = rot[1]
|
|
1339
|
+
}
|
|
1340
|
+
// The 2D floor-plan live frame is wall-local; a segment-local
|
|
1341
|
+
// value would render garbage — clear instead of publishing.
|
|
1342
|
+
useLiveTransforms.getState().clear(draft.id)
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const onRoofWallClick = (event: RoofEvent) => {
|
|
1347
|
+
const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
|
|
1348
|
+
if (!result) return
|
|
1349
|
+
|
|
1350
|
+
event.stopPropagation()
|
|
1351
|
+
if (draftNode.current) {
|
|
1352
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1353
|
+
}
|
|
1354
|
+
const committedId = draftNode.current?.id ?? null
|
|
1355
|
+
const wasAdopted = draftNode.isAdopted
|
|
1356
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1357
|
+
|
|
1358
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1359
|
+
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
|
1360
|
+
if (enterResult) {
|
|
1361
|
+
applyTransition(enterResult)
|
|
1362
|
+
} else {
|
|
1363
|
+
revalidate()
|
|
1364
|
+
}
|
|
1365
|
+
})
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const onRoofWallLeave = (event: RoofEvent) => {
|
|
1369
|
+
const result = roofWallStrategy.leave(getContext())
|
|
1370
|
+
if (!result) return
|
|
1371
|
+
|
|
1372
|
+
event.stopPropagation()
|
|
1373
|
+
|
|
1374
|
+
if (draftNode.isAdopted) {
|
|
1375
|
+
// Move mode: keep draft alive, reparent to level
|
|
1376
|
+
applyTransition(result)
|
|
1377
|
+
const draft = draftNode.current
|
|
1378
|
+
if (draft) {
|
|
1379
|
+
useScene.getState().updateNode(draft.id, {
|
|
1380
|
+
parentId: result.nodeUpdate.parentId as string,
|
|
1381
|
+
roofSegmentId: undefined,
|
|
1382
|
+
})
|
|
1383
|
+
}
|
|
1384
|
+
} else {
|
|
1385
|
+
// Create mode: destroy transient and reset state
|
|
1386
|
+
draftNode.destroy()
|
|
1387
|
+
Object.assign(placementState.current, result.stateUpdate)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
797
1391
|
// ---- Item Surface Handlers ----
|
|
798
1392
|
|
|
799
1393
|
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
|
1394
|
+
hostSurfaceDragAnchor = null
|
|
1395
|
+
// Coming back from a host: forget the floor grab too, so the item
|
|
1396
|
+
// centers under the cursor instead of restoring the pre-drag offset —
|
|
1397
|
+
// and landing on the floor is "anchoring elsewhere", so a later return
|
|
1398
|
+
// to the grabbed host centers as well.
|
|
1399
|
+
relativeFloorStart = null
|
|
1400
|
+
floorDragAnchor = null
|
|
1401
|
+
grabForgotten = true
|
|
800
1402
|
const buildingLocalPoint = worldToBuildingLocal(
|
|
801
1403
|
event.position[0],
|
|
802
1404
|
event.position[1],
|
|
803
1405
|
event.position[2],
|
|
804
1406
|
)
|
|
805
|
-
|
|
806
|
-
const
|
|
1407
|
+
// Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
|
|
1408
|
+
const wx = snapToHalf(buildingLocalPoint.x)
|
|
1409
|
+
const wz = snapToHalf(buildingLocalPoint.z)
|
|
807
1410
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
808
1411
|
|
|
809
|
-
Object.assign(placementState.current, {
|
|
1412
|
+
Object.assign(placementState.current, {
|
|
1413
|
+
surface: 'floor',
|
|
1414
|
+
surfaceItemId: null,
|
|
1415
|
+
shelfId: null,
|
|
1416
|
+
})
|
|
810
1417
|
gridPosition.current.set(wx, 0, wz)
|
|
811
|
-
|
|
1418
|
+
const levelId = useViewer.getState().selection.levelId as AnyNodeId | null
|
|
1419
|
+
const floorVisualPosition = getFloorVisualPosition(
|
|
1420
|
+
floorPos,
|
|
1421
|
+
levelId ? { parentId: levelId } : undefined,
|
|
1422
|
+
)
|
|
1423
|
+
if (cursorGroupRef.current) {
|
|
1424
|
+
cursorGroupRef.current.position.set(...floorVisualPosition)
|
|
1425
|
+
}
|
|
812
1426
|
|
|
813
1427
|
const draft = draftNode.current
|
|
814
1428
|
if (draft) {
|
|
1429
|
+
// The stored yaw is still HOST-local — the surface enter counter-
|
|
1430
|
+
// rotated it against the host's world yaw so the item wouldn't spin
|
|
1431
|
+
// when attaching. Re-express the same world yaw in the level frame
|
|
1432
|
+
// before re-parenting, or the item visibly spins by the host's
|
|
1433
|
+
// rotation the moment it returns to the floor.
|
|
1434
|
+
let rotation = draft.rotation
|
|
1435
|
+
const draftMesh = sceneRegistry.nodes.get(draft.id)
|
|
1436
|
+
if (draftMesh) {
|
|
1437
|
+
const quat = new Quaternion()
|
|
1438
|
+
draftMesh.getWorldQuaternion(quat)
|
|
1439
|
+
const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
|
|
1440
|
+
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
1441
|
+
let levelYaw = 0
|
|
1442
|
+
if (levelMesh) {
|
|
1443
|
+
levelMesh.getWorldQuaternion(quat)
|
|
1444
|
+
levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
|
|
1445
|
+
}
|
|
1446
|
+
rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]]
|
|
1447
|
+
draft.rotation = rotation
|
|
1448
|
+
}
|
|
815
1449
|
draft.position = floorPos
|
|
1450
|
+
// Sync the ref's parent too (the store-only write left the ref
|
|
1451
|
+
// pointing at the host, so `getFloorPlacedElevation` bailed on its
|
|
1452
|
+
// parent-must-be-a-level guard and the item + grid stopped following
|
|
1453
|
+
// slab elevations for the rest of the drag).
|
|
1454
|
+
if (levelId) draft.parentId = levelId
|
|
816
1455
|
useScene.getState().updateNode(draft.id, {
|
|
817
1456
|
parentId: useViewer.getState().selection.levelId as string,
|
|
818
1457
|
position: floorPos,
|
|
1458
|
+
rotation,
|
|
819
1459
|
})
|
|
820
1460
|
}
|
|
821
1461
|
|
|
@@ -824,6 +1464,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
824
1464
|
|
|
825
1465
|
const onItemEnter = (event: ItemEvent) => {
|
|
826
1466
|
if (event.node.id === draftNode.current?.id) return
|
|
1467
|
+
has3DPointerDrivenMoveRef.current = true
|
|
827
1468
|
const result = itemSurfaceStrategy.enter(getContext(), event)
|
|
828
1469
|
if (!result) return
|
|
829
1470
|
|
|
@@ -840,6 +1481,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
840
1481
|
|
|
841
1482
|
const onItemMove = (event: ItemEvent) => {
|
|
842
1483
|
if (event.node.id === draftNode.current?.id) return
|
|
1484
|
+
releaseCommit = () => onItemClick(event)
|
|
1485
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1486
|
+
if (!cursorGroupRef.current) return
|
|
843
1487
|
const ctx = getContext()
|
|
844
1488
|
|
|
845
1489
|
if (ctx.state.surface !== 'item-surface') {
|
|
@@ -881,8 +1525,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
881
1525
|
return
|
|
882
1526
|
}
|
|
883
1527
|
|
|
884
|
-
|
|
885
|
-
|
|
1528
|
+
const surfaceWorld =
|
|
1529
|
+
ctx.state.surfaceItemId !== null
|
|
1530
|
+
? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
|
|
1531
|
+
: null
|
|
1532
|
+
const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
|
|
1533
|
+
lastRawPos.current.set(
|
|
1534
|
+
itemMoveEvent.position[0],
|
|
1535
|
+
itemMoveEvent.position[1],
|
|
1536
|
+
itemMoveEvent.position[2],
|
|
1537
|
+
)
|
|
1538
|
+
const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
|
|
886
1539
|
if (!result) return
|
|
887
1540
|
|
|
888
1541
|
event.stopPropagation()
|
|
@@ -923,7 +1576,108 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
923
1576
|
}
|
|
924
1577
|
|
|
925
1578
|
const onItemClick = (event: ItemEvent) => {
|
|
926
|
-
|
|
1579
|
+
// Click on the draft item itself. R3F dispatches click events to
|
|
1580
|
+
// the closest intersected mesh only — when the draft is hovering
|
|
1581
|
+
// on a host (shelf / table / etc.) the draft's mesh is *above*
|
|
1582
|
+
// the host's mesh, so the host's `${kind}:click` never fires.
|
|
1583
|
+
// If we're currently hosting on a shelf-surface, treat the
|
|
1584
|
+
// self-click as a commit on the active shelf so the user doesn't
|
|
1585
|
+
// have to aim around the cursor preview to drop the item.
|
|
1586
|
+
if (event.node.id === draftNode.current?.id) {
|
|
1587
|
+
const ctx = getContext()
|
|
1588
|
+
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
|
|
1589
|
+
const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
|
|
1590
|
+
if (shelfNode && shelfNode.type === 'shelf') {
|
|
1591
|
+
const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
|
|
1592
|
+
const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
|
|
1593
|
+
if (result) {
|
|
1594
|
+
event.stopPropagation()
|
|
1595
|
+
if (draftNode.current) {
|
|
1596
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1597
|
+
}
|
|
1598
|
+
const committedId = draftNode.current?.id ?? null
|
|
1599
|
+
const wasAdopted = draftNode.isAdopted
|
|
1600
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1601
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1602
|
+
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
|
|
1603
|
+
if (enterResult) {
|
|
1604
|
+
applyTransition(enterResult)
|
|
1605
|
+
} else {
|
|
1606
|
+
revalidate()
|
|
1607
|
+
}
|
|
1608
|
+
})
|
|
1609
|
+
return
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
// Same self-click forwarding for item-surface hosts (tables,
|
|
1614
|
+
// counters) — the draft mesh sits on top of the host mesh, so
|
|
1615
|
+
// the host's own click event is blocked by the cursor preview.
|
|
1616
|
+
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
|
|
1617
|
+
const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
|
|
1618
|
+
if (hostNode && hostNode.type === 'item') {
|
|
1619
|
+
const synthetic = { ...event, node: hostNode } as ItemEvent
|
|
1620
|
+
const result = itemSurfaceStrategy.click(ctx, synthetic)
|
|
1621
|
+
if (result) {
|
|
1622
|
+
event.stopPropagation()
|
|
1623
|
+
if (draftNode.current) {
|
|
1624
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1625
|
+
}
|
|
1626
|
+
const committedId = draftNode.current?.id ?? null
|
|
1627
|
+
const wasAdopted = draftNode.isAdopted
|
|
1628
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1629
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1630
|
+
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
|
|
1631
|
+
if (enterResult) {
|
|
1632
|
+
applyTransition(enterResult)
|
|
1633
|
+
} else {
|
|
1634
|
+
revalidate()
|
|
1635
|
+
}
|
|
1636
|
+
})
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
// Ceiling-hosted draft: when placing a ceiling-attached item the
|
|
1642
|
+
// draft hangs below the ceiling and intercepts the click ray
|
|
1643
|
+
// before the ceiling-grid mesh does — so `ceiling:click` never
|
|
1644
|
+
// fires and the user's commit click is dropped. Forward the
|
|
1645
|
+
// self-click to `ceilingStrategy.click` so placement commits the
|
|
1646
|
+
// same way it would from a click on the ceiling itself.
|
|
1647
|
+
if (ctx.state.surface === 'ceiling' && ctx.state.ceilingId) {
|
|
1648
|
+
const ceilingNode = useScene.getState().nodes[ctx.state.ceilingId as AnyNodeId]
|
|
1649
|
+
if (ceilingNode && ceilingNode.type === 'ceiling') {
|
|
1650
|
+
const synthetic = { ...event, node: ceilingNode } as unknown as CeilingEvent
|
|
1651
|
+
const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
|
|
1652
|
+
if (result) {
|
|
1653
|
+
event.stopPropagation()
|
|
1654
|
+
if (draftNode.current) {
|
|
1655
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1656
|
+
}
|
|
1657
|
+
const committedId = draftNode.current?.id ?? null
|
|
1658
|
+
const wasAdopted = draftNode.isAdopted
|
|
1659
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1660
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1661
|
+
const nodes = useScene.getState().nodes
|
|
1662
|
+
const enterResult = ceilingStrategy.enter(
|
|
1663
|
+
getContext(),
|
|
1664
|
+
synthetic,
|
|
1665
|
+
resolveLevelId,
|
|
1666
|
+
nodes,
|
|
1667
|
+
)
|
|
1668
|
+
if (enterResult) {
|
|
1669
|
+
applyTransition(enterResult)
|
|
1670
|
+
} else {
|
|
1671
|
+
revalidate()
|
|
1672
|
+
}
|
|
1673
|
+
})
|
|
1674
|
+
return
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
return
|
|
1679
|
+
}
|
|
1680
|
+
|
|
927
1681
|
const result = itemSurfaceStrategy.click(getContext(), event)
|
|
928
1682
|
if (!result) return
|
|
929
1683
|
|
|
@@ -932,9 +1686,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
932
1686
|
if (draftNode.current) {
|
|
933
1687
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
934
1688
|
}
|
|
935
|
-
draftNode.
|
|
1689
|
+
const committedId = draftNode.current?.id ?? null
|
|
1690
|
+
const wasAdopted = draftNode.isAdopted
|
|
1691
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
936
1692
|
|
|
937
|
-
|
|
1693
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
938
1694
|
// Try to set up next draft on the same surface
|
|
939
1695
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
|
940
1696
|
if (enterResult) {
|
|
@@ -942,12 +1698,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
942
1698
|
} else {
|
|
943
1699
|
revalidate()
|
|
944
1700
|
}
|
|
945
|
-
}
|
|
1701
|
+
})
|
|
946
1702
|
}
|
|
947
1703
|
|
|
948
1704
|
// ---- Ceiling Handlers ----
|
|
949
1705
|
|
|
950
1706
|
const onCeilingEnter = (event: CeilingEvent) => {
|
|
1707
|
+
has3DPointerDrivenMoveRef.current = true
|
|
951
1708
|
const nodes = useScene.getState().nodes
|
|
952
1709
|
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
953
1710
|
if (!result) return
|
|
@@ -967,6 +1724,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
967
1724
|
}
|
|
968
1725
|
|
|
969
1726
|
const onCeilingMove = (event: CeilingEvent) => {
|
|
1727
|
+
releaseCommit = () => onCeilingClick(event)
|
|
1728
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1729
|
+
if (!cursorGroupRef.current) return
|
|
970
1730
|
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
|
971
1731
|
const nodes = useScene.getState().nodes
|
|
972
1732
|
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
@@ -977,8 +1737,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
977
1737
|
return
|
|
978
1738
|
}
|
|
979
1739
|
|
|
980
|
-
|
|
981
|
-
|
|
1740
|
+
let ceilingMoveEvent = event
|
|
1741
|
+
if (preserveDragOffset && draftNode.current) {
|
|
1742
|
+
const rawX = event.localPosition[0]
|
|
1743
|
+
const rawZ = event.localPosition[2]
|
|
1744
|
+
if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
|
|
1745
|
+
// Same rule as the wall grab anchor: only the grabbed ceiling
|
|
1746
|
+
// preserves the offset, any other ceiling centers under the cursor.
|
|
1747
|
+
const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId)
|
|
1748
|
+
ceilingDragAnchor = {
|
|
1749
|
+
ceilingId: event.node.id,
|
|
1750
|
+
rawX,
|
|
1751
|
+
rawZ,
|
|
1752
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
|
1753
|
+
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
ceilingMoveEvent = {
|
|
1757
|
+
...event,
|
|
1758
|
+
localPosition: [
|
|
1759
|
+
ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
|
|
1760
|
+
event.localPosition[1],
|
|
1761
|
+
ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
|
|
1762
|
+
],
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
lastRawPos.current.set(
|
|
1766
|
+
ceilingMoveEvent.localPosition[0],
|
|
1767
|
+
ceilingMoveEvent.localPosition[1],
|
|
1768
|
+
ceilingMoveEvent.localPosition[2],
|
|
1769
|
+
)
|
|
1770
|
+
const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
|
|
982
1771
|
if (!result) return
|
|
983
1772
|
|
|
984
1773
|
event.stopPropagation()
|
|
@@ -1005,9 +1794,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1005
1794
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1006
1795
|
if (mesh) mesh.position.copy(gridPosition.current)
|
|
1007
1796
|
|
|
1008
|
-
// Publish live transform for 2D floorplan
|
|
1797
|
+
// Publish live transform for 2D floorplan. The item override in
|
|
1798
|
+
// `floorplan-registry-layer` treats `live.position` as building-local
|
|
1799
|
+
// plan coords (parentId forced to null so the resolver renders it
|
|
1800
|
+
// directly), so publish the building-local cursor — not the
|
|
1801
|
+
// world-space `result.cursorPosition`, which otherwise lands the 2D
|
|
1802
|
+
// visual off the cursor whenever the building isn't at the origin
|
|
1803
|
+
// with zero rotation.
|
|
1009
1804
|
useLiveTransforms.getState().set(draft.id, {
|
|
1010
|
-
position:
|
|
1805
|
+
position: [cc.x, cc.y, cc.z],
|
|
1011
1806
|
rotation: cursorGroupRef.current.rotation.y,
|
|
1012
1807
|
})
|
|
1013
1808
|
}
|
|
@@ -1022,9 +1817,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1022
1817
|
if (draftNode.current) {
|
|
1023
1818
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1024
1819
|
}
|
|
1025
|
-
draftNode.
|
|
1820
|
+
const committedId = draftNode.current?.id ?? null
|
|
1821
|
+
const wasAdopted = draftNode.isAdopted
|
|
1822
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1026
1823
|
|
|
1027
|
-
|
|
1824
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1028
1825
|
const nodes = useScene.getState().nodes
|
|
1029
1826
|
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
1030
1827
|
if (enterResult) {
|
|
@@ -1032,10 +1829,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1032
1829
|
} else {
|
|
1033
1830
|
revalidate()
|
|
1034
1831
|
}
|
|
1035
|
-
}
|
|
1832
|
+
})
|
|
1036
1833
|
}
|
|
1037
1834
|
|
|
1038
1835
|
const onCeilingLeave = (event: CeilingEvent) => {
|
|
1836
|
+
ceilingDragAnchor = null
|
|
1039
1837
|
const result = ceilingStrategy.leave(getContext())
|
|
1040
1838
|
if (!result) return
|
|
1041
1839
|
|
|
@@ -1065,12 +1863,119 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1065
1863
|
}
|
|
1066
1864
|
}
|
|
1067
1865
|
|
|
1866
|
+
// ---- Shelf Handlers ----
|
|
1867
|
+
//
|
|
1868
|
+
// Items can host on shelves the same way they host on tables and
|
|
1869
|
+
// counters (item-surface). The shelf's `surfaces.custom` exposes one
|
|
1870
|
+
// candidate Y per row; `shelfSurfaceStrategy` picks the closest one
|
|
1871
|
+
// to the cursor's local-Y so the user can target a specific row.
|
|
1872
|
+
|
|
1873
|
+
const onShelfEnter = (event: ShelfEvent) => {
|
|
1874
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1875
|
+
const result = shelfSurfaceStrategy.enter(getContext(), event)
|
|
1876
|
+
if (!result) return
|
|
1877
|
+
|
|
1878
|
+
event.stopPropagation()
|
|
1879
|
+
applyTransition(result)
|
|
1880
|
+
|
|
1881
|
+
if (!draftNode.current) {
|
|
1882
|
+
ensureDraft(result)
|
|
1883
|
+
} else if (result.nodeUpdate.parentId) {
|
|
1884
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const onShelfMove = (event: ShelfEvent) => {
|
|
1889
|
+
releaseCommit = () => onShelfClick(event)
|
|
1890
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1891
|
+
// A shelf event can fire before the cursor group mounts or after
|
|
1892
|
+
// teardown, leaving the ref null; bail before dereferencing it below.
|
|
1893
|
+
if (!cursorGroupRef.current) return
|
|
1894
|
+
const ctx = getContext()
|
|
1895
|
+
if (ctx.state.surface !== 'shelf-surface') {
|
|
1896
|
+
// Cursor entered via a move event without an enter — try
|
|
1897
|
+
// transitioning in so the user doesn't need to mouse out + back
|
|
1898
|
+
// in to start hosting.
|
|
1899
|
+
const enterResult = shelfSurfaceStrategy.enter(ctx, event)
|
|
1900
|
+
if (!enterResult) return
|
|
1901
|
+
event.stopPropagation()
|
|
1902
|
+
applyTransition(enterResult)
|
|
1903
|
+
if (!draftNode.current) {
|
|
1904
|
+
ensureDraft(enterResult)
|
|
1905
|
+
} else if (enterResult.nodeUpdate.parentId) {
|
|
1906
|
+
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
|
1907
|
+
}
|
|
1908
|
+
return
|
|
1909
|
+
}
|
|
1910
|
+
const shelfWorld =
|
|
1911
|
+
ctx.state.shelfId !== null
|
|
1912
|
+
? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
|
|
1913
|
+
: null
|
|
1914
|
+
const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
|
|
1915
|
+
const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
|
|
1916
|
+
if (!result) return
|
|
1917
|
+
|
|
1918
|
+
event.stopPropagation()
|
|
1919
|
+
|
|
1920
|
+
gridPosition.current.set(...result.gridPosition)
|
|
1921
|
+
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
1922
|
+
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
1923
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
1924
|
+
|
|
1925
|
+
const draft = draftNode.current
|
|
1926
|
+
if (draft) {
|
|
1927
|
+
draft.position = result.gridPosition
|
|
1928
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1929
|
+
if (mesh) mesh.position.set(...result.gridPosition)
|
|
1930
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
1931
|
+
position: result.cursorPosition,
|
|
1932
|
+
rotation: result.cursorRotationY,
|
|
1933
|
+
})
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
revalidate()
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const onShelfLeave = (event: ShelfEvent) => {
|
|
1940
|
+
if (placementState.current.surface !== 'shelf-surface') return
|
|
1941
|
+
if (event.node.id !== placementState.current.shelfId) return
|
|
1942
|
+
// Intentionally do NOT detach to the floor here. `shelf:leave` fires
|
|
1943
|
+
// constantly while hosting because the cursor ray slips off the shelf's
|
|
1944
|
+
// thin boards and through its gaps — detaching on each of those would
|
|
1945
|
+
// thrash the item between the shelf row and the floor. The grid handler
|
|
1946
|
+
// owns the real shelf→floor transition (see `isOverActiveShelfFootprint`
|
|
1947
|
+
// in `onGridMove`): it detaches only once the cursor is clearly off the
|
|
1948
|
+
// shelf footprint, which is the genuine "left the shelf" signal.
|
|
1949
|
+
event.stopPropagation()
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
const onShelfClick = (event: ShelfEvent) => {
|
|
1953
|
+
const result = shelfSurfaceStrategy.click(getContext(), event)
|
|
1954
|
+
if (!result) return
|
|
1955
|
+
|
|
1956
|
+
event.stopPropagation()
|
|
1957
|
+
if (draftNode.current) {
|
|
1958
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1959
|
+
}
|
|
1960
|
+
const committedId = draftNode.current?.id ?? null
|
|
1961
|
+
const wasAdopted = draftNode.isAdopted
|
|
1962
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1963
|
+
|
|
1964
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1965
|
+
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
|
|
1966
|
+
if (enterResult) {
|
|
1967
|
+
applyTransition(enterResult)
|
|
1968
|
+
} else {
|
|
1969
|
+
revalidate()
|
|
1970
|
+
}
|
|
1971
|
+
})
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1068
1974
|
// ---- Keyboard rotation ----
|
|
1069
1975
|
|
|
1070
|
-
const ROTATION_STEP = Math.PI / 2
|
|
1071
1976
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1072
|
-
if (event.key === '
|
|
1073
|
-
|
|
1977
|
+
if (event.key === 'Alt') {
|
|
1978
|
+
altFreeRef.current = true
|
|
1074
1979
|
revalidate()
|
|
1075
1980
|
return
|
|
1076
1981
|
}
|
|
@@ -1083,21 +1988,28 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1083
1988
|
const draft = draftNode.current
|
|
1084
1989
|
if (!draft) return
|
|
1085
1990
|
|
|
1086
|
-
|
|
1991
|
+
// Roof-wall drafts live flat in the host face frame (yaw 0) —
|
|
1992
|
+
// manual rotation would skew them off the wall plane.
|
|
1993
|
+
if (placementState.current.surface === 'roof-wall') return
|
|
1994
|
+
|
|
1995
|
+
let rotationDir: 1 | -1 | 0 = 0
|
|
1087
1996
|
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
|
1088
|
-
|
|
1997
|
+
rotationDir = 1
|
|
1089
1998
|
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
|
|
1090
|
-
|
|
1999
|
+
rotationDir = -1
|
|
1091
2000
|
|
|
1092
|
-
if (
|
|
2001
|
+
if (rotationDir !== 0) {
|
|
1093
2002
|
event.preventDefault()
|
|
1094
2003
|
sfxEmitter.emit('sfx:item-rotate')
|
|
1095
2004
|
const currentRotation = draft.rotation
|
|
1096
|
-
|
|
2005
|
+
// Round to the nearest 45° then step, matching the placed-item R/T.
|
|
2006
|
+
const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
|
|
1097
2007
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
1098
2008
|
|
|
1099
2009
|
// Ref + cursor mesh + item mesh — no store update during drag
|
|
1100
|
-
cursorGroupRef.current
|
|
2010
|
+
if (cursorGroupRef.current) {
|
|
2011
|
+
cursorGroupRef.current.rotation.y = newRotationY
|
|
2012
|
+
}
|
|
1101
2013
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1102
2014
|
if (mesh) mesh.rotation.y = newRotationY
|
|
1103
2015
|
|
|
@@ -1111,11 +2023,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1111
2023
|
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
|
|
1112
2024
|
gridPosition.current.set(x, gridPosition.current.y, z)
|
|
1113
2025
|
draft.position = [x, gridPosition.current.y, z]
|
|
1114
|
-
cursorGroupRef.current
|
|
1115
|
-
|
|
2026
|
+
if (cursorGroupRef.current) {
|
|
2027
|
+
if (surface === 'floor') {
|
|
2028
|
+
cursorGroupRef.current.position.set(
|
|
2029
|
+
...getFloorVisualPosition([x, gridPosition.current.y, z]),
|
|
2030
|
+
)
|
|
2031
|
+
} else {
|
|
2032
|
+
cursorGroupRef.current.position.x = x
|
|
2033
|
+
cursorGroupRef.current.position.z = z
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
1116
2036
|
if (mesh) {
|
|
1117
2037
|
mesh.position.x = x
|
|
1118
2038
|
mesh.position.z = z
|
|
2039
|
+
if (surface === 'floor') {
|
|
2040
|
+
mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1]
|
|
2041
|
+
}
|
|
1119
2042
|
}
|
|
1120
2043
|
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
|
|
1121
2044
|
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
|
|
@@ -1135,7 +2058,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1135
2058
|
worldSnapped.y,
|
|
1136
2059
|
worldSnapped.z,
|
|
1137
2060
|
)
|
|
1138
|
-
|
|
2061
|
+
const surfaceQuat = new Quaternion()
|
|
2062
|
+
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
2063
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
2064
|
+
if (cursorGroupRef.current) {
|
|
2065
|
+
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
|
2066
|
+
// The box lives in building-local space while the mesh is parented to the host
|
|
2067
|
+
// item, so add the host's world yaw: the box must track the item's true
|
|
2068
|
+
// orientation, not its host-local `rotation[1]`.
|
|
2069
|
+
cursorGroupRef.current.rotation.y = newRotationY + surfaceWorldY
|
|
2070
|
+
}
|
|
1139
2071
|
if (mesh) mesh.position.set(x, y, z)
|
|
1140
2072
|
}
|
|
1141
2073
|
}
|
|
@@ -1143,13 +2075,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1143
2075
|
// Update live transform for 2D floorplan with post-snap position
|
|
1144
2076
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
|
1145
2077
|
if (currentLive) {
|
|
2078
|
+
const livePosition: [number, number, number] =
|
|
2079
|
+
surface === 'floor'
|
|
2080
|
+
? [draft.position[0], draft.position[1], draft.position[2]]
|
|
2081
|
+
: cursorGroupRef.current
|
|
2082
|
+
? [
|
|
2083
|
+
cursorGroupRef.current.position.x,
|
|
2084
|
+
cursorGroupRef.current.position.y,
|
|
2085
|
+
cursorGroupRef.current.position.z,
|
|
2086
|
+
]
|
|
2087
|
+
: [draft.position[0], draft.position[1], draft.position[2]]
|
|
1146
2088
|
useLiveTransforms.getState().set(draft.id, {
|
|
1147
2089
|
...currentLive,
|
|
1148
|
-
position:
|
|
1149
|
-
cursorGroupRef.current.position.x,
|
|
1150
|
-
cursorGroupRef.current.position.y,
|
|
1151
|
-
cursorGroupRef.current.position.z,
|
|
1152
|
-
] as [number, number, number],
|
|
2090
|
+
position: livePosition,
|
|
1153
2091
|
rotation: newRotationY,
|
|
1154
2092
|
})
|
|
1155
2093
|
}
|
|
@@ -1159,8 +2097,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1159
2097
|
}
|
|
1160
2098
|
|
|
1161
2099
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
1162
|
-
if (event.key === '
|
|
1163
|
-
|
|
2100
|
+
if (event.key === 'Alt') {
|
|
2101
|
+
altFreeRef.current = false
|
|
1164
2102
|
revalidate()
|
|
1165
2103
|
}
|
|
1166
2104
|
}
|
|
@@ -1170,19 +2108,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1170
2108
|
|
|
1171
2109
|
// ---- tool:cancel (Escape / programmatic) ----
|
|
1172
2110
|
const onCancel = () => {
|
|
2111
|
+
useAlignmentGuides.getState().clear()
|
|
1173
2112
|
if (configRef.current.onCancel) {
|
|
1174
2113
|
configRef.current.onCancel()
|
|
1175
2114
|
}
|
|
1176
2115
|
}
|
|
1177
2116
|
emitter.on('tool:cancel', onCancel)
|
|
1178
2117
|
|
|
1179
|
-
// ---- Right-click cancel ----
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
2118
|
+
// ---- Right-click cancel (quick click only, never a right-drag orbit) ----
|
|
2119
|
+
// The right button is also the camera-orbit control, so a contextmenu/up
|
|
2120
|
+
// alone can't tell "cancel placement" from "move the camera". Record the
|
|
2121
|
+
// right-button-down point + time and only cancel on release when the
|
|
2122
|
+
// pointer barely moved within a short window — a longer / further press is
|
|
2123
|
+
// an orbit and must leave the placement untouched.
|
|
2124
|
+
let rightDown: { x: number; y: number; t: number } | null = null
|
|
2125
|
+
const onRightPointerDown = (event: PointerEvent) => {
|
|
2126
|
+
if (event.button !== 2) return
|
|
2127
|
+
rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
|
|
2128
|
+
}
|
|
2129
|
+
const onRightPointerUp = (event: PointerEvent) => {
|
|
2130
|
+
if (event.button !== 2) return
|
|
2131
|
+
const down = rightDown
|
|
2132
|
+
rightDown = null
|
|
2133
|
+
if (!down || !configRef.current.onCancel) return
|
|
2134
|
+
const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
|
|
2135
|
+
const elapsed = performance.now() - down.t
|
|
2136
|
+
if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
|
|
2137
|
+
onCancel()
|
|
1184
2138
|
}
|
|
1185
2139
|
}
|
|
2140
|
+
// Suppress the OS context menu while placing; the cancel itself is decided
|
|
2141
|
+
// on pointerup above.
|
|
2142
|
+
const onContextMenu = (event: MouseEvent) => {
|
|
2143
|
+
if (configRef.current.onCancel) event.preventDefault()
|
|
2144
|
+
}
|
|
2145
|
+
window.addEventListener('pointerdown', onRightPointerDown, true)
|
|
2146
|
+
window.addEventListener('pointerup', onRightPointerUp, true)
|
|
1186
2147
|
window.addEventListener('contextmenu', onContextMenu)
|
|
1187
2148
|
|
|
1188
2149
|
// ---- Bounding box geometry ----
|
|
@@ -1235,14 +2196,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1235
2196
|
emitter.on('wall:move', onWallMove)
|
|
1236
2197
|
emitter.on('wall:click', onWallClick)
|
|
1237
2198
|
emitter.on('wall:leave', onWallLeave)
|
|
2199
|
+
emitter.on('roof:enter', onRoofWallEnter)
|
|
2200
|
+
emitter.on('roof:move', onRoofWallMove)
|
|
2201
|
+
emitter.on('roof:click', onRoofWallClick)
|
|
2202
|
+
emitter.on('roof:leave', onRoofWallLeave)
|
|
1238
2203
|
emitter.on('ceiling:enter', onCeilingEnter)
|
|
1239
2204
|
emitter.on('ceiling:move', onCeilingMove)
|
|
1240
2205
|
emitter.on('ceiling:click', onCeilingClick)
|
|
1241
2206
|
emitter.on('ceiling:leave', onCeilingLeave)
|
|
2207
|
+
emitter.on('shelf:enter', onShelfEnter)
|
|
2208
|
+
emitter.on('shelf:move', onShelfMove)
|
|
2209
|
+
emitter.on('shelf:click', onShelfClick)
|
|
2210
|
+
emitter.on('shelf:leave', onShelfLeave)
|
|
2211
|
+
|
|
2212
|
+
// A floor placement commits at the tracked floor cursor (`gridPosition`),
|
|
2213
|
+
// which keeps following the floor even when the click ray lands on a wall
|
|
2214
|
+
// (grid:move uses a separate ground-plane raycast). Without this, a commit
|
|
2215
|
+
// click whose ray hits a wall fires only `wall:click` — whose handler
|
|
2216
|
+
// declines for a floor item — and the click is silently eaten (the user
|
|
2217
|
+
// has to click again until the ray happens to clear the wall). Route every
|
|
2218
|
+
// surface click to the floor commit too; `floorStrategy.click` guards on
|
|
2219
|
+
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
|
|
2220
|
+
// draft is actually resting on that surface.
|
|
2221
|
+
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
|
|
2222
|
+
if (placementState.current.surface !== 'floor') return
|
|
2223
|
+
onGridClick(event as unknown as GridEvent)
|
|
2224
|
+
}
|
|
2225
|
+
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
|
|
2226
|
+
emitter.on('item:click', commitFloorOnSurfaceClick as never)
|
|
2227
|
+
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2228
|
+
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
|
|
2229
|
+
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
|
|
2230
|
+
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
|
1242
2231
|
|
|
1243
2232
|
return () => {
|
|
1244
2233
|
tearingDown = true
|
|
2234
|
+
if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
|
|
1245
2235
|
unsubDraftWatch()
|
|
2236
|
+
useAlignmentGuides.getState().clear()
|
|
1246
2237
|
// Clear live transform for any remaining draft
|
|
1247
2238
|
if (draftNode.current) {
|
|
1248
2239
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
@@ -1259,16 +2250,41 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1259
2250
|
emitter.off('wall:move', onWallMove)
|
|
1260
2251
|
emitter.off('wall:click', onWallClick)
|
|
1261
2252
|
emitter.off('wall:leave', onWallLeave)
|
|
2253
|
+
emitter.off('roof:enter', onRoofWallEnter)
|
|
2254
|
+
emitter.off('roof:move', onRoofWallMove)
|
|
2255
|
+
emitter.off('roof:click', onRoofWallClick)
|
|
2256
|
+
emitter.off('roof:leave', onRoofWallLeave)
|
|
1262
2257
|
emitter.off('ceiling:enter', onCeilingEnter)
|
|
1263
2258
|
emitter.off('ceiling:move', onCeilingMove)
|
|
1264
2259
|
emitter.off('ceiling:click', onCeilingClick)
|
|
1265
2260
|
emitter.off('ceiling:leave', onCeilingLeave)
|
|
2261
|
+
emitter.off('shelf:enter', onShelfEnter)
|
|
2262
|
+
emitter.off('shelf:move', onShelfMove)
|
|
2263
|
+
emitter.off('shelf:click', onShelfClick)
|
|
2264
|
+
emitter.off('shelf:leave', onShelfLeave)
|
|
2265
|
+
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
|
|
2266
|
+
emitter.off('item:click', commitFloorOnSurfaceClick as never)
|
|
2267
|
+
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2268
|
+
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
|
|
2269
|
+
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
|
|
1266
2270
|
emitter.off('tool:cancel', onCancel)
|
|
1267
2271
|
window.removeEventListener('keydown', onKeyDown)
|
|
1268
2272
|
window.removeEventListener('keyup', onKeyUp)
|
|
2273
|
+
window.removeEventListener('pointerdown', onRightPointerDown, true)
|
|
2274
|
+
window.removeEventListener('pointerup', onRightPointerUp, true)
|
|
1269
2275
|
window.removeEventListener('contextmenu', onContextMenu)
|
|
1270
2276
|
}
|
|
1271
|
-
}, [
|
|
2277
|
+
}, [
|
|
2278
|
+
asset,
|
|
2279
|
+
canPlaceOnFloor,
|
|
2280
|
+
canPlaceOnWall,
|
|
2281
|
+
canPlaceOnCeiling,
|
|
2282
|
+
draftNode,
|
|
2283
|
+
getFloorVisualPosition,
|
|
2284
|
+
gridSnapStep,
|
|
2285
|
+
updateDimensionGuides,
|
|
2286
|
+
updatePreviewGeometry,
|
|
2287
|
+
])
|
|
1272
2288
|
|
|
1273
2289
|
// Refresh wireframe when the grid step changes mid-placement so the green/red
|
|
1274
2290
|
// box snaps to the new cell size right away.
|
|
@@ -1282,7 +2298,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1282
2298
|
)
|
|
1283
2299
|
updatePreviewGeometry(previewBounds)
|
|
1284
2300
|
updateDimensionGuides(previewBounds)
|
|
1285
|
-
}, [gridSnapStep, asset, draftNode])
|
|
2301
|
+
}, [gridSnapStep, asset, draftNode, updateDimensionGuides, updatePreviewGeometry])
|
|
1286
2302
|
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
|
1287
2303
|
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
|
1288
2304
|
useEffect(() => {
|
|
@@ -1290,14 +2306,139 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1290
2306
|
const draft = draftNode.current
|
|
1291
2307
|
if (!(draft && viewerLevelId) || asset.attachTo) return
|
|
1292
2308
|
if (draft.parentId === viewerLevelId) return
|
|
2309
|
+
// A non-attach item resting on a host surface (table / counter / shelf) is
|
|
2310
|
+
// intentionally parented to that host while it's moved — the surface move
|
|
2311
|
+
// handlers keep it hosted and the commit writes the host parent back. Only
|
|
2312
|
+
// free floor items get re-homed to the level here; yanking a hosted item
|
|
2313
|
+
// onto the level would re-interpret its host-local position in level space
|
|
2314
|
+
// and float the dragged mesh off the host toward the building origin.
|
|
2315
|
+
const draftParent = draft.parentId
|
|
2316
|
+
? useScene.getState().nodes[draft.parentId as AnyNodeId]
|
|
2317
|
+
: undefined
|
|
2318
|
+
if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
|
|
1293
2319
|
draft.parentId = viewerLevelId
|
|
1294
2320
|
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
|
1295
2321
|
}, [viewerLevelId, draftNode, asset])
|
|
1296
2322
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
2323
|
+
// Disable raycasting on the live draft mesh (and restore it when the draft
|
|
2324
|
+
// changes or goes away) so the cursor ray passes through the item being
|
|
2325
|
+
// moved and lands on the surface beneath it.
|
|
2326
|
+
const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
|
|
2327
|
+
if (raycastDisabledMeshRef.current !== mesh) {
|
|
2328
|
+
// New draft root (or cleared): restore the prior mesh and reset tracking.
|
|
2329
|
+
for (const restore of restoreRaycastsRef.current) restore()
|
|
2330
|
+
restoreRaycastsRef.current = []
|
|
2331
|
+
raycastDisabledChildrenRef.current = new WeakSet()
|
|
2332
|
+
raycastDisabledMeshRef.current = mesh
|
|
2333
|
+
}
|
|
2334
|
+
if (!mesh) return
|
|
2335
|
+
// Disable any descendant not handled yet. Item drafts are GLB models whose
|
|
2336
|
+
// child meshes mount asynchronously (Suspense), so a one-shot traverse
|
|
2337
|
+
// misses them — those late children keep intercepting the ray and corrupt
|
|
2338
|
+
// the shelf-row hit the moment the item moves onto a row. Re-walking each
|
|
2339
|
+
// frame is cheap: the WeakSet makes it idempotent, so only new children pay.
|
|
2340
|
+
mesh.traverse((child) => {
|
|
2341
|
+
if (raycastDisabledChildrenRef.current.has(child)) return
|
|
2342
|
+
raycastDisabledChildrenRef.current.add(child)
|
|
2343
|
+
const original = child.raycast
|
|
2344
|
+
child.raycast = () => {}
|
|
2345
|
+
restoreRaycastsRef.current.push(() => {
|
|
2346
|
+
child.raycast = original
|
|
2347
|
+
})
|
|
2348
|
+
})
|
|
2349
|
+
}, [])
|
|
2350
|
+
|
|
2351
|
+
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
|
2352
|
+
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
|
2353
|
+
|
|
2354
|
+
// Publish the ghost's surface (contact point + normal) so the grid's snap
|
|
2355
|
+
// patch sits at the item's resolved height (e.g. a shelf top) and orients to
|
|
2356
|
+
// the surface (vertical in a wall plane), AND publish the forward-facing
|
|
2357
|
+
// triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
|
|
2358
|
+
// instead of drawing an inline triangle. Only this coordinator publishes — a
|
|
2359
|
+
// moving existing node has no draft here, so the grid reads that case straight
|
|
2360
|
+
// off the node's mesh. Cleared when idle.
|
|
2361
|
+
const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
|
|
2362
|
+
const facingForwardRef = useRef(new Vector3(0, 0, 1))
|
|
2363
|
+
const facingQuatRef = useRef(new Quaternion())
|
|
2364
|
+
useFrame(() => {
|
|
2365
|
+
const ghost = cursorGroupRef.current
|
|
2366
|
+
if (!(asset && ghost)) {
|
|
2367
|
+
clearPlacementSurface()
|
|
2368
|
+
useFacingPose.getState().clear()
|
|
2369
|
+
return
|
|
2370
|
+
}
|
|
2371
|
+
const surf = placementState.current.surface
|
|
2372
|
+
const n = surfaceNormalRef.current
|
|
2373
|
+
const shape = facingShapeRef.current
|
|
2374
|
+
// Triangle yaw / Y default to the floor case: the cursor group's own yaw is
|
|
2375
|
+
// the item's forward on the floor, and the triangle rides at the ghost's Y.
|
|
2376
|
+
let facingYaw = ghost.rotation.y
|
|
2377
|
+
let facingY = ghost.position.y
|
|
2378
|
+
if (surf === 'wall' || surf === 'roof-wall') {
|
|
2379
|
+
// Wall/roof-segment faces: the cursor group's yaw is the symmetric
|
|
2380
|
+
// wireframe yaw (π off the real facing for a wall, and a different frame
|
|
2381
|
+
// for a roof face), so derive the item's TRUE outward facing from the
|
|
2382
|
+
// draft mesh's world orientation — its local +Z faces out of the host
|
|
2383
|
+
// surface. This keeps BOTH the grid normal and the triangle correct for
|
|
2384
|
+
// wall and roof-segment hosts alike, rather than the old quaternion read
|
|
2385
|
+
// that pointed the wrong way.
|
|
2386
|
+
const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
|
|
2387
|
+
if (mesh) {
|
|
2388
|
+
mesh.getWorldQuaternion(facingQuatRef.current)
|
|
2389
|
+
const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
|
|
2390
|
+
fwd.y = 0
|
|
2391
|
+
if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
|
|
2392
|
+
}
|
|
2393
|
+
// The forward triangle is a floor aid; drop it to the building-local floor
|
|
2394
|
+
// under the wall (the ghost Y is up on the wall).
|
|
2395
|
+
facingY = 0
|
|
2396
|
+
n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
|
|
2397
|
+
} else {
|
|
2398
|
+
n.set(0, 1, 0)
|
|
2399
|
+
}
|
|
2400
|
+
publishPlacementSurface(ghost.position, n)
|
|
2401
|
+
|
|
2402
|
+
if (shape.depth > 0) {
|
|
2403
|
+
useFacingPose.getState().set({
|
|
2404
|
+
position: [ghost.position.x, facingY, ghost.position.z],
|
|
2405
|
+
rotationY: facingYaw,
|
|
2406
|
+
depth: shape.depth,
|
|
2407
|
+
center: shape.center,
|
|
2408
|
+
})
|
|
2409
|
+
} else {
|
|
2410
|
+
useFacingPose.getState().clear()
|
|
2411
|
+
}
|
|
2412
|
+
})
|
|
2413
|
+
useEffect(
|
|
2414
|
+
() => () => {
|
|
2415
|
+
clearPlacementSurface()
|
|
2416
|
+
useFacingPose.getState().clear()
|
|
2417
|
+
},
|
|
2418
|
+
[],
|
|
2419
|
+
)
|
|
2420
|
+
|
|
2421
|
+
useFrame(() => {
|
|
2422
|
+
if (!asset) {
|
|
2423
|
+
reconcileDraftRaycast(null)
|
|
2424
|
+
return
|
|
2425
|
+
}
|
|
2426
|
+
if (!draftNode.current) {
|
|
2427
|
+
reconcileDraftRaycast(null)
|
|
2428
|
+
return
|
|
2429
|
+
}
|
|
2430
|
+
const mesh = sceneRegistry.nodes.get(draftNode.current.id) ?? null
|
|
2431
|
+
reconcileDraftRaycast(mesh)
|
|
2432
|
+
// mitt listeners outlive the cursor group's mount; bail if it's gone
|
|
2433
|
+
// (mount/teardown race, #323). Placed after reconcileDraftRaycast so the
|
|
2434
|
+
// draft's raycast is still restored during that window.
|
|
2435
|
+
if (!cursorGroupRef.current) return
|
|
2436
|
+
// The mesh-position lerp below only makes sense once this coordinator
|
|
2437
|
+
// owns the move via a 3D pointer event. Skip until then so that
|
|
2438
|
+
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
|
|
2439
|
+
// writing scene.position directly) aren't fought by useFrame pulling
|
|
2440
|
+
// the mesh back to its pre-move location.
|
|
2441
|
+
if (!has3DPointerDrivenMoveRef.current) return
|
|
1301
2442
|
if (!mesh) return
|
|
1302
2443
|
|
|
1303
2444
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
|
@@ -1308,27 +2449,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1308
2449
|
mesh.visible = true
|
|
1309
2450
|
|
|
1310
2451
|
if (placementState.current.surface === 'floor') {
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
2452
|
+
// Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
|
|
2453
|
+
// active move visibly trail the cursor and — combined with React
|
|
2454
|
+
// re-renders momentarily pulling the mesh back toward its committed
|
|
2455
|
+
// position — read as a laggy snap-back on every move. Copying each frame
|
|
2456
|
+
// locks placement/move to the cursor and overrides any stray reset
|
|
2457
|
+
// within a single frame, so it feels precise instead of dragging.
|
|
2458
|
+
mesh.position.copy(gridPosition.current)
|
|
1317
2459
|
|
|
1318
2460
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
|
1319
2461
|
if (!asset.attachTo) {
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
draftNode.current.asset.attachTo,
|
|
1328
|
-
),
|
|
1329
|
-
draftNode.current.rotation,
|
|
1330
|
-
)
|
|
1331
|
-
mesh.position.y = slabElevation
|
|
2462
|
+
const visualPosition = getFloorVisualPosition([
|
|
2463
|
+
gridPosition.current.x,
|
|
2464
|
+
gridPosition.current.y,
|
|
2465
|
+
gridPosition.current.z,
|
|
2466
|
+
])
|
|
2467
|
+
mesh.position.y = visualPosition[1]
|
|
2468
|
+
cursorGroupRef.current.position.y = visualPosition[1]
|
|
1332
2469
|
}
|
|
1333
2470
|
}
|
|
1334
2471
|
})
|
|
@@ -1339,9 +2476,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1339
2476
|
? getScaledDimensions(initialDraft)
|
|
1340
2477
|
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
1341
2478
|
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
|
1342
|
-
const wallSideZOffset = initialAttachTo === 'wall-side' ?
|
|
2479
|
+
const wallSideZOffset = initialAttachTo === 'wall-side' ? dims[2] / 2 : 0
|
|
1343
2480
|
const initialDimensionBounds = expandBoundsToGrid(
|
|
1344
|
-
getFallbackPreviewBounds(initialDraft, config.asset
|
|
2481
|
+
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
|
1345
2482
|
initialAttachTo,
|
|
1346
2483
|
gridSnapStep,
|
|
1347
2484
|
)
|
|
@@ -1354,6 +2491,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1354
2491
|
initialDimensionBounds.dimensions[0],
|
|
1355
2492
|
initialDimensionBounds.dimensions[1],
|
|
1356
2493
|
initialDimensionBounds.dimensions[2],
|
|
2494
|
+
initialDimensionBounds,
|
|
1357
2495
|
],
|
|
1358
2496
|
)
|
|
1359
2497
|
const basePlaneGeometry = useMemo(() => {
|
|
@@ -1366,9 +2504,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1366
2504
|
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1367
2505
|
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1368
2506
|
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
2507
|
+
// Feed the footprint shape to the per-frame surface publisher, which orients
|
|
2508
|
+
// and positions the forward-facing triangle via `useFacingPose`.
|
|
2509
|
+
facingShapeRef.current = {
|
|
2510
|
+
depth: currentDimensionBounds.dimensions[2],
|
|
2511
|
+
center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
|
|
2512
|
+
}
|
|
2513
|
+
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
|
|
2514
|
+
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
|
|
2515
|
+
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
|
|
1372
2516
|
const widthLabelPosition: [number, number, number] = [
|
|
1373
2517
|
currentDimensionBounds.center[0],
|
|
1374
2518
|
0.04,
|
|
@@ -1384,7 +2528,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1384
2528
|
currentDimensionBounds.dimensions[1] / 2,
|
|
1385
2529
|
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
|
|
1386
2530
|
]
|
|
1387
|
-
|
|
1388
2531
|
const measurementContent = (
|
|
1389
2532
|
<>
|
|
1390
2533
|
<lineSegments
|