@pascal-app/editor 1.0.0-beta.5 → 1.0.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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -6,14 +6,20 @@ import {
6
6
  type CeilingEvent,
7
7
  collectAlignmentAnchors,
8
8
  emitter,
9
+ findLevelAncestorId,
9
10
  type GridEvent,
10
11
  getScaledDimensions,
11
12
  type ItemEvent,
12
13
  movingFootprintAnchors,
14
+ type NodeEvent,
15
+ nodeRegistry,
13
16
  type RoofEvent,
17
+ resolveFrozenFloorPlacementPatch,
14
18
  resolveLevelId,
15
19
  type ShelfEvent,
20
+ type SurfaceRejectReason,
16
21
  sceneRegistry,
22
+ useLiveNodeOverrides,
17
23
  useLiveTransforms,
18
24
  useScene,
19
25
  useSpatialQuery,
@@ -25,16 +31,13 @@ import { Html } from '@react-three/drei'
25
31
  import { useFrame, useThree } from '@react-three/fiber'
26
32
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
27
33
  import {
28
- Box3,
29
34
  Euler,
30
35
  type Group,
31
36
  type LineSegments,
32
- Matrix4,
33
37
  type Mesh,
34
38
  type Object3D,
35
39
  PlaneGeometry,
36
40
  Quaternion,
37
- Ray,
38
41
  Vector3,
39
42
  } from 'three'
40
43
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
@@ -45,16 +48,29 @@ import {
45
48
  } from '../../../lib/active-placement-surface'
46
49
  import { EDITOR_LAYER } from '../../../lib/constants'
47
50
  import { formatLinearMeasurement } from '../../../lib/measurements'
51
+ import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
48
52
  import { sfxEmitter } from '../../../lib/sfx-bus'
49
-
53
+ import {
54
+ surfaceAttachmentId,
55
+ surfaceFramePose,
56
+ updateSurfaceNode,
57
+ } from '../../../lib/surface-attachment'
50
58
  import {
51
59
  projectAlignmentGuidesWorldToActiveBuildingLocal,
52
60
  resolveAlignmentForActiveBuilding,
53
61
  } from '../../../lib/world-grid-snap'
54
62
  import useAlignmentGuides from '../../../store/use-alignment-guides'
55
- import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
56
-
63
+ import useEditor, {
64
+ isAlignmentGuideActive,
65
+ isGridSnapActive,
66
+ isMagneticSnapActive,
67
+ } from '../../../store/use-editor'
57
68
  import useFacingPose from '../../../store/use-facing-pose'
69
+ import usePlacementPreview from '../../../store/use-placement-preview'
70
+ import {
71
+ createItemSurfaceGridDispatch,
72
+ createItemSurfacePointerArbitration,
73
+ } from '../registry/item-surface-move'
58
74
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
59
75
  import {
60
76
  createLineGeometry,
@@ -63,9 +79,19 @@ import {
63
79
  updateLineGeometry,
64
80
  } from '../shared/placement-box-geometry'
65
81
  import {
82
+ type PointerSupportSurface,
66
83
  resolvePointerSupportElevation,
67
84
  resolvePointerSupportSurface,
68
85
  } from '../shared/pointer-support-cap'
86
+ import { createShelfStickiness } from '../shared/shelf-stickiness'
87
+ import { createSurfaceRejectionFeedback } from '../shared/surface-rejection'
88
+ import { shouldCreateFloorDraft } from './draft-creation'
89
+ import { commitFaceHostClick, resolveFaceHostPreviewCommit } from './face-host-commit'
90
+ import {
91
+ applyFaceHostPreviewPose,
92
+ resolveFaceHostSwitch,
93
+ shouldDetachFaceHostOnLeave,
94
+ } from './face-host-preview'
69
95
  import {
70
96
  getDetachedAttachmentPreviewLift,
71
97
  getGridAlignedDimensions,
@@ -77,12 +103,14 @@ import {
77
103
  import {
78
104
  ceilingStrategy,
79
105
  checkCanPlace,
106
+ faceHostStrategy,
80
107
  floorStrategy,
81
108
  itemSurfaceStrategy,
82
109
  roofWallStrategy,
83
110
  shelfSurfaceStrategy,
84
111
  wallStrategy,
85
112
  } from './placement-strategies'
113
+ import { resolveItemPlacementSurfaceNormal } from './placement-surface'
86
114
  import type { PlacementState, TransitionResult } from './placement-types'
87
115
  import type { DraftNodeHandle } from './use-draft-node'
88
116
 
@@ -178,6 +206,27 @@ function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
178
206
  }
179
207
  }
180
208
 
209
+ /**
210
+ * Building-local Y of the storey the floor-path ghost belongs to.
211
+ *
212
+ * The cursor group is mounted inside ToolManager's building-local group, which
213
+ * carries no per-floor elevation, while every floor-path position (grid
214
+ * position, `getFloorVisualPosition`) is LEVEL-local — so on an upper storey the
215
+ * wireframe and its dimension labels render a floor too low. The wall / ceiling
216
+ * / item-surface paths don't need this: they convert a world hit through
217
+ * `worldToBuildingLocal`, which already carries the storey.
218
+ *
219
+ * Read off the level mesh (same source as `LevelOffsetGroup`) rather than the
220
+ * stored elevation so the ghost also follows the exploded-view lerp.
221
+ */
222
+ function getPlacementLevelY(draft: ItemNode | null | undefined): number {
223
+ const levelId =
224
+ (draft ? findLevelAncestorId(draft.id, useScene.getState().nodes) : null) ??
225
+ useViewer.getState().selection.levelId
226
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null
227
+ return levelMesh ? levelMesh.position.y : 0
228
+ }
229
+
181
230
  // Shared materials for placement cursor - we just change colors, not swap materials
182
231
  // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
183
232
  const edgeMaterial = new LineBasicNodeMaterial({
@@ -280,6 +329,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
280
329
  // by the MAX overlapping slab and a deck above the aimed-at floor
281
330
  // captures the item (and the grid-plane feedback makes it blink).
282
331
  const pointerSupportCapRef = useRef<number | null>(null)
332
+ const pointerSupportSurfaceRef = useRef<PointerSupportSurface | null>(null)
333
+ const frozenSupportSlabIdRef = useRef<string | undefined>(undefined)
283
334
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
284
335
 
285
336
  // Live camera ref — the shelf-stickiness test reconstructs the cursor world
@@ -427,17 +478,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
427
478
  ): [number, number, number] => {
428
479
  const draft = draftNode.current
429
480
  if (!(draft && !asset?.attachTo)) return position
430
- const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode)
481
+ const previewNode = getGridAlignedPreviewNode({
482
+ ...draft,
483
+ ...nodeUpdate,
484
+ ...(pointerSupportSurfaceRef.current?.sourceNodeId
485
+ ? { supportSlabId: frozenSupportSlabIdRef.current }
486
+ : {}),
487
+ } as ItemNode)
431
488
  return getFloorStackPreviewPosition({
432
489
  node: previewNode,
433
490
  position,
434
491
  rotation: previewNode.rotation,
435
- maxElevation: pointerSupportCapRef.current,
492
+ maxElevation: pointerSupportSurfaceRef.current?.sourceNodeId
493
+ ? null
494
+ : pointerSupportCapRef.current,
436
495
  })
437
496
  },
438
497
  [asset?.attachTo, draftNode],
439
498
  )
440
499
 
500
+ // Disable raycasting on the live draft mesh (and restore it when the draft
501
+ // changes or goes away) so the cursor ray passes through the item being
502
+ // moved and lands on the surface beneath it.
503
+ const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
504
+ if (raycastDisabledMeshRef.current !== mesh) {
505
+ // New draft root (or cleared): restore the prior mesh and reset tracking.
506
+ for (const restore of restoreRaycastsRef.current) restore()
507
+ restoreRaycastsRef.current = []
508
+ raycastDisabledChildrenRef.current = new WeakSet()
509
+ raycastDisabledMeshRef.current = mesh
510
+ }
511
+ if (!mesh) return
512
+ // Item models can mount descendants asynchronously. Re-walk the root so
513
+ // newly mounted meshes cannot intercept the next pointer event.
514
+ mesh.traverse((child) => {
515
+ if (raycastDisabledChildrenRef.current.has(child)) return
516
+ raycastDisabledChildrenRef.current.add(child)
517
+ const original = child.raycast
518
+ child.raycast = () => {}
519
+ restoreRaycastsRef.current.push(() => {
520
+ child.raycast = original
521
+ })
522
+ })
523
+ }, [])
524
+
441
525
  useEffect(() => {
442
526
  if (!asset) return
443
527
  useScene.temporal.getState().pause()
@@ -452,6 +536,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
452
536
  // layer's frame.
453
537
  let alignmentCandidates: AlignmentAnchor[] | null = null
454
538
  let floorDragAnchor: [number, number] | null = null
539
+ let pendingFaceHostId: string | null = null
455
540
 
456
541
  // Reset placement state
457
542
  placementState.current = configRef.current.initialState ?? {
@@ -465,17 +550,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
465
550
  // No pointer surface known yet — fall back to the uncapped election
466
551
  // (an adopted move draft keeps its persisted host until the first move).
467
552
  pointerSupportCapRef.current = null
553
+ pointerSupportSurfaceRef.current = null
554
+ frozenSupportSlabIdRef.current = undefined
468
555
  if (!asset.attachTo && placementState.current.surface === 'floor') {
469
556
  gridPosition.current.y = 0
470
557
  if (cursorGroupRef.current) {
471
- cursorGroupRef.current.position.y = 0
558
+ cursorGroupRef.current.position.y = getPlacementLevelY(draftNode.current)
472
559
  }
473
560
  }
474
561
 
562
+ let lastSurfaceEvent: NodeEvent<AnyNode> | null = null
563
+ const feedback = createSurfaceRejectionFeedback()
564
+ feedback.clear()
565
+
475
566
  // ---- Helpers ----
476
567
 
477
568
  const getContext = () => ({
478
569
  asset,
570
+ onSurfaceReject: (reason: SurfaceRejectReason) => feedback.reject(reason),
479
571
  levelId: useViewer.getState().selection.levelId,
480
572
  draftItem: draftNode.current,
481
573
  gridPosition: gridPosition.current,
@@ -493,6 +585,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
493
585
  }
494
586
  : validators
495
587
 
588
+ const disableDraftRaycastNow = () => {
589
+ const draft = draftNode.current
590
+ if (!draft) return
591
+ const mesh = sceneRegistry.nodes.get(draft.id)
592
+ if (mesh) reconcileDraftRaycast(mesh)
593
+ }
594
+
496
595
  const finishCommittedPlacement = (
497
596
  committedId: string | null,
498
597
  wasAdopted: boolean,
@@ -517,14 +616,94 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
517
616
  useEditor.getState().setMode('select')
518
617
  }
519
618
 
619
+ const commitDraft = (
620
+ nodeUpdate: Partial<ItemNode>,
621
+ options?: {
622
+ supportElevationCap?: number | null
623
+ preferredSupportSlabId?: string | null
624
+ pinSupport?: boolean
625
+ },
626
+ ) => {
627
+ const draftId = draftNode.current?.id ?? null
628
+ const wasAdopted = draftNode.isAdopted
629
+ const finalId = draftNode.commit(nodeUpdate, options)
630
+ if (draftId) {
631
+ useLiveTransforms.getState().clear(draftId)
632
+ useLiveNodeOverrides.getState().clearFields(draftId, faceHostClearFields(draftNode.current))
633
+ }
634
+ return { committedId: finalId ?? draftId, wasAdopted }
635
+ }
636
+
637
+ const faceHostClearFields = (draft: ItemNode | null | undefined): Array<keyof ItemNode> => {
638
+ if (!draft?.parentId) return ['position', 'rotation']
639
+ const host = useScene.getState().nodes[draft.parentId as AnyNodeId]
640
+ return host
641
+ ? [...(nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields ?? [])]
642
+ : ['position', 'rotation']
643
+ }
644
+
645
+ const currentFaceHostId = (draft: ItemNode | null | undefined): string | null => {
646
+ if (!draft?.parentId) return null
647
+ const host = useScene.getState().nodes[draft.parentId as AnyNodeId]
648
+ return host
649
+ ? (nodeRegistry.get(host.type)?.capabilities.faceHost?.currentFaceId(draft) ?? null)
650
+ : null
651
+ }
652
+
653
+ const surfacePointer = createItemSurfacePointerArbitration()
520
654
  const revalidate = (): boolean => {
521
- const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
655
+ const fits = checkCanPlace(getContext(), validators)
656
+ if (
657
+ !fits &&
658
+ lastSurfaceEvent &&
659
+ (feedback.reason === 'footprint-outside-surface' ||
660
+ feedback.reason === 'footprint-exceeds-host' ||
661
+ feedback.reason === 'no-surface') &&
662
+ (placementState.current.surface === 'item-surface' ||
663
+ placementState.current.surface === 'shelf-surface')
664
+ ) {
665
+ const hostId =
666
+ placementState.current.surface === 'shelf-surface'
667
+ ? placementState.current.shelfId
668
+ : placementState.current.surfaceItemId
669
+ const mesh = hostId ? sceneRegistry.nodes.get(hostId) : undefined
670
+ if (mesh) {
671
+ const position = mesh.localToWorld(gridPosition.current.clone()).toArray()
672
+ feedback.clear()
673
+ detachItemSurfaceToFloor({ ...lastSurfaceEvent, position } as ItemEvent)
674
+ return revalidate()
675
+ }
676
+ }
677
+ const placeable = !feedback.reason && (altFreeRef.current || fits)
522
678
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
523
679
  edgeMaterial.color.setHex(color)
524
680
  basePlaneMaterial.color.setHex(color)
525
681
  return placeable
526
682
  }
527
683
 
684
+ const surfaceContext = (event: NodeEvent<AnyNode>) => {
685
+ lastSurfaceEvent = event
686
+ return {
687
+ ...getContext(),
688
+ onSurfaceReject: (reason: SurfaceRejectReason) => {
689
+ if (
690
+ (reason === 'footprint-outside-surface' ||
691
+ reason === 'footprint-exceeds-host' ||
692
+ reason === 'no-surface') &&
693
+ (placementState.current.surface === 'item-surface' ||
694
+ placementState.current.surface === 'shelf-surface')
695
+ ) {
696
+ feedback.clear()
697
+ detachItemSurfaceToFloor(event as ItemEvent)
698
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
699
+ return
700
+ }
701
+ feedback.reject(reason, event.nativeEvent.nativeEvent ?? event.nativeEvent)
702
+ revalidate()
703
+ },
704
+ }
705
+ }
706
+
528
707
  // Tool visuals are rendered inside the building-local ToolManager group, so all cursor
529
708
  // positions must be in building-local space. Wall/ceiling/item-surface strategies return
530
709
  // world-space cursor positions (from their event.position); convert them here.
@@ -540,7 +719,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
540
719
  return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
541
720
  }
542
721
 
543
- const applyTransition = (result: TransitionResult) => {
722
+ const worldRotationToBuildingLocal = (
723
+ rotation: [number, number, number],
724
+ ): [number, number, number] => {
725
+ const worldQuaternion = new Quaternion().setFromEuler(new Euler(...rotation))
726
+ const buildingId = useViewer.getState().selection.buildingId
727
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
728
+ if (buildingMesh) {
729
+ const buildingWorldQuaternion = new Quaternion()
730
+ buildingMesh.getWorldQuaternion(buildingWorldQuaternion)
731
+ worldQuaternion.premultiply(buildingWorldQuaternion.invert())
732
+ }
733
+ const localRotation = new Euler().setFromQuaternion(worldQuaternion, 'XYZ')
734
+ return [localRotation.x, localRotation.y, localRotation.z]
735
+ }
736
+
737
+ const { tick: tickMovementSfx } = createMovementSfxTick()
738
+ const tickSurfaceMovementSfx = (position: [number, number, number]) => {
739
+ const point = worldToBuildingLocal(...position)
740
+ tickMovementSfx({
741
+ coords: [point.x, point.z],
742
+ gridSnapActive: isGridSnapActive(),
743
+ gridStep: useEditor.getState().gridSnapStep,
744
+ })
745
+ }
746
+
747
+ const applyTransition = (result: TransitionResult, event?: NodeEvent<AnyNode>) => {
748
+ if (event) {
749
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
750
+ tickSurfaceMovementSfx(result.cursorPosition)
751
+ }
752
+ feedback.clear()
544
753
  // Alignment guides are floor-only; clear them when the cursor moves
545
754
  // onto a wall / ceiling / item surface (only those paths call this).
546
755
  useAlignmentGuides.getState().clear()
@@ -555,7 +764,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
555
764
  if (cursorGroupRef.current) {
556
765
  cursorGroupRef.current.position.set(c.x, c.y, c.z)
557
766
  if (result.cursorRotation) {
558
- cursorGroupRef.current.rotation.set(...result.cursorRotation)
767
+ cursorGroupRef.current.rotation.set(
768
+ ...worldRotationToBuildingLocal(result.cursorRotation),
769
+ )
559
770
  } else {
560
771
  cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
561
772
  }
@@ -575,7 +786,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
575
786
  if (cursorGroupRef.current) {
576
787
  cursorGroupRef.current.position.set(c.x, c.y, c.z)
577
788
  if (result.cursorRotation) {
578
- cursorGroupRef.current.rotation.set(...result.cursorRotation)
789
+ cursorGroupRef.current.rotation.set(
790
+ ...worldRotationToBuildingLocal(result.cursorRotation),
791
+ )
579
792
  } else {
580
793
  cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
581
794
  }
@@ -599,7 +812,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
599
812
  if (draft) {
600
813
  Object.assign(draft, result.nodeUpdate)
601
814
  // One-time setup: put node in the right parent so it renders correctly
602
- useScene.getState().updateNode(draft.id, result.nodeUpdate)
815
+ if (result.surfaceId !== undefined)
816
+ draftNode.updateSurface(result.nodeUpdate, result.surfaceId)
817
+ else updateSurfaceNode(draft.id, result.nodeUpdate)
818
+ disableDraftRaycastNow()
603
819
  }
604
820
 
605
821
  const previewBounds = expandBoundsToGrid(
@@ -617,6 +833,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
617
833
 
618
834
  // ---- Init draft ----
619
835
  configRef.current.initDraft(gridPosition.current)
836
+ if (draftNode.current && surfaceAttachmentId(draftNode.current))
837
+ gridPosition.current.set(...draftNode.current.position)
620
838
  const preserveDragOffset = configRef.current.preserveDragOffset === true
621
839
  // The host the item was grabbed from + its pre-drag host-local position.
622
840
  // Each surface's grab anchor preserves the grab offset only on THAT host,
@@ -775,7 +993,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
775
993
  }
776
994
  }
777
995
  } else if (cursorGroupRef.current) {
996
+ // No registered mesh yet (a just-created draft renders next tick), so
997
+ // fall back to the level-local grid position lifted onto its storey.
778
998
  cursorGroupRef.current.position.copy(gridPosition.current)
999
+ cursorGroupRef.current.position.y += getPlacementLevelY(draftNode.current)
779
1000
  cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
780
1001
  }
781
1002
  }
@@ -800,6 +1021,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
800
1021
  setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
801
1022
  }
802
1023
  const onReleaseCommit = () => {
1024
+ gridDispatch.flush()
803
1025
  if (!releaseCommit) return
804
1026
  const commit = releaseCommit
805
1027
  releaseCommit = null
@@ -809,56 +1031,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
809
1031
 
810
1032
  // ---- Floor Handlers ----
811
1033
 
812
- let previousGridPos: [number, number, number] | null = null
813
-
814
- // Scratch objects reused by the stickiness test (runs per grid:move).
815
- const stickyRay = new Ray()
816
- const stickyBox = new Box3()
817
- const stickyMat = new Matrix4()
818
- const stickyCamPos = new Vector3()
819
-
820
- // True while the cursor ray still points at the active shelf's volume.
821
- // Used to keep an item hosted on a shelf "sticky": from an angled camera
822
- // the cursor ray slips off the shelf's thin boards / through its gaps and
823
- // lands on the floor *behind* the shelf, which would otherwise thrash the
824
- // placement between the shelf row and the floor on every micro-move. We
825
- // reconstruct the world ray (camera → grid hit point) and test it against
826
- // the shelf's bounding box — so a ray that passes *through* the shelf but
827
- // lands behind it still counts as "on the shelf". Only a ray that misses
828
- // the shelf box entirely means the user genuinely moved off it. A simple
829
- // footprint test on the floor hit point can't distinguish those.
830
- const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
831
- const shelfId = placementState.current.shelfId
832
- if (!shelfId) return false
833
- const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
834
- const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
835
- | { width?: number; depth?: number; height?: number }
836
- | undefined
837
- if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
838
-
839
- cameraRef.current.getWorldPosition(stickyCamPos)
840
- stickyRay.origin.copy(stickyCamPos)
841
- stickyRay.direction
842
- .set(
843
- gridWorldPoint[0] - stickyCamPos.x,
844
- gridWorldPoint[1] - stickyCamPos.y,
845
- gridWorldPoint[2] - stickyCamPos.z,
846
- )
847
- .normalize()
848
-
849
- // Into shelf-local space, then test the shelf's local AABB (origin at the
850
- // base: y ∈ [0, height]) with a small margin.
851
- stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
852
- const m = 0.08
853
- stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
854
- stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
855
- return stickyRay.intersectsBox(stickyBox)
856
- }
1034
+ const cursorRayIntersectsShelf = createShelfStickiness()
857
1035
 
858
1036
  const onGridMove = (event: GridEvent) => {
1037
+ if (surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent)) return
859
1038
  releaseCommit = () => onGridClick(event)
860
1039
  // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
861
- if (draftNode.current === null && asset.attachTo === undefined) {
1040
+ if (
1041
+ shouldCreateFloorDraft(draftNode.current, asset.attachTo, placementState.current.surface)
1042
+ ) {
862
1043
  configRef.current.initDraft(gridPosition.current)
863
1044
  }
864
1045
 
@@ -875,6 +1056,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
875
1056
  // a drag over a deck-above-a-floor hop between the two surfaces).
876
1057
  const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
877
1058
  pointerSupportCapRef.current = pointed?.elevation ?? null
1059
+ pointerSupportSurfaceRef.current = pointed
878
1060
  const surfaceEvent: GridEvent =
879
1061
  pointed?.worldPoint && pointed.localPoint
880
1062
  ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
@@ -885,13 +1067,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
885
1067
  // off a board / through a gap and hit the floor behind). Detach to the
886
1068
  // floor only once the ray misses the shelf entirely — without this the
887
1069
  // item oscillates between the shelf row and the floor on every micro-move.
1070
+ const counterId =
1071
+ placementState.current.surface === 'item-surface'
1072
+ ? placementState.current.surfaceItemId
1073
+ : null
1074
+ if (counterId && useScene.getState().nodes[counterId as AnyNodeId]?.type === 'cabinet') {
1075
+ if (
1076
+ surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent) ||
1077
+ cursorRayIntersectsShelf(counterId, cameraRef.current, event.position)
1078
+ )
1079
+ return
1080
+ detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
1081
+ }
888
1082
  if (placementState.current.surface === 'shelf-surface') {
889
- if (cursorRayIntersectsActiveShelf(event.position)) return
1083
+ if (
1084
+ cursorRayIntersectsShelf(
1085
+ placementState.current.shelfId,
1086
+ cameraRef.current,
1087
+ event.position,
1088
+ )
1089
+ )
1090
+ return
890
1091
  // Land at the pointed surface's plan point — the raw grid hit is
891
1092
  // still skewed by the plane riding at the shelf-surface height.
892
1093
  detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
893
1094
  }
894
1095
 
1096
+ if (placementState.current.surface === 'floor')
1097
+ feedback.grid(event.nativeEvent.nativeEvent ?? event.nativeEvent)
895
1098
  const floorEvent = applyFloorGrabOffset(surfaceEvent)
896
1099
 
897
1100
  lastRawPos.current.set(
@@ -946,27 +1149,53 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
946
1149
  useAlignmentGuides.getState().clear()
947
1150
  }
948
1151
 
949
- const gridPos: [number, number, number] = [
1152
+ // `result.gridPosition[1]` is the LIVE `gridPosition.current.y` — seeded
1153
+ // from the draft's authored Y by `initDraft` (so a block-face / raised
1154
+ // construction-plane item keeps its height) and zeroed by
1155
+ // `detachItemSurfaceToFloor` / `faceHostStrategy.leave` when the item
1156
+ // comes back down. Freezing it at drag start instead left an item taken
1157
+ // off a shelf floating at the shelf's height.
1158
+ let gridPos: [number, number, number] = [
950
1159
  result.gridPosition[0] + alignX,
951
1160
  result.gridPosition[1],
952
1161
  result.gridPosition[2] + alignZ,
953
1162
  ]
954
-
955
- // Play snap sound when grid position changes
956
- if (
957
- previousGridPos &&
958
- (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
959
- ) {
960
- sfxEmitter.emit('sfx:grid-snap')
1163
+ frozenSupportSlabIdRef.current = undefined
1164
+ if (draft && pointed?.sourceNodeId) {
1165
+ const effectiveNode = {
1166
+ ...draft,
1167
+ position: gridPos,
1168
+ parentId: useViewer.getState().selection.levelId ?? draft.parentId,
1169
+ } as ItemNode
1170
+ const frozenPatch = resolveFrozenFloorPlacementPatch(
1171
+ effectiveNode,
1172
+ useScene.getState().nodes,
1173
+ {
1174
+ position: gridPos,
1175
+ rotation: effectiveNode.rotation,
1176
+ elevation: pointed.elevation,
1177
+ preferredSlabId: pointed.supportSlabId,
1178
+ },
1179
+ )
1180
+ gridPos = frozenPatch.position
1181
+ frozenSupportSlabIdRef.current = frozenPatch.supportSlabId
961
1182
  }
962
1183
 
963
- previousGridPos = [...gridPos]
1184
+ tickMovementSfx({
1185
+ coords: [gridPos[0], gridPos[2]],
1186
+ gridSnapActive: isGridSnapActive(),
1187
+ gridStep: useEditor.getState().gridSnapStep,
1188
+ })
964
1189
  gridPosition.current.set(...gridPos)
965
1190
  const cursorPosition = getFloorVisualPosition(gridPos)
966
1191
  if (!draft && asset.attachTo) {
967
1192
  cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
968
1193
  }
969
- cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
1194
+ cursorGroupRef.current.position.set(
1195
+ cursorPosition[0],
1196
+ cursorPosition[1] + getPlacementLevelY(draft),
1197
+ cursorPosition[2],
1198
+ )
970
1199
  // Floor items only rotate on Y; keep the preview box (and the live
971
1200
  // transform the 2D floorplan mirrors) aligned with the draft's
972
1201
  // rotation. Without this the box stays at its seed rotation until a
@@ -989,6 +1218,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
989
1218
  }
990
1219
 
991
1220
  const onGridClick = (event: GridEvent) => {
1221
+ gridDispatch.flush()
1222
+ if (feedback.reason) return
992
1223
  // Drop alignment guides on click — the move commits (guides done) or
993
1224
  // placement re-arms (the next move republishes them).
994
1225
  useAlignmentGuides.getState().clear()
@@ -1002,20 +1233,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1002
1233
  0,
1003
1234
  ]
1004
1235
 
1005
- // Clear live transform before commit
1006
- if (draftNode.current) {
1007
- useLiveTransforms.getState().clear(draftNode.current.id)
1008
- }
1009
-
1010
- const committedId = draftNode.current?.id ?? null
1011
- const wasAdopted = draftNode.isAdopted
1012
1236
  // Carry the pointer surface cap into the commit so the persisted
1013
1237
  // supportSlabId reproduces the capped election (elects the aimed-at
1014
1238
  // lower slab — or the ground — instead of a deck hanging above).
1015
- const finalId = draftNode.commit(result.nodeUpdate, {
1239
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate, {
1016
1240
  supportElevationCap: pointerSupportCapRef.current,
1241
+ preferredSupportSlabId: pointerSupportSurfaceRef.current?.supportSlabId,
1242
+ pinSupport: pointerSupportSurfaceRef.current?.sourceNodeId != null,
1017
1243
  })
1018
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1244
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1019
1245
  draftNode.create(
1020
1246
  gridPosition.current,
1021
1247
  asset,
@@ -1055,7 +1281,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1055
1281
  ensureDraft(result)
1056
1282
  } else if (result.nodeUpdate.parentId) {
1057
1283
  // Existing draft (move mode): reparent to new wall
1058
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1284
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1059
1285
  if (result.stateUpdate.wallId) {
1060
1286
  useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
1061
1287
  }
@@ -1082,7 +1308,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1082
1308
  event.stopPropagation()
1083
1309
  applyTransition(enterResult)
1084
1310
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1085
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1311
+ updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
1086
1312
  if (enterResult.stateUpdate.wallId) {
1087
1313
  useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
1088
1314
  }
@@ -1208,18 +1434,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1208
1434
  if (!result) return
1209
1435
 
1210
1436
  event.stopPropagation()
1211
- // Clear live transform before commit
1212
- if (draftNode.current) {
1213
- useLiveTransforms.getState().clear(draftNode.current.id)
1214
- }
1215
- const committedId = draftNode.current?.id ?? null
1216
- const wasAdopted = draftNode.isAdopted
1217
- const finalId = draftNode.commit(result.nodeUpdate)
1437
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1218
1438
  if (result.dirtyNodeId) {
1219
1439
  useScene.getState().dirtyNodes.add(result.dirtyNodeId)
1220
1440
  }
1221
1441
 
1222
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1442
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1223
1443
  const nodes = useScene.getState().nodes
1224
1444
  const enterResult = wallStrategy.enter(
1225
1445
  getContext(),
@@ -1285,7 +1505,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1285
1505
  ensureDraft(result)
1286
1506
  } else if (result.nodeUpdate.parentId) {
1287
1507
  // Existing draft (move mode): reparent to the segment
1288
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1508
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1289
1509
  }
1290
1510
  return true
1291
1511
  }
@@ -1368,14 +1588,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1368
1588
  if (!result) return
1369
1589
 
1370
1590
  event.stopPropagation()
1371
- if (draftNode.current) {
1372
- useLiveTransforms.getState().clear(draftNode.current.id)
1373
- }
1374
- const committedId = draftNode.current?.id ?? null
1375
- const wasAdopted = draftNode.isAdopted
1376
- const finalId = draftNode.commit(result.nodeUpdate)
1591
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1377
1592
 
1378
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1593
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1379
1594
  const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1380
1595
  if (enterResult) {
1381
1596
  applyTransition(enterResult)
@@ -1408,10 +1623,124 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1408
1623
  }
1409
1624
  }
1410
1625
 
1626
+ // ---- Face Host Handlers ----
1627
+
1628
+ const enterFaceHost = (event: NodeEvent): boolean => {
1629
+ const result = faceHostStrategy.enter(getContext(), event)
1630
+ if (!result) return false
1631
+ pendingFaceHostId = null
1632
+ event.stopPropagation()
1633
+ applyTransition(result)
1634
+ if (!draftNode.current) {
1635
+ ensureDraft(result)
1636
+ } else if (result.nodeUpdate.parentId) {
1637
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1638
+ disableDraftRaycastNow()
1639
+ }
1640
+ if (draftNode.current) useLiveTransforms.getState().clear(draftNode.current.id)
1641
+ return true
1642
+ }
1643
+
1644
+ const onFaceHostEnter = (event: NodeEvent) => {
1645
+ has3DPointerDrivenMoveRef.current = true
1646
+ enterFaceHost(event)
1647
+ }
1648
+
1649
+ const onFaceHostMove = (event: NodeEvent) => {
1650
+ has3DPointerDrivenMoveRef.current = true
1651
+ if (!cursorGroupRef.current) return
1652
+ const ctx = getContext()
1653
+ if (ctx.state.surface !== 'block-face' || !draftNode.current) {
1654
+ if (enterFaceHost(event)) releaseCommit = () => onFaceHostClick(event)
1655
+ return
1656
+ }
1657
+ const result = faceHostStrategy.move(ctx, event)
1658
+ if (!result) {
1659
+ event.stopPropagation()
1660
+ return
1661
+ }
1662
+
1663
+ event.stopPropagation()
1664
+ const draft = draftNode.current
1665
+ const nextFaceId = result.hostFaceId
1666
+ const faceSwitch = resolveFaceHostSwitch(
1667
+ currentFaceHostId(draft),
1668
+ nextFaceId,
1669
+ pendingFaceHostId,
1670
+ )
1671
+ pendingFaceHostId = faceSwitch.pendingFaceId
1672
+ if (!faceSwitch.accept) return
1673
+ releaseCommit = () => onFaceHostClick(event)
1674
+
1675
+ const posChanged =
1676
+ gridPosition.current.x !== result.gridPosition[0] ||
1677
+ gridPosition.current.y !== result.gridPosition[1] ||
1678
+ gridPosition.current.z !== result.gridPosition[2]
1679
+ if (posChanged) sfxEmitter.emit('sfx:grid-snap')
1680
+ gridPosition.current.set(...result.gridPosition)
1681
+ const cursor = worldToBuildingLocal(...result.cursorPosition)
1682
+ cursorGroupRef.current.position.copy(cursor)
1683
+ if (result.cursorRotation) {
1684
+ cursorGroupRef.current.rotation.set(...worldRotationToBuildingLocal(result.cursorRotation))
1685
+ }
1686
+
1687
+ if (draft && result.nodeUpdate) {
1688
+ Object.assign(draft, result.nodeUpdate)
1689
+ const mesh = sceneRegistry.nodes.get(draft.id)
1690
+ const rotation = result.nodeUpdate.rotation ?? draft.rotation
1691
+ if (mesh) applyFaceHostPreviewPose(mesh, result.gridPosition, rotation)
1692
+ useLiveNodeOverrides.getState().set(draft.id, {
1693
+ position: result.gridPosition,
1694
+ rotation,
1695
+ ...result.nodeUpdate,
1696
+ })
1697
+ }
1698
+ revalidate()
1699
+ }
1700
+
1701
+ const onFaceHostClick = (event: NodeEvent) => {
1702
+ const outcome = commitFaceHostClick({
1703
+ commitDraft,
1704
+ enterFaceHost,
1705
+ event,
1706
+ getContext,
1707
+ })
1708
+ if (!outcome) return
1709
+ const { committedId, wasAdopted } = outcome
1710
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1711
+ const enterResult = faceHostStrategy.enter(getContext(), event)
1712
+ if (enterResult) applyTransition(enterResult)
1713
+ else revalidate()
1714
+ })
1715
+ }
1716
+
1717
+ const onFaceHostLeave = (event: NodeEvent) => {
1718
+ pendingFaceHostId = null
1719
+ if (!shouldDetachFaceHostOnLeave(asset.attachTo)) {
1720
+ event.stopPropagation()
1721
+ return
1722
+ }
1723
+ const result = faceHostStrategy.leave(getContext())
1724
+ if (!result) return
1725
+ event.stopPropagation()
1726
+ const draft = draftNode.current
1727
+ if (draft) {
1728
+ useLiveNodeOverrides.getState().clearFields(draft.id, faceHostClearFields(draft))
1729
+ }
1730
+ if (draftNode.isAdopted) {
1731
+ applyTransition(result)
1732
+ if (draft) useScene.getState().updateNode(draft.id, result.nodeUpdate)
1733
+ } else {
1734
+ draftNode.destroy()
1735
+ Object.assign(placementState.current, result.stateUpdate)
1736
+ }
1737
+ }
1738
+
1411
1739
  // ---- Item Surface Handlers ----
1412
1740
 
1413
- const detachItemSurfaceToFloor = (event: ItemEvent) => {
1741
+ const detachItemSurfaceToFloor = (event: NodeEvent<AnyNode>) => {
1414
1742
  hostSurfaceDragAnchor = null
1743
+ surfacePointer.clear()
1415
1744
  // Landing back on the floor: refresh the pointer surface cap from
1416
1745
  // this event's world hit so the first floor position already targets
1417
1746
  // the aimed-at surface (not a deck above it).
@@ -1449,7 +1778,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1449
1778
  levelId ? { parentId: levelId } : undefined,
1450
1779
  )
1451
1780
  if (cursorGroupRef.current) {
1452
- cursorGroupRef.current.position.set(...floorVisualPosition)
1781
+ cursorGroupRef.current.position.set(
1782
+ floorVisualPosition[0],
1783
+ floorVisualPosition[1] + getPlacementLevelY(draftNode.current),
1784
+ floorVisualPosition[2],
1785
+ )
1453
1786
  }
1454
1787
 
1455
1788
  const draft = draftNode.current
@@ -1480,7 +1813,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1480
1813
  // parent-must-be-a-level guard and the item + grid stopped following
1481
1814
  // slab elevations for the rest of the drag).
1482
1815
  if (levelId) draft.parentId = levelId
1483
- useScene.getState().updateNode(draft.id, {
1816
+ updateSurfaceNode(draft.id, {
1484
1817
  parentId: useViewer.getState().selection.levelId as string,
1485
1818
  position: floorPos,
1486
1819
  rotation,
@@ -1490,29 +1823,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1490
1823
  revalidate()
1491
1824
  }
1492
1825
 
1493
- const onItemEnter = (event: ItemEvent) => {
1826
+ const onItemEnter = (event: NodeEvent<AnyNode>) => {
1827
+ if (event.node.type === 'cabinet' && placementState.current.surfaceItemId === event.node.id) {
1828
+ onItemMove(event)
1829
+ return
1830
+ }
1831
+ if (event.node.type === 'cabinet') {
1832
+ lastRawPos.current.set(...event.position)
1833
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1834
+ }
1494
1835
  if (event.node.id === draftNode.current?.id) return
1495
1836
  has3DPointerDrivenMoveRef.current = true
1496
- const result = itemSurfaceStrategy.enter(getContext(), event)
1837
+ const result = itemSurfaceStrategy.enter(surfaceContext(event), event)
1497
1838
  if (!result) return
1839
+ feedback.clear()
1498
1840
 
1499
1841
  event.stopPropagation()
1500
- applyTransition(result)
1842
+ applyTransition(result, event)
1501
1843
 
1502
1844
  if (!draftNode.current) {
1503
1845
  ensureDraft(result)
1504
1846
  } else if (result.nodeUpdate.parentId) {
1505
1847
  // Existing draft (move mode): reparent to surface item
1506
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1848
+ draftNode.updateSurface(result.nodeUpdate, result.surfaceId ?? null)
1507
1849
  }
1508
1850
  }
1509
1851
 
1510
- const onItemMove = (event: ItemEvent) => {
1852
+ const onItemMove = (event: NodeEvent<AnyNode>) => {
1511
1853
  if (event.node.id === draftNode.current?.id) return
1512
1854
  releaseCommit = () => onItemClick(event)
1513
1855
  has3DPointerDrivenMoveRef.current = true
1514
1856
  if (!cursorGroupRef.current) return
1515
- const ctx = getContext()
1857
+ const ctx = surfaceContext(event)
1858
+ if (event.node.type === 'cabinet') {
1859
+ lastRawPos.current.set(...event.position)
1860
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1861
+ }
1516
1862
 
1517
1863
  if (ctx.state.surface !== 'item-surface') {
1518
1864
  // Try entering surface mode
@@ -1520,9 +1866,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1520
1866
  if (!enterResult) return
1521
1867
 
1522
1868
  event.stopPropagation()
1523
- applyTransition(enterResult)
1869
+ applyTransition(enterResult, event)
1524
1870
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1525
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1871
+ draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
1526
1872
  }
1527
1873
  return
1528
1874
  }
@@ -1535,18 +1881,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1535
1881
 
1536
1882
  event.stopPropagation()
1537
1883
  if (enterResult) {
1538
- applyTransition(enterResult)
1884
+ applyTransition(enterResult, event)
1539
1885
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1540
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1886
+ draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
1541
1887
  }
1542
- } else {
1888
+ } else if (!feedback.reason) {
1543
1889
  detachItemSurfaceToFloor(event)
1544
1890
  }
1545
1891
  return
1546
1892
  }
1547
1893
 
1548
1894
  if (!draftNode.current) {
1549
- const enterResult = itemSurfaceStrategy.enter(getContext(), event)
1895
+ const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
1550
1896
  if (!enterResult) return
1551
1897
  event.stopPropagation()
1552
1898
  ensureDraft(enterResult)
@@ -1563,11 +1909,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1563
1909
  itemMoveEvent.position[1],
1564
1910
  itemMoveEvent.position[2],
1565
1911
  )
1566
- const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
1567
- if (!result) return
1912
+ const result = itemSurfaceStrategy.move(ctx, itemMoveEvent, event)
1913
+ if (!result) {
1914
+ revalidate()
1915
+ return
1916
+ }
1917
+ feedback.clear()
1568
1918
 
1569
1919
  event.stopPropagation()
1570
1920
 
1921
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1922
+ tickSurfaceMovementSfx(result.cursorPosition)
1571
1923
  gridPosition.current.set(...result.gridPosition)
1572
1924
  const ic = worldToBuildingLocal(...result.cursorPosition)
1573
1925
  cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
@@ -1576,8 +1928,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1576
1928
  const draft = draftNode.current
1577
1929
  if (draft) {
1578
1930
  draft.position = result.gridPosition
1931
+ if (event.node.type === 'procedural-item')
1932
+ draftNode.updateSurface(result.nodeUpdate ?? {}, result.surfaceId ?? null)
1579
1933
  const mesh = sceneRegistry.nodes.get(draft.id)
1580
- if (mesh) mesh.position.set(...result.gridPosition)
1934
+ if (mesh)
1935
+ mesh.position.set(
1936
+ ...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
1937
+ )
1581
1938
 
1582
1939
  // Publish live transform for 2D floorplan
1583
1940
  useLiveTransforms.getState().set(draft.id, {
@@ -1589,7 +1946,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1589
1946
  revalidate()
1590
1947
  }
1591
1948
 
1592
- const onItemLeave = (event: ItemEvent) => {
1949
+ const onItemLeave = (event: NodeEvent<AnyNode>) => {
1950
+ if (event.node.type === 'cabinet') return
1593
1951
  if (event.node.id === draftNode.current?.id) return
1594
1952
  if (placementState.current.surface !== 'item-surface') return
1595
1953
 
@@ -1603,7 +1961,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1603
1961
  detachItemSurfaceToFloor(event)
1604
1962
  }
1605
1963
 
1606
- const onItemClick = (event: ItemEvent) => {
1964
+ const onItemClick = (event: NodeEvent<AnyNode>) => {
1965
+ gridDispatch.flush()
1966
+ if (feedback.reason) return
1607
1967
  // Click on the draft item itself. R3F dispatches click events to
1608
1968
  // the closest intersected mesh only — when the draft is hovering
1609
1969
  // on a host (shelf / table / etc.) the draft's mesh is *above*
@@ -1612,7 +1972,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1612
1972
  // self-click as a commit on the active shelf so the user doesn't
1613
1973
  // have to aim around the cursor preview to drop the item.
1614
1974
  if (event.node.id === draftNode.current?.id) {
1615
- const ctx = getContext()
1975
+ const ctx = surfaceContext(event)
1976
+ if (ctx.state.surface === 'block-face') {
1977
+ const result = resolveFaceHostPreviewCommit(ctx)
1978
+ if (result) {
1979
+ event.stopPropagation()
1980
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1981
+ finishCommittedPlacement(committedId, wasAdopted, revalidate)
1982
+ return
1983
+ }
1984
+ }
1616
1985
  if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
1617
1986
  const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
1618
1987
  if (shelfNode && shelfNode.type === 'shelf') {
@@ -1620,13 +1989,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1620
1989
  const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
1621
1990
  if (result) {
1622
1991
  event.stopPropagation()
1623
- if (draftNode.current) {
1624
- useLiveTransforms.getState().clear(draftNode.current.id)
1625
- }
1626
- const committedId = draftNode.current?.id ?? null
1627
- const wasAdopted = draftNode.isAdopted
1628
- const finalId = draftNode.commit(result.nodeUpdate)
1629
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1992
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1993
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1630
1994
  const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
1631
1995
  if (enterResult) {
1632
1996
  applyTransition(enterResult)
@@ -1643,18 +2007,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1643
2007
  // the host's own click event is blocked by the cursor preview.
1644
2008
  if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
1645
2009
  const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
1646
- if (hostNode && hostNode.type === 'item') {
2010
+ if (
2011
+ hostNode &&
2012
+ (hostNode.type === 'item' ||
2013
+ hostNode.type === 'cabinet' ||
2014
+ hostNode.type === 'procedural-item')
2015
+ ) {
1647
2016
  const synthetic = { ...event, node: hostNode } as ItemEvent
1648
2017
  const result = itemSurfaceStrategy.click(ctx, synthetic)
1649
2018
  if (result) {
1650
2019
  event.stopPropagation()
1651
- if (draftNode.current) {
1652
- useLiveTransforms.getState().clear(draftNode.current.id)
1653
- }
1654
- const committedId = draftNode.current?.id ?? null
1655
- const wasAdopted = draftNode.isAdopted
1656
- const finalId = draftNode.commit(result.nodeUpdate)
1657
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2020
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
2021
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1658
2022
  const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
1659
2023
  if (enterResult) {
1660
2024
  applyTransition(enterResult)
@@ -1679,16 +2043,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1679
2043
  const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
1680
2044
  if (result) {
1681
2045
  event.stopPropagation()
1682
- if (draftNode.current) {
1683
- useLiveTransforms.getState().clear(draftNode.current.id)
1684
- }
1685
- const committedId = draftNode.current?.id ?? null
1686
- const wasAdopted = draftNode.isAdopted
1687
- const finalId = draftNode.commit(result.nodeUpdate)
1688
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2046
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
2047
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1689
2048
  const nodes = useScene.getState().nodes
1690
2049
  const enterResult = ceilingStrategy.enter(
1691
- getContext(),
2050
+ surfaceContext(event),
1692
2051
  synthetic,
1693
2052
  resolveLevelId,
1694
2053
  nodes,
@@ -1706,21 +2065,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1706
2065
  return
1707
2066
  }
1708
2067
 
1709
- const result = itemSurfaceStrategy.click(getContext(), event)
2068
+ const result = itemSurfaceStrategy.click(surfaceContext(event), event)
1710
2069
  if (!result) return
1711
2070
 
1712
2071
  event.stopPropagation()
1713
- // Clear live transform before commit
1714
- if (draftNode.current) {
1715
- useLiveTransforms.getState().clear(draftNode.current.id)
1716
- }
1717
- const committedId = draftNode.current?.id ?? null
1718
- const wasAdopted = draftNode.isAdopted
1719
- const finalId = draftNode.commit(result.nodeUpdate)
2072
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1720
2073
 
1721
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2074
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1722
2075
  // Try to set up next draft on the same surface
1723
- const enterResult = itemSurfaceStrategy.enter(getContext(), event)
2076
+ const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
1724
2077
  if (enterResult) {
1725
2078
  applyTransition(enterResult)
1726
2079
  } else {
@@ -1744,7 +2097,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1744
2097
  ensureDraft(result)
1745
2098
  } else if (result.nodeUpdate.parentId) {
1746
2099
  // Existing draft (move mode): reparent to new ceiling
1747
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
2100
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1748
2101
  if (result.stateUpdate.ceilingId) {
1749
2102
  useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
1750
2103
  }
@@ -1841,15 +2194,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1841
2194
  if (!result) return
1842
2195
 
1843
2196
  event.stopPropagation()
1844
- // Clear live transform before commit
1845
- if (draftNode.current) {
1846
- useLiveTransforms.getState().clear(draftNode.current.id)
1847
- }
1848
- const committedId = draftNode.current?.id ?? null
1849
- const wasAdopted = draftNode.isAdopted
1850
- const finalId = draftNode.commit(result.nodeUpdate)
2197
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1851
2198
 
1852
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2199
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1853
2200
  const nodes = useScene.getState().nodes
1854
2201
  const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
1855
2202
  if (enterResult) {
@@ -1900,16 +2247,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1900
2247
 
1901
2248
  const onShelfEnter = (event: ShelfEvent) => {
1902
2249
  has3DPointerDrivenMoveRef.current = true
1903
- const result = shelfSurfaceStrategy.enter(getContext(), event)
2250
+ const result = shelfSurfaceStrategy.enter(surfaceContext(event), event)
1904
2251
  if (!result) return
1905
2252
 
1906
2253
  event.stopPropagation()
1907
- applyTransition(result)
2254
+ applyTransition(result, event)
1908
2255
 
1909
2256
  if (!draftNode.current) {
1910
2257
  ensureDraft(result)
1911
2258
  } else if (result.nodeUpdate.parentId) {
1912
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
2259
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1913
2260
  }
1914
2261
  }
1915
2262
 
@@ -1919,7 +2266,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1919
2266
  // A shelf event can fire before the cursor group mounts or after
1920
2267
  // teardown, leaving the ref null; bail before dereferencing it below.
1921
2268
  if (!cursorGroupRef.current) return
1922
- const ctx = getContext()
2269
+ const ctx = surfaceContext(event)
1923
2270
  if (ctx.state.surface !== 'shelf-surface') {
1924
2271
  // Cursor entered via a move event without an enter — try
1925
2272
  // transitioning in so the user doesn't need to mouse out + back
@@ -1927,11 +2274,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1927
2274
  const enterResult = shelfSurfaceStrategy.enter(ctx, event)
1928
2275
  if (!enterResult) return
1929
2276
  event.stopPropagation()
1930
- applyTransition(enterResult)
2277
+ applyTransition(enterResult, event)
1931
2278
  if (!draftNode.current) {
1932
2279
  ensureDraft(enterResult)
1933
2280
  } else if (enterResult.nodeUpdate.parentId) {
1934
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
2281
+ updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
1935
2282
  }
1936
2283
  return
1937
2284
  }
@@ -1943,8 +2290,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1943
2290
  const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
1944
2291
  if (!result) return
1945
2292
 
2293
+ feedback.clear()
1946
2294
  event.stopPropagation()
1947
2295
 
2296
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
2297
+ tickSurfaceMovementSfx(result.cursorPosition)
1948
2298
  gridPosition.current.set(...result.gridPosition)
1949
2299
  const ic = worldToBuildingLocal(...result.cursorPosition)
1950
2300
  cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
@@ -1954,7 +2304,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1954
2304
  if (draft) {
1955
2305
  draft.position = result.gridPosition
1956
2306
  const mesh = sceneRegistry.nodes.get(draft.id)
1957
- if (mesh) mesh.position.set(...result.gridPosition)
2307
+ if (mesh)
2308
+ mesh.position.set(
2309
+ ...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
2310
+ )
1958
2311
  useLiveTransforms.getState().set(draft.id, {
1959
2312
  position: result.cursorPosition,
1960
2313
  rotation: result.cursorRotationY,
@@ -1978,19 +2331,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1978
2331
  }
1979
2332
 
1980
2333
  const onShelfClick = (event: ShelfEvent) => {
1981
- const result = shelfSurfaceStrategy.click(getContext(), event)
2334
+ gridDispatch.flush()
2335
+ if (feedback.reason) return
2336
+ const result = shelfSurfaceStrategy.click(surfaceContext(event), event)
1982
2337
  if (!result) return
1983
2338
 
1984
2339
  event.stopPropagation()
1985
- if (draftNode.current) {
1986
- useLiveTransforms.getState().clear(draftNode.current.id)
1987
- }
1988
- const committedId = draftNode.current?.id ?? null
1989
- const wasAdopted = draftNode.isAdopted
1990
- const finalId = draftNode.commit(result.nodeUpdate)
2340
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1991
2341
 
1992
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1993
- const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
2342
+ finishCommittedPlacement(committedId, wasAdopted, () => {
2343
+ const enterResult = shelfSurfaceStrategy.enter(surfaceContext(event), event)
1994
2344
  if (enterResult) {
1995
2345
  applyTransition(enterResult)
1996
2346
  } else {
@@ -2018,7 +2368,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2018
2368
 
2019
2369
  // Roof-wall drafts live flat in the host face frame (yaw 0) —
2020
2370
  // manual rotation would skew them off the wall plane.
2021
- if (placementState.current.surface === 'roof-wall') return
2371
+ if (
2372
+ placementState.current.surface === 'roof-wall' ||
2373
+ placementState.current.surface === 'block-face'
2374
+ )
2375
+ return
2022
2376
 
2023
2377
  let rotationDir: 1 | -1 | 0 = 0
2024
2378
  if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
@@ -2034,9 +2388,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2034
2388
  const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
2035
2389
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
2036
2390
 
2037
- // Ref + cursor mesh + item mesh — no store update during drag
2391
+ // Rotate the building-local cursor by the same delta as the host-local
2392
+ // draft. This preserves the host's composed yaw for items resting on a
2393
+ // table or shelf while still matching the draft exactly on the floor.
2038
2394
  if (cursorGroupRef.current) {
2039
- cursorGroupRef.current.rotation.y = newRotationY
2395
+ cursorGroupRef.current.rotation.y += newRotationY - currentRotation[1]
2040
2396
  }
2041
2397
  const mesh = sceneRegistry.nodes.get(draft.id)
2042
2398
  if (mesh) mesh.rotation.y = newRotationY
@@ -2053,8 +2409,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2053
2409
  draft.position = [x, gridPosition.current.y, z]
2054
2410
  if (cursorGroupRef.current) {
2055
2411
  if (surface === 'floor') {
2412
+ const visual = getFloorVisualPosition([x, gridPosition.current.y, z])
2056
2413
  cursorGroupRef.current.position.set(
2057
- ...getFloorVisualPosition([x, gridPosition.current.y, z]),
2414
+ visual[0],
2415
+ visual[1] + getPlacementLevelY(draft),
2416
+ visual[2],
2058
2417
  )
2059
2418
  } else {
2060
2419
  cursorGroupRef.current.position.x = x
@@ -2100,27 +2459,63 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2100
2459
  }
2101
2460
  }
2102
2461
 
2103
- // Update live transform for 2D floorplan with post-snap position
2462
+ // Keep both preview renderers on the rotated draft. The item renderer
2463
+ // consumes live node overrides, while the floor-plan renderer consumes
2464
+ // the live transform and placement-preview snapshot.
2465
+ const storedSurfacePose = surfaceFramePose(
2466
+ draft.parentId,
2467
+ surfaceAttachmentId(draft),
2468
+ draft,
2469
+ true,
2470
+ )
2471
+ if (surfaceAttachmentId(draft)) {
2472
+ draftNode.updateSurface(
2473
+ { position: draft.position, rotation: draft.rotation },
2474
+ surfaceAttachmentId(draft),
2475
+ )
2476
+ mesh?.position.set(...storedSurfacePose.position)
2477
+ mesh?.rotation.set(...storedSurfacePose.rotation)
2478
+ }
2479
+ useLiveNodeOverrides.getState().set(draft.id, { rotation: storedSurfacePose.rotation })
2104
2480
  const currentLive = useLiveTransforms.getState().get(draft.id)
2105
- if (currentLive) {
2106
- const livePosition: [number, number, number] =
2107
- surface === 'floor'
2108
- ? [draft.position[0], draft.position[1], draft.position[2]]
2109
- : cursorGroupRef.current
2110
- ? [
2111
- cursorGroupRef.current.position.x,
2112
- cursorGroupRef.current.position.y,
2113
- cursorGroupRef.current.position.z,
2114
- ]
2115
- : [draft.position[0], draft.position[1], draft.position[2]]
2116
- useLiveTransforms.getState().set(draft.id, {
2117
- ...currentLive,
2118
- position: livePosition,
2119
- rotation: newRotationY,
2120
- })
2481
+ const livePosition: [number, number, number] =
2482
+ surface === 'floor'
2483
+ ? [draft.position[0], draft.position[1], draft.position[2]]
2484
+ : cursorGroupRef.current
2485
+ ? [
2486
+ cursorGroupRef.current.position.x,
2487
+ cursorGroupRef.current.position.y,
2488
+ cursorGroupRef.current.position.z,
2489
+ ]
2490
+ : [draft.position[0], draft.position[1], draft.position[2]]
2491
+ useLiveTransforms.getState().set(draft.id, {
2492
+ ...currentLive,
2493
+ position: livePosition,
2494
+ rotation: cursorGroupRef.current?.rotation.y ?? newRotationY,
2495
+ })
2496
+
2497
+ const placementPreview = usePlacementPreview.getState()
2498
+ if (placementPreview.node?.id === draft.id) {
2499
+ const parentNode = draft.parentId
2500
+ ? (useScene.getState().nodes[draft.parentId as AnyNodeId] ?? null)
2501
+ : null
2502
+ placementPreview.set(
2503
+ {
2504
+ ...draft,
2505
+ position: [...draft.position],
2506
+ rotation: [...draft.rotation],
2507
+ },
2508
+ parentNode,
2509
+ )
2121
2510
  }
2122
2511
 
2123
- revalidate()
2512
+ if (feedback.reason && lastSurfaceEvent) {
2513
+ if (lastSurfaceEvent.node.type === 'shelf') onShelfMove(lastSurfaceEvent as ShelfEvent)
2514
+ else onItemMove(lastSurfaceEvent as NodeEvent<AnyNode>)
2515
+ } else {
2516
+ feedback.clear()
2517
+ revalidate()
2518
+ }
2124
2519
  }
2125
2520
  }
2126
2521
 
@@ -2214,7 +2609,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2214
2609
 
2215
2610
  // ---- Subscribe ----
2216
2611
 
2217
- emitter.on('grid:move', onGridMove)
2612
+ const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
2613
+ emitter.on('grid:move', gridDispatch.schedule)
2218
2614
  emitter.on('grid:click', onGridClick)
2219
2615
  emitter.on('item:enter', onItemEnter)
2220
2616
  emitter.on('item:move', onItemMove)
@@ -2228,10 +2624,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2228
2624
  emitter.on('roof:move', onRoofWallMove)
2229
2625
  emitter.on('roof:click', onRoofWallClick)
2230
2626
  emitter.on('roof:leave', onRoofWallLeave)
2627
+ emitter.on('node:enter', onFaceHostEnter)
2628
+ emitter.on('node:move', onFaceHostMove)
2629
+ emitter.on('node:click', onFaceHostClick)
2630
+ emitter.on('node:leave', onFaceHostLeave)
2231
2631
  emitter.on('ceiling:enter', onCeilingEnter)
2232
2632
  emitter.on('ceiling:move', onCeilingMove)
2233
2633
  emitter.on('ceiling:click', onCeilingClick)
2234
2634
  emitter.on('ceiling:leave', onCeilingLeave)
2635
+ emitter.on('cabinet:enter', onItemEnter)
2636
+ emitter.on('procedural-item:enter' as never, onItemEnter)
2637
+ emitter.on('cabinet:move', onItemMove)
2638
+ emitter.on('procedural-item:move' as never, onItemMove)
2639
+ emitter.on('cabinet:click', onItemClick)
2640
+ emitter.on('procedural-item:click' as never, onItemClick)
2641
+ emitter.on('cabinet:leave', onItemLeave)
2642
+ emitter.on('procedural-item:leave' as never, onItemLeave)
2235
2643
  emitter.on('shelf:enter', onShelfEnter)
2236
2644
  emitter.on('shelf:move', onShelfMove)
2237
2645
  emitter.on('shelf:click', onShelfClick)
@@ -2250,25 +2658,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2250
2658
  if (placementState.current.surface !== 'floor') return
2251
2659
  onGridClick(event as unknown as GridEvent)
2252
2660
  }
2253
- emitter.on('wall:click', commitFloorOnSurfaceClick as never)
2254
- emitter.on('item:click', commitFloorOnSurfaceClick as never)
2255
- emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
2256
- emitter.on('roof:click', commitFloorOnSurfaceClick as never)
2257
- emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
2661
+ emitter.on('node:click', commitFloorOnSurfaceClick as never)
2258
2662
  if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
2259
2663
 
2260
2664
  return () => {
2261
2665
  tearingDown = true
2666
+ feedback.clear()
2262
2667
  if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
2263
2668
  unsubDraftWatch()
2264
2669
  useAlignmentGuides.getState().clear()
2265
- // Clear live transform for any remaining draft
2670
+ // Clear every live preview channel before restoring or deleting the draft.
2266
2671
  if (draftNode.current) {
2267
2672
  useLiveTransforms.getState().clear(draftNode.current.id)
2673
+ useLiveNodeOverrides.getState().clearFields(draftNode.current.id, ['rotation'])
2268
2674
  }
2269
2675
  draftNode.destroy()
2270
2676
  useScene.temporal.getState().resume()
2271
- emitter.off('grid:move', onGridMove)
2677
+ gridDispatch.cancel()
2678
+ emitter.off('grid:move', gridDispatch.schedule)
2272
2679
  emitter.off('grid:click', onGridClick)
2273
2680
  emitter.off('item:enter', onItemEnter)
2274
2681
  emitter.off('item:move', onItemMove)
@@ -2282,19 +2689,27 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2282
2689
  emitter.off('roof:move', onRoofWallMove)
2283
2690
  emitter.off('roof:click', onRoofWallClick)
2284
2691
  emitter.off('roof:leave', onRoofWallLeave)
2692
+ emitter.off('node:enter', onFaceHostEnter)
2693
+ emitter.off('node:move', onFaceHostMove)
2694
+ emitter.off('node:click', onFaceHostClick)
2695
+ emitter.off('node:leave', onFaceHostLeave)
2285
2696
  emitter.off('ceiling:enter', onCeilingEnter)
2286
2697
  emitter.off('ceiling:move', onCeilingMove)
2287
2698
  emitter.off('ceiling:click', onCeilingClick)
2288
2699
  emitter.off('ceiling:leave', onCeilingLeave)
2700
+ emitter.off('cabinet:enter', onItemEnter)
2701
+ emitter.off('procedural-item:enter' as never, onItemEnter)
2702
+ emitter.off('cabinet:move', onItemMove)
2703
+ emitter.off('procedural-item:move' as never, onItemMove)
2704
+ emitter.off('cabinet:click', onItemClick)
2705
+ emitter.off('procedural-item:click' as never, onItemClick)
2706
+ emitter.off('cabinet:leave', onItemLeave)
2707
+ emitter.off('procedural-item:leave' as never, onItemLeave)
2289
2708
  emitter.off('shelf:enter', onShelfEnter)
2290
2709
  emitter.off('shelf:move', onShelfMove)
2291
2710
  emitter.off('shelf:click', onShelfClick)
2292
2711
  emitter.off('shelf:leave', onShelfLeave)
2293
- emitter.off('wall:click', commitFloorOnSurfaceClick as never)
2294
- emitter.off('item:click', commitFloorOnSurfaceClick as never)
2295
- emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
2296
- emitter.off('roof:click', commitFloorOnSurfaceClick as never)
2297
- emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
2712
+ emitter.off('node:click', commitFloorOnSurfaceClick as never)
2298
2713
  emitter.off('tool:cancel', onCancel)
2299
2714
  window.removeEventListener('keydown', onKeyDown)
2300
2715
  window.removeEventListener('keyup', onKeyUp)
@@ -2312,6 +2727,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2312
2727
  gridSnapStep,
2313
2728
  updateDimensionGuides,
2314
2729
  updatePreviewGeometry,
2730
+ reconcileDraftRaycast,
2315
2731
  ])
2316
2732
 
2317
2733
  // Refresh wireframe when the grid step changes mid-placement so the green/red
@@ -2343,39 +2759,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2343
2759
  const draftParent = draft.parentId
2344
2760
  ? useScene.getState().nodes[draft.parentId as AnyNodeId]
2345
2761
  : undefined
2346
- if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
2762
+ if (
2763
+ draftParent?.type === 'item' ||
2764
+ draftParent?.type === 'shelf' ||
2765
+ draftParent?.type === 'cabinet' ||
2766
+ draftParent?.type === 'procedural-item' ||
2767
+ (draftParent &&
2768
+ nodeRegistry.get(draftParent.type)?.capabilities.faceHost?.currentFaceId(draft))
2769
+ )
2770
+ return
2347
2771
  draft.parentId = viewerLevelId
2348
2772
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
2349
2773
  }, [viewerLevelId, draftNode, asset])
2350
2774
 
2351
- // Disable raycasting on the live draft mesh (and restore it when the draft
2352
- // changes or goes away) so the cursor ray passes through the item being
2353
- // moved and lands on the surface beneath it.
2354
- const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
2355
- if (raycastDisabledMeshRef.current !== mesh) {
2356
- // New draft root (or cleared): restore the prior mesh and reset tracking.
2357
- for (const restore of restoreRaycastsRef.current) restore()
2358
- restoreRaycastsRef.current = []
2359
- raycastDisabledChildrenRef.current = new WeakSet()
2360
- raycastDisabledMeshRef.current = mesh
2361
- }
2362
- if (!mesh) return
2363
- // Disable any descendant not handled yet. Item drafts are GLB models whose
2364
- // child meshes mount asynchronously (Suspense), so a one-shot traverse
2365
- // misses them — those late children keep intercepting the ray and corrupt
2366
- // the shelf-row hit the moment the item moves onto a row. Re-walking each
2367
- // frame is cheap: the WeakSet makes it idempotent, so only new children pay.
2368
- mesh.traverse((child) => {
2369
- if (raycastDisabledChildrenRef.current.has(child)) return
2370
- raycastDisabledChildrenRef.current.add(child)
2371
- const original = child.raycast
2372
- child.raycast = () => {}
2373
- restoreRaycastsRef.current.push(() => {
2374
- child.raycast = original
2375
- })
2376
- })
2377
- }, [])
2378
-
2379
2775
  // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
2380
2776
  useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
2381
2777
 
@@ -2387,8 +2783,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2387
2783
  // moving existing node has no draft here, so the grid reads that case straight
2388
2784
  // off the node's mesh. Cleared when idle.
2389
2785
  const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2786
+ const surfaceWorldPointRef = useRef(new Vector3())
2390
2787
  const facingForwardRef = useRef(new Vector3(0, 0, 1))
2391
2788
  const facingQuatRef = useRef(new Quaternion())
2789
+ const ghostSurfaceQuatRef = useRef(new Quaternion())
2392
2790
  useFrame(() => {
2393
2791
  const ghost = cursorGroupRef.current
2394
2792
  if (!(asset && ghost)) {
@@ -2403,7 +2801,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2403
2801
  // the item's forward on the floor, and the triangle rides at the ghost's Y.
2404
2802
  let facingYaw = ghost.rotation.y
2405
2803
  let facingY = ghost.position.y
2406
- if (surf === 'wall' || surf === 'roof-wall') {
2804
+ if (surf === 'wall' || surf === 'roof-wall' || surf === 'block-face') {
2407
2805
  // Wall/roof-segment faces: the cursor group's yaw is the symmetric
2408
2806
  // wireframe yaw (π off the real facing for a wall, and a different frame
2409
2807
  // for a roof face), so derive the item's TRUE outward facing from the
@@ -2411,21 +2809,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2411
2809
  // surface. This keeps BOTH the grid normal and the triangle correct for
2412
2810
  // wall and roof-segment hosts alike, rather than the old quaternion read
2413
2811
  // that pointed the wrong way.
2414
- const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
2415
- if (mesh) {
2416
- mesh.getWorldQuaternion(facingQuatRef.current)
2417
- const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
2418
- fwd.y = 0
2419
- if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
2420
- }
2812
+ const mesh =
2813
+ surf === 'block-face' || !draftNode.current
2814
+ ? null
2815
+ : sceneRegistry.nodes.get(draftNode.current.id)
2816
+ ghost.getWorldQuaternion(ghostSurfaceQuatRef.current)
2817
+ const hostedQuaternion = mesh ? mesh.getWorldQuaternion(facingQuatRef.current) : null
2818
+ resolveItemPlacementSurfaceNormal(
2819
+ surf,
2820
+ ghostSurfaceQuatRef.current,
2821
+ hostedQuaternion,
2822
+ n,
2823
+ asset.attachTo,
2824
+ )
2825
+ const fwd = facingForwardRef.current.copy(n)
2826
+ if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
2421
2827
  // The forward triangle is a floor aid; drop it to the building-local floor
2422
- // under the wall (the ghost Y is up on the wall).
2423
- facingY = 0
2424
- n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
2828
+ // under the hosted plane — the storey's floor, not world ground.
2829
+ facingY = getPlacementLevelY(draftNode.current)
2425
2830
  } else {
2426
- n.set(0, 1, 0)
2831
+ ghost.getWorldQuaternion(ghostSurfaceQuatRef.current)
2832
+ resolveItemPlacementSurfaceNormal(surf, ghostSurfaceQuatRef.current, null, n)
2427
2833
  }
2428
- publishPlacementSurface(ghost.position, n)
2834
+ // `publishPlacementSurface` is a WORLD-space contract (the grid reads it in
2835
+ // world space), but the ghost lives in the building-local tool group.
2836
+ publishPlacementSurface(ghost.getWorldPosition(surfaceWorldPointRef.current), n)
2429
2837
 
2430
2838
  if (shape.depth > 0) {
2431
2839
  useFacingPose.getState().set({
@@ -2493,8 +2901,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2493
2901
  gridPosition.current.z,
2494
2902
  ])
2495
2903
  mesh.position.y = visualPosition[1]
2496
- cursorGroupRef.current.position.y = visualPosition[1]
2497
- }
2904
+ cursorGroupRef.current.position.y =
2905
+ visualPosition[1] + getPlacementLevelY(draftNode.current)
2906
+ }
2907
+ } else if (placementState.current.surface === 'block-face') {
2908
+ const rotation = draftNode.current.rotation
2909
+ applyFaceHostPreviewPose(
2910
+ mesh,
2911
+ [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
2912
+ rotation,
2913
+ )
2498
2914
  }
2499
2915
  })
2500
2916