@pascal-app/editor 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -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 +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +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/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- 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 +80 -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 +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -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/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- 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 +188 -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 +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -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/floating-level-selector.tsx +1 -1
- 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 +227 -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/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- 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 +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- 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 +40 -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-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- 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-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -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 +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -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 +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -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 +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- 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 +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/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-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 +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +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 +43 -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/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,20 @@ import {
|
|
|
50
62
|
type PreviewBounds,
|
|
51
63
|
updateLineGeometry,
|
|
52
64
|
} from '../shared/placement-box-geometry'
|
|
53
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
getDetachedAttachmentPreviewLift,
|
|
67
|
+
getGridAlignedDimensions,
|
|
68
|
+
snapToGrid,
|
|
69
|
+
snapToHalf,
|
|
70
|
+
snapUpToGridStep,
|
|
71
|
+
steppedRotation,
|
|
72
|
+
} from './placement-math'
|
|
54
73
|
import {
|
|
55
74
|
ceilingStrategy,
|
|
56
75
|
checkCanPlace,
|
|
57
76
|
floorStrategy,
|
|
58
77
|
itemSurfaceStrategy,
|
|
78
|
+
roofWallStrategy,
|
|
59
79
|
shelfSurfaceStrategy,
|
|
60
80
|
wallStrategy,
|
|
61
81
|
} from './placement-strategies'
|
|
@@ -68,23 +88,20 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
|
68
88
|
* floor-plan overlay and the 3D registry move tool. */
|
|
69
89
|
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
70
90
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return `${wholeFeet}'${inches}"`
|
|
78
|
-
}
|
|
79
|
-
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
80
|
-
}
|
|
91
|
+
/** Right-click cancels an active placement — but the right button also orbits
|
|
92
|
+
* the camera (CameraControls ROTATE). Only a quick, near-stationary right
|
|
93
|
+
* press/release counts as a cancel; anything that moves past the pixel
|
|
94
|
+
* threshold or is held longer is treated as a camera orbit and left alone. */
|
|
95
|
+
const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
|
|
96
|
+
const RIGHT_CLICK_CANCEL_MAX_MS = 200
|
|
81
97
|
|
|
82
98
|
/**
|
|
83
99
|
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
|
84
100
|
* The wireframe stays centered on the original bounds centre on each axis we
|
|
85
101
|
* expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
102
|
+
* `min.z = 0` (the mounted face flush with the wall plane) and extend into the
|
|
103
|
+
* room along +Z — matching the body and the 2D footprint; the bottom (`min.y`)
|
|
104
|
+
* is preserved so the box still sits on the floor / attachment plane.
|
|
88
105
|
*
|
|
89
106
|
* Floor / ceiling / item-surface: X and Z expand; Y stays exact.
|
|
90
107
|
* Wall / wall-side: X and Y expand; Z stays exact.
|
|
@@ -110,9 +127,9 @@ function expandBoundsToGrid(
|
|
|
110
127
|
let maxZ: number
|
|
111
128
|
let newCz: number
|
|
112
129
|
if (attachTo === 'wall-side') {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
newCz =
|
|
130
|
+
minZ = 0
|
|
131
|
+
maxZ = expandedD
|
|
132
|
+
newCz = expandedD / 2
|
|
116
133
|
} else {
|
|
117
134
|
minZ = cz - expandedD / 2
|
|
118
135
|
maxZ = cz + expandedD / 2
|
|
@@ -134,10 +151,10 @@ function getFallbackPreviewBounds(
|
|
|
134
151
|
): PreviewBounds {
|
|
135
152
|
const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
136
153
|
return {
|
|
137
|
-
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ?
|
|
138
|
-
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ?
|
|
154
|
+
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
|
|
155
|
+
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
|
|
139
156
|
dimensions: dims,
|
|
140
|
-
center: [0, dims[1] / 2, attachTo === 'wall-side' ?
|
|
157
|
+
center: [0, dims[1] / 2, attachTo === 'wall-side' ? dims[2] / 2 : 0],
|
|
141
158
|
}
|
|
142
159
|
}
|
|
143
160
|
|
|
@@ -195,8 +212,13 @@ export interface PlacementCoordinatorConfig {
|
|
|
195
212
|
initialState?: PlacementState
|
|
196
213
|
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
|
197
214
|
defaultScale?: [number, number, number]
|
|
198
|
-
/**
|
|
199
|
-
|
|
215
|
+
/** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling
|
|
216
|
+
* duplicates) so the duplicate keeps its materials. */
|
|
217
|
+
slots?: ItemNode['slots']
|
|
218
|
+
/** Move-mode sessions keep the grabbed item offset from the first surface hit
|
|
219
|
+
* (floor / wall / ceiling / item-surface / shelf) instead of snapping the
|
|
220
|
+
* item's origin under the cursor. */
|
|
221
|
+
preserveDragOffset?: boolean
|
|
200
222
|
}
|
|
201
223
|
|
|
202
224
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
|
@@ -213,13 +235,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
213
235
|
config.initialState ?? {
|
|
214
236
|
surface: 'floor',
|
|
215
237
|
wallId: null,
|
|
238
|
+
roofSegmentId: null,
|
|
216
239
|
ceilingId: null,
|
|
217
240
|
surfaceItemId: null,
|
|
218
241
|
shelfId: null,
|
|
219
242
|
},
|
|
220
243
|
)
|
|
221
|
-
const
|
|
244
|
+
const altFreeRef = useRef(false)
|
|
222
245
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
|
246
|
+
// Footprint shape (depth along local +Z and [x, z] centre) of the current
|
|
247
|
+
// preview box, mirrored from the rendered dimension bounds so the per-frame
|
|
248
|
+
// surface publisher can position the forward-facing triangle without reading
|
|
249
|
+
// React state. Updated in the render body below.
|
|
250
|
+
const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
|
|
251
|
+
depth: 0,
|
|
252
|
+
center: [0, 0],
|
|
253
|
+
})
|
|
223
254
|
// Goes true the first time a 3D pointer event drives this coordinator.
|
|
224
255
|
// The per-frame mesh-position lerp below is only useful for that path;
|
|
225
256
|
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
|
|
@@ -413,6 +444,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
413
444
|
placementState.current = configRef.current.initialState ?? {
|
|
414
445
|
surface: 'floor',
|
|
415
446
|
wallId: null,
|
|
447
|
+
roofSegmentId: null,
|
|
416
448
|
ceilingId: null,
|
|
417
449
|
surfaceItemId: null,
|
|
418
450
|
shelfId: null,
|
|
@@ -437,7 +469,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
437
469
|
})
|
|
438
470
|
|
|
439
471
|
const getActiveValidators = () =>
|
|
440
|
-
|
|
472
|
+
altFreeRef.current
|
|
441
473
|
? {
|
|
442
474
|
canPlaceOnFloor: () => ({ valid: true }),
|
|
443
475
|
canPlaceOnWall: () => ({ valid: true }),
|
|
@@ -445,8 +477,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
445
477
|
}
|
|
446
478
|
: validators
|
|
447
479
|
|
|
480
|
+
const finishCommittedPlacement = (
|
|
481
|
+
committedId: string | null,
|
|
482
|
+
wasAdopted: boolean,
|
|
483
|
+
repeat: () => void,
|
|
484
|
+
) => {
|
|
485
|
+
if (configRef.current.onCommitted()) {
|
|
486
|
+
repeat()
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
useAlignmentGuides.getState().clear()
|
|
491
|
+
useScene.temporal.getState().resume()
|
|
492
|
+
if (committedId) {
|
|
493
|
+
useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
|
|
494
|
+
}
|
|
495
|
+
if (!wasAdopted) {
|
|
496
|
+
useEditor.getState().setTool(null)
|
|
497
|
+
}
|
|
498
|
+
// A non-repeating placement is finished: return to select mode so the user
|
|
499
|
+
// lands on the just-placed (now selected) node instead of a tool-less build
|
|
500
|
+
// limbo. Repeat placements took the early return above and stay armed.
|
|
501
|
+
useEditor.getState().setMode('select')
|
|
502
|
+
}
|
|
503
|
+
|
|
448
504
|
const revalidate = (): boolean => {
|
|
449
|
-
const placeable =
|
|
505
|
+
const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
|
|
450
506
|
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
|
451
507
|
edgeMaterial.color.setHex(color)
|
|
452
508
|
basePlaneMaterial.color.setHex(color)
|
|
@@ -462,10 +518,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
462
518
|
return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
463
519
|
}
|
|
464
520
|
|
|
521
|
+
const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
|
|
522
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
523
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
524
|
+
return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
525
|
+
}
|
|
526
|
+
|
|
465
527
|
const applyTransition = (result: TransitionResult) => {
|
|
466
528
|
// Alignment guides are floor-only; clear them when the cursor moves
|
|
467
529
|
// onto a wall / ceiling / item surface (only those paths call this).
|
|
468
530
|
useAlignmentGuides.getState().clear()
|
|
531
|
+
// Roof faces carry no grab anchor, but landing on one still counts as
|
|
532
|
+
// anchoring elsewhere — a later return to the grabbed wall must center
|
|
533
|
+
// under the cursor, not restore the stale grab offset.
|
|
534
|
+
if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
|
|
469
535
|
Object.assign(placementState.current, result.stateUpdate)
|
|
470
536
|
gridPosition.current.set(...result.gridPosition)
|
|
471
537
|
|
|
@@ -505,7 +571,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
505
571
|
0,
|
|
506
572
|
]
|
|
507
573
|
|
|
508
|
-
draftNode.create(
|
|
574
|
+
draftNode.create(
|
|
575
|
+
gridPosition.current,
|
|
576
|
+
asset,
|
|
577
|
+
initRotation,
|
|
578
|
+
configRef.current.defaultScale,
|
|
579
|
+
configRef.current.slots,
|
|
580
|
+
)
|
|
509
581
|
|
|
510
582
|
const draft = draftNode.current
|
|
511
583
|
if (draft) {
|
|
@@ -529,11 +601,134 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
529
601
|
|
|
530
602
|
// ---- Init draft ----
|
|
531
603
|
configRef.current.initDraft(gridPosition.current)
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
604
|
+
const preserveDragOffset = configRef.current.preserveDragOffset === true
|
|
605
|
+
// The host the item was grabbed from + its pre-drag host-local position.
|
|
606
|
+
// Each surface's grab anchor preserves the grab offset only on THAT host,
|
|
607
|
+
// and only until the item anchors on ANY other host (another wall/ceiling/
|
|
608
|
+
// shelf, a roof face, or the floor after a host visit) — `grabForgotten`
|
|
609
|
+
// then latches and every host, the original included, centers the item
|
|
610
|
+
// under the cursor. Merely passing through empty space (wall/ceiling items
|
|
611
|
+
// hide between surfaces) does NOT forget the grab.
|
|
612
|
+
const grabWallId =
|
|
613
|
+
placementState.current.surface === 'wall' ? placementState.current.wallId : null
|
|
614
|
+
const grabCeilingId =
|
|
615
|
+
placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null
|
|
616
|
+
const grabSurfaceHostId =
|
|
617
|
+
placementState.current.surface === 'item-surface'
|
|
618
|
+
? placementState.current.surfaceItemId
|
|
619
|
+
: placementState.current.surface === 'shelf-surface'
|
|
620
|
+
? placementState.current.shelfId
|
|
621
|
+
: null
|
|
622
|
+
const grabStartPosition: [number, number, number] | null = draftNode.current
|
|
623
|
+
? [
|
|
624
|
+
draftNode.current.position[0],
|
|
625
|
+
draftNode.current.position[1],
|
|
626
|
+
draftNode.current.position[2],
|
|
627
|
+
]
|
|
628
|
+
: null
|
|
629
|
+
let grabForgotten = grabStartPosition === null
|
|
630
|
+
// Anchoring on `hostId`: returns whether the grab offset still applies
|
|
631
|
+
// there, and latches `grabForgotten` the first time it doesn't.
|
|
632
|
+
const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => {
|
|
633
|
+
const preserve = !grabForgotten && hostId !== null && hostId === grabHostId
|
|
634
|
+
if (!preserve) grabForgotten = true
|
|
635
|
+
return preserve
|
|
636
|
+
}
|
|
637
|
+
// Nulled when the item returns to the floor FROM a host (see
|
|
638
|
+
// `detachItemSurfaceToFloor`): the floor grab offset only survives until
|
|
639
|
+
// the item anchors elsewhere, like every other surface's grab anchor.
|
|
640
|
+
let relativeFloorStart =
|
|
641
|
+
preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
|
|
642
|
+
? gridPosition.current.clone()
|
|
643
|
+
: null
|
|
644
|
+
|
|
645
|
+
// Grab anchors for the non-floor surfaces. Each captures the cursor's
|
|
646
|
+
// surface-local position and the item's stored position on the first move
|
|
647
|
+
// for a given host, then offsets every later move by
|
|
648
|
+
// `start + (raw - anchor)` — mirroring the floor path and the door/window
|
|
649
|
+
// move tools so the item tracks the grabbed point instead of teleporting
|
|
650
|
+
// its origin under the cursor. Reset on host change (re-seeded from the
|
|
651
|
+
// item's then-current position) by the surface leave handlers.
|
|
652
|
+
let wallDragAnchor: {
|
|
653
|
+
wallId: string
|
|
654
|
+
rawX: number
|
|
655
|
+
rawY: number
|
|
656
|
+
startX: number
|
|
657
|
+
startY: number
|
|
658
|
+
} | null = null
|
|
659
|
+
let ceilingDragAnchor: {
|
|
660
|
+
ceilingId: string
|
|
661
|
+
rawX: number
|
|
662
|
+
rawZ: number
|
|
663
|
+
startX: number
|
|
664
|
+
startZ: number
|
|
665
|
+
} | null = null
|
|
666
|
+
let hostSurfaceDragAnchor: {
|
|
667
|
+
hostId: string
|
|
668
|
+
rawX: number
|
|
669
|
+
rawZ: number
|
|
670
|
+
startX: number
|
|
671
|
+
startZ: number
|
|
672
|
+
} | null = null
|
|
673
|
+
|
|
674
|
+
// Item-surface / shelf moves snap from a WORLD cursor hit projected into the
|
|
675
|
+
// host's local frame. Re-project the offset-corrected local point back to
|
|
676
|
+
// world so the strategy (which re-derives both the stored position and the
|
|
677
|
+
// visual cursor from `event.position`) stays self-consistent.
|
|
678
|
+
const resolveHostSurfaceWorld = (
|
|
679
|
+
hostId: string,
|
|
680
|
+
worldPos: readonly [number, number, number],
|
|
681
|
+
): [number, number, number] | null => {
|
|
682
|
+
const draft = draftNode.current
|
|
683
|
+
const hostMesh = sceneRegistry.nodes.get(hostId)
|
|
684
|
+
if (!(preserveDragOffset && draft && hostMesh)) return null
|
|
685
|
+
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
|
|
686
|
+
if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
|
|
687
|
+
// Grab offset survives only on the host the item was grabbed from and
|
|
688
|
+
// only until it anchors elsewhere — any other shelf/table centers the
|
|
689
|
+
// item under the cursor (same rule as the wall grab anchor).
|
|
690
|
+
const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId)
|
|
691
|
+
hostSurfaceDragAnchor = {
|
|
692
|
+
hostId,
|
|
693
|
+
rawX: rawLocal.x,
|
|
694
|
+
rawZ: rawLocal.z,
|
|
695
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
|
|
696
|
+
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
|
|
700
|
+
const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
|
|
701
|
+
const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
|
|
702
|
+
return [world.x, world.y, world.z]
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Floor grab-offset: the item tracks the grabbed point instead of snapping
|
|
706
|
+
// its origin under the cursor. The offset is computed in building-local space
|
|
707
|
+
// (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
|
|
708
|
+
// (`event.position`), so the corrected local point is re-projected to a
|
|
709
|
+
// corrected world point and both frames carry the offset to stay consistent.
|
|
710
|
+
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
|
|
711
|
+
if (relativeFloorStart === null) return event
|
|
712
|
+
const rawX = event.localPosition[0]
|
|
713
|
+
const rawZ = event.localPosition[2]
|
|
714
|
+
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
|
715
|
+
floorDragAnchor = anchor
|
|
716
|
+
const correctedLocal: [number, number, number] = [
|
|
717
|
+
relativeFloorStart.x + (rawX - anchor[0]),
|
|
718
|
+
event.localPosition[1],
|
|
719
|
+
relativeFloorStart.z + (rawZ - anchor[1]),
|
|
720
|
+
]
|
|
721
|
+
const correctedWorld = buildingLocalToWorld(
|
|
722
|
+
correctedLocal[0],
|
|
723
|
+
correctedLocal[1],
|
|
724
|
+
correctedLocal[2],
|
|
725
|
+
)
|
|
726
|
+
return {
|
|
727
|
+
...event,
|
|
728
|
+
position: [correctedWorld.x, event.position[1], correctedWorld.z],
|
|
729
|
+
localPosition: correctedLocal,
|
|
730
|
+
}
|
|
731
|
+
}
|
|
537
732
|
|
|
538
733
|
// Sync cursor to the draft mesh's world position and rotation
|
|
539
734
|
if (draftNode.current) {
|
|
@@ -544,8 +739,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
544
739
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
|
545
740
|
if (cursorGroupRef.current) {
|
|
546
741
|
cursorGroupRef.current.position.copy(localPos)
|
|
547
|
-
if (
|
|
548
|
-
|
|
742
|
+
if (
|
|
743
|
+
draftNode.current.asset.attachTo ||
|
|
744
|
+
placementState.current?.surface === 'item-surface'
|
|
745
|
+
) {
|
|
746
|
+
// Wall/ceiling items AND items hosted on another item: the mesh is parented
|
|
747
|
+
// to a rotated host, so the box's building-local yaw must come from the mesh's
|
|
748
|
+
// world rotation, not the node's host-local `rotation[1]` (which would leave the
|
|
749
|
+
// box rotated by the host's yaw relative to the item).
|
|
549
750
|
const q = new Quaternion()
|
|
550
751
|
mesh.getWorldQuaternion(q)
|
|
551
752
|
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
|
@@ -657,23 +858,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
657
858
|
detachItemSurfaceToFloor(event as unknown as ItemEvent)
|
|
658
859
|
}
|
|
659
860
|
|
|
660
|
-
const floorEvent =
|
|
661
|
-
relativeFloorStart !== null
|
|
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
|
|
861
|
+
const floorEvent = applyFloorGrabOffset(event)
|
|
677
862
|
|
|
678
863
|
lastRawPos.current.set(
|
|
679
864
|
floorEvent.localPosition[0],
|
|
@@ -689,12 +874,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
689
874
|
// item's edge, snap and publish a guide. The guide connects to the
|
|
690
875
|
// nearest real corner of the candidate (resolver tie-break), so the dot
|
|
691
876
|
// always sits on an actual point. The delta is applied to BOTH the grid
|
|
692
|
-
// and cursor positions below. Alt
|
|
877
|
+
// and cursor positions below. Alt is force-place only (it does NOT bypass
|
|
878
|
+
// snapping — 'off' mode is the no-snap bypass); the active snapping mode
|
|
879
|
+
// governs whether alignment runs at all ('off' / 'angles' disable
|
|
880
|
+
// magnetic alignment, 'lines' enables it, matching the wall/fence flow).
|
|
693
881
|
const draft = draftNode.current
|
|
694
882
|
let alignX = 0
|
|
695
883
|
let alignZ = 0
|
|
696
|
-
|
|
697
|
-
|
|
884
|
+
// Alignment "lines" are DISPLAYED in every snapping mode except Off
|
|
885
|
+
// (isAlignmentGuideActive); the magnetic pull toward them is applied only
|
|
886
|
+
// in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
|
|
887
|
+
// bypass.
|
|
888
|
+
if (isAlignmentGuideActive() && draft) {
|
|
698
889
|
alignmentCandidates ??= collectAlignmentAnchors(
|
|
699
890
|
useScene.getState().nodes,
|
|
700
891
|
draft.id,
|
|
@@ -710,11 +901,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
710
901
|
candidates: alignmentCandidates,
|
|
711
902
|
threshold: ALIGNMENT_THRESHOLD_M,
|
|
712
903
|
})
|
|
713
|
-
if (ar.snap) {
|
|
904
|
+
if (ar.snap && isMagneticSnapActive()) {
|
|
714
905
|
alignX = ar.snap.dx
|
|
715
906
|
alignZ = ar.snap.dz
|
|
716
907
|
}
|
|
717
|
-
useAlignmentGuides
|
|
908
|
+
useAlignmentGuides
|
|
909
|
+
.getState()
|
|
910
|
+
.set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
|
|
718
911
|
} else {
|
|
719
912
|
useAlignmentGuides.getState().clear()
|
|
720
913
|
}
|
|
@@ -736,6 +929,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
736
929
|
previousGridPos = [...gridPos]
|
|
737
930
|
gridPosition.current.set(...gridPos)
|
|
738
931
|
const cursorPosition = getFloorVisualPosition(gridPos)
|
|
932
|
+
if (!draft && asset.attachTo) {
|
|
933
|
+
cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
|
|
934
|
+
}
|
|
739
935
|
cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
|
|
740
936
|
// Floor items only rotate on Y; keep the preview box (and the live
|
|
741
937
|
// transform the 2D floorplan mirrors) aligned with the draft's
|
|
@@ -775,9 +971,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
775
971
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
776
972
|
}
|
|
777
973
|
|
|
778
|
-
draftNode.
|
|
779
|
-
|
|
780
|
-
|
|
974
|
+
const committedId = draftNode.current?.id ?? null
|
|
975
|
+
const wasAdopted = draftNode.isAdopted
|
|
976
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
977
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
978
|
+
draftNode.create(
|
|
979
|
+
gridPosition.current,
|
|
980
|
+
asset,
|
|
981
|
+
currentRotation,
|
|
982
|
+
configRef.current.defaultScale,
|
|
983
|
+
configRef.current.slots,
|
|
984
|
+
)
|
|
781
985
|
const previewBounds = expandBoundsToGrid(
|
|
782
986
|
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
|
783
987
|
asset.attachTo,
|
|
@@ -786,7 +990,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
786
990
|
updatePreviewGeometry(previewBounds)
|
|
787
991
|
updateDimensionGuides(previewBounds)
|
|
788
992
|
revalidate()
|
|
789
|
-
}
|
|
993
|
+
})
|
|
790
994
|
}
|
|
791
995
|
|
|
792
996
|
// ---- Wall Handlers ----
|
|
@@ -861,7 +1065,46 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
861
1065
|
return
|
|
862
1066
|
}
|
|
863
1067
|
|
|
864
|
-
|
|
1068
|
+
let wallMoveEvent = event
|
|
1069
|
+
if (preserveDragOffset && draftNode.current) {
|
|
1070
|
+
const rawX = event.localPosition[0]
|
|
1071
|
+
const rawY = event.localPosition[1]
|
|
1072
|
+
if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
|
|
1073
|
+
// Preserve the grab offset only on the wall the item was grabbed
|
|
1074
|
+
// from (no teleport under the pointer at grab time). Any OTHER host
|
|
1075
|
+
// centers the item under the cursor — a host change is already a
|
|
1076
|
+
// jump, so tracking the pointer exactly is the expected feel, while
|
|
1077
|
+
// re-seeding from the carried-over position (the old behavior)
|
|
1078
|
+
// baked an arbitrary along-wall offset into the whole stay on that
|
|
1079
|
+
// wall. Once anchored elsewhere the grab is forgotten for good, so
|
|
1080
|
+
// re-entering the original wall centers under the cursor too.
|
|
1081
|
+
const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
|
|
1082
|
+
wallDragAnchor = {
|
|
1083
|
+
wallId: event.node.id,
|
|
1084
|
+
rawX,
|
|
1085
|
+
rawY,
|
|
1086
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
|
1087
|
+
startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
|
|
1091
|
+
const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
|
|
1092
|
+
const wallMesh = sceneRegistry.nodes.get(event.node.id)
|
|
1093
|
+
// Derive the world cursor from the corrected wall-local point so the
|
|
1094
|
+
// visual cursor (world) and the stored position (wall-local) agree; if
|
|
1095
|
+
// the wall mesh is somehow absent, keep the raw world hit unchanged.
|
|
1096
|
+
const correctedWorld = wallMesh
|
|
1097
|
+
? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
|
|
1098
|
+
: null
|
|
1099
|
+
wallMoveEvent = {
|
|
1100
|
+
...event,
|
|
1101
|
+
localPosition: [correctedX, correctedY, event.localPosition[2]],
|
|
1102
|
+
position: correctedWorld
|
|
1103
|
+
? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
|
|
1104
|
+
: event.position,
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
|
|
865
1108
|
if (!result) return
|
|
866
1109
|
|
|
867
1110
|
event.stopPropagation()
|
|
@@ -918,10 +1161,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
918
1161
|
}
|
|
919
1162
|
}
|
|
920
1163
|
|
|
921
|
-
// Publish live transform for 2D floorplan
|
|
1164
|
+
// Publish live transform for the 2D floorplan. The floorplan resolves a
|
|
1165
|
+
// wall item's footprint (and its wall-side depth offset) from this
|
|
1166
|
+
// rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
|
|
1167
|
+
// yaw, which is π off from the plan rotation on a wall face — feeding it
|
|
1168
|
+
// raw flips the footprint to the far side of the wall during placement.
|
|
1169
|
+
// Publish the plan rotation (wall angle + the item's wall-local yaw) so
|
|
1170
|
+
// the preview matches what the committed node resolves to.
|
|
1171
|
+
let liveRotation = result.cursorRotationY
|
|
1172
|
+
const liveWallId = placementState.current.wallId
|
|
1173
|
+
const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
|
|
1174
|
+
if (liveWall?.type === 'wall') {
|
|
1175
|
+
const w = liveWall as WallNode
|
|
1176
|
+
const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
|
|
1177
|
+
liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
|
|
1178
|
+
}
|
|
922
1179
|
useLiveTransforms.getState().set(draft.id, {
|
|
923
1180
|
position: result.cursorPosition,
|
|
924
|
-
rotation:
|
|
1181
|
+
rotation: liveRotation,
|
|
925
1182
|
})
|
|
926
1183
|
}
|
|
927
1184
|
}
|
|
@@ -935,12 +1192,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
935
1192
|
if (draftNode.current) {
|
|
936
1193
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
937
1194
|
}
|
|
938
|
-
draftNode.
|
|
1195
|
+
const committedId = draftNode.current?.id ?? null
|
|
1196
|
+
const wasAdopted = draftNode.isAdopted
|
|
1197
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
939
1198
|
if (result.dirtyNodeId) {
|
|
940
1199
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
941
1200
|
}
|
|
942
1201
|
|
|
943
|
-
|
|
1202
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
944
1203
|
const nodes = useScene.getState().nodes
|
|
945
1204
|
const enterResult = wallStrategy.enter(
|
|
946
1205
|
getContext(),
|
|
@@ -954,10 +1213,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
954
1213
|
} else {
|
|
955
1214
|
revalidate()
|
|
956
1215
|
}
|
|
957
|
-
}
|
|
1216
|
+
})
|
|
958
1217
|
}
|
|
959
1218
|
|
|
960
1219
|
const onWallLeave = (event: WallEvent) => {
|
|
1220
|
+
wallDragAnchor = null
|
|
961
1221
|
const result = wallStrategy.leave(getContext())
|
|
962
1222
|
if (!result) return
|
|
963
1223
|
|
|
@@ -987,16 +1247,166 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
987
1247
|
}
|
|
988
1248
|
}
|
|
989
1249
|
|
|
1250
|
+
// ---- Roof Wall Handlers ----
|
|
1251
|
+
// Wall-attach items also host on the vertical wall faces a roof
|
|
1252
|
+
// segment generates (base walls + coplanar gable ends). Unlike walls,
|
|
1253
|
+
// crossing between segments inside ONE roof never re-fires
|
|
1254
|
+
// `roof:enter` (events come from the roof group), so the move handler
|
|
1255
|
+
// re-enters whenever the strategy reports a segment change.
|
|
1256
|
+
|
|
1257
|
+
const enterRoofWall = (event: RoofEvent): boolean => {
|
|
1258
|
+
const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
|
1259
|
+
if (!result) return false
|
|
1260
|
+
|
|
1261
|
+
event.stopPropagation()
|
|
1262
|
+
applyTransition(result)
|
|
1263
|
+
|
|
1264
|
+
if (!draftNode.current) {
|
|
1265
|
+
ensureDraft(result)
|
|
1266
|
+
} else if (result.nodeUpdate.parentId) {
|
|
1267
|
+
// Existing draft (move mode): reparent to the segment
|
|
1268
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
1269
|
+
}
|
|
1270
|
+
return true
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const onRoofWallEnter = (event: RoofEvent) => {
|
|
1274
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1275
|
+
enterRoofWall(event)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const onRoofWallMove = (event: RoofEvent) => {
|
|
1279
|
+
releaseCommit = () => onRoofWallClick(event)
|
|
1280
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1281
|
+
if (!cursorGroupRef.current) return
|
|
1282
|
+
const ctx = getContext()
|
|
1283
|
+
|
|
1284
|
+
if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
|
|
1285
|
+
enterRoofWall(event)
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
|
|
1290
|
+
if (!result) {
|
|
1291
|
+
// Different segment under the pointer (or no placeable face) —
|
|
1292
|
+
// try a fresh enter; a null resolve leaves the draft where it is.
|
|
1293
|
+
enterRoofWall(event)
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
event.stopPropagation()
|
|
1298
|
+
|
|
1299
|
+
const posChanged =
|
|
1300
|
+
gridPosition.current.x !== result.gridPosition[0] ||
|
|
1301
|
+
gridPosition.current.y !== result.gridPosition[1] ||
|
|
1302
|
+
gridPosition.current.z !== result.gridPosition[2]
|
|
1303
|
+
|
|
1304
|
+
if (posChanged) {
|
|
1305
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
gridPosition.current.set(...result.gridPosition)
|
|
1309
|
+
const wc = worldToBuildingLocal(...result.cursorPosition)
|
|
1310
|
+
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
|
|
1311
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
1312
|
+
|
|
1313
|
+
const draft = draftNode.current
|
|
1314
|
+
if (draft && result.nodeUpdate) {
|
|
1315
|
+
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
|
|
1316
|
+
if ('rotation' in result.nodeUpdate)
|
|
1317
|
+
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const placeable = revalidate()
|
|
1321
|
+
|
|
1322
|
+
if (draft && placeable) {
|
|
1323
|
+
draft.position = result.gridPosition
|
|
1324
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1325
|
+
if (mesh) {
|
|
1326
|
+
mesh.position.copy(gridPosition.current)
|
|
1327
|
+
// Wall-side items sit on the outer surface: mirror ItemSystem's
|
|
1328
|
+
// push (z = thickness/2 off the face frame's mid-plane) so the
|
|
1329
|
+
// drag preview doesn't sink into the wall until commit.
|
|
1330
|
+
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
|
|
1331
|
+
const segment =
|
|
1332
|
+
useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
|
|
1333
|
+
if (segment?.type === 'roof-segment') {
|
|
1334
|
+
mesh.position.z = (segment.wallThickness ?? 0.1) / 2
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
const rot = result.nodeUpdate?.rotation
|
|
1338
|
+
if (rot) mesh.rotation.y = rot[1]
|
|
1339
|
+
}
|
|
1340
|
+
// The 2D floor-plan live frame is wall-local; a segment-local
|
|
1341
|
+
// value would render garbage — clear instead of publishing.
|
|
1342
|
+
useLiveTransforms.getState().clear(draft.id)
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const onRoofWallClick = (event: RoofEvent) => {
|
|
1347
|
+
const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
|
|
1348
|
+
if (!result) return
|
|
1349
|
+
|
|
1350
|
+
event.stopPropagation()
|
|
1351
|
+
if (draftNode.current) {
|
|
1352
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1353
|
+
}
|
|
1354
|
+
const committedId = draftNode.current?.id ?? null
|
|
1355
|
+
const wasAdopted = draftNode.isAdopted
|
|
1356
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1357
|
+
|
|
1358
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1359
|
+
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
|
1360
|
+
if (enterResult) {
|
|
1361
|
+
applyTransition(enterResult)
|
|
1362
|
+
} else {
|
|
1363
|
+
revalidate()
|
|
1364
|
+
}
|
|
1365
|
+
})
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const onRoofWallLeave = (event: RoofEvent) => {
|
|
1369
|
+
const result = roofWallStrategy.leave(getContext())
|
|
1370
|
+
if (!result) return
|
|
1371
|
+
|
|
1372
|
+
event.stopPropagation()
|
|
1373
|
+
|
|
1374
|
+
if (draftNode.isAdopted) {
|
|
1375
|
+
// Move mode: keep draft alive, reparent to level
|
|
1376
|
+
applyTransition(result)
|
|
1377
|
+
const draft = draftNode.current
|
|
1378
|
+
if (draft) {
|
|
1379
|
+
useScene.getState().updateNode(draft.id, {
|
|
1380
|
+
parentId: result.nodeUpdate.parentId as string,
|
|
1381
|
+
roofSegmentId: undefined,
|
|
1382
|
+
})
|
|
1383
|
+
}
|
|
1384
|
+
} else {
|
|
1385
|
+
// Create mode: destroy transient and reset state
|
|
1386
|
+
draftNode.destroy()
|
|
1387
|
+
Object.assign(placementState.current, result.stateUpdate)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
990
1391
|
// ---- Item Surface Handlers ----
|
|
991
1392
|
|
|
992
1393
|
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
|
1394
|
+
hostSurfaceDragAnchor = null
|
|
1395
|
+
// Coming back from a host: forget the floor grab too, so the item
|
|
1396
|
+
// centers under the cursor instead of restoring the pre-drag offset —
|
|
1397
|
+
// and landing on the floor is "anchoring elsewhere", so a later return
|
|
1398
|
+
// to the grabbed host centers as well.
|
|
1399
|
+
relativeFloorStart = null
|
|
1400
|
+
floorDragAnchor = null
|
|
1401
|
+
grabForgotten = true
|
|
993
1402
|
const buildingLocalPoint = worldToBuildingLocal(
|
|
994
1403
|
event.position[0],
|
|
995
1404
|
event.position[1],
|
|
996
1405
|
event.position[2],
|
|
997
1406
|
)
|
|
998
|
-
|
|
999
|
-
const
|
|
1407
|
+
// Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
|
|
1408
|
+
const wx = snapToHalf(buildingLocalPoint.x)
|
|
1409
|
+
const wz = snapToHalf(buildingLocalPoint.z)
|
|
1000
1410
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
1001
1411
|
|
|
1002
1412
|
Object.assign(placementState.current, {
|
|
@@ -1016,10 +1426,36 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1016
1426
|
|
|
1017
1427
|
const draft = draftNode.current
|
|
1018
1428
|
if (draft) {
|
|
1429
|
+
// The stored yaw is still HOST-local — the surface enter counter-
|
|
1430
|
+
// rotated it against the host's world yaw so the item wouldn't spin
|
|
1431
|
+
// when attaching. Re-express the same world yaw in the level frame
|
|
1432
|
+
// before re-parenting, or the item visibly spins by the host's
|
|
1433
|
+
// rotation the moment it returns to the floor.
|
|
1434
|
+
let rotation = draft.rotation
|
|
1435
|
+
const draftMesh = sceneRegistry.nodes.get(draft.id)
|
|
1436
|
+
if (draftMesh) {
|
|
1437
|
+
const quat = new Quaternion()
|
|
1438
|
+
draftMesh.getWorldQuaternion(quat)
|
|
1439
|
+
const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
|
|
1440
|
+
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
1441
|
+
let levelYaw = 0
|
|
1442
|
+
if (levelMesh) {
|
|
1443
|
+
levelMesh.getWorldQuaternion(quat)
|
|
1444
|
+
levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
|
|
1445
|
+
}
|
|
1446
|
+
rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]]
|
|
1447
|
+
draft.rotation = rotation
|
|
1448
|
+
}
|
|
1019
1449
|
draft.position = floorPos
|
|
1450
|
+
// Sync the ref's parent too (the store-only write left the ref
|
|
1451
|
+
// pointing at the host, so `getFloorPlacedElevation` bailed on its
|
|
1452
|
+
// parent-must-be-a-level guard and the item + grid stopped following
|
|
1453
|
+
// slab elevations for the rest of the drag).
|
|
1454
|
+
if (levelId) draft.parentId = levelId
|
|
1020
1455
|
useScene.getState().updateNode(draft.id, {
|
|
1021
1456
|
parentId: useViewer.getState().selection.levelId as string,
|
|
1022
1457
|
position: floorPos,
|
|
1458
|
+
rotation,
|
|
1023
1459
|
})
|
|
1024
1460
|
}
|
|
1025
1461
|
|
|
@@ -1089,8 +1525,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1089
1525
|
return
|
|
1090
1526
|
}
|
|
1091
1527
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1528
|
+
const surfaceWorld =
|
|
1529
|
+
ctx.state.surfaceItemId !== null
|
|
1530
|
+
? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
|
|
1531
|
+
: null
|
|
1532
|
+
const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
|
|
1533
|
+
lastRawPos.current.set(
|
|
1534
|
+
itemMoveEvent.position[0],
|
|
1535
|
+
itemMoveEvent.position[1],
|
|
1536
|
+
itemMoveEvent.position[2],
|
|
1537
|
+
)
|
|
1538
|
+
const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
|
|
1094
1539
|
if (!result) return
|
|
1095
1540
|
|
|
1096
1541
|
event.stopPropagation()
|
|
@@ -1150,15 +1595,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1150
1595
|
if (draftNode.current) {
|
|
1151
1596
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1152
1597
|
}
|
|
1153
|
-
draftNode.
|
|
1154
|
-
|
|
1598
|
+
const committedId = draftNode.current?.id ?? null
|
|
1599
|
+
const wasAdopted = draftNode.isAdopted
|
|
1600
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1601
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1155
1602
|
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
|
|
1156
1603
|
if (enterResult) {
|
|
1157
1604
|
applyTransition(enterResult)
|
|
1158
1605
|
} else {
|
|
1159
1606
|
revalidate()
|
|
1160
1607
|
}
|
|
1161
|
-
}
|
|
1608
|
+
})
|
|
1162
1609
|
return
|
|
1163
1610
|
}
|
|
1164
1611
|
}
|
|
@@ -1176,15 +1623,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1176
1623
|
if (draftNode.current) {
|
|
1177
1624
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1178
1625
|
}
|
|
1179
|
-
draftNode.
|
|
1180
|
-
|
|
1626
|
+
const committedId = draftNode.current?.id ?? null
|
|
1627
|
+
const wasAdopted = draftNode.isAdopted
|
|
1628
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1629
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1181
1630
|
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
|
|
1182
1631
|
if (enterResult) {
|
|
1183
1632
|
applyTransition(enterResult)
|
|
1184
1633
|
} else {
|
|
1185
1634
|
revalidate()
|
|
1186
1635
|
}
|
|
1187
|
-
}
|
|
1636
|
+
})
|
|
1188
1637
|
return
|
|
1189
1638
|
}
|
|
1190
1639
|
}
|
|
@@ -1205,8 +1654,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1205
1654
|
if (draftNode.current) {
|
|
1206
1655
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1207
1656
|
}
|
|
1208
|
-
draftNode.
|
|
1209
|
-
|
|
1657
|
+
const committedId = draftNode.current?.id ?? null
|
|
1658
|
+
const wasAdopted = draftNode.isAdopted
|
|
1659
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1660
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1210
1661
|
const nodes = useScene.getState().nodes
|
|
1211
1662
|
const enterResult = ceilingStrategy.enter(
|
|
1212
1663
|
getContext(),
|
|
@@ -1219,7 +1670,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1219
1670
|
} else {
|
|
1220
1671
|
revalidate()
|
|
1221
1672
|
}
|
|
1222
|
-
}
|
|
1673
|
+
})
|
|
1223
1674
|
return
|
|
1224
1675
|
}
|
|
1225
1676
|
}
|
|
@@ -1235,9 +1686,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1235
1686
|
if (draftNode.current) {
|
|
1236
1687
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1237
1688
|
}
|
|
1238
|
-
draftNode.
|
|
1689
|
+
const committedId = draftNode.current?.id ?? null
|
|
1690
|
+
const wasAdopted = draftNode.isAdopted
|
|
1691
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1239
1692
|
|
|
1240
|
-
|
|
1693
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1241
1694
|
// Try to set up next draft on the same surface
|
|
1242
1695
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
|
1243
1696
|
if (enterResult) {
|
|
@@ -1245,7 +1698,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1245
1698
|
} else {
|
|
1246
1699
|
revalidate()
|
|
1247
1700
|
}
|
|
1248
|
-
}
|
|
1701
|
+
})
|
|
1249
1702
|
}
|
|
1250
1703
|
|
|
1251
1704
|
// ---- Ceiling Handlers ----
|
|
@@ -1284,8 +1737,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1284
1737
|
return
|
|
1285
1738
|
}
|
|
1286
1739
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1740
|
+
let ceilingMoveEvent = event
|
|
1741
|
+
if (preserveDragOffset && draftNode.current) {
|
|
1742
|
+
const rawX = event.localPosition[0]
|
|
1743
|
+
const rawZ = event.localPosition[2]
|
|
1744
|
+
if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
|
|
1745
|
+
// Same rule as the wall grab anchor: only the grabbed ceiling
|
|
1746
|
+
// preserves the offset, any other ceiling centers under the cursor.
|
|
1747
|
+
const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId)
|
|
1748
|
+
ceilingDragAnchor = {
|
|
1749
|
+
ceilingId: event.node.id,
|
|
1750
|
+
rawX,
|
|
1751
|
+
rawZ,
|
|
1752
|
+
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
|
1753
|
+
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
ceilingMoveEvent = {
|
|
1757
|
+
...event,
|
|
1758
|
+
localPosition: [
|
|
1759
|
+
ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
|
|
1760
|
+
event.localPosition[1],
|
|
1761
|
+
ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
|
|
1762
|
+
],
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
lastRawPos.current.set(
|
|
1766
|
+
ceilingMoveEvent.localPosition[0],
|
|
1767
|
+
ceilingMoveEvent.localPosition[1],
|
|
1768
|
+
ceilingMoveEvent.localPosition[2],
|
|
1769
|
+
)
|
|
1770
|
+
const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
|
|
1289
1771
|
if (!result) return
|
|
1290
1772
|
|
|
1291
1773
|
event.stopPropagation()
|
|
@@ -1335,9 +1817,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1335
1817
|
if (draftNode.current) {
|
|
1336
1818
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1337
1819
|
}
|
|
1338
|
-
draftNode.
|
|
1820
|
+
const committedId = draftNode.current?.id ?? null
|
|
1821
|
+
const wasAdopted = draftNode.isAdopted
|
|
1822
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1339
1823
|
|
|
1340
|
-
|
|
1824
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1341
1825
|
const nodes = useScene.getState().nodes
|
|
1342
1826
|
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
1343
1827
|
if (enterResult) {
|
|
@@ -1345,10 +1829,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1345
1829
|
} else {
|
|
1346
1830
|
revalidate()
|
|
1347
1831
|
}
|
|
1348
|
-
}
|
|
1832
|
+
})
|
|
1349
1833
|
}
|
|
1350
1834
|
|
|
1351
1835
|
const onCeilingLeave = (event: CeilingEvent) => {
|
|
1836
|
+
ceilingDragAnchor = null
|
|
1352
1837
|
const result = ceilingStrategy.leave(getContext())
|
|
1353
1838
|
if (!result) return
|
|
1354
1839
|
|
|
@@ -1422,7 +1907,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1422
1907
|
}
|
|
1423
1908
|
return
|
|
1424
1909
|
}
|
|
1425
|
-
const
|
|
1910
|
+
const shelfWorld =
|
|
1911
|
+
ctx.state.shelfId !== null
|
|
1912
|
+
? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
|
|
1913
|
+
: null
|
|
1914
|
+
const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
|
|
1915
|
+
const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
|
|
1426
1916
|
if (!result) return
|
|
1427
1917
|
|
|
1428
1918
|
event.stopPropagation()
|
|
@@ -1467,26 +1957,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1467
1957
|
if (draftNode.current) {
|
|
1468
1958
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1469
1959
|
}
|
|
1470
|
-
draftNode.
|
|
1960
|
+
const committedId = draftNode.current?.id ?? null
|
|
1961
|
+
const wasAdopted = draftNode.isAdopted
|
|
1962
|
+
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1471
1963
|
|
|
1472
|
-
|
|
1964
|
+
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1473
1965
|
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
|
|
1474
1966
|
if (enterResult) {
|
|
1475
1967
|
applyTransition(enterResult)
|
|
1476
1968
|
} else {
|
|
1477
1969
|
revalidate()
|
|
1478
1970
|
}
|
|
1479
|
-
}
|
|
1971
|
+
})
|
|
1480
1972
|
}
|
|
1481
1973
|
|
|
1482
1974
|
// ---- Keyboard rotation ----
|
|
1483
1975
|
|
|
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
1976
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1488
|
-
if (event.key === '
|
|
1489
|
-
|
|
1977
|
+
if (event.key === 'Alt') {
|
|
1978
|
+
altFreeRef.current = true
|
|
1490
1979
|
revalidate()
|
|
1491
1980
|
return
|
|
1492
1981
|
}
|
|
@@ -1499,17 +1988,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1499
1988
|
const draft = draftNode.current
|
|
1500
1989
|
if (!draft) return
|
|
1501
1990
|
|
|
1502
|
-
|
|
1991
|
+
// Roof-wall drafts live flat in the host face frame (yaw 0) —
|
|
1992
|
+
// manual rotation would skew them off the wall plane.
|
|
1993
|
+
if (placementState.current.surface === 'roof-wall') return
|
|
1994
|
+
|
|
1995
|
+
let rotationDir: 1 | -1 | 0 = 0
|
|
1503
1996
|
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
|
1504
|
-
|
|
1997
|
+
rotationDir = 1
|
|
1505
1998
|
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
|
|
1506
|
-
|
|
1999
|
+
rotationDir = -1
|
|
1507
2000
|
|
|
1508
|
-
if (
|
|
2001
|
+
if (rotationDir !== 0) {
|
|
1509
2002
|
event.preventDefault()
|
|
1510
2003
|
sfxEmitter.emit('sfx:item-rotate')
|
|
1511
2004
|
const currentRotation = draft.rotation
|
|
1512
|
-
|
|
2005
|
+
// Round to the nearest 45° then step, matching the placed-item R/T.
|
|
2006
|
+
const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
|
|
1513
2007
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
1514
2008
|
|
|
1515
2009
|
// Ref + cursor mesh + item mesh — no store update during drag
|
|
@@ -1564,8 +2058,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1564
2058
|
worldSnapped.y,
|
|
1565
2059
|
worldSnapped.z,
|
|
1566
2060
|
)
|
|
2061
|
+
const surfaceQuat = new Quaternion()
|
|
2062
|
+
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
2063
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
1567
2064
|
if (cursorGroupRef.current) {
|
|
1568
2065
|
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
|
2066
|
+
// The box lives in building-local space while the mesh is parented to the host
|
|
2067
|
+
// item, so add the host's world yaw: the box must track the item's true
|
|
2068
|
+
// orientation, not its host-local `rotation[1]`.
|
|
2069
|
+
cursorGroupRef.current.rotation.y = newRotationY + surfaceWorldY
|
|
1569
2070
|
}
|
|
1570
2071
|
if (mesh) mesh.position.set(x, y, z)
|
|
1571
2072
|
}
|
|
@@ -1596,8 +2097,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1596
2097
|
}
|
|
1597
2098
|
|
|
1598
2099
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
1599
|
-
if (event.key === '
|
|
1600
|
-
|
|
2100
|
+
if (event.key === 'Alt') {
|
|
2101
|
+
altFreeRef.current = false
|
|
1601
2102
|
revalidate()
|
|
1602
2103
|
}
|
|
1603
2104
|
}
|
|
@@ -1614,13 +2115,35 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1614
2115
|
}
|
|
1615
2116
|
emitter.on('tool:cancel', onCancel)
|
|
1616
2117
|
|
|
1617
|
-
// ---- Right-click cancel ----
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
2118
|
+
// ---- Right-click cancel (quick click only, never a right-drag orbit) ----
|
|
2119
|
+
// The right button is also the camera-orbit control, so a contextmenu/up
|
|
2120
|
+
// alone can't tell "cancel placement" from "move the camera". Record the
|
|
2121
|
+
// right-button-down point + time and only cancel on release when the
|
|
2122
|
+
// pointer barely moved within a short window — a longer / further press is
|
|
2123
|
+
// an orbit and must leave the placement untouched.
|
|
2124
|
+
let rightDown: { x: number; y: number; t: number } | null = null
|
|
2125
|
+
const onRightPointerDown = (event: PointerEvent) => {
|
|
2126
|
+
if (event.button !== 2) return
|
|
2127
|
+
rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
|
|
2128
|
+
}
|
|
2129
|
+
const onRightPointerUp = (event: PointerEvent) => {
|
|
2130
|
+
if (event.button !== 2) return
|
|
2131
|
+
const down = rightDown
|
|
2132
|
+
rightDown = null
|
|
2133
|
+
if (!down || !configRef.current.onCancel) return
|
|
2134
|
+
const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
|
|
2135
|
+
const elapsed = performance.now() - down.t
|
|
2136
|
+
if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
|
|
2137
|
+
onCancel()
|
|
1622
2138
|
}
|
|
1623
2139
|
}
|
|
2140
|
+
// Suppress the OS context menu while placing; the cancel itself is decided
|
|
2141
|
+
// on pointerup above.
|
|
2142
|
+
const onContextMenu = (event: MouseEvent) => {
|
|
2143
|
+
if (configRef.current.onCancel) event.preventDefault()
|
|
2144
|
+
}
|
|
2145
|
+
window.addEventListener('pointerdown', onRightPointerDown, true)
|
|
2146
|
+
window.addEventListener('pointerup', onRightPointerUp, true)
|
|
1624
2147
|
window.addEventListener('contextmenu', onContextMenu)
|
|
1625
2148
|
|
|
1626
2149
|
// ---- Bounding box geometry ----
|
|
@@ -1673,6 +2196,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1673
2196
|
emitter.on('wall:move', onWallMove)
|
|
1674
2197
|
emitter.on('wall:click', onWallClick)
|
|
1675
2198
|
emitter.on('wall:leave', onWallLeave)
|
|
2199
|
+
emitter.on('roof:enter', onRoofWallEnter)
|
|
2200
|
+
emitter.on('roof:move', onRoofWallMove)
|
|
2201
|
+
emitter.on('roof:click', onRoofWallClick)
|
|
2202
|
+
emitter.on('roof:leave', onRoofWallLeave)
|
|
1676
2203
|
emitter.on('ceiling:enter', onCeilingEnter)
|
|
1677
2204
|
emitter.on('ceiling:move', onCeilingMove)
|
|
1678
2205
|
emitter.on('ceiling:click', onCeilingClick)
|
|
@@ -1681,6 +2208,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1681
2208
|
emitter.on('shelf:move', onShelfMove)
|
|
1682
2209
|
emitter.on('shelf:click', onShelfClick)
|
|
1683
2210
|
emitter.on('shelf:leave', onShelfLeave)
|
|
2211
|
+
|
|
2212
|
+
// A floor placement commits at the tracked floor cursor (`gridPosition`),
|
|
2213
|
+
// which keeps following the floor even when the click ray lands on a wall
|
|
2214
|
+
// (grid:move uses a separate ground-plane raycast). Without this, a commit
|
|
2215
|
+
// click whose ray hits a wall fires only `wall:click` — whose handler
|
|
2216
|
+
// declines for a floor item — and the click is silently eaten (the user
|
|
2217
|
+
// has to click again until the ray happens to clear the wall). Route every
|
|
2218
|
+
// surface click to the floor commit too; `floorStrategy.click` guards on
|
|
2219
|
+
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
|
|
2220
|
+
// draft is actually resting on that surface.
|
|
2221
|
+
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
|
|
2222
|
+
if (placementState.current.surface !== 'floor') return
|
|
2223
|
+
onGridClick(event as unknown as GridEvent)
|
|
2224
|
+
}
|
|
2225
|
+
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
|
|
2226
|
+
emitter.on('item:click', commitFloorOnSurfaceClick as never)
|
|
2227
|
+
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2228
|
+
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
|
|
2229
|
+
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
|
|
1684
2230
|
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
|
1685
2231
|
|
|
1686
2232
|
return () => {
|
|
@@ -1704,6 +2250,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1704
2250
|
emitter.off('wall:move', onWallMove)
|
|
1705
2251
|
emitter.off('wall:click', onWallClick)
|
|
1706
2252
|
emitter.off('wall:leave', onWallLeave)
|
|
2253
|
+
emitter.off('roof:enter', onRoofWallEnter)
|
|
2254
|
+
emitter.off('roof:move', onRoofWallMove)
|
|
2255
|
+
emitter.off('roof:click', onRoofWallClick)
|
|
2256
|
+
emitter.off('roof:leave', onRoofWallLeave)
|
|
1707
2257
|
emitter.off('ceiling:enter', onCeilingEnter)
|
|
1708
2258
|
emitter.off('ceiling:move', onCeilingMove)
|
|
1709
2259
|
emitter.off('ceiling:click', onCeilingClick)
|
|
@@ -1712,9 +2262,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1712
2262
|
emitter.off('shelf:move', onShelfMove)
|
|
1713
2263
|
emitter.off('shelf:click', onShelfClick)
|
|
1714
2264
|
emitter.off('shelf:leave', onShelfLeave)
|
|
2265
|
+
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
|
|
2266
|
+
emitter.off('item:click', commitFloorOnSurfaceClick as never)
|
|
2267
|
+
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2268
|
+
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
|
|
2269
|
+
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
|
|
1715
2270
|
emitter.off('tool:cancel', onCancel)
|
|
1716
2271
|
window.removeEventListener('keydown', onKeyDown)
|
|
1717
2272
|
window.removeEventListener('keyup', onKeyUp)
|
|
2273
|
+
window.removeEventListener('pointerdown', onRightPointerDown, true)
|
|
2274
|
+
window.removeEventListener('pointerup', onRightPointerUp, true)
|
|
1718
2275
|
window.removeEventListener('contextmenu', onContextMenu)
|
|
1719
2276
|
}
|
|
1720
2277
|
}, [
|
|
@@ -1749,6 +2306,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1749
2306
|
const draft = draftNode.current
|
|
1750
2307
|
if (!(draft && viewerLevelId) || asset.attachTo) return
|
|
1751
2308
|
if (draft.parentId === viewerLevelId) return
|
|
2309
|
+
// A non-attach item resting on a host surface (table / counter / shelf) is
|
|
2310
|
+
// intentionally parented to that host while it's moved — the surface move
|
|
2311
|
+
// handlers keep it hosted and the commit writes the host parent back. Only
|
|
2312
|
+
// free floor items get re-homed to the level here; yanking a hosted item
|
|
2313
|
+
// onto the level would re-interpret its host-local position in level space
|
|
2314
|
+
// and float the dragged mesh off the host toward the building origin.
|
|
2315
|
+
const draftParent = draft.parentId
|
|
2316
|
+
? useScene.getState().nodes[draft.parentId as AnyNodeId]
|
|
2317
|
+
: undefined
|
|
2318
|
+
if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
|
|
1752
2319
|
draft.parentId = viewerLevelId
|
|
1753
2320
|
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
|
1754
2321
|
}, [viewerLevelId, draftNode, asset])
|
|
@@ -1784,7 +2351,74 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1784
2351
|
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
|
1785
2352
|
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
|
1786
2353
|
|
|
1787
|
-
|
|
2354
|
+
// Publish the ghost's surface (contact point + normal) so the grid's snap
|
|
2355
|
+
// patch sits at the item's resolved height (e.g. a shelf top) and orients to
|
|
2356
|
+
// the surface (vertical in a wall plane), AND publish the forward-facing
|
|
2357
|
+
// triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
|
|
2358
|
+
// instead of drawing an inline triangle. Only this coordinator publishes — a
|
|
2359
|
+
// moving existing node has no draft here, so the grid reads that case straight
|
|
2360
|
+
// off the node's mesh. Cleared when idle.
|
|
2361
|
+
const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
|
|
2362
|
+
const facingForwardRef = useRef(new Vector3(0, 0, 1))
|
|
2363
|
+
const facingQuatRef = useRef(new Quaternion())
|
|
2364
|
+
useFrame(() => {
|
|
2365
|
+
const ghost = cursorGroupRef.current
|
|
2366
|
+
if (!(asset && ghost)) {
|
|
2367
|
+
clearPlacementSurface()
|
|
2368
|
+
useFacingPose.getState().clear()
|
|
2369
|
+
return
|
|
2370
|
+
}
|
|
2371
|
+
const surf = placementState.current.surface
|
|
2372
|
+
const n = surfaceNormalRef.current
|
|
2373
|
+
const shape = facingShapeRef.current
|
|
2374
|
+
// Triangle yaw / Y default to the floor case: the cursor group's own yaw is
|
|
2375
|
+
// the item's forward on the floor, and the triangle rides at the ghost's Y.
|
|
2376
|
+
let facingYaw = ghost.rotation.y
|
|
2377
|
+
let facingY = ghost.position.y
|
|
2378
|
+
if (surf === 'wall' || surf === 'roof-wall') {
|
|
2379
|
+
// Wall/roof-segment faces: the cursor group's yaw is the symmetric
|
|
2380
|
+
// wireframe yaw (π off the real facing for a wall, and a different frame
|
|
2381
|
+
// for a roof face), so derive the item's TRUE outward facing from the
|
|
2382
|
+
// draft mesh's world orientation — its local +Z faces out of the host
|
|
2383
|
+
// surface. This keeps BOTH the grid normal and the triangle correct for
|
|
2384
|
+
// wall and roof-segment hosts alike, rather than the old quaternion read
|
|
2385
|
+
// that pointed the wrong way.
|
|
2386
|
+
const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
|
|
2387
|
+
if (mesh) {
|
|
2388
|
+
mesh.getWorldQuaternion(facingQuatRef.current)
|
|
2389
|
+
const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
|
|
2390
|
+
fwd.y = 0
|
|
2391
|
+
if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
|
|
2392
|
+
}
|
|
2393
|
+
// The forward triangle is a floor aid; drop it to the building-local floor
|
|
2394
|
+
// under the wall (the ghost Y is up on the wall).
|
|
2395
|
+
facingY = 0
|
|
2396
|
+
n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
|
|
2397
|
+
} else {
|
|
2398
|
+
n.set(0, 1, 0)
|
|
2399
|
+
}
|
|
2400
|
+
publishPlacementSurface(ghost.position, n)
|
|
2401
|
+
|
|
2402
|
+
if (shape.depth > 0) {
|
|
2403
|
+
useFacingPose.getState().set({
|
|
2404
|
+
position: [ghost.position.x, facingY, ghost.position.z],
|
|
2405
|
+
rotationY: facingYaw,
|
|
2406
|
+
depth: shape.depth,
|
|
2407
|
+
center: shape.center,
|
|
2408
|
+
})
|
|
2409
|
+
} else {
|
|
2410
|
+
useFacingPose.getState().clear()
|
|
2411
|
+
}
|
|
2412
|
+
})
|
|
2413
|
+
useEffect(
|
|
2414
|
+
() => () => {
|
|
2415
|
+
clearPlacementSurface()
|
|
2416
|
+
useFacingPose.getState().clear()
|
|
2417
|
+
},
|
|
2418
|
+
[],
|
|
2419
|
+
)
|
|
2420
|
+
|
|
2421
|
+
useFrame(() => {
|
|
1788
2422
|
if (!asset) {
|
|
1789
2423
|
reconcileDraftRaycast(null)
|
|
1790
2424
|
return
|
|
@@ -1815,12 +2449,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1815
2449
|
mesh.visible = true
|
|
1816
2450
|
|
|
1817
2451
|
if (placementState.current.surface === 'floor') {
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
2452
|
+
// Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
|
|
2453
|
+
// active move visibly trail the cursor and — combined with React
|
|
2454
|
+
// re-renders momentarily pulling the mesh back toward its committed
|
|
2455
|
+
// position — read as a laggy snap-back on every move. Copying each frame
|
|
2456
|
+
// locks placement/move to the cursor and overrides any stray reset
|
|
2457
|
+
// within a single frame, so it feels precise instead of dragging.
|
|
2458
|
+
mesh.position.copy(gridPosition.current)
|
|
1824
2459
|
|
|
1825
2460
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
|
1826
2461
|
if (!asset.attachTo) {
|
|
@@ -1841,7 +2476,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1841
2476
|
? getScaledDimensions(initialDraft)
|
|
1842
2477
|
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
|
1843
2478
|
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
|
1844
|
-
const wallSideZOffset = initialAttachTo === 'wall-side' ?
|
|
2479
|
+
const wallSideZOffset = initialAttachTo === 'wall-side' ? dims[2] / 2 : 0
|
|
1845
2480
|
const initialDimensionBounds = expandBoundsToGrid(
|
|
1846
2481
|
getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
|
|
1847
2482
|
initialAttachTo,
|
|
@@ -1869,9 +2504,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1869
2504
|
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1870
2505
|
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
|
1871
2506
|
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2507
|
+
// Feed the footprint shape to the per-frame surface publisher, which orients
|
|
2508
|
+
// and positions the forward-facing triangle via `useFacingPose`.
|
|
2509
|
+
facingShapeRef.current = {
|
|
2510
|
+
depth: currentDimensionBounds.dimensions[2],
|
|
2511
|
+
center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
|
|
2512
|
+
}
|
|
2513
|
+
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
|
|
2514
|
+
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
|
|
2515
|
+
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
|
|
1875
2516
|
const widthLabelPosition: [number, number, number] = [
|
|
1876
2517
|
currentDimensionBounds.center[0],
|
|
1877
2518
|
0.04,
|
|
@@ -1887,7 +2528,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1887
2528
|
currentDimensionBounds.dimensions[1] / 2,
|
|
1888
2529
|
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
|
|
1889
2530
|
]
|
|
1890
|
-
|
|
1891
2531
|
const measurementContent = (
|
|
1892
2532
|
<>
|
|
1893
2533
|
<lineSegments
|