@pascal-app/editor 0.7.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  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 +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  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 +5837 -12006
  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 +234 -190
  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 +435 -158
  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 +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  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-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,29 +1,32 @@
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
28
  Box3,
24
- BufferGeometry,
25
29
  Euler,
26
- Float32BufferAttribute,
27
30
  type Group,
28
31
  type LineSegments,
29
32
  Matrix4,
@@ -31,19 +34,29 @@ import {
31
34
  type Object3D,
32
35
  PlaneGeometry,
33
36
  Quaternion,
37
+ Ray,
34
38
  Vector3,
35
39
  } from 'three'
36
40
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
37
41
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
38
42
  import { EDITOR_LAYER } from '../../../lib/constants'
39
43
  import { sfxEmitter } from '../../../lib/sfx-bus'
44
+ import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
40
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'
41
53
  import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
42
54
  import {
43
55
  ceilingStrategy,
44
56
  checkCanPlace,
45
57
  floorStrategy,
46
58
  itemSurfaceStrategy,
59
+ shelfSurfaceStrategy,
47
60
  wallStrategy,
48
61
  } from './placement-strategies'
49
62
  import type { PlacementState, TransitionResult } from './placement-types'
@@ -51,6 +64,10 @@ import type { DraftNodeHandle } from './use-draft-node'
51
64
 
52
65
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
53
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
+
54
71
  function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
55
72
  if (unit === 'imperial') {
56
73
  const feet = value * 3.280_84
@@ -62,13 +79,6 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
62
79
  return `${Number.parseFloat(value.toFixed(2))}m`
63
80
  }
64
81
 
