@pascal-app/editor 0.9.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +49 -0
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +332 -199
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
- package/src/components/editor/first-person/build-collider-world.ts +52 -5
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +410 -136
- package/src/components/editor/floating-action-menu.tsx +435 -64
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +72 -0
- package/src/components/editor/floorplan-background-selection.ts +7 -20
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +2517 -1492
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +597 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +49 -6
- package/src/components/editor/index.tsx +289 -35
- package/src/components/editor/measurement-pill.tsx +68 -23
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +409 -180
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +76 -0
- package/src/components/editor/quick-measurement-hud.tsx +34 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +814 -466
- package/src/components/editor/site-edge-labels.tsx +28 -6
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +118 -253
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
- package/src/components/editor/wall-measurement-label.tsx +10 -28
- package/src/components/editor/wall-move-side-handles.tsx +229 -39
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +107 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +146 -57
- package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
- package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
- package/src/components/tools/roof/roof-tool.tsx +336 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +101 -42
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +164 -42
- package/src/components/tools/tool-manager.tsx +149 -30
- package/src/components/tools/wall/wall-drafting.test.ts +447 -0
- package/src/components/tools/wall/wall-drafting.ts +304 -108
- package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +98 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +44 -94
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +267 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +16 -3
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +93 -18
- package/src/components/ui/controls/material-picker.tsx +162 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +74 -20
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +273 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +36 -14
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +19 -4
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +36 -596
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +438 -70
- package/src/index.tsx +355 -9
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +42 -0
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +275 -0
- package/src/lib/direct-manipulation.ts +118 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +978 -0
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +52 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +283 -3
- package/src/lib/fresh-planar-placement.ts +85 -1
- package/src/lib/glb-export.test.ts +556 -0
- package/src/lib/glb-export.ts +1071 -0
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +31 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +52 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +243 -0
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +188 -0
- package/src/lib/measurements.ts +210 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +325 -0
- package/src/lib/scene-clipboard.ts +310 -21
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +313 -0
- package/src/lib/selection-routing.ts +180 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +82 -14
- package/src/lib/sfx-player.test.ts +266 -0
- package/src/lib/sfx-player.ts +262 -69
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +165 -0
- package/src/lib/snapping-mode.ts +191 -0
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +58 -86
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +665 -126
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +51 -0
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +151 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +206 -0
- package/src/store/use-interaction-scope.ts +140 -0
- package/src/store/use-measurement-draft.test.ts +559 -0
- package/src/store/use-measurement-draft.ts +570 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +53 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/floorplan/selection-tool.ts +0 -271
- package/src/lib/level-name.ts +0 -11
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getScaledDimensions,
|
|
11
11
|
type ItemEvent,
|
|
12
12
|
movingFootprintAnchors,
|
|
13
|
+
type RoofEvent,
|
|
13
14
|
resolveLevelId,
|
|
14
15
|
type ShelfEvent,
|
|
15
16
|
sceneRegistry,
|
|
@@ -19,7 +20,6 @@ import {
|
|
|
19
20
|
type WallEvent,
|
|
20
21
|
type WallNode,
|
|
21
22
|
} from '@pascal-app/core'
|
|
22
|
-
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
23
23
|
import { useViewer } from '@pascal-app/viewer'
|
|
24
24
|
import { Html } from '@react-three/drei'
|
|
25
25
|
import { useFrame, useThree } from '@react-three/fiber'
|
|
@@ -39,10 +39,22 @@ import {
|
|
|
39
39
|
} from 'three'
|
|
40
40
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
41
41
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
42
|
+
import {
|
|
43
|
+
clearPlacementSurface,
|
|
44
|
+
publishPlacementSurface,
|
|
45
|
+
} from '../../../lib/active-placement-surface'
|
|
42
46
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
47
|
+
import { formatLinearMeasurement } from '../../../lib/measurements'
|
|
43
48
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
44
|
-
|
|
45
|
-
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'
|
|
46
58
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
47
59
|
import {
|
|
48
60
|
createLineGeometry,
|
|
@@ -50,12 +62,24 @@ import {
|
|
|
50
62
|
type PreviewBounds,
|
|
51
63
|
updateLineGeometry,
|
|
52
64
|
} from '../shared/placement-box-geometry'
|
|
53
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
resolvePointerSupportElevation,
|
|
67
|
+
resolvePointerSupportSurface,
|
|
68
|
+
} from '../shared/pointer-support-cap'
|
|
69
|
+
import {
|
|
70
|
+
getDetachedAttachmentPreviewLift,
|
|
71
|
+
getGridAlignedDimensions,
|
|
72
|
+
snapToGrid,
|
|
73
|
+
snapToHalf,
|
|
74
|
+
snapUpToGridStep,
|
|
75
|
+
steppedRotation,
|
|
76
|
+
} from './placement-math'
|
|
54
77
|
import {
|
|
55
78
|
ceilingStrategy,
|
|
56
79
|
checkCanPlace,
|
|
57
80
|
floorStrategy,
|
|
58
81
|
itemSurfaceStrategy,
|
|
82
|
+
roofWallStrategy,
|
|
59
83
|
shelfSurfaceStrategy,
|
|
60
84
|
wallStrategy,
|
|
61
85
|
} from './placement-strategies'
|
|
@@ -68,23 +92,20 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
|
68
92
|
* floor-plan overlay and the 3D registry move tool. */
|
|
69
93
|
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
70
94
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return `${wholeFeet}'${inches}"`
|
|
78
|
-
}
|
|
79
|
-
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
80
|
-
}
|
|
95
|
+
/** Right-click cancels an active placement — but the right button also orbits
|
|
96
|
+
* the camera (CameraControls ROTATE). Only a quick, near-stationary right
|
|
97
|
+
* press/release counts as a cancel; anything that moves past the pixel
|
|
98
|
+
* threshold or is held longer is treated as a camera orbit and left alone. */
|
|
99
|
+
const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
|
|
100
|
+
const RIGHT_CLICK_CANCEL_MAX_MS = 200
|
|
81
101
|
|
|
82
102
|
/**
|
|
83
103
|
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
|
84
104
|
* The wireframe stays centered on the original bounds centre on each axis we
|
|
85
105
|
* expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
106
|
+
* `min.z = 0` (the mounted face flush with the wall plane) and extend into the
|
|
107
|
+
* room along +Z — matching the body and the 2D footprint; the bottom (`min.y`)
|
|
108
|
+
* is preserved so the box still sits on the floor / attachment plane.
|
|
88
109
|
*
|
|
89
110
|
* Floor / ceiling / item-surface: X and Z expand; Y stays exact.
|
|
90
111
|
* Wall / wall-side: X and Y expand; Z stays exact.
|
|
@@ -110,9 +131,9 @@ function expandBoundsToGrid(
|
|
|
110
131
|
let maxZ: number
|
|
111
132
|
let newCz: number
|
|
112
133
|
if (attachTo === 'wall-side') {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
newCz =
|
|
134
|
+
minZ = 0
|
|
135
|
+
maxZ = expandedD
|
|
136
|
+
newCz = expandedD / 2
|
|
116
137
|
} else {
|
|
117
138
|
minZ = cz - expandedD / 2
|
|
118
139
|
maxZ = cz + expandedD / 2
|
|
@@ -134,10 +155,10 @@ function getFallbackPreviewBounds(
|
|
|
134
155
|
): PreviewBounds {
|
|
135
156
|
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
136
157
|
return {
|
|
137
|
-
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ?
|
|
138
|
-
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ?
|
|
158
|
+
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
|
|
159
|
+
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
|
|
139
160
|
dimensions: dims,
|
|
140
|
-
center: [0, dims[1] / 2, attachTo === 'wall-side' ?
|
|
161
|
+
center: [0, dims[1] / 2, attachTo === 'wall-side' ? dims[2] / 2 : 0],
|
|
141
162
|
}
|
|
142
163
|
}
|
|
143
164
|
|
|
@@ -195,8 +216,13 @@ export interface PlacementCoordinatorConfig {
|
|
|
195
216
|
initialState?: PlacementState
|
|
196
217
|
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
|
197
218
|
defaultScale?: [number, number, number]
|
|
198
|
-
/**
|
|
199
|
-
|
|
219
|
+
/** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling
|
|
220
|
+
* duplicates) so the duplicate keeps its materials. */
|
|
221
|
+
slots?: ItemNode['slots']
|
|
222
|
+
/** Move-mode sessions keep the grabbed item offset from the first surface hit
|
|
223
|
+
* (floor / wall / ceiling / item-surface / shelf) instead of snapping the
|
|
224
|
+
* item's origin under the cursor. */
|
|
225
|
+
preserveDragOffset?: boolean
|
|
200
226
|
}
|
|
201
227
|
|
|
202
228
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
|
@@ -213,13 +239,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
213
239
|
config.initialState ?? {
|
|
214
240
|
surface: 'floor',
|
|
215
241
|
wallId: null,
|
|
242
|
+
roofSegmentId: null,
|
|
216
243
|
ceilingId: null,
|
|
217
244
|
surfaceItemId: null,
|
|
218
245
|
shelfId: null,
|
|
219
246
|
},
|
|
220
247
|
)
|
|
221
|
-
const
|
|
248
|
+
const altFreeRef = useRef(false)
|
|
222
249
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
|
250
|
+
// Footprint shape (depth along local +Z and [x, z] centre) of the current
|
|
251
|
+
// preview box, mirrored from the rendered dimension bounds so the per-frame
|
|
252
|
+
// surface publisher can position the forward-facing triangle without reading
|
|
253
|
+
// React state. Updated in the render body below.
|
|
254
|
+
const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
|
|
255
|
+
depth: 0,
|
|
256
|
+
center: [0, 0],
|
|
257
|
+
})
|
|
223
258
|
// Goes true the first time a 3D pointer event drives this coordinator.
|
|
224
259
|
// The per-frame mesh-position lerp below is only useful for that path;
|
|
225
260
|
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
|
|
@@ -238,6 +273,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
238
273
|
const raycastDisabledMeshRef = useRef<Object3D | null>(null)
|
|
239
274
|
const restoreRaycastsRef = useRef<Array<() => void>>([])
|
|
240
275
|
const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
|
|
276
|
+
// Level-local elevation of the surface the pointer ray actually points
|
|
277
|
+
// at (deck top, floor slab, or ground), refreshed on every grid move.
|
|
278
|
+
// Threaded into the floor-support election as its cap so the POINTER
|
|
279
|
+
// decides the target surface — without it the election lifts the ghost
|
|
280
|
+
// by the MAX overlapping slab and a deck above the aimed-at floor
|
|
281
|
+
// captures the item (and the grid-plane feedback makes it blink).
|
|
282
|
+
const pointerSupportCapRef = useRef<number | null>(null)
|
|
241
283
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
242
284
|
|
|
243
285
|
// Live camera ref — the shelf-stickiness test reconstructs the cursor world
|
|
@@ -389,6 +431,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
389
431
|
node: previewNode,
|
|
390
432
|
position,
|
|
391
433
|
rotation: previewNode.rotation,
|
|
434
|
+
maxElevation: pointerSupportCapRef.current,
|
|
392
435
|
})
|
|
393
436
|
},
|
|
394
437
|
[asset?.attachTo, draftNode],
|
|
@@ -413,10 +456,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
413
456
|
placementState.current = configRef.current.initialState ?? {
|
|
414
457
|
surface: 'floor',
|
|
415
458
|
wallId: null,
|
|
459
|
+
roofSegmentId: null,
|
|
416
460
|
ceilingId: null,
|
|
417
461
|
surfaceItemId: null,
|
|
418
462
|
shelfId: null,
|
|
419
463
|
}
|
|
464
|
+
// No pointer surface known yet — fall back to the uncapped election
|
|
465
|
+
// (an adopted move draft keeps its persisted host until the first move).
|
|
466
|
+
pointerSupportCapRef.current = null
|
|
420
467
|
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
|
421
468
|
gridPosition.current.y = 0
|
|
422
469
|
if (cursorGroupRef.current) {
|
|
@@ -437,7 +484,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
437
484
|
})
|
|
438
485
|
|
|
439
486
|
const getActiveValidators = () =>
|
|
440
|
-
|
|
487
|
+
altFreeRef.current
|
|
441
488
|
? {
|
|
442
489
|
canPlaceOnFloor: () => ({ valid: true }),
|
|
443
490
|
canPlaceOnWall: () => ({ valid: true }),
|
|
@@ -445,8 +492,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
445
492
|
}
|
|
446
493
|
: validators
|
|
447
494
|
|
|
495
|
+
const finishCommittedPlacement = (
|
|
496
|
+
committedId: string | null,
|
|
497
|
+
wasAdopted: boolean,
|
|
498
|
+
repeat: () => void,
|
|
499
|
+
) => {
|
|
500
|
+
if (configRef.current.onCommitted()) {
|
|
501
|
+
repeat()
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
useAlignmentGuides.getState().clear()
|
|
506
|
+
useScene.temporal.getState().resume()
|
|
507
|
+
if (committedId) {
|
|
508
|
+
useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
|
|
509
|
+
}
|
|
510
|
+
if (!wasAdopted) {
|
|
511
|
+
useEditor.getState().setTool(null)
|
|
512
|
+
}
|
|
513
|
+
// A non-repeating placement is finished: return to select mode so the user
|
|
514
|
+
// lands on the just-placed (now selected) node instead of a tool-less build
|
|
515
|
+
// limbo. Repeat placements took the early return above and stay armed.
|
|
516
|
+
useEditor.getState().setMode('select')
|
|
517
|
+
}
|
|
518
|
+
|
|
448
519
|
const revalidate = (): boolean => {
|
|
449
|
-
const placeable =
|
|
520
|
+
const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
|
|
450
521
|
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
|
451
522
|
edgeMaterial.color.setHex(color)
|
|
452
523
|
basePlaneMaterial.color.setHex(color)
|
|
@@ -462,10 +533,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
462
533
|
return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
463
534
|
}
|
|
464
535
|
|
|
536
|
+
const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
|
|
537
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
538
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
539
|
+
return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
540
|
+
}
|
|
541
|
+
|
|
465
542
|
const applyTransition = (result: TransitionResult) => {
|
|
466
543
|
// Alignment guides are floor-only; clear them when the cursor moves
|
|
467
544
|
// onto a wall / ceiling / item surface (only those paths call this).
|
|
468
545
|
useAlignmentGuides.getState().clear()
|
|
546
|
+
// Roof faces carry no grab anchor, but landing on one still counts as
|
|
547
|
+
// anchoring elsewhere — a later return to the grabbed wall must center
|
|
548
|
+
// under the cursor, not restore the stale grab offset.
|
|
549
|
+
if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
|
|
469
550
|
Object.assign(placementState.current, result.stateUpdate)
|
|
470
551
|
gridPosition.current.set(...result.gridPosition)
|
|
471
552
|
|
|
@@ -505,7 +586,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
505
586
|
0,
|
|
506
587
|
]
|
|
507
588
|
|
|
508
|
-
draftNode.create(
|
|
589
|
+
draftNode.create(
|
|
590
|
+
gridPosition.current,
|
|
591
|
+
asset,
|
|
592
|
+
initRotation,
|
|
593
|
+
configRef.current.defaultScale,
|
|
594
|
+
configRef.current.slots,
|
|
595
|
+
)
|
|
509
596
|
|
|
510
597
|
const draft = draftNode.current
|
|
511
598
|
if (draft) {
|
|
@@ -529,11 +616,134 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
529
616
|
|
|
530
617
|
// ---- Init draft ----
|
|
531
618
|
configRef.current.initDraft(gridPosition.current)
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
619
|
+
const preserveDragOffset = configRef.current.preserveDragOffset === true
|
|
620
|
+
// The host the item was grabbed from + its pre-drag host-local position.
|
|
621
|
+
// Each surface's grab anchor preserves the grab offset only on THAT host,
|
|
622
|
+
// and only until the item anchors on ANY other host (another wall/ceiling/
|
|
623
|
+
// shelf, a roof face, or the floor after a host visit) — `grabForgotten`
|
|
624
|
+
// then latches and every host, the original included, centers the item
|
|
625
|
+
// under the cursor. Merely passing through empty space (wall/ceiling items
|
|
626
|
+
// hide between surfaces) does NOT forget the grab.
|
|
627
|
+
const grabWallId =
|
|
628
|
+
placementState.current.surface === 'wall' ? placementState.current.wallId : null
|
|
629
|
+
const grabCeilingId =
|
|
630
|
+
placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null
|
|
631
|
+
const grabSurfaceHostId =
|
|
632
|
+
placementState.current.surface === 'item-surface'
|
|
633
|
+
? placementState.current.surfaceItemId
|
|
634
|
+
: placementState.current.surface === 'shelf-surface'
|
|
635
|
+
? placementState.current.shelfId
|
|
636
|
+
: null
|
|
637
|
+
const grabStartPosition: [number, number, number] | null = draftNode.current
|
|
638
|
+
? [
|
|
639
|
+
draftNode.current.position[0],
|
|
640
|
+
draftNode.current.position[1],
|
|
641
|
+
draftNode.current.position[2],
|
|
642
|
+
]
|
|
643
|
+
: null
|
|
644
|
+
let grabForgotten = grabStartPosition === null
|
|
645
|
+
// Anchoring on `hostId`: returns whether the grab offset still applies
|
|
646
|
+
// there, and latches `grabForgotten` the first time it doesn't.
|
|
647
|
+
const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => {
|
|
648
|
+
const preserve = !grabForgotten && hostId !== null && hostId === grabHostId
|
|
649
|
+
if (!preserve) grabForgotten = true
|
|
650
|
+
return preserve
|
|
651
|
+
}
|
|
652
|
+
// Nulled when the item returns to the floor FROM a host (see
|
|
653
|
+
// `detachItemSurfaceToFloor`): the floor grab offset only survives until
|
|
654
|
+
// the item anchors elsewhere, like every other surface's grab anchor.
|
|
655
|
+
let relativeFloorStart =
|
|
656
|
+
preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
|
|
657
|
+
? gridPosition.current.clone()
|
|
658
|
+
: null
|
|
659
|
+
|
|
660
|
+
// Grab anchors for the non-floor surfaces. Each captures the cursor's
|
|
661
|
+
// surface-local position and the item's stored position on the first move
|
|
662
|
+
// for a given host, then offsets every later move by
|
|
663
|
+
// `start + (raw - anchor)` — mirroring the floor path and the door/window
|
|
664
|
+
// move tools so the item tracks the grabbed point instead of teleporting
|
|
665
|
+
// its origin under the cursor. Reset on host change (re-seeded from the
|
|
666
|
+
// item's then-current position) by the surface leave handlers.
|
|
667
|
+
let wallDragAnchor: {
|
|
668
|
+
wallId: string
|
|
669
|
+
rawX: number
|
|
670
|
+
rawY: number
|
|
671
|
+
startX: number
|
|
672
|
+
startY: number
|
|
673
|
+
} | null = null
|
|
674
|
+
let ceilingDragAnchor: {
|
|
675
|
+
ceilingId: string
|
|
676
|
+
rawX: number
|
|
677
|
+
rawZ: number
|
|
678
|
+
startX: number
|
|
679
|
+
startZ: number
|
|
680
|
+
} | null = null
|
|
681
|
+
let hostSurfaceDragAnchor: {
|
|
682
|
+
hostId: string
|
|
683
|
+
rawX: number
|
|
684
|
+
rawZ: number
|
|
685
|
+
startX: number
|
|
686
|
+
startZ: number
|
|
687
|
+
} | null = null
|
|
688
|
+
|
|
689
|
+
// Item-surface / shelf moves snap from a WORLD cursor hit projected into the
|
|
690
|
+
// host's local frame. Re-project the offset-corrected local point back to
|
|
691
|
+
// world so the strategy (which re-derives both the stored position and the
|
|
692
|
+
// visual cursor from `event.position`) stays self-consistent.
|
|
693
|
+
const resolveHostSurfaceWorld = (
|
|
694
|
+
hostId: string,
|
|
695
|
+
worldPos: readonly [number, number, number],
|
|
696
|
+
): [number, number, number] | null => {
|
|
697
|
+
const draft = draftNode.current
|
|
698
|
+
const hostMesh = sceneRegistry.nodes.get(hostId)
|
|
699
|
+
if (!(preserveDragOffset && draft && hostMesh)) return null
|
|
700
|
+
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
|
|
701
|
+
if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
|
|
702
|
+
// Grab offset survives only on the host the item was grabbed from and
|
|
703
|
+
// only until it anchors elsewhere — any other shelf/table centers the
|
|
704
|
+
// item under the cursor (same rule as the wall grab anchor).
|
|
705
|
+
const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId)
|
|
706
|
+
hostSurfaceDragAnchor = {
|
|
707
|
+
hostId,
|
|
708
|
+
rawX: rawLocal.x,
|
|
709
|
+
rawZ: rawLocal.z,
|
|
710
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
|
|
711
|
+
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
|
|
715
|
+
const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
|
|
716
|
+
const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
|
|
717
|
+
return [world.x, world.y, world.z]
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Floor grab-offset: the item tracks the grabbed point instead of snapping
|
|
721
|
+
// its origin under the cursor. The offset is computed in building-local space
|
|
722
|
+
// (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
|
|
723
|
+
// (`event.position`), so the corrected local point is re-projected to a
|
|
724
|
+
// corrected world point and both frames carry the offset to stay consistent.
|
|
725
|
+
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
|
|
726
|
+
if (relativeFloorStart === null) return event
|
|
727
|
+
const rawX = event.localPosition[0]
|
|
728
|
+
const rawZ = event.localPosition[2]
|
|
729
|
+
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
|
730
|
+
floorDragAnchor = anchor
|
|
731
|
+
const correctedLocal: [number, number, number] = [
|
|
732
|
+
relativeFloorStart.x + (rawX - anchor[0]),
|
|
733
|
+
event.localPosition[1],
|
|
734
|
+
relativeFloorStart.z + (rawZ - anchor[1]),
|
|
735
|
+
]
|
|
736
|
+
const correctedWorld = buildingLocalToWorld(
|
|
737
|
+
correctedLocal[0],
|
|
738
|
+
correctedLocal[1],
|
|
739
|
+
correctedLocal[2],
|
|
740
|
+
)
|
|
741
|
+
return {
|
|
742
|
+
...event,
|
|
743
|
+
position: [correctedWorld.x, event.position[1], correctedWorld.z],
|
|
744
|
+
localPosition: correctedLocal,
|
|
745
|
+
}
|
|
746
|
+
}
|
|
537
747
|
|
|
538
748
|
// Sync cursor to the draft mesh's world position and rotation
|
|
539
749
|
if (draftNode.current) {
|
|
@@ -544,8 +754,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
544
754
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
|
545
755
|
if (cursorGroupRef.current) {
|
|
546
756
|
cursorGroupRef.current.position.copy(localPos)
|
|
547
|
-
if (
|
|
548
|
-
|
|
757
|
+
if (
|
|
758
|
+
draftNode.current.asset.attachTo ||
|
|
759
|
+
placementState.current?.surface === 'item-surface'
|
|
760
|
+
) {
|
|
761
|
+
// Wall/ceiling items AND items hosted on another item: the mesh is parented
|
|
762
|
+
// to a rotated host, so the box's building-local yaw must come from the mesh's
|
|
763
|
+
// world rotation, not the node's host-local `rotation[1]` (which would leave the
|
|
764
|
+
// box rotated by the host's yaw relative to the item).
|
|
549
765
|
const q = new Quaternion()
|
|
550
766
|
mesh.getWorldQuaternion(q)
|
|
551
767
|
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
|
@@ -647,6 +863,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
647
863
|
|
|
648
864
|
has3DPointerDrivenMoveRef.current = true
|
|
649
865
|
|
|
866
|
+
// The pointer decides the target surface AND the floor point: cap
|
|
867
|
+
// the floor-support election at the elevation of the surface the
|
|
868
|
+
// camera ray actually hits, and re-aim the event at the ray's
|
|
869
|
+
// crossing of that surface's plane. The grid event's own hit can't
|
|
870
|
+
// be used directly — its plane rides at the ghost's last height, so
|
|
871
|
+
// its Y is a feedback loop (the under-deck blink) and its XZ is
|
|
872
|
+
// perspective-skewed along the ray whenever the plane sits on a
|
|
873
|
+
// different storey than the pointed surface (the skew is what made
|
|
874
|
+
// a drag over a deck-above-a-floor hop between the two surfaces).
|
|
875
|
+
const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
|
|
876
|
+
pointerSupportCapRef.current = pointed?.elevation ?? null
|
|
877
|
+
const surfaceEvent: GridEvent =
|
|
878
|
+
pointed?.worldPoint && pointed.localPoint
|
|
879
|
+
? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
|
|
880
|
+
: event
|
|
881
|
+
|
|
650
882
|
// Shelf stickiness: while hosting on a shelf, ignore floor events while
|
|
651
883
|
// the cursor ray still points at the shelf volume (the ray merely slipped
|
|
652
884
|
// off a board / through a gap and hit the floor behind). Detach to the
|
|
@@ -654,26 +886,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
654
886
|
// item oscillates between the shelf row and the floor on every micro-move.
|
|
655
887
|
if (placementState.current.surface === 'shelf-surface') {
|
|
656
888
|
if (cursorRayIntersectsActiveShelf(event.position)) return
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const rawX = event.localPosition[0]
|
|
664
|
-
const rawZ = event.localPosition[2]
|
|
665
|
-
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
|
666
|
-
floorDragAnchor = anchor
|
|
667
|
-
return {
|
|
668
|
-
...event,
|
|
669
|
-
localPosition: [
|
|
670
|
-
relativeFloorStart.x + (rawX - anchor[0]),
|
|
671
|
-
event.localPosition[1],
|
|
672
|
-
relativeFloorStart.z + (rawZ - anchor[1]),
|
|
673
|
-
] as [number, number, number],
|
|
674
|
-
}
|
|
675
|
-
})()
|
|
676
|
-
: event
|
|
889
|
+
// Land at the pointed surface's plan point — the raw grid hit is
|
|
890
|
+
// still skewed by the plane riding at the shelf-surface height.
|
|
891
|
+
detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const floorEvent = applyFloorGrabOffset(surfaceEvent)
|
|
677
895
|
|
|
678
896
|
lastRawPos.current.set(
|
|
679
897
|
floorEvent.localPosition[0],
|
|
@@ -689,12 +907,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
689
907
|
// item's edge, snap and publish a guide. The guide connects to the
|
|
690
908
|
// nearest real corner of the candidate (resolver tie-break), so the dot
|
|
691
909
|
// always sits on an actual point. The delta is applied to BOTH the grid
|
|
692
|
-
// and cursor positions below. Alt
|
|
910
|
+
// and cursor positions below. Alt is force-place only (it does NOT bypass
|
|
911
|
+
// snapping — 'off' mode is the no-snap bypass); the active snapping mode
|
|
912
|
+
// governs whether alignment runs at all ('off' / 'angles' disable
|
|
913
|
+
// magnetic alignment, 'lines' enables it, matching the wall/fence flow).
|
|
693
914
|
const draft = draftNode.current
|
|
694
915
|
let alignX = 0
|
|
695
916
|
let alignZ = 0
|
|
696
|
-
|
|
697
|
-
|
|
917
|
+
// Alignment "lines" are DISPLAYED in every snapping mode except Off
|
|
918
|
+
// (isAlignmentGuideActive); the magnetic pull toward them is applied only
|
|
919
|
+
// in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
|
|
920
|
+
// bypass.
|
|
921
|
+
if (isAlignmentGuideActive() && draft) {
|
|
698
922
|
alignmentCandidates ??= collectAlignmentAnchors(
|
|
699
923
|
useScene.getState().nodes,
|
|
700
924
|
draft.id,
|
|
@@ -710,11 +934,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
710
934
|
candidates: alignmentCandidates,
|
|
711
935
|
threshold: ALIGNMENT_THRESHOLD_M,
|
|
712
936
|
})
|
|
713
|
-
if (ar.snap) {
|
|
937
|
+
if (ar.snap && isMagneticSnapActive()) {
|
|
714
938
|
alignX = ar.snap.dx
|
|
715
939
|
alignZ = ar.snap.dz
|
|
716
940
|
}
|
|
717
|
-
useAlignmentGuides
|
|
941
|
+
useAlignmentGuides
|
|
942
|
+
.getState()
|
|
943
|
+
.set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
|
|
718
944
|
} else {
|
|
719
945
|
useAlignmentGuides.getState().clear()
|
|
720
946
|
}
|
|
@@ -736,6 +962,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
736
962
|
previousGridPos = [...gridPos]
|
|
737
963
|
gridPosition.current.set(...gridPos)
|
|
738
964
|
const cursorPosition = getFloorVisualPosition(gridPos)
|
|
965
|
+
if (!draft && asset.attachTo) {
|
|
966
|
+
cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
|
|
967
|
+
}
|
|
739
968
|
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
|
|
740
969
|
// Floor items only rotate on Y; keep the preview box (and the live
|
|
741
970
|
// transform the 2D floorplan mirrors) aligned with the draft's
|
|
@@ -745,11 +974,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
745
974
|
|
|
746
975
|
if (draft) draft.position = gridPos
|
|
747
976
|
|
|
748
|
-
// Publish live transform for 2D floorplan
|
|
977
|
+
// Publish live transform for 2D floorplan (and the pointer surface
|
|
978
|
+
// cap, so FloorElevationSystem's per-frame Y agrees with the ghost).
|
|
749
979
|
if (draft) {
|
|
750
980
|
useLiveTransforms.getState().set(draft.id, {
|
|
751
981
|
position: gridPos,
|
|
752
982
|
rotation: cursorGroupRef.current.rotation.y,
|
|
983
|
+
supportElevationCap: pointerSupportCapRef.current ?? undefined,
|
|
753
984
|
})
|
|
754
985
|
}
|
|
755
986
|
|
|
@@ -775,9 +1006,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
775
1006
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
776
1007
|
}
|
|
777
1008
|
|
|
778
|
-
draftNode.
|
|
779
|
-
|
|
780
|
-
|
|
1009
|
+
const committedId = draftNode.current?.id ?? null
|
|
1010
|
+
const wasAdopted = draftNode.isAdopted
|
|
1011
|
+
// Carry the pointer surface cap into the commit so the persisted
|
|
1012
|
+
// supportSlabId reproduces the capped election (elects the aimed-at
|
|
1013
|
+
// lower slab — or the ground — instead of a deck hanging above).
|
|
1014
|
+
const finalId = draftNode.commit(result.nodeUpdate, {
|
|
1015
|
+
supportElevationCap: pointerSupportCapRef.current,
|
|
1016
|
+
})
|
|
1017
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1018
|
+
draftNode.create(
|
|
1019
|
+
gridPosition.current,
|
|
1020
|
+
asset,
|
|
1021
|
+
currentRotation,
|
|
1022
|
+
configRef.current.defaultScale,
|
|
1023
|
+
configRef.current.slots,
|
|
1024
|
+
)
|
|
781
1025
|
const previewBounds = expandBoundsToGrid(
|
|
782
1026
|
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
|
783
1027
|
asset.attachTo,
|
|
@@ -786,7 +1030,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
786
1030
|
updatePreviewGeometry(previewBounds)
|
|
787
1031
|
updateDimensionGuides(previewBounds)
|
|
788
1032
|
revalidate()
|
|
789
|
-
}
|
|
1033
|
+
})
|
|
790
1034
|
}
|
|
791
1035
|
|
|
792
1036
|
// ---- Wall Handlers ----
|
|
@@ -861,7 +1105,46 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
861
1105
|
return
|
|
862
1106
|
}
|
|
863
1107
|
|
|
864
|
-
|
|
1108
|
+
let wallMoveEvent = event
|
|
1109
|
+
if (preserveDragOffset && draftNode.current) {
|
|
1110
|
+
const rawX = event.localPosition[0]
|
|
1111
|
+
const rawY = event.localPosition[1]
|
|
1112
|
+
if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
|
|
1113
|
+
// Preserve the grab offset only on the wall the item was grabbed
|
|
1114
|
+
// from (no teleport under the pointer at grab time). Any OTHER host
|
|
1115
|
+
// centers the item under the cursor — a host change is already a
|
|
1116
|
+
// jump, so tracking the pointer exactly is the expected feel, while
|
|
1117
|
+
// re-seeding from the carried-over position (the old behavior)
|
|
1118
|
+
// baked an arbitrary along-wall offset into the whole stay on that
|
|
1119
|
+
// wall. Once anchored elsewhere the grab is forgotten for good, so
|
|
1120
|
+
// re-entering the original wall centers under the cursor too.
|
|
1121
|
+
const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
|
|
1122
|
+
wallDragAnchor = {
|
|
1123
|
+
wallId: event.node.id,
|
|
1124
|
+
rawX,
|
|
1125
|
+
rawY,
|
|
1126
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
|
1127
|
+
startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
|
|
1131
|
+
const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
|
|
1132
|
+
const wallMesh = sceneRegistry.nodes.get(event.node.id)
|
|
1133
|
+
// Derive the world cursor from the corrected wall-local point so the
|
|
1134
|
+
// visual cursor (world) and the stored position (wall-local) agree; if
|
|
1135
|
+
// the wall mesh is somehow absent, keep the raw world hit unchanged.
|
|
1136
|
+
const correctedWorld = wallMesh
|
|
1137
|
+
? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
|
|
1138
|
+
: null
|
|
1139
|
+
wallMoveEvent = {
|
|
1140
|
+
...event,
|
|
1141
|
+
localPosition: [correctedX, correctedY, event.localPosition[2]],
|
|
1142
|
+
position: correctedWorld
|
|
1143
|
+
? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
|
|
1144
|
+
: event.position,
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
|
|
865
1148
|
if (!result) return
|
|
866
1149
|
|
|
867
1150
|
event.stopPropagation()
|
|
@@ -918,10 +1201,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
918
1201
|
}
|
|
919
1202
|
}
|
|
920
1203
|
|
|
921
|
-
// Publish live transform for 2D floorplan
|
|
1204
|
+
// Publish live transform for the 2D floorplan. The floorplan resolves a
|
|
1205
|
+
// wall item's footprint (and its wall-side depth offset) from this
|
|
1206
|
+
// rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
|
|
1207
|
+
// yaw, which is π off from the plan rotation on a wall face — feeding it
|
|
1208
|
+
// raw flips the footprint to the far side of the wall during placement.
|
|
1209
|
+
// Publish the plan rotation (wall angle + the item's wall-local yaw) so
|
|
1210
|
+
// the preview matches what the committed node resolves to.
|
|
1211
|
+
let liveRotation = result.cursorRotationY
|
|
1212
|
+
const liveWallId = placementState.current.wallId
|
|
1213
|
+
const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
|
|
1214
|
+
if (liveWall?.type === 'wall') {
|
|
1215
|
+
const w = liveWall as WallNode
|
|
1216
|
+
const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
|
|
1217
|
+
liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
|
|
1218
|
+
}
|
|
922
1219
|
useLiveTransforms.getState().set(draft.id, {
|
|
923
1220
|
position: result.cursorPosition,
|
|
924
|
-
rotation:
|
|
1221
|
+
rotation: liveRotation,
|
|
925
1222
|
})
|
|
926
1223
|
}
|
|
927
1224
|
}
|
|
@@ -935,12 +1232,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
935
1232
|
if (draftNode.current) {
|
|
936
1233
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
937
1234
|
}
|
|
938
|
-
draftNode.
|
|
1235
|
+
const committedId = draftNode.current?.id ?? null
|
|
1236
|
+
const wasAdopted = draftNode.isAdopted
|
|
1237
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
939
1238
|
if (result.dirtyNodeId) {
|
|
940
1239
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
941
1240
|
}
|
|
942
1241
|
|
|
943
|
-
|
|
1242
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
944
1243
|
const nodes = useScene.getState().nodes
|
|
945
1244
|
const enterResult = wallStrategy.enter(
|
|
946
1245
|
getContext(),
|
|
@@ -954,10 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
954
1253
|
} else {
|
|
955
1254
|
revalidate()
|
|
956
1255
|
}
|
|
957
|
-
}
|
|
1256
|
+
})
|
|
958
1257
|
}
|
|
959
1258
|
|
|
960
1259
|
const onWallLeave = (event: WallEvent) => {
|
|
1260
|
+
wallDragAnchor = null
|
|
961
1261
|
const result = wallStrategy.leave(getContext())
|
|
962
1262
|
if (!result) return
|
|
963
1263
|
|
|
@@ -987,16 +1287,174 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
987
1287
|
}
|
|
988
1288
|
}
|
|
989
1289
|
|
|
1290
|
+
// ---- Roof Wall Handlers ----
|
|
1291
|
+
// Wall-attach items also host on the vertical wall faces a roof
|
|
1292
|
+
// segment generates (base walls + coplanar gable ends). Unlike walls,
|
|
1293
|
+
// crossing between segments inside ONE roof never re-fires
|
|
1294
|
+
// `roof:enter` (events come from the roof group), so the move handler
|
|
1295
|
+
// re-enters whenever the strategy reports a segment change.
|
|
1296
|
+
|
|
1297
|
+
const enterRoofWall = (event: RoofEvent): boolean => {
|
|
1298
|
+
const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
|
1299
|
+
if (!result) return false
|
|
1300
|
+
|
|
1301
|
+
event.stopPropagation()
|
|
1302
|
+
applyTransition(result)
|
|
1303
|
+
|
|
1304
|
+
if (!draftNode.current) {
|
|
1305
|
+
ensureDraft(result)
|
|
1306
|
+
} else if (result.nodeUpdate.parentId) {
|
|
1307
|
+
// Existing draft (move mode): reparent to the segment
|
|
1308
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
1309
|
+
}
|
|
1310
|
+
return true
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const onRoofWallEnter = (event: RoofEvent) => {
|
|
1314
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1315
|
+
enterRoofWall(event)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const onRoofWallMove = (event: RoofEvent) => {
|
|
1319
|
+
releaseCommit = () => onRoofWallClick(event)
|
|
1320
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1321
|
+
if (!cursorGroupRef.current) return
|
|
1322
|
+
const ctx = getContext()
|
|
1323
|
+
|
|
1324
|
+
if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
|
|
1325
|
+
enterRoofWall(event)
|
|
1326
|
+
return
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
|
|
1330
|
+
if (!result) {
|
|
1331
|
+
// Different segment under the pointer (or no placeable face) —
|
|
1332
|
+
// try a fresh enter; a null resolve leaves the draft where it is.
|
|
1333
|
+
enterRoofWall(event)
|
|
1334
|
+
return
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
event.stopPropagation()
|
|
1338
|
+
|
|
1339
|
+
const posChanged =
|
|
1340
|
+
gridPosition.current.x !== result.gridPosition[0] ||
|
|
1341
|
+
gridPosition.current.y !== result.gridPosition[1] ||
|
|
1342
|
+
gridPosition.current.z !== result.gridPosition[2]
|
|
1343
|
+
|
|
1344
|
+
if (posChanged) {
|
|
1345
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
gridPosition.current.set(...result.gridPosition)
|
|
1349
|
+
const wc = worldToBuildingLocal(...result.cursorPosition)
|
|
1350
|
+
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
|
|
1351
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
1352
|
+
|
|
1353
|
+
const draft = draftNode.current
|
|
1354
|
+
if (draft && result.nodeUpdate) {
|
|
1355
|
+
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
|
|
1356
|
+
if ('rotation' in result.nodeUpdate)
|
|
1357
|
+
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const placeable = revalidate()
|
|
1361
|
+
|
|
1362
|
+
if (draft && placeable) {
|
|
1363
|
+
draft.position = result.gridPosition
|
|
1364
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1365
|
+
if (mesh) {
|
|
1366
|
+
mesh.position.copy(gridPosition.current)
|
|
1367
|
+
// Wall-side items sit on the outer surface: mirror ItemSystem's
|
|
1368
|
+
// push (z = thickness/2 off the face frame's mid-plane) so the
|
|
1369
|
+
// drag preview doesn't sink into the wall until commit.
|
|
1370
|
+
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
|
|
1371
|
+
const segment =
|
|
1372
|
+
useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
|
|
1373
|
+
if (segment?.type === 'roof-segment') {
|
|
1374
|
+
mesh.position.z = (segment.wallThickness ?? 0.1) / 2
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const rot = result.nodeUpdate?.rotation
|
|
1378
|
+
if (rot) mesh.rotation.y = rot[1]
|
|
1379
|
+
}
|
|
1380
|
+
// The 2D floor-plan live frame is wall-local; a segment-local
|
|
1381
|
+
// value would render garbage — clear instead of publishing.
|
|
1382
|
+
useLiveTransforms.getState().clear(draft.id)
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const onRoofWallClick = (event: RoofEvent) => {
|
|
1387
|
+
const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
|
|
1388
|
+
if (!result) return
|
|
1389
|
+
|
|
1390
|
+
event.stopPropagation()
|
|
1391
|
+
if (draftNode.current) {
|
|
1392
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1393
|
+
}
|
|
1394
|
+
const committedId = draftNode.current?.id ?? null
|
|
1395
|
+
const wasAdopted = draftNode.isAdopted
|
|
1396
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1397
|
+
|
|
1398
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1399
|
+
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
|
1400
|
+
if (enterResult) {
|
|
1401
|
+
applyTransition(enterResult)
|
|
1402
|
+
} else {
|
|
1403
|
+
revalidate()
|
|
1404
|
+
}
|
|
1405
|
+
})
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const onRoofWallLeave = (event: RoofEvent) => {
|
|
1409
|
+
const result = roofWallStrategy.leave(getContext())
|
|
1410
|
+
if (!result) return
|
|
1411
|
+
|
|
1412
|
+
event.stopPropagation()
|
|
1413
|
+
|
|
1414
|
+
if (draftNode.isAdopted) {
|
|
1415
|
+
// Move mode: keep draft alive, reparent to level
|
|
1416
|
+
applyTransition(result)
|
|
1417
|
+
const draft = draftNode.current
|
|
1418
|
+
if (draft) {
|
|
1419
|
+
useScene.getState().updateNode(draft.id, {
|
|
1420
|
+
parentId: result.nodeUpdate.parentId as string,
|
|
1421
|
+
roofSegmentId: undefined,
|
|
1422
|
+
})
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
// Create mode: destroy transient and reset state
|
|
1426
|
+
draftNode.destroy()
|
|
1427
|
+
Object.assign(placementState.current, result.stateUpdate)
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
990
1431
|
// ---- Item Surface Handlers ----
|
|
991
1432
|
|
|
992
1433
|
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
|
1434
|
+
hostSurfaceDragAnchor = null
|
|
1435
|
+
// Landing back on the floor: refresh the pointer surface cap from
|
|
1436
|
+
// this event's world hit so the first floor position already targets
|
|
1437
|
+
// the aimed-at surface (not a deck above it).
|
|
1438
|
+
pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [
|
|
1439
|
+
event.position[0],
|
|
1440
|
+
event.position[1],
|
|
1441
|
+
event.position[2],
|
|
1442
|
+
])
|
|
1443
|
+
// Coming back from a host: forget the floor grab too, so the item
|
|
1444
|
+
// centers under the cursor instead of restoring the pre-drag offset —
|
|
1445
|
+
// and landing on the floor is "anchoring elsewhere", so a later return
|
|
1446
|
+
// to the grabbed host centers as well.
|
|
1447
|
+
relativeFloorStart = null
|
|
1448
|
+
floorDragAnchor = null
|
|
1449
|
+
grabForgotten = true
|
|
993
1450
|
const buildingLocalPoint = worldToBuildingLocal(
|
|
994
1451
|
event.position[0],
|
|
995
1452
|
event.position[1],
|
|
996
1453
|
event.position[2],
|
|
997
1454
|
)
|
|
998
|
-
|
|
999
|
-
const
|
|
1455
|
+
// Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
|
|
1456
|
+
const wx = snapToHalf(buildingLocalPoint.x)
|
|
1457
|
+
const wz = snapToHalf(buildingLocalPoint.z)
|
|
1000
1458
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
1001
1459
|
|
|
1002
1460
|
Object.assign(placementState.current, {
|
|
@@ -1016,10 +1474,36 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1016
1474
|
|
|
1017
1475
|
const draft = draftNode.current
|
|
1018
1476
|
if (draft) {
|
|
1477
|
+
// The stored yaw is still HOST-local — the surface enter counter-
|
|
1478
|
+
// rotated it against the host's world yaw so the item wouldn't spin
|
|
1479
|
+
// when attaching. Re-express the same world yaw in the level frame
|
|
1480
|
+
// before re-parenting, or the item visibly spins by the host's
|
|
1481
|
+
// rotation the moment it returns to the floor.
|
|
1482
|
+
let rotation = draft.rotation
|
|
1483
|
+
const draftMesh = sceneRegistry.nodes.get(draft.id)
|
|
1484
|
+
if (draftMesh) {
|
|
1485
|
+
const quat = new Quaternion()
|
|
1486
|
+
draftMesh.getWorldQuaternion(quat)
|
|
1487
|
+
const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
|
|
1488
|
+
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
1489
|
+
let levelYaw = 0
|
|
1490
|
+
if (levelMesh) {
|
|
1491
|
+
levelMesh.getWorldQuaternion(quat)
|
|
1492
|
+
levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
|
|
1493
|
+
}
|
|
1494
|
+
rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]]
|
|
1495
|
+
draft.rotation = rotation
|
|
1496
|
+
}
|
|
1019
1497
|
draft.position = floorPos
|
|
1498
|
+
// Sync the ref's parent too (the store-only write left the ref
|
|
1499
|
+
// pointing at the host, so `getFloorPlacedElevation` bailed on its
|
|
1500
|
+
// parent-must-be-a-level guard and the item + grid stopped following
|
|
1501
|
+
// slab elevations for the rest of the drag).
|
|
1502
|
+
if (levelId) draft.parentId = levelId
|
|
1020
1503
|
useScene.getState().updateNode(draft.id, {
|
|
1021
1504
|
parentId: useViewer.getState().selection.levelId as string,
|
|
1022
1505
|
position: floorPos,
|
|
1506
|
+
rotation,
|
|
1023
1507
|
})
|
|
1024
1508
|
}
|
|
1025
1509
|
|
|
@@ -1089,8 +1573,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1089
1573
|
return
|
|
1090
1574
|
}
|
|
1091
1575
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1576
|
+
const surfaceWorld =
|
|
1577
|
+
ctx.state.surfaceItemId !== null
|
|
1578
|
+
? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
|
|
1579
|
+
: null
|
|
1580
|
+
const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
|
|
1581
|
+
lastRawPos.current.set(
|
|
1582
|
+
itemMoveEvent.position[0],
|
|
1583
|
+
itemMoveEvent.position[1],
|
|
1584
|
+
itemMoveEvent.position[2],
|
|
1585
|
+
)
|
|
1586
|
+
const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
|
|
1094
1587
|
if (!result) return
|
|
1095
1588
|
|
|
1096
1589
|
event.stopPropagation()
|
|
@@ -1150,15 +1643,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1150
1643
|
if (draftNode.current) {
|
|
1151
1644
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1152
1645
|
}
|
|
1153
|
-
draftNode.
|
|
1154
|
-
|
|
1646
|
+
const committedId = draftNode.current?.id ?? null
|
|
1647
|
+
const wasAdopted = draftNode.isAdopted
|
|
1648
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1649
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1155
1650
|
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
|
|
1156
1651
|
if (enterResult) {
|
|
1157
1652
|
applyTransition(enterResult)
|
|
1158
1653
|
} else {
|
|
1159
1654
|
revalidate()
|
|
1160
1655
|
}
|
|
1161
|
-
}
|
|
1656
|
+
})
|
|
1162
1657
|
return
|
|
1163
1658
|
}
|
|
1164
1659
|
}
|
|
@@ -1176,15 +1671,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1176
1671
|
if (draftNode.current) {
|
|
1177
1672
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1178
1673
|
}
|
|
1179
|
-
draftNode.
|
|
1180
|
-
|
|
1674
|
+
const committedId = draftNode.current?.id ?? null
|
|
1675
|
+
const wasAdopted = draftNode.isAdopted
|
|
1676
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1677
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1181
1678
|
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
|
|
1182
1679
|
if (enterResult) {
|
|
1183
1680
|
applyTransition(enterResult)
|
|
1184
1681
|
} else {
|
|
1185
1682
|
revalidate()
|
|
1186
1683
|
}
|
|
1187
|
-
}
|
|
1684
|
+
})
|
|
1188
1685
|
return
|
|
1189
1686
|
}
|
|
1190
1687
|
}
|
|
@@ -1205,8 +1702,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1205
1702
|
if (draftNode.current) {
|
|
1206
1703
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1207
1704
|
}
|
|
1208
|
-
draftNode.
|
|
1209
|
-
|
|
1705
|
+
const committedId = draftNode.current?.id ?? null
|
|
1706
|
+
const wasAdopted = draftNode.isAdopted
|
|
1707
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1708
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1210
1709
|
const nodes = useScene.getState().nodes
|
|
1211
1710
|
const enterResult = ceilingStrategy.enter(
|
|
1212
1711
|
getContext(),
|
|
@@ -1219,7 +1718,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1219
1718
|
} else {
|
|
1220
1719
|
revalidate()
|
|
1221
1720
|
}
|
|
1222
|
-
}
|
|
1721
|
+
})
|
|
1223
1722
|
return
|
|
1224
1723
|
}
|
|
1225
1724
|
}
|
|
@@ -1235,9 +1734,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1235
1734
|
if (draftNode.current) {
|
|
1236
1735
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1237
1736
|
}
|
|
1238
|
-
draftNode.
|
|
1737
|
+
const committedId = draftNode.current?.id ?? null
|
|
1738
|
+
const wasAdopted = draftNode.isAdopted
|
|
1739
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1239
1740
|
|
|
1240
|
-
|
|
1741
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1241
1742
|
// Try to set up next draft on the same surface
|
|
1242
1743
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
|
1243
1744
|
if (enterResult) {
|
|
@@ -1245,7 +1746,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1245
1746
|
} else {
|
|
1246
1747
|
revalidate()
|
|
1247
1748
|
}
|
|
1248
|
-
}
|
|
1749
|
+
})
|
|
1249
1750
|
}
|
|
1250
1751
|
|
|
1251
1752
|
// ---- Ceiling Handlers ----
|
|
@@ -1284,8 +1785,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1284
1785
|
return
|
|
1285
1786
|
}
|
|
1286
1787
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1788
|
+
let ceilingMoveEvent = event
|
|
1789
|
+
if (preserveDragOffset && draftNode.current) {
|
|
1790
|
+
const rawX = event.localPosition[0]
|
|
1791
|
+
const rawZ = event.localPosition[2]
|
|
1792
|
+
if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
|
|
1793
|
+
// Same rule as the wall grab anchor: only the grabbed ceiling
|
|
1794
|
+
// preserves the offset, any other ceiling centers under the cursor.
|
|
1795
|
+
const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId)
|
|
1796
|
+
ceilingDragAnchor = {
|
|
1797
|
+
ceilingId: event.node.id,
|
|
1798
|
+
rawX,
|
|
1799
|
+
rawZ,
|
|
1800
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
|
1801
|
+
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
ceilingMoveEvent = {
|
|
1805
|
+
...event,
|
|
1806
|
+
localPosition: [
|
|
1807
|
+
ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
|
|
1808
|
+
event.localPosition[1],
|
|
1809
|
+
ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
|
|
1810
|
+
],
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
lastRawPos.current.set(
|
|
1814
|
+
ceilingMoveEvent.localPosition[0],
|
|
1815
|
+
ceilingMoveEvent.localPosition[1],
|
|
1816
|
+
ceilingMoveEvent.localPosition[2],
|
|
1817
|
+
)
|
|
1818
|
+
const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
|
|
1289
1819
|
if (!result) return
|
|
1290
1820
|
|
|
1291
1821
|
event.stopPropagation()
|
|
@@ -1335,9 +1865,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1335
1865
|
if (draftNode.current) {
|
|
1336
1866
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1337
1867
|
}
|
|
1338
|
-
draftNode.
|
|
1868
|
+
const committedId = draftNode.current?.id ?? null
|
|
1869
|
+
const wasAdopted = draftNode.isAdopted
|
|
1870
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1339
1871
|
|
|
1340
|
-
|
|
1872
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1341
1873
|
const nodes = useScene.getState().nodes
|
|
1342
1874
|
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
1343
1875
|
if (enterResult) {
|
|
@@ -1345,10 +1877,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1345
1877
|
} else {
|
|
1346
1878
|
revalidate()
|
|
1347
1879
|
}
|
|
1348
|
-
}
|
|
1880
|
+
})
|
|
1349
1881
|
}
|
|
1350
1882
|
|
|
1351
1883
|
const onCeilingLeave = (event: CeilingEvent) => {
|
|
1884
|
+
ceilingDragAnchor = null
|
|
1352
1885
|
const result = ceilingStrategy.leave(getContext())
|
|
1353
1886
|
if (!result) return
|
|
1354
1887
|
|
|
@@ -1422,7 +1955,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1422
1955
|
}
|
|
1423
1956
|
return
|
|
1424
1957
|
}
|
|
1425
|
-
const
|
|
1958
|
+
const shelfWorld =
|
|
1959
|
+
ctx.state.shelfId !== null
|
|
1960
|
+
? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
|
|
1961
|
+
: null
|
|
1962
|
+
const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
|
|
1963
|
+
const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
|
|
1426
1964
|
if (!result) return
|
|
1427
1965
|
|
|
1428
1966
|
event.stopPropagation()
|
|
@@ -1467,26 +2005,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1467
2005
|
if (draftNode.current) {
|
|
1468
2006
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1469
2007
|
}
|
|
1470
|
-
draftNode.
|
|
2008
|
+
const committedId = draftNode.current?.id ?? null
|
|
2009
|
+
const wasAdopted = draftNode.isAdopted
|
|
2010
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1471
2011
|
|
|
1472
|
-
|
|
2012
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1473
2013
|
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
|
|
1474
2014
|
if (enterResult) {
|
|
1475
2015
|
applyTransition(enterResult)
|
|
1476
2016
|
} else {
|
|
1477
2017
|
revalidate()
|
|
1478
2018
|
}
|
|
1479
|
-
}
|
|
2019
|
+
})
|
|
1480
2020
|
}
|
|
1481
2021
|
|
|
1482
2022
|
// ---- Keyboard rotation ----
|
|
1483
2023
|
|
|
1484
|
-
// 45° increments — matches the R-key rotation step for already-placed
|
|
1485
|
-
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
|
|
1486
|
-
const ROTATION_STEP = Math.PI / 4
|
|
1487
2024
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1488
|
-
if (event.key === '
|
|
1489
|
-
|
|
2025
|
+
if (event.key === 'Alt') {
|
|
2026
|
+
altFreeRef.current = true
|
|
1490
2027
|
revalidate()
|
|
1491
2028
|
return
|
|
1492
2029
|
}
|
|
@@ -1499,17 +2036,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1499
2036
|
const draft = draftNode.current
|
|
1500
2037
|
if (!draft) return
|
|
1501
2038
|
|
|
1502
|
-
|
|
2039
|
+
// Roof-wall drafts live flat in the host face frame (yaw 0) —
|
|
2040
|
+
// manual rotation would skew them off the wall plane.
|
|
2041
|
+
if (placementState.current.surface === 'roof-wall') return
|
|
2042
|
+
|
|
2043
|
+
let rotationDir: 1 | -1 | 0 = 0
|
|
1503
2044
|
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
|
1504
|
-
|
|
2045
|
+
rotationDir = 1
|
|
1505
2046
|
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
|
|
1506
|
-
|
|
2047
|
+
rotationDir = -1
|
|
1507
2048
|
|
|
1508
|
-
if (
|
|
2049
|
+
if (rotationDir !== 0) {
|
|
1509
2050
|
event.preventDefault()
|
|
1510
2051
|
sfxEmitter.emit('sfx:item-rotate')
|
|
1511
2052
|
const currentRotation = draft.rotation
|
|
1512
|
-
|
|
2053
|
+
// Round to the nearest 45° then step, matching the placed-item R/T.
|
|
2054
|
+
const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
|
|
1513
2055
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
1514
2056
|
|
|
1515
2057
|
// Ref + cursor mesh + item mesh — no store update during drag
|
|
@@ -1564,8 +2106,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1564
2106
|
worldSnapped.y,
|
|
1565
2107
|
worldSnapped.z,
|
|
1566
2108
|
)
|
|
2109
|
+
const surfaceQuat = new Quaternion()
|
|
2110
|
+
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
2111
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
1567
2112
|
if (cursorGroupRef.current) {
|
|
1568
2113
|
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
|
2114
|
+
// The box lives in building-local space while the mesh is parented to the host
|
|
2115
|
+
// item, so add the host's world yaw: the box must track the item's true
|
|
2116
|
+
// orientation, not its host-local `rotation[1]`.
|
|
2117
|
+
cursorGroupRef.current.rotation.y = newRotationY + surfaceWorldY
|
|
1569
2118
|
}
|
|
1570
2119
|
if (mesh) mesh.position.set(x, y, z)
|
|
1571
2120
|
}
|
|
@@ -1596,8 +2145,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1596
2145
|
}
|
|
1597
2146
|
|
|
1598
2147
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
1599
|
-
if (event.key === '
|
|
1600
|
-
|
|
2148
|
+
if (event.key === 'Alt') {
|
|
2149
|
+
altFreeRef.current = false
|
|
1601
2150
|
revalidate()
|
|
1602
2151
|
}
|
|
1603
2152
|
}
|
|
@@ -1614,13 +2163,35 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1614
2163
|
}
|
|
1615
2164
|
emitter.on('tool:cancel', onCancel)
|
|
1616
2165
|
|
|
1617
|
-
// ---- Right-click cancel ----
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
2166
|
+
// ---- Right-click cancel (quick click only, never a right-drag orbit) ----
|
|
2167
|
+
// The right button is also the camera-orbit control, so a contextmenu/up
|
|
2168
|
+
// alone can't tell "cancel placement" from "move the camera". Record the
|
|
2169
|
+
// right-button-down point + time and only cancel on release when the
|
|
2170
|
+
// pointer barely moved within a short window — a longer / further press is
|
|
2171
|
+
// an orbit and must leave the placement untouched.
|
|
2172
|
+
let rightDown: { x: number; y: number; t: number } | null = null
|
|
2173
|
+
const onRightPointerDown = (event: PointerEvent) => {
|
|
2174
|
+
if (event.button !== 2) return
|
|
2175
|
+
rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
|
|
2176
|
+
}
|
|
2177
|
+
const onRightPointerUp = (event: PointerEvent) => {
|
|
2178
|
+
if (event.button !== 2) return
|
|
2179
|
+
const down = rightDown
|
|
2180
|
+
rightDown = null
|
|
2181
|
+
if (!down || !configRef.current.onCancel) return
|
|
2182
|
+
const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
|
|
2183
|
+
const elapsed = performance.now() - down.t
|
|
2184
|
+
if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
|
|
2185
|
+
onCancel()
|
|
1622
2186
|
}
|
|
1623
2187
|
}
|
|
2188
|
+
// Suppress the OS context menu while placing; the cancel itself is decided
|
|
2189
|
+
// on pointerup above.
|
|
2190
|
+
const onContextMenu = (event: MouseEvent) => {
|
|
2191
|
+
if (configRef.current.onCancel) event.preventDefault()
|
|
2192
|
+
}
|
|
2193
|
+
window.addEventListener('pointerdown', onRightPointerDown, true)
|
|
2194
|
+
window.addEventListener('pointerup', onRightPointerUp, true)
|
|
1624
2195
|
window.addEventListener('contextmenu', onContextMenu)
|
|
1625
2196
|
|
|
1626
2197
|
// ---- Bounding box geometry ----
|
|
@@ -1673,6 +2244,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1673
2244
|
emitter.on('wall:move', onWallMove)
|
|
1674
2245
|
emitter.on('wall:click', onWallClick)
|
|
1675
2246
|
emitter.on('wall:leave', onWallLeave)
|
|
2247
|
+
emitter.on('roof:enter', onRoofWallEnter)
|
|
2248
|
+
emitter.on('roof:move', onRoofWallMove)
|
|
2249
|
+
emitter.on('roof:click', onRoofWallClick)
|
|
2250
|
+
emitter.on('roof:leave', onRoofWallLeave)
|
|
1676
2251
|
emitter.on('ceiling:enter', onCeilingEnter)
|
|
1677
2252
|
emitter.on('ceiling:move', onCeilingMove)
|
|
1678
2253
|
emitter.on('ceiling:click', onCeilingClick)
|
|
@@ -1681,6 +2256,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1681
2256
|
emitter.on('shelf:move', onShelfMove)
|
|
1682
2257
|
emitter.on('shelf:click', onShelfClick)
|
|
1683
2258
|
emitter.on('shelf:leave', onShelfLeave)
|
|
2259
|
+
|
|
2260
|
+
// A floor placement commits at the tracked floor cursor (`gridPosition`),
|
|
2261
|
+
// which keeps following the floor even when the click ray lands on a wall
|
|
2262
|
+
// (grid:move uses a separate ground-plane raycast). Without this, a commit
|
|
2263
|
+
// click whose ray hits a wall fires only `wall:click` — whose handler
|
|
2264
|
+
// declines for a floor item — and the click is silently eaten (the user
|
|
2265
|
+
// has to click again until the ray happens to clear the wall). Route every
|
|
2266
|
+
// surface click to the floor commit too; `floorStrategy.click` guards on
|
|
2267
|
+
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
|
|
2268
|
+
// draft is actually resting on that surface.
|
|
2269
|
+
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
|
|
2270
|
+
if (placementState.current.surface !== 'floor') return
|
|
2271
|
+
onGridClick(event as unknown as GridEvent)
|
|
2272
|
+
}
|
|
2273
|
+
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
|
|
2274
|
+
emitter.on('item:click', commitFloorOnSurfaceClick as never)
|
|
2275
|
+
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2276
|
+
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
|
|
2277
|
+
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
|
|
1684
2278
|
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
|
1685
2279
|
|
|
1686
2280
|
return () => {
|
|
@@ -1704,6 +2298,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1704
2298
|
emitter.off('wall:move', onWallMove)
|
|
1705
2299
|
emitter.off('wall:click', onWallClick)
|
|
1706
2300
|
emitter.off('wall:leave', onWallLeave)
|
|
2301
|
+
emitter.off('roof:enter', onRoofWallEnter)
|
|
2302
|
+
emitter.off('roof:move', onRoofWallMove)
|
|
2303
|
+
emitter.off('roof:click', onRoofWallClick)
|
|
2304
|
+
emitter.off('roof:leave', onRoofWallLeave)
|
|
1707
2305
|
emitter.off('ceiling:enter', onCeilingEnter)
|
|
1708
2306
|
emitter.off('ceiling:move', onCeilingMove)
|
|
1709
2307
|
emitter.off('ceiling:click', onCeilingClick)
|
|
@@ -1712,9 +2310,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1712
2310
|
emitter.off('shelf:move', onShelfMove)
|
|
1713
2311
|
emitter.off('shelf:click', onShelfClick)
|
|
1714
2312
|
emitter.off('shelf:leave', onShelfLeave)
|
|
2313
|
+
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
|
|
2314
|
+
emitter.off('item:click', commitFloorOnSurfaceClick as never)
|
|
2315
|
+
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2316
|
+
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
|
|
2317
|
+
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
|
|
1715
2318
|
emitter.off('tool:cancel', onCancel)
|
|
1716
2319
|
window.removeEventListener('keydown', onKeyDown)
|
|
1717
2320
|
window.removeEventListener('keyup', onKeyUp)
|
|
2321
|
+
window.removeEventListener('pointerdown', onRightPointerDown, true)
|
|
2322
|
+
window.removeEventListener('pointerup', onRightPointerUp, true)
|
|
1718
2323
|
window.removeEventListener('contextmenu', onContextMenu)
|
|
1719
2324
|
}
|
|
1720
2325
|
}, [
|
|
@@ -1749,6 +2354,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1749
2354
|
const draft = draftNode.current
|
|
1750
2355
|
if (!(draft && viewerLevelId) || asset.attachTo) return
|
|
1751
2356
|
if (draft.parentId === viewerLevelId) return
|
|
2357
|
+
// A non-attach item resting on a host surface (table / counter / shelf) is
|
|
2358
|
+
// intentionally parented to that host while it's moved — the surface move
|
|
2359
|
+
// handlers keep it hosted and the commit writes the host parent back. Only
|
|
2360
|
+
// free floor items get re-homed to the level here; yanking a hosted item
|
|
2361
|
+
// onto the level would re-interpret its host-local position in level space
|
|
2362
|
+
// and float the dragged mesh off the host toward the building origin.
|
|
2363
|
+
const draftParent = draft.parentId
|
|
2364
|
+
? useScene.getState().nodes[draft.parentId as AnyNodeId]
|
|
2365
|
+
: undefined
|
|
2366
|
+
if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
|
|
1752
2367
|
draft.parentId = viewerLevelId
|
|
1753
2368
|
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
|
1754
2369
|
}, [viewerLevelId, draftNode, asset])
|
|
@@ -1784,7 +2399,74 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1784
2399
|
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
|
1785
2400
|
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
|
1786
2401
|
|
|
1787
|
-
|
|
2402
|
+
// Publish the ghost's surface (contact point + normal) so the grid's snap
|
|
2403
|
+
// patch sits at the item's resolved height (e.g. a shelf top) and orients to
|
|
2404
|
+
// the surface (vertical in a wall plane), AND publish the forward-facing
|
|
2405
|
+
// triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
|
|
2406
|
+
// instead of drawing an inline triangle. Only this coordinator publishes — a
|
|
2407
|
+
// moving existing node has no draft here, so the grid reads that case straight
|
|
2408
|
+
// off the node's mesh. Cleared when idle.
|
|
2409
|
+
const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
|
|
2410
|
+
const facingForwardRef = useRef(new Vector3(0, 0, 1))
|
|
2411
|
+
const facingQuatRef = useRef(new Quaternion())
|
|
2412
|
+
useFrame(() => {
|
|
2413
|
+
const ghost = cursorGroupRef.current
|
|
2414
|
+
if (!(asset && ghost)) {
|
|
2415
|
+
clearPlacementSurface()
|
|
2416
|
+
useFacingPose.getState().clear()
|
|
2417
|
+
return
|
|
2418
|
+
}
|
|
2419
|
+
const surf = placementState.current.surface
|
|
2420
|
+
const n = surfaceNormalRef.current
|
|
2421
|
+
const shape = facingShapeRef.current
|
|
2422
|
+
// Triangle yaw / Y default to the floor case: the cursor group's own yaw is
|
|
2423
|
+
// the item's forward on the floor, and the triangle rides at the ghost's Y.
|
|
2424
|
+
let facingYaw = ghost.rotation.y
|
|
2425
|
+
let facingY = ghost.position.y
|
|
2426
|
+
if (surf === 'wall' || surf === 'roof-wall') {
|
|
2427
|
+
// Wall/roof-segment faces: the cursor group's yaw is the symmetric
|
|
2428
|
+
// wireframe yaw (π off the real facing for a wall, and a different frame
|
|
2429
|
+
// for a roof face), so derive the item's TRUE outward facing from the
|
|
2430
|
+
// draft mesh's world orientation — its local +Z faces out of the host
|
|
2431
|
+
// surface. This keeps BOTH the grid normal and the triangle correct for
|
|
2432
|
+
// wall and roof-segment hosts alike, rather than the old quaternion read
|
|
2433
|
+
// that pointed the wrong way.
|
|
2434
|
+
const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
|
|
2435
|
+
if (mesh) {
|
|
2436
|
+
mesh.getWorldQuaternion(facingQuatRef.current)
|
|
2437
|
+
const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
|
|
2438
|
+
fwd.y = 0
|
|
2439
|
+
if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
|
|
2440
|
+
}
|
|
2441
|
+
// The forward triangle is a floor aid; drop it to the building-local floor
|
|
2442
|
+
// under the wall (the ghost Y is up on the wall).
|
|
2443
|
+
facingY = 0
|
|
2444
|
+
n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
|
|
2445
|
+
} else {
|
|
2446
|
+
n.set(0, 1, 0)
|
|
2447
|
+
}
|
|
2448
|
+
publishPlacementSurface(ghost.position, n)
|
|
2449
|
+
|
|
2450
|
+
if (shape.depth > 0) {
|
|
2451
|
+
useFacingPose.getState().set({
|
|
2452
|
+
position: [ghost.position.x, facingY, ghost.position.z],
|
|
2453
|
+
rotationY: facingYaw,
|
|
2454
|
+
depth: shape.depth,
|
|
2455
|
+
center: shape.center,
|
|
2456
|
+
})
|
|
2457
|
+
} else {
|
|
2458
|
+
useFacingPose.getState().clear()
|
|
2459
|
+
}
|
|
2460
|
+
})
|
|
2461
|
+
useEffect(
|
|
2462
|
+
() => () => {
|
|
2463
|
+
clearPlacementSurface()
|
|
2464
|
+
useFacingPose.getState().clear()
|
|
2465
|
+
},
|
|
2466
|
+
[],
|
|
2467
|
+
)
|
|
2468
|
+
|
|
2469
|
+
useFrame(() => {
|
|
1788
2470
|
if (!asset) {
|
|
1789
2471
|
reconcileDraftRaycast(null)
|
|
1790
2472
|
return
|
|
@@ -1815,12 +2497,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1815
2497
|
mesh.visible = true
|
|
1816
2498
|
|
|
1817
2499
|
if (placementState.current.surface === 'floor') {
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
2500
|
+
// Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
|
|
2501
|
+
// active move visibly trail the cursor and — combined with React
|
|
2502
|
+
// re-renders momentarily pulling the mesh back toward its committed
|
|
2503
|
+
// position — read as a laggy snap-back on every move. Copying each frame
|
|
2504
|
+
// locks placement/move to the cursor and overrides any stray reset
|
|
2505
|
+
// within a single frame, so it feels precise instead of dragging.
|
|
2506
|
+
mesh.position.copy(gridPosition.current)
|
|
1824
2507
|
|
|
1825
2508
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
|
1826
2509
|
if (!asset.attachTo) {
|
|
@@ -1841,7 +2524,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1841
2524
|
? getScaledDimensions(initialDraft)
|
|
1842
2525
|
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
1843
2526
|
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
|
1844
|
-
const wallSideZOffset = initialAttachTo === 'wall-side' ?
|
|
2527
|
+
const wallSideZOffset = initialAttachTo === 'wall-side' ? dims[2] / 2 : 0
|
|
1845
2528
|
const initialDimensionBounds = expandBoundsToGrid(
|
|
1846
2529
|
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
|
1847
2530
|
initialAttachTo,
|
|
@@ -1869,9 +2552,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1869
2552
|
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1870
2553
|
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1871
2554
|
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2555
|
+
// Feed the footprint shape to the per-frame surface publisher, which orients
|
|
2556
|
+
// and positions the forward-facing triangle via `useFacingPose`.
|
|
2557
|
+
facingShapeRef.current = {
|
|
2558
|
+
depth: currentDimensionBounds.dimensions[2],
|
|
2559
|
+
center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
|
|
2560
|
+
}
|
|
2561
|
+
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
|
|
2562
|
+
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
|
|
2563
|
+
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
|
|
1875
2564
|
const widthLabelPosition: [number, number, number] = [
|
|
1876
2565
|
currentDimensionBounds.center[0],
|
|
1877
2566
|
0.04,
|
|
@@ -1887,7 +2576,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1887
2576
|
currentDimensionBounds.dimensions[1] / 2,
|
|
1888
2577
|
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
|
|
1889
2578
|
]
|
|
1890
|
-
|
|
1891
2579
|
const measurementContent = (
|
|
1892
2580
|
<>
|
|
1893
2581
|
<lineSegments
|