@pascal-app/editor 0.8.0 → 0.9.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.
Files changed (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,46 +1,62 @@
1
- import type { AssetInput } from '@pascal-app/core'
1
+ import type { AssetInput, ItemNode } from '@pascal-app/core'
2
2
  import {
3
+ type AlignmentAnchor,
4
+ type AnyNode,
3
5
  type AnyNodeId,
4
6
  type CeilingEvent,
7
+ collectAlignmentAnchors,
5
8
  emitter,
6
9
  type GridEvent,
7
10
  getScaledDimensions,
8
11
  type ItemEvent,
12
+ movingFootprintAnchors,
9
13
  resolveLevelId,
14
+ type ShelfEvent,
10
15
  sceneRegistry,
11
- spatialGridManager,
12
16
  useLiveTransforms,
13
17
  useScene,
14
18
  useSpatialQuery,
15
19
  type WallEvent,
16
20
  type WallNode,
17
21
  } from '@pascal-app/core'
22
+ import { useAlignmentGuides } from '@pascal-app/editor'
18
23
  import { useViewer } from '@pascal-app/viewer'
19
24
  import { Html } from '@react-three/drei'
20
- import { useFrame } from '@react-three/fiber'
21
- import { useEffect, useMemo, useRef, useState } from 'react'
25
+ import { useFrame, useThree } from '@react-three/fiber'
26
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
27
  import {
23
- BufferGeometry,
28
+ Box3,
24
29
  Euler,
25
- Float32BufferAttribute,
26
30
  type Group,
27
31
  type LineSegments,
32
+ Matrix4,
28
33
  type Mesh,
34
+ type Object3D,
29
35
  PlaneGeometry,
30
36
  Quaternion,
37
+ Ray,
31
38
  Vector3,
32
39
  } from 'three'
33
40
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
34
41
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
35
42
  import { EDITOR_LAYER } from '../../../lib/constants'
36
43
  import { sfxEmitter } from '../../../lib/sfx-bus'
44
+ import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
37
45
  import useEditor from '../../../store/use-editor'
46
+ import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
47
+ import {
48
+ createLineGeometry,
49
+ getBoxEdgePoints,
50
+ type PreviewBounds,
51
+ updateLineGeometry,
52
+ } from '../shared/placement-box-geometry'
38
53
  import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
39
54
  import {
40
55
  ceilingStrategy,
41
56
  checkCanPlace,
42
57
  floorStrategy,
43
58
  itemSurfaceStrategy,
59
+ shelfSurfaceStrategy,
44
60
  wallStrategy,
45
61
  } from './placement-strategies'
46
62
  import type { PlacementState, TransitionResult } from './placement-types'
@@ -48,6 +64,10 @@ import type { DraftNodeHandle } from './use-draft-node'
48
64
 
49
65
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
50
66
 
67
+ /** Figma-style alignment-snap threshold (meters), matching the 2D
68
+ * floor-plan overlay and the 3D registry move tool. */
69
+ const ALIGNMENT_THRESHOLD_M = 0.08
70
+
51
71
  function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
52
72
  if (unit === 'imperial') {
53
73
  const feet = value * 3.280_84
@@ -59,13 +79,6 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
59
79
  return `${Number.parseFloat(value.toFixed(2))}m`
60
80
  }
61
81
 
62
- type PreviewBounds = {
63
- min: [number, number, number]
64
- max: [number, number, number]
65
- dimensions: [number, number, number]
66
- center: [number, number, number]
67
- }
68
-
69
82
  /**
70
83
  * Expand `bounds` outward so each axis is rounded up to the active grid step.
71
84
  * The wireframe stays centered on the original bounds centre on each axis we
@@ -116,10 +129,10 @@ function expandBoundsToGrid(
116
129
 
117
130
  function getFallbackPreviewBounds(
118
131
  item: import('@pascal-app/core').ItemNode | null,
119
- asset: AssetInput,
120
- attachTo: AssetInput['attachTo'],
132
+ asset: AssetInput | null | undefined,
133
+ attachTo: AssetInput['attachTo'] | null | undefined,
121
134
  ): PreviewBounds {
122
- const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
135
+ const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
123
136
  return {
124
137
  min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
125
138
  max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
@@ -128,112 +141,20 @@ function getFallbackPreviewBounds(
128
141
  }
129
142
  }
130
143
 
131
- function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
132
- const geometry = new BufferGeometry()
133
- geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
134
- return geometry
135
- }
136
-
137
- function getBoxEdgePoints(bounds: PreviewBounds): number[] {
138
- const [width, height, depth] = bounds.dimensions
139
- const [centerX, centerY, centerZ] = bounds.center
140
- const minX = centerX - width / 2
141
- const maxX = centerX + width / 2
142
- const minY = centerY - height / 2
143
- const maxY = centerY + height / 2
144
- const minZ = centerZ - depth / 2
145
- const maxZ = centerZ + depth / 2
146
-
147
- return [
148
- minX,
149
- minY,
150
- minZ,
151
- maxX,
152
- minY,
153
- minZ,
154
- maxX,
155
- minY,
156
- minZ,
157
- maxX,
158
- minY,
159
- maxZ,
160
- maxX,
161
- minY,
162
- maxZ,
163
- minX,
164
- minY,
165
- maxZ,
166
- minX,
167
- minY,
168
- maxZ,
169
- minX,
170
- minY,
171
- minZ,
172
-
173
- minX,
174
- maxY,
175
- minZ,
176
- maxX,
177
- maxY,
178
- minZ,
179
- maxX,
180
- maxY,
181
- minZ,
182
- maxX,
183
- maxY,
184
- maxZ,
185
- maxX,
186
- maxY,
187
- maxZ,
188
- minX,
189
- maxY,
190
- maxZ,
191
- minX,
192
- maxY,
193
- maxZ,
194
- minX,
195
- maxY,
196
- minZ,
197
-
198
- minX,
199
- minY,
200
- minZ,
201
- minX,
202
- maxY,
203
- minZ,
204
- maxX,
205
- minY,
206
- minZ,
207
- maxX,
208
- maxY,
209
- minZ,
210
- maxX,
211
- minY,
212
- maxZ,
213
- maxX,
214
- maxY,
215
- maxZ,
216
- minX,
217
- minY,
218
- maxZ,
219
- minX,
220
- maxY,
221
- maxZ,
222
- ]
223
- }
144
+ function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
145
+ const scaled = getScaledDimensions(item)
146
+ const aligned = getGridAlignedDimensions(scaled, item.asset.attachTo)
147
+ if (scaled[0] === aligned[0] && scaled[1] === aligned[1] && scaled[2] === aligned[2]) {
148
+ return item
149
+ }
224
150
 
225
- function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
226
- const geometry = ref.current?.geometry
227
- if (!geometry) return
151
+ const scaleAxis = (axis: 0 | 1 | 2) =>
152
+ scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis])
228
153
 
229
- const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
230
- if (!attribute || attribute.array.length !== points.length) {
231
- geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
232
- } else {
233
- attribute.set(points)
234
- attribute.needsUpdate = true
154
+ return {
155
+ ...item,
156
+ scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number],
235
157
  }
236
- geometry.computeBoundingSphere()
237
158
  }
238
159
 
239
160
  // Shared materials for placement cursor - we just change colors, not swap materials
@@ -274,6 +195,8 @@ export interface PlacementCoordinatorConfig {
274
195
  initialState?: PlacementState
275
196
  /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
276
197
  defaultScale?: [number, number, number]
198
+ /** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
199
+ preserveFloorDragOffset?: boolean
277
200
  }
278
201
 
279
202
  export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
@@ -285,13 +208,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
285
208
  const basePlaneRef = useRef<Mesh>(null!)
286
209
  const gridPosition = useRef(new Vector3(0, 0, 0))
287
210
  const lastRawPos = useRef(new Vector3(0, 0, 0))
211
+ const lastWallDirtyAtRef = useRef(new Map<string, number>())
288
212
  const placementState = useRef<PlacementState>(
289
- config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
213
+ config.initialState ?? {
214
+ surface: 'floor',
215
+ wallId: null,
216
+ ceilingId: null,
217
+ surfaceItemId: null,
218
+ shelfId: null,
219
+ },
290
220
  )
291
221
  const shiftFreeRef = useRef(false)
292
222
  const previewBoundsSignatureRef = useRef<string | null>(null)
223
+ // Goes true the first time a 3D pointer event drives this coordinator.
224
+ // The per-frame mesh-position lerp below is only useful for that path;
225
+ // when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
226
+ // writing scene.position directly), the lerp fights React's render and
227
+ // pulls the rendered item back toward its pre-move position. Gating
228
+ // the lerp on this flag keeps 3D placement smooth without hijacking
229
+ // 2D drags that share the same draft.
230
+ const has3DPointerDrivenMoveRef = useRef(false)
231
+ // The draft mesh's raycast is disabled while placing so the cursor ray
232
+ // passes through it to the surface beneath (grid / item / shelf). Without
233
+ // this, the ray hits the moving draft first and the surface strategy keeps
234
+ // re-deriving the host point from the draft's own (just-moved) geometry —
235
+ // on a multi-row shelf this oscillates the chosen row, jittering the item.
236
+ // Mirrors MoveRegistryNodeTool. Reconciled per-frame (the draft mesh can be
237
+ // recreated mid-session) and restored on unmount.
238
+ const raycastDisabledMeshRef = useRef<Object3D | null>(null)
239
+ const restoreRaycastsRef = useRef<Array<() => void>>([])
240
+ const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
293
241
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
294
242
 
243
+ // Live camera ref — the shelf-stickiness test reconstructs the cursor world
244
+ // ray (camera → grid hit) to check it still points at the shelf volume.
245
+ const camera = useThree((s) => s.camera)
246
+ const cameraRef = useRef(camera)
247
+ cameraRef.current = camera
248
+
295
249
  // Store config callbacks in refs to avoid re-running effect when they change
296
250
  const configRef = useRef(config)
297
251
  configRef.current = config
@@ -300,7 +254,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
300
254
  const { asset, draftNode } = config
301
255
  const unit = useViewer((state) => state.unit)
302
256
  const gridSnapStep = useEditor((s) => s.gridSnapStep)
303
- const updatePreviewGeometry = (bounds: PreviewBounds) => {
257
+ const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
304
258
  const [width, height, depth] = bounds.dimensions
305
259
  const [centerX, centerY, centerZ] = bounds.center
306
260
  const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
@@ -317,9 +271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
317
271
  const oldBasePlaneGeometry = basePlaneRef.current.geometry
318
272
  basePlaneRef.current.geometry = nextBasePlaneGeometry
319
273
  oldBasePlaneGeometry.dispose()
320
- }
274
+ }, [])
321
275
 
322
- const updateDimensionGuides = (bounds: PreviewBounds) => {
276
+ const updateDimensionGuides = useCallback((bounds: PreviewBounds) => {
323
277
  setDimensionBounds((current) => {
324
278
  if (
325
279
  current &&
@@ -421,7 +375,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
421
375
  applyPoints(measurementWidthRef, widthPoints)
422
376
  applyPoints(measurementDepthRef, depthPoints)
423
377
  applyPoints(measurementHeightRef, heightPoints)
424
- }
378
+ }, [])
379
+
380
+ const getFloorVisualPosition = useCallback(
381
+ (
382
+ position: [number, number, number],
383
+ nodeUpdate?: Partial<ItemNode>,
384
+ ): [number, number, number] => {
385
+ const draft = draftNode.current
386
+ if (!(draft && !asset?.attachTo)) return position
387
+ const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode)
388
+ return getFloorStackPreviewPosition({
389
+ node: previewNode,
390
+ position,
391
+ rotation: previewNode.rotation,
392
+ })
393
+ },
394
+ [asset?.attachTo, draftNode],
395
+ )
425
396
 
426
397
  useEffect(() => {
427
398
  if (!asset) return
@@ -429,16 +400,28 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
429
400
 
430
401
  const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
431
402
 
403
+ // Lazily-gathered alignment candidates — the corner anchors of every
404
+ // OTHER floor-placed node, excluding the draft. Computed on the first
405
+ // floor move (once the draft id exists) and reused for the rest of the
406
+ // drag; the scene graph is stable during placement. Coords are
407
+ // building-local, matching the draft's grid position and the guide
408
+ // layer's frame.
409
+ let alignmentCandidates: AlignmentAnchor[] | null = null
410
+ let floorDragAnchor: [number, number] | null = null
411
+
432
412
  // Reset placement state
433
413
  placementState.current = configRef.current.initialState ?? {
434
414
  surface: 'floor',
435
415
  wallId: null,
436
416
  ceilingId: null,
437
417
  surfaceItemId: null,
418
+ shelfId: null,
438
419
  }
439
420
  if (!asset.attachTo && placementState.current.surface === 'floor') {
440
421
  gridPosition.current.y = 0
441
- cursorGroupRef.current.position.y = 0
422
+ if (cursorGroupRef.current) {
423
+ cursorGroupRef.current.position.y = 0
424
+ }
442
425
  }
443
426
 
444
427
  // ---- Helpers ----
@@ -449,7 +432,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
449
432
  draftItem: draftNode.current,
450
433
  gridPosition: gridPosition.current,
451
434
  state: { ...placementState.current },
452
- currentCursorRotationY: cursorGroupRef.current.rotation.y,
435
+ currentCursorRotationY:
436
+ cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
453
437
  })
454
438
 
455
439
  const getActiveValidators = () =>
@@ -479,12 +463,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
479
463
  }
480
464
 
481
465
  const applyTransition = (result: TransitionResult) => {
466
+ // Alignment guides are floor-only; clear them when the cursor moves
467
+ // onto a wall / ceiling / item surface (only those paths call this).
468
+ useAlignmentGuides.getState().clear()
482
469
  Object.assign(placementState.current, result.stateUpdate)
483
470
  gridPosition.current.set(...result.gridPosition)
484
471
 
485
472
  const c = worldToBuildingLocal(...result.cursorPosition)
486
- cursorGroupRef.current.position.set(c.x, c.y, c.z)
487
- cursorGroupRef.current.rotation.y = result.cursorRotationY
473
+ if (cursorGroupRef.current) {
474
+ cursorGroupRef.current.position.set(c.x, c.y, c.z)
475
+ if (result.cursorRotation) {
476
+ cursorGroupRef.current.rotation.set(...result.cursorRotation)
477
+ } else {
478
+ cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
479
+ }
480
+ }
488
481
 
489
482
  const draft = draftNode.current
490
483
  if (draft) {
@@ -497,15 +490,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
497
490
  const ensureDraft = (result: TransitionResult) => {
498
491
  gridPosition.current.set(...result.gridPosition)
499
492
  const c = worldToBuildingLocal(...result.cursorPosition)
500
- cursorGroupRef.current.position.set(c.x, c.y, c.z)
501
- cursorGroupRef.current.rotation.y = result.cursorRotationY
493
+ if (cursorGroupRef.current) {
494
+ cursorGroupRef.current.position.set(c.x, c.y, c.z)
495
+ if (result.cursorRotation) {
496
+ cursorGroupRef.current.rotation.set(...result.cursorRotation)
497
+ } else {
498
+ cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
499
+ }
500
+ }
502
501
 
503
- draftNode.create(
504
- gridPosition.current,
505
- asset,
506
- [0, result.cursorRotationY, 0],
507
- configRef.current.defaultScale,
508
- )
502
+ const initRotation: [number, number, number] = result.cursorRotation ?? [
503
+ 0,
504
+ result.cursorRotationY,
505
+ 0,
506
+ ]
507
+
508
+ draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale)
509
509
 
510
510
  const draft = draftNode.current
511
511
  if (draft) {
@@ -514,6 +514,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
514
514
  useScene.getState().updateNode(draft.id, result.nodeUpdate)
515
515
  }
516
516
 
517
+ const previewBounds = expandBoundsToGrid(
518
+ getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
519
+ asset.attachTo,
520
+ gridSnapStep,
521
+ )
522
+ updatePreviewGeometry(previewBounds)
523
+ updateDimensionGuides(previewBounds)
524
+
517
525
  if (!revalidate()) {
518
526
  draftNode.destroy()
519
527
  }
@@ -521,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
521
529
 
522
530
  // ---- Init draft ----
523
531
  configRef.current.initDraft(gridPosition.current)
532
+ const preserveFloorDragOffset =
533
+ configRef.current.preserveFloorDragOffset === true &&
534
+ placementState.current.surface === 'floor' &&
535
+ !asset.attachTo
536
+ const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
524
537
 
525
538
  // Sync cursor to the draft mesh's world position and rotation
526
539
  if (draftNode.current) {
@@ -529,12 +542,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
529
542
  const worldPos = new Vector3()
530
543
  mesh.getWorldPosition(worldPos)
531
544
  const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
532
- cursorGroupRef.current.position.copy(localPos)
533
- // Extract world Y rotation (handles wall-parented items correctly)
534
- const q = new Quaternion()
535
- mesh.getWorldQuaternion(q)
536
- cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
537
- } else {
545
+ if (cursorGroupRef.current) {
546
+ cursorGroupRef.current.position.copy(localPos)
547
+ if (draftNode.current.asset.attachTo) {
548
+ // Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
549
+ const q = new Quaternion()
550
+ mesh.getWorldQuaternion(q)
551
+ cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
552
+ } else {
553
+ // Floor items: the cursor group lives in building-local space, so use the
554
+ // node's local Y rotation — the same value onGridMove applies. The world
555
+ // quaternion would double-count any building rotation, leaving the initial
556
+ // box mis-rotated until the first cursor move.
557
+ cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
558
+ }
559
+ }
560
+ } else if (cursorGroupRef.current) {
538
561
  cursorGroupRef.current.position.copy(gridPosition.current)
539
562
  cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
540
563
  }
@@ -542,44 +565,190 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
542
565
 
543
566
  revalidate()
544
567
 
568
+ // ---- Press-drag commit-on-release ----
569
+ // When the move was engaged by the press-drag move cross (vs. click-to-
570
+ // place), commit on pointer-up instead of waiting for a click. Each surface
571
+ // move handler records how to commit at the current cursor; the release
572
+ // replays it. Captured once at setup — a fresh coordinator mounts per move.
573
+ const dragMode = useEditor.getState().placementDragMode
574
+ let releaseCommit: (() => void) | null = null
575
+ // Eat the click the browser fires after pointer-up so the surface
576
+ // `:click` handlers don't commit a second time.
577
+ const swallowNextClick = () => {
578
+ const swallow = (e: Event) => {
579
+ e.stopPropagation()
580
+ e.preventDefault()
581
+ }
582
+ window.addEventListener('click', swallow, { capture: true, once: true })
583
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
584
+ }
585
+ const onReleaseCommit = () => {
586
+ if (!releaseCommit) return
587
+ const commit = releaseCommit
588
+ releaseCommit = null
589
+ swallowNextClick()
590
+ commit()
591
+ }
592
+
545
593
  // ---- Floor Handlers ----
546
594
 
547
595
  let previousGridPos: [number, number, number] | null = null
548
596
 
597
+ // Scratch objects reused by the stickiness test (runs per grid:move).
598
+ const stickyRay = new Ray()
599
+ const stickyBox = new Box3()
600
+ const stickyMat = new Matrix4()
601
+ const stickyCamPos = new Vector3()
602
+
603
+ // True while the cursor ray still points at the active shelf's volume.
604
+ // Used to keep an item hosted on a shelf "sticky": from an angled camera
605
+ // the cursor ray slips off the shelf's thin boards / through its gaps and
606
+ // lands on the floor *behind* the shelf, which would otherwise thrash the
607
+ // placement between the shelf row and the floor on every micro-move. We
608
+ // reconstruct the world ray (camera → grid hit point) and test it against
609
+ // the shelf's bounding box — so a ray that passes *through* the shelf but
610
+ // lands behind it still counts as "on the shelf". Only a ray that misses
611
+ // the shelf box entirely means the user genuinely moved off it. A simple
612
+ // footprint test on the floor hit point can't distinguish those.
613
+ const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
614
+ const shelfId = placementState.current.shelfId
615
+ if (!shelfId) return false
616
+ const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
617
+ const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
618
+ | { width?: number; depth?: number; height?: number }
619
+ | undefined
620
+ if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
621
+
622
+ cameraRef.current.getWorldPosition(stickyCamPos)
623
+ stickyRay.origin.copy(stickyCamPos)
624
+ stickyRay.direction
625
+ .set(
626
+ gridWorldPoint[0] - stickyCamPos.x,
627
+ gridWorldPoint[1] - stickyCamPos.y,
628
+ gridWorldPoint[2] - stickyCamPos.z,
629
+ )
630
+ .normalize()
631
+
632
+ // Into shelf-local space, then test the shelf's local AABB (origin at the
633
+ // base: y ∈ [0, height]) with a small margin.
634
+ stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
635
+ const m = 0.08
636
+ stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
637
+ stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
638
+ return stickyRay.intersectsBox(stickyBox)
639
+ }
640
+
549
641
  const onGridMove = (event: GridEvent) => {
642
+ releaseCommit = () => onGridClick(event)
550
643
  // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
551
644
  if (draftNode.current === null && asset.attachTo === undefined) {
552
645
  configRef.current.initDraft(gridPosition.current)
553
646
  }
554
647
 
555
- lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
556
- const result = floorStrategy.move(getContext(), event)
648
+ has3DPointerDrivenMoveRef.current = true
649
+
650
+ // Shelf stickiness: while hosting on a shelf, ignore floor events while
651
+ // the cursor ray still points at the shelf volume (the ray merely slipped
652
+ // off a board / through a gap and hit the floor behind). Detach to the
653
+ // floor only once the ray misses the shelf entirely — without this the
654
+ // item oscillates between the shelf row and the floor on every micro-move.
655
+ if (placementState.current.surface === 'shelf-surface') {
656
+ if (cursorRayIntersectsActiveShelf(event.position)) return
657
+ detachItemSurfaceToFloor(event as unknown as ItemEvent)
658
+ }
659
+
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
677
+
678
+ lastRawPos.current.set(
679
+ floorEvent.localPosition[0],
680
+ floorEvent.localPosition[1],
681
+ floorEvent.localPosition[2],
682
+ )
683
+ if (!cursorGroupRef.current) return
684
+ const result = floorStrategy.move(getContext(), floorEvent)
557
685
  if (!result) return
558
686
 
687
+ // Figma-style alignment snap layered on top of the floor strategy's
688
+ // grid snap: when the draft's edge lines up (on X or Z) with another
689
+ // item's edge, snap and publish a guide. The guide connects to the
690
+ // nearest real corner of the candidate (resolver tie-break), so the dot
691
+ // always sits on an actual point. The delta is applied to BOTH the grid
692
+ // and cursor positions below. Alt bypasses.
693
+ const draft = draftNode.current
694
+ let alignX = 0
695
+ let alignZ = 0
696
+ const bypassAlign = floorEvent.nativeEvent?.altKey === true
697
+ if (!bypassAlign && draft) {
698
+ alignmentCandidates ??= collectAlignmentAnchors(
699
+ useScene.getState().nodes,
700
+ draft.id,
701
+ useViewer.getState().selection.levelId,
702
+ )
703
+ const ar = resolveAlignmentForActiveBuilding({
704
+ moving: movingFootprintAnchors(
705
+ draft as unknown as AnyNode,
706
+ result.gridPosition[0],
707
+ result.gridPosition[2],
708
+ cursorGroupRef.current.rotation.y,
709
+ ),
710
+ candidates: alignmentCandidates,
711
+ threshold: ALIGNMENT_THRESHOLD_M,
712
+ })
713
+ if (ar.snap) {
714
+ alignX = ar.snap.dx
715
+ alignZ = ar.snap.dz
716
+ }
717
+ useAlignmentGuides.getState().set(ar.guides)
718
+ } else {
719
+ useAlignmentGuides.getState().clear()
720
+ }
721
+
722
+ const gridPos: [number, number, number] = [
723
+ result.gridPosition[0] + alignX,
724
+ result.gridPosition[1],
725
+ result.gridPosition[2] + alignZ,
726
+ ]
727
+
559
728
  // Play snap sound when grid position changes
560
729
  if (
561
730
  previousGridPos &&
562
- (result.gridPosition[0] !== previousGridPos[0] ||
563
- result.gridPosition[2] !== previousGridPos[2])
731
+ (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
564
732
  ) {
565
733
  sfxEmitter.emit('sfx:grid-snap')
566
734
  }
567
735
 
568
- previousGridPos = [...result.gridPosition]
569
- gridPosition.current.set(...result.gridPosition)
570
- cursorGroupRef.current.position.set(
571
- result.cursorPosition[0],
572
- result.cursorPosition[1],
573
- result.cursorPosition[2],
574
- )
736
+ previousGridPos = [...gridPos]
737
+ gridPosition.current.set(...gridPos)
738
+ const cursorPosition = getFloorVisualPosition(gridPos)
739
+ cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
740
+ // Floor items only rotate on Y; keep the preview box (and the live
741
+ // transform the 2D floorplan mirrors) aligned with the draft's
742
+ // rotation. Without this the box stays at its seed rotation until a
743
+ // manual R/T, so a moved already-rotated item shows an axis-aligned box.
744
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
575
745
 
576
- const draft = draftNode.current
577
- if (draft) draft.position = result.gridPosition
746
+ if (draft) draft.position = gridPos
578
747
 
579
748
  // Publish live transform for 2D floorplan
580
749
  if (draft) {
581
750
  useLiveTransforms.getState().set(draft.id, {
582
- position: result.gridPosition,
751
+ position: gridPos,
583
752
  rotation: cursorGroupRef.current.rotation.y,
584
753
  })
585
754
  }
@@ -588,11 +757,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
588
757
  }
589
758
 
590
759
  const onGridClick = (event: GridEvent) => {
760
+ // Drop alignment guides on click — the move commits (guides done) or
761
+ // placement re-arms (the next move republishes them).
762
+ useAlignmentGuides.getState().clear()
591
763
  const result = floorStrategy.click(getContext(), event, getActiveValidators())
592
764
  if (!result) return
593
765
 
594
766
  // Preserve cursor rotation for the next draft
595
- const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
767
+ const currentRotation: [number, number, number] = [
768
+ 0,
769
+ cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
770
+ 0,
771
+ ]
596
772
 
597
773
  // Clear live transform before commit
598
774
  if (draftNode.current) {
@@ -602,6 +778,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
602
778
  draftNode.commit(result.nodeUpdate)
603
779
  if (configRef.current.onCommitted()) {
604
780
  draftNode.create(gridPosition.current, asset, currentRotation)
781
+ const previewBounds = expandBoundsToGrid(
782
+ getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
783
+ asset.attachTo,
784
+ gridSnapStep,
785
+ )
786
+ updatePreviewGeometry(previewBounds)
787
+ updateDimensionGuides(previewBounds)
605
788
  revalidate()
606
789
  }
607
790
  }
@@ -609,6 +792,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
609
792
  // ---- Wall Handlers ----
610
793
 
611
794
  const onWallEnter = (event: WallEvent) => {
795
+ has3DPointerDrivenMoveRef.current = true
612
796
  const nodes = useScene.getState().nodes
613
797
  const result = wallStrategy.enter(
614
798
  getContext(),
@@ -634,6 +818,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
634
818
  }
635
819
 
636
820
  const onWallMove = (event: WallEvent) => {
821
+ releaseCommit = () => onWallClick(event)
822
+ has3DPointerDrivenMoveRef.current = true
823
+ if (!cursorGroupRef.current) return
637
824
  const ctx = getContext()
638
825
 
639
826
  if (ctx.state.surface !== 'wall') {
@@ -722,7 +909,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
722
909
  }
723
910
  // Mark parent wall dirty so it rebuilds geometry — only when position changed
724
911
  if (result.dirtyNodeId && posChanged) {
725
- useScene.getState().dirtyNodes.add(result.dirtyNodeId)
912
+ const now = globalThis.performance?.now?.() ?? Date.now()
913
+ const last = lastWallDirtyAtRef.current.get(result.dirtyNodeId) ?? 0
914
+ // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
915
+ if (now - last > 120) {
916
+ lastWallDirtyAtRef.current.set(result.dirtyNodeId, now)
917
+ useScene.getState().dirtyNodes.add(result.dirtyNodeId)
918
+ }
726
919
  }
727
920
 
728
921
  // Publish live transform for 2D floorplan
@@ -806,9 +999,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
806
999
  const wz = Math.round(buildingLocalPoint.z * 2) / 2
807
1000
  const floorPos: [number, number, number] = [wx, 0, wz]
808
1001
 
809
- Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
1002
+ Object.assign(placementState.current, {
1003
+ surface: 'floor',
1004
+ surfaceItemId: null,
1005
+ shelfId: null,
1006
+ })
810
1007
  gridPosition.current.set(wx, 0, wz)
811
- cursorGroupRef.current.position.set(wx, 0, wz)
1008
+ const levelId = useViewer.getState().selection.levelId as AnyNodeId | null
1009
+ const floorVisualPosition = getFloorVisualPosition(
1010
+ floorPos,
1011
+ levelId ? { parentId: levelId } : undefined,
1012
+ )
1013
+ if (cursorGroupRef.current) {
1014
+ cursorGroupRef.current.position.set(...floorVisualPosition)
1015
+ }
812
1016
 
813
1017
  const draft = draftNode.current
814
1018
  if (draft) {
@@ -824,6 +1028,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
824
1028
 
825
1029
  const onItemEnter = (event: ItemEvent) => {
826
1030
  if (event.node.id === draftNode.current?.id) return
1031
+ has3DPointerDrivenMoveRef.current = true
827
1032
  const result = itemSurfaceStrategy.enter(getContext(), event)
828
1033
  if (!result) return
829
1034
 
@@ -840,6 +1045,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
840
1045
 
841
1046
  const onItemMove = (event: ItemEvent) => {
842
1047
  if (event.node.id === draftNode.current?.id) return
1048
+ releaseCommit = () => onItemClick(event)
1049
+ has3DPointerDrivenMoveRef.current = true
1050
+ if (!cursorGroupRef.current) return
843
1051
  const ctx = getContext()
844
1052
 
845
1053
  if (ctx.state.surface !== 'item-surface') {
@@ -923,7 +1131,102 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
923
1131
  }
924
1132
 
925
1133
  const onItemClick = (event: ItemEvent) => {
926
- if (event.node.id === draftNode.current?.id) return
1134
+ // Click on the draft item itself. R3F dispatches click events to
1135
+ // the closest intersected mesh only — when the draft is hovering
1136
+ // on a host (shelf / table / etc.) the draft's mesh is *above*
1137
+ // the host's mesh, so the host's `${kind}:click` never fires.
1138
+ // If we're currently hosting on a shelf-surface, treat the
1139
+ // self-click as a commit on the active shelf so the user doesn't
1140
+ // have to aim around the cursor preview to drop the item.
1141
+ if (event.node.id === draftNode.current?.id) {
1142
+ const ctx = getContext()
1143
+ if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
1144
+ const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
1145
+ if (shelfNode && shelfNode.type === 'shelf') {
1146
+ const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
1147
+ const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
1148
+ if (result) {
1149
+ event.stopPropagation()
1150
+ if (draftNode.current) {
1151
+ useLiveTransforms.getState().clear(draftNode.current.id)
1152
+ }
1153
+ draftNode.commit(result.nodeUpdate)
1154
+ if (configRef.current.onCommitted()) {
1155
+ const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
1156
+ if (enterResult) {
1157
+ applyTransition(enterResult)
1158
+ } else {
1159
+ revalidate()
1160
+ }
1161
+ }
1162
+ return
1163
+ }
1164
+ }
1165
+ }
1166
+ // Same self-click forwarding for item-surface hosts (tables,
1167
+ // counters) — the draft mesh sits on top of the host mesh, so
1168
+ // the host's own click event is blocked by the cursor preview.
1169
+ if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
1170
+ const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
1171
+ if (hostNode && hostNode.type === 'item') {
1172
+ const synthetic = { ...event, node: hostNode } as ItemEvent
1173
+ const result = itemSurfaceStrategy.click(ctx, synthetic)
1174
+ if (result) {
1175
+ event.stopPropagation()
1176
+ if (draftNode.current) {
1177
+ useLiveTransforms.getState().clear(draftNode.current.id)
1178
+ }
1179
+ draftNode.commit(result.nodeUpdate)
1180
+ if (configRef.current.onCommitted()) {
1181
+ const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
1182
+ if (enterResult) {
1183
+ applyTransition(enterResult)
1184
+ } else {
1185
+ revalidate()
1186
+ }
1187
+ }
1188
+ return
1189
+ }
1190
+ }
1191
+ }
1192
+ // Ceiling-hosted draft: when placing a ceiling-attached item the
1193
+ // draft hangs below the ceiling and intercepts the click ray
1194
+ // before the ceiling-grid mesh does — so `ceiling:click` never
1195
+ // fires and the user's commit click is dropped. Forward the
1196
+ // self-click to `ceilingStrategy.click` so placement commits the
1197
+ // same way it would from a click on the ceiling itself.
1198
+ if (ctx.state.surface === 'ceiling' && ctx.state.ceilingId) {
1199
+ const ceilingNode = useScene.getState().nodes[ctx.state.ceilingId as AnyNodeId]
1200
+ if (ceilingNode && ceilingNode.type === 'ceiling') {
1201
+ const synthetic = { ...event, node: ceilingNode } as unknown as CeilingEvent
1202
+ const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
1203
+ if (result) {
1204
+ event.stopPropagation()
1205
+ if (draftNode.current) {
1206
+ useLiveTransforms.getState().clear(draftNode.current.id)
1207
+ }
1208
+ draftNode.commit(result.nodeUpdate)
1209
+ if (configRef.current.onCommitted()) {
1210
+ const nodes = useScene.getState().nodes
1211
+ const enterResult = ceilingStrategy.enter(
1212
+ getContext(),
1213
+ synthetic,
1214
+ resolveLevelId,
1215
+ nodes,
1216
+ )
1217
+ if (enterResult) {
1218
+ applyTransition(enterResult)
1219
+ } else {
1220
+ revalidate()
1221
+ }
1222
+ }
1223
+ return
1224
+ }
1225
+ }
1226
+ }
1227
+ return
1228
+ }
1229
+
927
1230
  const result = itemSurfaceStrategy.click(getContext(), event)
928
1231
  if (!result) return
929
1232
 
@@ -948,6 +1251,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
948
1251
  // ---- Ceiling Handlers ----
949
1252
 
950
1253
  const onCeilingEnter = (event: CeilingEvent) => {
1254
+ has3DPointerDrivenMoveRef.current = true
951
1255
  const nodes = useScene.getState().nodes
952
1256
  const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
953
1257
  if (!result) return
@@ -967,6 +1271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
967
1271
  }
968
1272
 
969
1273
  const onCeilingMove = (event: CeilingEvent) => {
1274
+ releaseCommit = () => onCeilingClick(event)
1275
+ has3DPointerDrivenMoveRef.current = true
1276
+ if (!cursorGroupRef.current) return
970
1277
  if (!draftNode.current && placementState.current.surface === 'ceiling') {
971
1278
  const nodes = useScene.getState().nodes
972
1279
  const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
@@ -1005,9 +1312,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1005
1312
  const mesh = sceneRegistry.nodes.get(draft.id)
1006
1313
  if (mesh) mesh.position.copy(gridPosition.current)
1007
1314
 
1008
- // Publish live transform for 2D floorplan
1315
+ // Publish live transform for 2D floorplan. The item override in
1316
+ // `floorplan-registry-layer` treats `live.position` as building-local
1317
+ // plan coords (parentId forced to null so the resolver renders it
1318
+ // directly), so publish the building-local cursor — not the
1319
+ // world-space `result.cursorPosition`, which otherwise lands the 2D
1320
+ // visual off the cursor whenever the building isn't at the origin
1321
+ // with zero rotation.
1009
1322
  useLiveTransforms.getState().set(draft.id, {
1010
- position: result.cursorPosition,
1323
+ position: [cc.x, cc.y, cc.z],
1011
1324
  rotation: cursorGroupRef.current.rotation.y,
1012
1325
  })
1013
1326
  }
@@ -1065,9 +1378,112 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1065
1378
  }
1066
1379
  }
1067
1380
 
1381
+ // ---- Shelf Handlers ----
1382
+ //
1383
+ // Items can host on shelves the same way they host on tables and
1384
+ // counters (item-surface). The shelf's `surfaces.custom` exposes one
1385
+ // candidate Y per row; `shelfSurfaceStrategy` picks the closest one
1386
+ // to the cursor's local-Y so the user can target a specific row.
1387
+
1388
+ const onShelfEnter = (event: ShelfEvent) => {
1389
+ has3DPointerDrivenMoveRef.current = true
1390
+ const result = shelfSurfaceStrategy.enter(getContext(), event)
1391
+ if (!result) return
1392
+
1393
+ event.stopPropagation()
1394
+ applyTransition(result)
1395
+
1396
+ if (!draftNode.current) {
1397
+ ensureDraft(result)
1398
+ } else if (result.nodeUpdate.parentId) {
1399
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1400
+ }
1401
+ }
1402
+
1403
+ const onShelfMove = (event: ShelfEvent) => {
1404
+ releaseCommit = () => onShelfClick(event)
1405
+ has3DPointerDrivenMoveRef.current = true
1406
+ // A shelf event can fire before the cursor group mounts or after
1407
+ // teardown, leaving the ref null; bail before dereferencing it below.
1408
+ if (!cursorGroupRef.current) return
1409
+ const ctx = getContext()
1410
+ if (ctx.state.surface !== 'shelf-surface') {
1411
+ // Cursor entered via a move event without an enter — try
1412
+ // transitioning in so the user doesn't need to mouse out + back
1413
+ // in to start hosting.
1414
+ const enterResult = shelfSurfaceStrategy.enter(ctx, event)
1415
+ if (!enterResult) return
1416
+ event.stopPropagation()
1417
+ applyTransition(enterResult)
1418
+ if (!draftNode.current) {
1419
+ ensureDraft(enterResult)
1420
+ } else if (enterResult.nodeUpdate.parentId) {
1421
+ useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1422
+ }
1423
+ return
1424
+ }
1425
+ const result = shelfSurfaceStrategy.move(ctx, event)
1426
+ if (!result) return
1427
+
1428
+ event.stopPropagation()
1429
+
1430
+ gridPosition.current.set(...result.gridPosition)
1431
+ const ic = worldToBuildingLocal(...result.cursorPosition)
1432
+ cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
1433
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
1434
+
1435
+ const draft = draftNode.current
1436
+ if (draft) {
1437
+ draft.position = result.gridPosition
1438
+ const mesh = sceneRegistry.nodes.get(draft.id)
1439
+ if (mesh) mesh.position.set(...result.gridPosition)
1440
+ useLiveTransforms.getState().set(draft.id, {
1441
+ position: result.cursorPosition,
1442
+ rotation: result.cursorRotationY,
1443
+ })
1444
+ }
1445
+
1446
+ revalidate()
1447
+ }
1448
+
1449
+ const onShelfLeave = (event: ShelfEvent) => {
1450
+ if (placementState.current.surface !== 'shelf-surface') return
1451
+ if (event.node.id !== placementState.current.shelfId) return
1452
+ // Intentionally do NOT detach to the floor here. `shelf:leave` fires
1453
+ // constantly while hosting because the cursor ray slips off the shelf's
1454
+ // thin boards and through its gaps — detaching on each of those would
1455
+ // thrash the item between the shelf row and the floor. The grid handler
1456
+ // owns the real shelf→floor transition (see `isOverActiveShelfFootprint`
1457
+ // in `onGridMove`): it detaches only once the cursor is clearly off the
1458
+ // shelf footprint, which is the genuine "left the shelf" signal.
1459
+ event.stopPropagation()
1460
+ }
1461
+
1462
+ const onShelfClick = (event: ShelfEvent) => {
1463
+ const result = shelfSurfaceStrategy.click(getContext(), event)
1464
+ if (!result) return
1465
+
1466
+ event.stopPropagation()
1467
+ if (draftNode.current) {
1468
+ useLiveTransforms.getState().clear(draftNode.current.id)
1469
+ }
1470
+ draftNode.commit(result.nodeUpdate)
1471
+
1472
+ if (configRef.current.onCommitted()) {
1473
+ const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
1474
+ if (enterResult) {
1475
+ applyTransition(enterResult)
1476
+ } else {
1477
+ revalidate()
1478
+ }
1479
+ }
1480
+ }
1481
+
1068
1482
  // ---- Keyboard rotation ----
1069
1483
 
1070
- const ROTATION_STEP = Math.PI / 2
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
1071
1487
  const onKeyDown = (event: KeyboardEvent) => {
1072
1488
  if (event.key === 'Shift') {
1073
1489
  shiftFreeRef.current = true
@@ -1097,7 +1513,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1097
1513
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
1098
1514
 
1099
1515
  // Ref + cursor mesh + item mesh — no store update during drag
1100
- cursorGroupRef.current.rotation.y = newRotationY
1516
+ if (cursorGroupRef.current) {
1517
+ cursorGroupRef.current.rotation.y = newRotationY
1518
+ }
1101
1519
  const mesh = sceneRegistry.nodes.get(draft.id)
1102
1520
  if (mesh) mesh.rotation.y = newRotationY
1103
1521
 
@@ -1111,11 +1529,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1111
1529
  const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
1112
1530
  gridPosition.current.set(x, gridPosition.current.y, z)
1113
1531
  draft.position = [x, gridPosition.current.y, z]
1114
- cursorGroupRef.current.position.x = x
1115
- cursorGroupRef.current.position.z = z
1532
+ if (cursorGroupRef.current) {
1533
+ if (surface === 'floor') {
1534
+ cursorGroupRef.current.position.set(
1535
+ ...getFloorVisualPosition([x, gridPosition.current.y, z]),
1536
+ )
1537
+ } else {
1538
+ cursorGroupRef.current.position.x = x
1539
+ cursorGroupRef.current.position.z = z
1540
+ }
1541
+ }
1116
1542
  if (mesh) {
1117
1543
  mesh.position.x = x
1118
1544
  mesh.position.z = z
1545
+ if (surface === 'floor') {
1546
+ mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1]
1547
+ }
1119
1548
  }
1120
1549
  } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
1121
1550
  const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
@@ -1135,7 +1564,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1135
1564
  worldSnapped.y,
1136
1565
  worldSnapped.z,
1137
1566
  )
1138
- cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
1567
+ if (cursorGroupRef.current) {
1568
+ cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
1569
+ }
1139
1570
  if (mesh) mesh.position.set(x, y, z)
1140
1571
  }
1141
1572
  }
@@ -1143,13 +1574,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1143
1574
  // Update live transform for 2D floorplan with post-snap position
1144
1575
  const currentLive = useLiveTransforms.getState().get(draft.id)
1145
1576
  if (currentLive) {
1577
+ const livePosition: [number, number, number] =
1578
+ surface === 'floor'
1579
+ ? [draft.position[0], draft.position[1], draft.position[2]]
1580
+ : cursorGroupRef.current
1581
+ ? [
1582
+ cursorGroupRef.current.position.x,
1583
+ cursorGroupRef.current.position.y,
1584
+ cursorGroupRef.current.position.z,
1585
+ ]
1586
+ : [draft.position[0], draft.position[1], draft.position[2]]
1146
1587
  useLiveTransforms.getState().set(draft.id, {
1147
1588
  ...currentLive,
1148
- position: [
1149
- cursorGroupRef.current.position.x,
1150
- cursorGroupRef.current.position.y,
1151
- cursorGroupRef.current.position.z,
1152
- ] as [number, number, number],
1589
+ position: livePosition,
1153
1590
  rotation: newRotationY,
1154
1591
  })
1155
1592
  }
@@ -1170,6 +1607,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1170
1607
 
1171
1608
  // ---- tool:cancel (Escape / programmatic) ----
1172
1609
  const onCancel = () => {
1610
+ useAlignmentGuides.getState().clear()
1173
1611
  if (configRef.current.onCancel) {
1174
1612
  configRef.current.onCancel()
1175
1613
  }
@@ -1239,10 +1677,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1239
1677
  emitter.on('ceiling:move', onCeilingMove)
1240
1678
  emitter.on('ceiling:click', onCeilingClick)
1241
1679
  emitter.on('ceiling:leave', onCeilingLeave)
1680
+ emitter.on('shelf:enter', onShelfEnter)
1681
+ emitter.on('shelf:move', onShelfMove)
1682
+ emitter.on('shelf:click', onShelfClick)
1683
+ emitter.on('shelf:leave', onShelfLeave)
1684
+ if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
1242
1685
 
1243
1686
  return () => {
1244
1687
  tearingDown = true
1688
+ if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
1245
1689
  unsubDraftWatch()
1690
+ useAlignmentGuides.getState().clear()
1246
1691
  // Clear live transform for any remaining draft
1247
1692
  if (draftNode.current) {
1248
1693
  useLiveTransforms.getState().clear(draftNode.current.id)
@@ -1263,12 +1708,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1263
1708
  emitter.off('ceiling:move', onCeilingMove)
1264
1709
  emitter.off('ceiling:click', onCeilingClick)
1265
1710
  emitter.off('ceiling:leave', onCeilingLeave)
1711
+ emitter.off('shelf:enter', onShelfEnter)
1712
+ emitter.off('shelf:move', onShelfMove)
1713
+ emitter.off('shelf:click', onShelfClick)
1714
+ emitter.off('shelf:leave', onShelfLeave)
1266
1715
  emitter.off('tool:cancel', onCancel)
1267
1716
  window.removeEventListener('keydown', onKeyDown)
1268
1717
  window.removeEventListener('keyup', onKeyUp)
1269
1718
  window.removeEventListener('contextmenu', onContextMenu)
1270
1719
  }
1271
- }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
1720
+ }, [
1721
+ asset,
1722
+ canPlaceOnFloor,
1723
+ canPlaceOnWall,
1724
+ canPlaceOnCeiling,
1725
+ draftNode,
1726
+ getFloorVisualPosition,
1727
+ gridSnapStep,
1728
+ updateDimensionGuides,
1729
+ updatePreviewGeometry,
1730
+ ])
1272
1731
 
1273
1732
  // Refresh wireframe when the grid step changes mid-placement so the green/red
1274
1733
  // box snaps to the new cell size right away.
@@ -1282,7 +1741,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1282
1741
  )
1283
1742
  updatePreviewGeometry(previewBounds)
1284
1743
  updateDimensionGuides(previewBounds)
1285
- }, [gridSnapStep, asset, draftNode])
1744
+ }, [gridSnapStep, asset, draftNode, updateDimensionGuides, updatePreviewGeometry])
1286
1745
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
1287
1746
  const viewerLevelId = useViewer((s) => s.selection.levelId)
1288
1747
  useEffect(() => {
@@ -1294,10 +1753,58 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1294
1753
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
1295
1754
  }, [viewerLevelId, draftNode, asset])
1296
1755
 
1756
+ // Disable raycasting on the live draft mesh (and restore it when the draft
1757
+ // changes or goes away) so the cursor ray passes through the item being
1758
+ // moved and lands on the surface beneath it.
1759
+ const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
1760
+ if (raycastDisabledMeshRef.current !== mesh) {
1761
+ // New draft root (or cleared): restore the prior mesh and reset tracking.
1762
+ for (const restore of restoreRaycastsRef.current) restore()
1763
+ restoreRaycastsRef.current = []
1764
+ raycastDisabledChildrenRef.current = new WeakSet()
1765
+ raycastDisabledMeshRef.current = mesh
1766
+ }
1767
+ if (!mesh) return
1768
+ // Disable any descendant not handled yet. Item drafts are GLB models whose
1769
+ // child meshes mount asynchronously (Suspense), so a one-shot traverse
1770
+ // misses them — those late children keep intercepting the ray and corrupt
1771
+ // the shelf-row hit the moment the item moves onto a row. Re-walking each
1772
+ // frame is cheap: the WeakSet makes it idempotent, so only new children pay.
1773
+ mesh.traverse((child) => {
1774
+ if (raycastDisabledChildrenRef.current.has(child)) return
1775
+ raycastDisabledChildrenRef.current.add(child)
1776
+ const original = child.raycast
1777
+ child.raycast = () => {}
1778
+ restoreRaycastsRef.current.push(() => {
1779
+ child.raycast = original
1780
+ })
1781
+ })
1782
+ }, [])
1783
+
1784
+ // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
1785
+ useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
1786
+
1297
1787
  useFrame((_, delta) => {
1298
- if (!asset) return
1299
- if (!draftNode.current) return
1300
- const mesh = sceneRegistry.nodes.get(draftNode.current.id)
1788
+ if (!asset) {
1789
+ reconcileDraftRaycast(null)
1790
+ return
1791
+ }
1792
+ if (!draftNode.current) {
1793
+ reconcileDraftRaycast(null)
1794
+ return
1795
+ }
1796
+ const mesh = sceneRegistry.nodes.get(draftNode.current.id) ?? null
1797
+ reconcileDraftRaycast(mesh)
1798
+ // mitt listeners outlive the cursor group's mount; bail if it's gone
1799
+ // (mount/teardown race, #323). Placed after reconcileDraftRaycast so the
1800
+ // draft's raycast is still restored during that window.
1801
+ if (!cursorGroupRef.current) return
1802
+ // The mesh-position lerp below only makes sense once this coordinator
1803
+ // owns the move via a 3D pointer event. Skip until then so that
1804
+ // external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
1805
+ // writing scene.position directly) aren't fought by useFrame pulling
1806
+ // the mesh back to its pre-move location.
1807
+ if (!has3DPointerDrivenMoveRef.current) return
1301
1808
  if (!mesh) return
1302
1809
 
1303
1810
  // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
@@ -1317,18 +1824,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1317
1824
 
1318
1825
  // Adjust Y for slab elevation (floor items on top of slabs)
1319
1826
  if (!asset.attachTo) {
1320
- const nodes = useScene.getState().nodes
1321
- const levelId = resolveLevelId(draftNode.current, nodes)
1322
- const slabElevation = spatialGridManager.getSlabElevationForItem(
1323
- levelId,
1324
- [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
1325
- getGridAlignedDimensions(
1326
- getScaledDimensions(draftNode.current),
1327
- draftNode.current.asset.attachTo,
1328
- ),
1329
- draftNode.current.rotation,
1330
- )
1331
- mesh.position.y = slabElevation
1827
+ const visualPosition = getFloorVisualPosition([
1828
+ gridPosition.current.x,
1829
+ gridPosition.current.y,
1830
+ gridPosition.current.z,
1831
+ ])
1832
+ mesh.position.y = visualPosition[1]
1833
+ cursorGroupRef.current.position.y = visualPosition[1]
1332
1834
  }
1333
1835
  }
1334
1836
  })
@@ -1341,7 +1843,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1341
1843
  const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
1342
1844
  const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
1343
1845
  const initialDimensionBounds = expandBoundsToGrid(
1344
- getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
1846
+ getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
1345
1847
  initialAttachTo,
1346
1848
  gridSnapStep,
1347
1849
  )
@@ -1354,6 +1856,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1354
1856
  initialDimensionBounds.dimensions[0],
1355
1857
  initialDimensionBounds.dimensions[1],
1356
1858
  initialDimensionBounds.dimensions[2],
1859
+ initialDimensionBounds,
1357
1860
  ],
1358
1861
  )
1359
1862
  const basePlaneGeometry = useMemo(() => {