65
- type PreviewBounds = {
66
- min: [number, number, number]
67
- max: [number, number, number]
68
- dimensions: [number, number, number]
69
- center: [number, number, number]
70
- }
71
-
72
82
  /**
73
83
  * Expand `bounds` outward so each axis is rounded up to the active grid step.
74
84
  * The wireframe stays centered on the original bounds centre on each axis we
@@ -117,85 +127,12 @@ function expandBoundsToGrid(
117
127
  }
118
128
  }
119
129
 
120
- function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
121
- if (!object) return null
122
-
123
- object.updateWorldMatrix(true, true)
124
-
125
- const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
126
- const localMatrix = new Matrix4()
127
- const localBounds = new Box3()
128
- const scratchBounds = new Box3()
129
- const hasBounds = { current: false }
130
- const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
131
-
132
- const expandBounds = (child: Object3D) => {
133
- if (child !== object && registeredNodeObjects.has(child)) {
134
- return
135
- }
136
-
137
- const mesh = child as Object3D & {
138
- isMesh?: boolean
139
- name?: string
140
- geometry?: {
141
- boundingBox: Box3 | null
142
- computeBoundingBox?: () => void
143
- }
144
- }
145
-
146
- if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
147
- if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
148
- mesh.geometry.computeBoundingBox()
149
- }
150
-
151
- if (mesh.geometry.boundingBox) {
152
- localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
153
- scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
154
- if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
155
- if (!hasBounds.current) {
156
- localBounds.copy(scratchBounds)
157
- hasBounds.current = true
158
- } else {
159
- localBounds.union(scratchBounds)
160
- }
161
- }
162
- }
163
- }
164
-
165
- for (const grandchild of child.children) {
166
- expandBounds(grandchild)
167
- }
168
- }
169
-
170
- for (const child of object.children) {
171
- expandBounds(child)
172
- }
173
-
174
- if (!hasBounds.current) return null
175
-
176
- const size = new Vector3()
177
- const center = new Vector3()
178
- localBounds.getSize(size)
179
- localBounds.getCenter(center)
180
-
181
- if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
182
- return null
183
- }
184
-
185
- return {
186
- min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
187
- max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
188
- dimensions: [size.x, size.y, size.z],
189
- center: [center.x, center.y, center.z],
190
- }
191
- }
192
-
193
130
  function getFallbackPreviewBounds(
194
131
  item: import('@pascal-app/core').ItemNode | null,
195
- asset: AssetInput,
196
- attachTo: AssetInput['attachTo'],
132
+ asset: AssetInput | null | undefined,
133
+ attachTo: AssetInput['attachTo'] | null | undefined,
197
134
  ): PreviewBounds {
198
- const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
135
+ const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
199
136
  return {
200
137
  min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
201
138
  max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
@@ -204,112 +141,20 @@ function getFallbackPreviewBounds(
204
141
  }
205
142
  }
206
143
 
207
- function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
208
- const geometry = new BufferGeometry()
209
- geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
210
- return geometry
211
- }
212
-
213
- function getBoxEdgePoints(bounds: PreviewBounds): number[] {
214
- const [width, height, depth] = bounds.dimensions
215
- const [centerX, centerY, centerZ] = bounds.center
216
- const minX = centerX - width / 2
217
- const maxX = centerX + width / 2
218
- const minY = centerY - height / 2
219
- const maxY = centerY + height / 2
220
- const minZ = centerZ - depth / 2
221
- const maxZ = centerZ + depth / 2
222
-
223
- return [
224
- minX,
225
- minY,
226
- minZ,
227
- maxX,
228
- minY,
229
- minZ,
230
- maxX,
231
- minY,
232
- minZ,
233
- maxX,
234
- minY,
235
- maxZ,
236
- maxX,
237
- minY,
238
- maxZ,
239
- minX,
240
- minY,
241
- maxZ,
242
- minX,
243
- minY,
244
- maxZ,
245
- minX,
246
- minY,
247
- minZ,
248
-
249
- minX,
250
- maxY,
251
- minZ,
252
- maxX,
253
- maxY,
254
- minZ,
255
- maxX,
256
- maxY,
257
- minZ,
258
- maxX,
259
- maxY,
260
- maxZ,
261
- maxX,
262
- maxY,
263
- maxZ,
264
- minX,
265
- maxY,
266
- maxZ,
267
- minX,
268
- maxY,
269
- maxZ,
270
- minX,
271
- maxY,
272
- minZ,
273
-
274
- minX,
275
- minY,
276
- minZ,
277
- minX,
278
- maxY,
279
- minZ,
280
- maxX,
281
- minY,
282
- minZ,
283
- maxX,
284
- maxY,
285
- minZ,
286
- maxX,
287
- minY,
288
- maxZ,
289
- maxX,
290
- maxY,
291
- maxZ,
292
- minX,
293
- minY,
294
- maxZ,
295
- minX,
296
- maxY,
297
- maxZ,
298
- ]
299
- }
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
+ }
300
150
 
301
- function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
302
- const geometry = ref.current?.geometry
303
- if (!geometry) return
151
+ const scaleAxis = (axis: 0 | 1 | 2) =>
152
+ scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis])
304
153
 
305
- const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
306
- if (!attribute || attribute.array.length !== points.length) {
307
- geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
308
- } else {
309
- attribute.set(points)
310
- attribute.needsUpdate = true
154
+ return {
155
+ ...item,
156
+ scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number],
311
157
  }
312
- geometry.computeBoundingSphere()
313
158
  }
314
159
 
315
160
  // Shared materials for placement cursor - we just change colors, not swap materials
@@ -350,6 +195,8 @@ export interface PlacementCoordinatorConfig {
350
195
  initialState?: PlacementState
351
196
  /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
352
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
353
200
  }
354
201
 
355
202
  export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
@@ -361,14 +208,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
361
208
  const basePlaneRef = useRef<Mesh>(null!)
362
209
  const gridPosition = useRef(new Vector3(0, 0, 0))
363
210
  const lastRawPos = useRef(new Vector3(0, 0, 0))
211
+ const lastWallDirtyAtRef = useRef(new Map<string, number>())
364
212
  const placementState = useRef<PlacementState>(
365
- 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
+ },
366
220
  )
367
221
  const shiftFreeRef = useRef(false)
368
222
  const previewBoundsSignatureRef = useRef<string | null>(null)
369
- const meshPreviewAppliedRef = useRef(false)
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>())
370
241
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
371
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
+
372
249
  // Store config callbacks in refs to avoid re-running effect when they change
373
250
  const configRef = useRef(config)
374
251
  configRef.current = config
@@ -377,7 +254,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
377
254
  const { asset, draftNode } = config
378
255
  const unit = useViewer((state) => state.unit)
379
256
  const gridSnapStep = useEditor((s) => s.gridSnapStep)
380
- const updatePreviewGeometry = (bounds: PreviewBounds) => {
257
+ const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
381
258
  const [width, height, depth] = bounds.dimensions
382
259
  const [centerX, centerY, centerZ] = bounds.center
383
260
  const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
@@ -394,9 +271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
394
271
  const oldBasePlaneGeometry = basePlaneRef.current.geometry
395
272
  basePlaneRef.current.geometry = nextBasePlaneGeometry
396
273
  oldBasePlaneGeometry.dispose()
397
- }
274
+ }, [])
398
275
 
399
- const updateDimensionGuides = (bounds: PreviewBounds) => {
276
+ const updateDimensionGuides = useCallback((bounds: PreviewBounds) => {
400
277
  setDimensionBounds((current) => {
401
278
  if (
402
279
  current &&
@@ -498,25 +375,53 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
498
375
  applyPoints(measurementWidthRef, widthPoints)
499
376
  applyPoints(measurementDepthRef, depthPoints)
500
377
  applyPoints(measurementHeightRef, heightPoints)
501
- }
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
+ )
502
396
 
503
397
  useEffect(() => {
504
398
  if (!asset) return
505
399
  useScene.temporal.getState().pause()
506
- meshPreviewAppliedRef.current = false
507
400
 
508
401
  const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
509
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
+
510
412
  // Reset placement state
511
413
  placementState.current = configRef.current.initialState ?? {
512
414
  surface: 'floor',
513
415
  wallId: null,
514
416
  ceilingId: null,
515
417
  surfaceItemId: null,
418
+ shelfId: null,
516
419
  }
517
420
  if (!asset.attachTo && placementState.current.surface === 'floor') {
518
421
  gridPosition.current.y = 0
519
- cursorGroupRef.current.position.y = 0
422
+ if (cursorGroupRef.current) {
423
+ cursorGroupRef.current.position.y = 0
424
+ }
520
425
  }
521
426
 
522
427
  // ---- Helpers ----
@@ -527,7 +432,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
527
432
  draftItem: draftNode.current,
528
433
  gridPosition: gridPosition.current,
529
434
  state: { ...placementState.current },
530
- currentCursorRotationY: cursorGroupRef.current.rotation.y,
435
+ currentCursorRotationY:
436
+ cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
531
437
  })
532
438
 
533
439
  const getActiveValidators = () =>
@@ -557,12 +463,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
557
463
  }
558
464
 
559
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()
560
469
  Object.assign(placementState.current, result.stateUpdate)
561
470
  gridPosition.current.set(...result.gridPosition)
562
471
 
563
472
  const c = worldToBuildingLocal(...result.cursorPosition)
564
- cursorGroupRef.current.position.set(c.x, c.y, c.z)
565
- 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
+ }
566
481
 
567
482
  const draft = draftNode.current
568
483
  if (draft) {
@@ -575,15 +490,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
575
490
  const ensureDraft = (result: TransitionResult) => {
576
491
  gridPosition.current.set(...result.gridPosition)
577
492
  const c = worldToBuildingLocal(...result.cursorPosition)
578
- cursorGroupRef.current.position.set(c.x, c.y, c.z)
579
- 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
+ }
580
501
 
581
- draftNode.create(
582
- gridPosition.current,
583
- asset,
584
- [0, result.cursorRotationY, 0],
585
- configRef.current.defaultScale,
586
- )
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)
587
509
 
588
510
  const draft = draftNode.current
589
511
  if (draft) {
@@ -592,6 +514,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
592
514
  useScene.getState().updateNode(draft.id, result.nodeUpdate)
593
515
  }
594
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
+
595
525
  if (!revalidate()) {
596
526
  draftNode.destroy()
597
527
  }
@@ -599,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
599
529
 
600
530
  // ---- Init draft ----
601
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
602
537
 
603
538
  // Sync cursor to the draft mesh's world position and rotation
604
539
  if (draftNode.current) {
@@ -607,12 +542,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
607
542
  const worldPos = new Vector3()
608
543
  mesh.getWorldPosition(worldPos)
609
544
  const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
610
- cursorGroupRef.current.position.copy(localPos)
611
- // Extract world Y rotation (handles wall-parented items correctly)
612
- const q = new Quaternion()
613
- mesh.getWorldQuaternion(q)
614
- cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
615
- } 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) {
616
561
  cursorGroupRef.current.position.copy(gridPosition.current)
617
562
  cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
618
563
  }
@@ -620,44 +565,190 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
620
565
 
621
566
  revalidate()
622
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
+
623
593
  // ---- Floor Handlers ----
624
594
 
625
595
  let previousGridPos: [number, number, number] | null = null
626
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
+
627
641
  const onGridMove = (event: GridEvent) => {
642
+ releaseCommit = () => onGridClick(event)
628
643
  // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
629
644
  if (draftNode.current === null && asset.attachTo === undefined) {
630
645
  configRef.current.initDraft(gridPosition.current)
631
646
  }
632
647
 
633
- lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
634
- 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)
635
685
  if (!result) return
636
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
+
637
728
  // Play snap sound when grid position changes
638
729
  if (
639
730
  previousGridPos &&
640
- (result.gridPosition[0] !== previousGridPos[0] ||
641
- result.gridPosition[2] !== previousGridPos[2])
731
+ (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
642
732
  ) {
643
733
  sfxEmitter.emit('sfx:grid-snap')
644
734
  }
645
735
 
646
- previousGridPos = [...result.gridPosition]
647
- gridPosition.current.set(...result.gridPosition)
648
- cursorGroupRef.current.position.set(
649
- result.cursorPosition[0],
650
- result.cursorPosition[1],
651
- result.cursorPosition[2],
652
- )
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
653
745
 
654
- const draft = draftNode.current
655
- if (draft) draft.position = result.gridPosition
746
+ if (draft) draft.position = gridPos
656
747
 
657
748
  // Publish live transform for 2D floorplan
658
749
  if (draft) {
659
750
  useLiveTransforms.getState().set(draft.id, {
660
- position: result.gridPosition,
751
+ position: gridPos,
661
752
  rotation: cursorGroupRef.current.rotation.y,
662
753
  })
663
754
  }
@@ -666,11 +757,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
666
757
  }
667
758
 
668
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()
669
763
  const result = floorStrategy.click(getContext(), event, getActiveValidators())
670
764
  if (!result) return
671
765
 
672
766
  // Preserve cursor rotation for the next draft
673
- 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
+ ]
674
772
 
675
773
  // Clear live transform before commit
676
774
  if (draftNode.current) {
@@ -680,6 +778,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
680
778
  draftNode.commit(result.nodeUpdate)
681
779
  if (configRef.current.onCommitted()) {
682
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)
683
788
  revalidate()
684
789
  }
685
790
  }
@@ -687,6 +792,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
687
792
  // ---- Wall Handlers ----
688
793
 
689
794
  const onWallEnter = (event: WallEvent) => {
795
+ has3DPointerDrivenMoveRef.current = true
690
796
  const nodes = useScene.getState().nodes
691
797
  const result = wallStrategy.enter(
692
798
  getContext(),
@@ -712,6 +818,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
712
818
  }
713
819
 
714
820
  const onWallMove = (event: WallEvent) => {
821
+ releaseCommit = () => onWallClick(event)
822
+ has3DPointerDrivenMoveRef.current = true
823
+ if (!cursorGroupRef.current) return
715
824
  const ctx = getContext()
716
825
 
717
826
  if (ctx.state.surface !== 'wall') {
@@ -800,7 +909,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
800
909
  }
801
910
  // Mark parent wall dirty so it rebuilds geometry — only when position changed
802
911
  if (result.dirtyNodeId && posChanged) {
803
- 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
+ }
804
919
  }
805
920
 
806
921
  // Publish live transform for 2D floorplan
@@ -884,9 +999,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
884
999
  const wz = Math.round(buildingLocalPoint.z * 2) / 2
885
1000
  const floorPos: [number, number, number] = [wx, 0, wz]
886
1001
 
887
- Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
1002
+ Object.assign(placementState.current, {
1003
+ surface: 'floor',
1004
+ surfaceItemId: null,
1005
+ shelfId: null,
1006
+ })
888
1007
  gridPosition.current.set(wx, 0, wz)
889
- 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
+ }
890
1016
 
891
1017
  const draft = draftNode.current
892
1018
  if (draft) {
@@ -902,6 +1028,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
902
1028
 
903
1029
  const onItemEnter = (event: ItemEvent) => {
904
1030
  if (event.node.id === draftNode.current?.id) return
1031
+ has3DPointerDrivenMoveRef.current = true
905
1032
  const result = itemSurfaceStrategy.enter(getContext(), event)
906
1033
  if (!result) return
907
1034
 
@@ -918,6 +1045,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
918
1045
 
919
1046
  const onItemMove = (event: ItemEvent) => {
920
1047
  if (event.node.id === draftNode.current?.id) return
1048
+ releaseCommit = () => onItemClick(event)
1049
+ has3DPointerDrivenMoveRef.current = true
1050
+ if (!cursorGroupRef.current) return
921
1051
  const ctx = getContext()
922
1052
 
923
1053
  if (ctx.state.surface !== 'item-surface') {
@@ -1001,7 +1131,102 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1001
1131
  }
1002
1132
 
1003
1133
  const onItemClick = (event: ItemEvent) => {
1004
- 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
+
1005
1230
  const result = itemSurfaceStrategy.click(getContext(), event)
1006
1231
  if (!result) return
1007
1232
 
@@ -1026,6 +1251,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1026
1251
  // ---- Ceiling Handlers ----
1027
1252
 
1028
1253
  const onCeilingEnter = (event: CeilingEvent) => {
1254
+ has3DPointerDrivenMoveRef.current = true
1029
1255
  const nodes = useScene.getState().nodes
1030
1256
  const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
1031
1257
  if (!result) return
@@ -1045,6 +1271,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1045
1271
  }
1046
1272
 
1047
1273
  const onCeilingMove = (event: CeilingEvent) => {
1274
+ releaseCommit = () => onCeilingClick(event)
1275
+ has3DPointerDrivenMoveRef.current = true
1276
+ if (!cursorGroupRef.current) return
1048
1277
  if (!draftNode.current && placementState.current.surface === 'ceiling') {
1049
1278
  const nodes = useScene.getState().nodes
1050
1279
  const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
@@ -1083,9 +1312,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1083
1312
  const mesh = sceneRegistry.nodes.get(draft.id)
1084
1313
  if (mesh) mesh.position.copy(gridPosition.current)
1085
1314
 
1086
- // 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.
1087
1322
  useLiveTransforms.getState().set(draft.id, {
1088
- position: result.cursorPosition,
1323
+ position: [cc.x, cc.y, cc.z],
1089
1324
  rotation: cursorGroupRef.current.rotation.y,
1090
1325
  })
1091
1326
  }
@@ -1143,9 +1378,112 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1143
1378
  }
1144
1379
  }
1145
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
+
1146
1482
  // ---- Keyboard rotation ----
1147
1483
 
1148
- 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
1149
1487
  const onKeyDown = (event: KeyboardEvent) => {
1150
1488
  if (event.key === 'Shift') {
1151
1489
  shiftFreeRef.current = true
@@ -1175,7 +1513,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1175
1513
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
1176
1514
 
1177
1515
  // Ref + cursor mesh + item mesh — no store update during drag
1178
- cursorGroupRef.current.rotation.y = newRotationY
1516
+ if (cursorGroupRef.current) {
1517
+ cursorGroupRef.current.rotation.y = newRotationY
1518
+ }
1179
1519
  const mesh = sceneRegistry.nodes.get(draft.id)
1180
1520
  if (mesh) mesh.rotation.y = newRotationY
1181
1521
 
@@ -1189,11 +1529,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1189
1529
  const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
1190
1530
  gridPosition.current.set(x, gridPosition.current.y, z)
1191
1531
  draft.position = [x, gridPosition.current.y, z]
1192
- cursorGroupRef.current.position.x = x
1193
- 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
+ }
1194
1542
  if (mesh) {
1195
1543
  mesh.position.x = x
1196
1544
  mesh.position.z = z
1545
+ if (surface === 'floor') {
1546
+ mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1]
1547
+ }
1197
1548
  }
1198
1549
  } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
1199
1550
  const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
@@ -1213,7 +1564,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1213
1564
  worldSnapped.y,
1214
1565
  worldSnapped.z,
1215
1566
  )
1216
- 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
+ }
1217
1570
  if (mesh) mesh.position.set(x, y, z)
1218
1571
  }
1219
1572
  }
@@ -1221,13 +1574,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1221
1574
  // Update live transform for 2D floorplan with post-snap position
1222
1575
  const currentLive = useLiveTransforms.getState().get(draft.id)
1223
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]]
1224
1587
  useLiveTransforms.getState().set(draft.id, {
1225
1588
  ...currentLive,
1226
- position: [
1227
- cursorGroupRef.current.position.x,
1228
- cursorGroupRef.current.position.y,
1229
- cursorGroupRef.current.position.z,
1230
- ] as [number, number, number],
1589
+ position: livePosition,
1231
1590
  rotation: newRotationY,
1232
1591
  })
1233
1592
  }
@@ -1248,6 +1607,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1248
1607
 
1249
1608
  // ---- tool:cancel (Escape / programmatic) ----
1250
1609
  const onCancel = () => {
1610
+ useAlignmentGuides.getState().clear()
1251
1611
  if (configRef.current.onCancel) {
1252
1612
  configRef.current.onCancel()
1253
1613
  }
@@ -1264,21 +1624,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1264
1624
  window.addEventListener('contextmenu', onContextMenu)
1265
1625
 
1266
1626
  // ---- Bounding box geometry ----
1627
+ // Always derive the wireframe from `asset.dimensions × scale` rather than
1628
+ // the rendered mesh bounds. Asset dimensions describe the item's footprint
1629
+ // (e.g. only the trunk for a palm tree), while the mesh bbox would include
1630
+ // foliage or other visual overhang the snap logic intentionally ignores.
1267
1631
 
1268
1632
  const draft = draftNode.current
1269
- const fallbackBounds = expandBoundsToGrid(
1633
+ const previewBounds = expandBoundsToGrid(
1270
1634
  getFallbackPreviewBounds(draft, asset, asset.attachTo),
1271
1635
  asset.attachTo,
1272
1636
  gridSnapStep,
1273
1637
  )
1274
- const previewBounds = draft
1275
- ? expandBoundsToGrid(
1276
- getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
1277
- getFallbackPreviewBounds(draft, asset, asset.attachTo),
1278
- asset.attachTo,
1279
- gridSnapStep,
1280
- )
1281
- : fallbackBounds
1282
1638
  updatePreviewGeometry(previewBounds)
1283
1639
  updateDimensionGuides(previewBounds)
1284
1640
 
@@ -1321,11 +1677,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1321
1677
  emitter.on('ceiling:move', onCeilingMove)
1322
1678
  emitter.on('ceiling:click', onCeilingClick)
1323
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)
1324
1685
 
1325
1686
  return () => {
1326
1687
  tearingDown = true
1327
- meshPreviewAppliedRef.current = false
1688
+ if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
1328
1689
  unsubDraftWatch()
1690
+ useAlignmentGuides.getState().clear()
1329
1691
  // Clear live transform for any remaining draft
1330
1692
  if (draftNode.current) {
1331
1693
  useLiveTransforms.getState().clear(draftNode.current.id)
@@ -1346,32 +1708,40 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1346
1708
  emitter.off('ceiling:move', onCeilingMove)
1347
1709
  emitter.off('ceiling:click', onCeilingClick)
1348
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)
1349
1715
  emitter.off('tool:cancel', onCancel)
1350
1716
  window.removeEventListener('keydown', onKeyDown)
1351
1717
  window.removeEventListener('keyup', onKeyUp)
1352
1718
  window.removeEventListener('contextmenu', onContextMenu)
1353
1719
  }
1354
- }, [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
+ ])
1355
1731
 
1356
1732
  // Refresh wireframe when the grid step changes mid-placement so the green/red
1357
1733
  // box snaps to the new cell size right away.
1358
1734
  useEffect(() => {
1359
1735
  if (!asset) return
1360
1736
  const draft = draftNode.current
1361
- const fallbackBounds = expandBoundsToGrid(
1737
+ const previewBounds = expandBoundsToGrid(
1362
1738
  getFallbackPreviewBounds(draft, asset, asset.attachTo),
1363
1739
  asset.attachTo,
1364
1740
  gridSnapStep,
1365
1741
  )
1366
- const meshBounds = draft
1367
- ? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
1368
- : null
1369
- const previewBounds = meshBounds
1370
- ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
1371
- : fallbackBounds
1372
1742
  updatePreviewGeometry(previewBounds)
1373
1743
  updateDimensionGuides(previewBounds)
1374
- }, [gridSnapStep, asset, draftNode])
1744
+ }, [gridSnapStep, asset, draftNode, updateDimensionGuides, updatePreviewGeometry])
1375
1745
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
1376
1746
  const viewerLevelId = useViewer((s) => s.selection.levelId)
1377
1747
  useEffect(() => {
@@ -1383,24 +1753,59 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1383
1753
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
1384
1754
  }, [viewerLevelId, draftNode, asset])
1385
1755
 
1386
- useFrame((_, delta) => {
1387
- if (!asset) return
1388
- if (!draftNode.current) return
1389
- const mesh = sceneRegistry.nodes.get(draftNode.current.id)
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
+ }
1390
1767
  if (!mesh) return
1391
- if (!meshPreviewAppliedRef.current) {
1392
- const previewBounds = getPreviewBoundsFromObject(mesh)
1393
- if (previewBounds) {
1394
- const expandedBounds = expandBoundsToGrid(
1395
- previewBounds,
1396
- asset.attachTo,
1397
- useEditor.getState().gridSnapStep,
1398
- )
1399
- updatePreviewGeometry(expandedBounds)
1400
- updateDimensionGuides(expandedBounds)
1401
- meshPreviewAppliedRef.current = true
1402
- }
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
+
1787
+ useFrame((_, delta) => {
1788
+ if (!asset) {
1789
+ reconcileDraftRaycast(null)
1790
+ return
1791
+ }
1792
+ if (!draftNode.current) {
1793
+ reconcileDraftRaycast(null)
1794
+ return
1403
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
1808
+ if (!mesh) return
1404
1809
 
1405
1810
  // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
1406
1811
  if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -1419,18 +1824,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1419
1824
 
1420
1825
  // Adjust Y for slab elevation (floor items on top of slabs)
1421
1826
  if (!asset.attachTo) {
1422
- const nodes = useScene.getState().nodes
1423
- const levelId = resolveLevelId(draftNode.current, nodes)
1424
- const slabElevation = spatialGridManager.getSlabElevationForItem(
1425
- levelId,
1426
- [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
1427
- getGridAlignedDimensions(
1428
- getScaledDimensions(draftNode.current),
1429
- draftNode.current.asset.attachTo,
1430
- ),
1431
- draftNode.current.rotation,
1432
- )
1433
- 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]
1434
1834
  }
1435
1835
  }
1436
1836
  })
@@ -1443,7 +1843,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1443
1843
  const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
1444
1844
  const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
1445
1845
  const initialDimensionBounds = expandBoundsToGrid(
1446
- getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
1846
+ getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
1447
1847
  initialAttachTo,
1448
1848
  gridSnapStep,
1449
1849
  )
@@ -1456,6 +1856,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1456
1856
  initialDimensionBounds.dimensions[0],
1457
1857
  initialDimensionBounds.dimensions[1],
1458
1858
  initialDimensionBounds.dimensions[2],
1859
+ initialDimensionBounds,
1459
1860
  ],
1460
1861
  )
1461
1862
  const basePlaneGeometry = useMemo(() => {
@@ -1490,22 +1891,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1490
1891
  const measurementContent = (
1491
1892
  <>
1492
1893
  <lineSegments
1493
- layers={EDITOR_LAYER}
1494
1894
  geometry={initialWidthGuideGeometry}
1895
+ layers={EDITOR_LAYER}
1495
1896
  material={measurementMaterial}
1496
1897
  ref={measurementWidthRef}
1497
1898
  renderOrder={998}
1498
1899
  />
1499
1900
  <lineSegments
1500
- layers={EDITOR_LAYER}
1501
1901
  geometry={initialDepthGuideGeometry}
1902
+ layers={EDITOR_LAYER}
1502
1903
  material={measurementMaterial}
1503
1904
  ref={measurementDepthRef}
1504
1905
  renderOrder={998}
1505
1906
  />
1506
1907
  <lineSegments
1507
- layers={EDITOR_LAYER}
1508
1908
  geometry={initialHeightGuideGeometry}
1909
+ layers={EDITOR_LAYER}
1509
1910
  material={measurementMaterial}
1510
1911
  ref={measurementHeightRef}
1511
1912
  renderOrder={998}