@pascal-app/editor 1.0.0-beta.5 → 1.0.0

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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -6,14 +6,19 @@ 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,
16
20
  sceneRegistry,
21
+ useLiveNodeOverrides,
17
22
  useLiveTransforms,
18
23
  useScene,
19
24
  useSpatialQuery,
@@ -55,6 +60,7 @@ import useAlignmentGuides from '../../../store/use-alignment-guides'
55
60
  import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
56
61
 
57
62
  import useFacingPose from '../../../store/use-facing-pose'
63
+ import usePlacementPreview from '../../../store/use-placement-preview'
58
64
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
59
65
  import {
60
66
  createLineGeometry,
@@ -63,9 +69,17 @@ import {
63
69
  updateLineGeometry,
64
70
  } from '../shared/placement-box-geometry'
65
71
  import {
72
+ type PointerSupportSurface,
66
73
  resolvePointerSupportElevation,
67
74
  resolvePointerSupportSurface,
68
75
  } from '../shared/pointer-support-cap'
76
+ import { shouldCreateFloorDraft } from './draft-creation'
77
+ import { commitFaceHostClick, resolveFaceHostPreviewCommit } from './face-host-commit'
78
+ import {
79
+ applyFaceHostPreviewPose,
80
+ resolveFaceHostSwitch,
81
+ shouldDetachFaceHostOnLeave,
82
+ } from './face-host-preview'
69
83
  import {
70
84
  getDetachedAttachmentPreviewLift,
71
85
  getGridAlignedDimensions,
@@ -77,12 +91,14 @@ import {
77
91
  import {
78
92
  ceilingStrategy,
79
93
  checkCanPlace,
94
+ faceHostStrategy,
80
95
  floorStrategy,
81
96
  itemSurfaceStrategy,
82
97
  roofWallStrategy,
83
98
  shelfSurfaceStrategy,
84
99
  wallStrategy,
85
100
  } from './placement-strategies'
101
+ import { resolveItemPlacementSurfaceNormal } from './placement-surface'
86
102
  import type { PlacementState, TransitionResult } from './placement-types'
87
103
  import type { DraftNodeHandle } from './use-draft-node'
88
104
 
@@ -178,6 +194,27 @@ function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
178
194
  }
179
195
  }
180
196
 
197
+ /**
198
+ * Building-local Y of the storey the floor-path ghost belongs to.
199
+ *
200
+ * The cursor group is mounted inside ToolManager's building-local group, which
201
+ * carries no per-floor elevation, while every floor-path position (grid
202
+ * position, `getFloorVisualPosition`) is LEVEL-local — so on an upper storey the
203
+ * wireframe and its dimension labels render a floor too low. The wall / ceiling
204
+ * / item-surface paths don't need this: they convert a world hit through
205
+ * `worldToBuildingLocal`, which already carries the storey.
206
+ *
207
+ * Read off the level mesh (same source as `LevelOffsetGroup`) rather than the
208
+ * stored elevation so the ghost also follows the exploded-view lerp.
209
+ */
210
+ function getPlacementLevelY(draft: ItemNode | null | undefined): number {
211
+ const levelId =
212
+ (draft ? findLevelAncestorId(draft.id, useScene.getState().nodes) : null) ??
213
+ useViewer.getState().selection.levelId
214
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null
215
+ return levelMesh ? levelMesh.position.y : 0
216
+ }
217
+
181
218
  // Shared materials for placement cursor - we just change colors, not swap materials
182
219
  // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
183
220
  const edgeMaterial = new LineBasicNodeMaterial({
@@ -280,6 +317,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
280
317
  // by the MAX overlapping slab and a deck above the aimed-at floor
281
318
  // captures the item (and the grid-plane feedback makes it blink).
282
319
  const pointerSupportCapRef = useRef<number | null>(null)
320
+ const pointerSupportSurfaceRef = useRef<PointerSupportSurface | null>(null)
321
+ const frozenSupportSlabIdRef = useRef<string | undefined>(undefined)
283
322
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
284
323
 
285
324
  // Live camera ref — the shelf-stickiness test reconstructs the cursor world
@@ -427,17 +466,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
427
466
  ): [number, number, number] => {
428
467
  const draft = draftNode.current
429
468
  if (!(draft && !asset?.attachTo)) return position
430
- const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode)
469
+ const previewNode = getGridAlignedPreviewNode({
470
+ ...draft,
471
+ ...nodeUpdate,
472
+ ...(pointerSupportSurfaceRef.current?.sourceNodeId
473
+ ? { supportSlabId: frozenSupportSlabIdRef.current }
474
+ : {}),
475
+ } as ItemNode)
431
476
  return getFloorStackPreviewPosition({
432
477
  node: previewNode,
433
478
  position,
434
479
  rotation: previewNode.rotation,
435
- maxElevation: pointerSupportCapRef.current,
480
+ maxElevation: pointerSupportSurfaceRef.current?.sourceNodeId
481
+ ? null
482
+ : pointerSupportCapRef.current,
436
483
  })
437
484
  },
438
485
  [asset?.attachTo, draftNode],
439
486
  )
440
487
 
488
+ // Disable raycasting on the live draft mesh (and restore it when the draft
489
+ // changes or goes away) so the cursor ray passes through the item being
490
+ // moved and lands on the surface beneath it.
491
+ const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
492
+ if (raycastDisabledMeshRef.current !== mesh) {
493
+ // New draft root (or cleared): restore the prior mesh and reset tracking.
494
+ for (const restore of restoreRaycastsRef.current) restore()
495
+ restoreRaycastsRef.current = []
496
+ raycastDisabledChildrenRef.current = new WeakSet()
497
+ raycastDisabledMeshRef.current = mesh
498
+ }
499
+ if (!mesh) return
500
+ // Item models can mount descendants asynchronously. Re-walk the root so
501
+ // newly mounted meshes cannot intercept the next pointer event.
502
+ mesh.traverse((child) => {
503
+ if (raycastDisabledChildrenRef.current.has(child)) return
504
+ raycastDisabledChildrenRef.current.add(child)
505
+ const original = child.raycast
506
+ child.raycast = () => {}
507
+ restoreRaycastsRef.current.push(() => {
508
+ child.raycast = original
509
+ })
510
+ })
511
+ }, [])
512
+
441
513
  useEffect(() => {
442
514
  if (!asset) return
443
515
  useScene.temporal.getState().pause()
@@ -452,6 +524,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
452
524
  // layer's frame.
453
525
  let alignmentCandidates: AlignmentAnchor[] | null = null
454
526
  let floorDragAnchor: [number, number] | null = null
527
+ let pendingFaceHostId: string | null = null
455
528
 
456
529
  // Reset placement state
457
530
  placementState.current = configRef.current.initialState ?? {
@@ -465,10 +538,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
465
538
  // No pointer surface known yet — fall back to the uncapped election
466
539
  // (an adopted move draft keeps its persisted host until the first move).
467
540
  pointerSupportCapRef.current = null
541
+ pointerSupportSurfaceRef.current = null
542
+ frozenSupportSlabIdRef.current = undefined
468
543
  if (!asset.attachTo && placementState.current.surface === 'floor') {
469
544
  gridPosition.current.y = 0
470
545
  if (cursorGroupRef.current) {
471
- cursorGroupRef.current.position.y = 0
546
+ cursorGroupRef.current.position.y = getPlacementLevelY(draftNode.current)
472
547
  }
473
548
  }
474
549
 
@@ -493,6 +568,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
493
568
  }
494
569
  : validators
495
570
 
571
+ const disableDraftRaycastNow = () => {
572
+ const draft = draftNode.current
573
+ if (!draft) return
574
+ const mesh = sceneRegistry.nodes.get(draft.id)
575
+ if (mesh) reconcileDraftRaycast(mesh)
576
+ }
577
+
496
578
  const finishCommittedPlacement = (
497
579
  committedId: string | null,
498
580
  wasAdopted: boolean,
@@ -517,6 +599,40 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
517
599
  useEditor.getState().setMode('select')
518
600
  }
519
601
 
602
+ const commitDraft = (
603
+ nodeUpdate: Partial<ItemNode>,
604
+ options?: {
605
+ supportElevationCap?: number | null
606
+ preferredSupportSlabId?: string | null
607
+ pinSupport?: boolean
608
+ },
609
+ ) => {
610
+ const draftId = draftNode.current?.id ?? null
611
+ const wasAdopted = draftNode.isAdopted
612
+ const finalId = draftNode.commit(nodeUpdate, options)
613
+ if (draftId) {
614
+ useLiveTransforms.getState().clear(draftId)
615
+ useLiveNodeOverrides.getState().clearFields(draftId, faceHostClearFields(draftNode.current))
616
+ }
617
+ return { committedId: finalId ?? draftId, wasAdopted }
618
+ }
619
+
620
+ const faceHostClearFields = (draft: ItemNode | null | undefined): Array<keyof ItemNode> => {
621
+ if (!draft?.parentId) return ['position', 'rotation']
622
+ const host = useScene.getState().nodes[draft.parentId as AnyNodeId]
623
+ return host
624
+ ? [...(nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields ?? [])]
625
+ : ['position', 'rotation']
626
+ }
627
+
628
+ const currentFaceHostId = (draft: ItemNode | null | undefined): string | null => {
629
+ if (!draft?.parentId) return null
630
+ const host = useScene.getState().nodes[draft.parentId as AnyNodeId]
631
+ return host
632
+ ? (nodeRegistry.get(host.type)?.capabilities.faceHost?.currentFaceId(draft) ?? null)
633
+ : null
634
+ }
635
+
520
636
  const revalidate = (): boolean => {
521
637
  const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
522
638
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
@@ -540,6 +656,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
540
656
  return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
541
657
  }
542
658
 
659
+ const worldRotationToBuildingLocal = (
660
+ rotation: [number, number, number],
661
+ ): [number, number, number] => {
662
+ const worldQuaternion = new Quaternion().setFromEuler(new Euler(...rotation))
663
+ const buildingId = useViewer.getState().selection.buildingId
664
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
665
+ if (buildingMesh) {
666
+ const buildingWorldQuaternion = new Quaternion()
667
+ buildingMesh.getWorldQuaternion(buildingWorldQuaternion)
668
+ worldQuaternion.premultiply(buildingWorldQuaternion.invert())
669
+ }
670
+ const localRotation = new Euler().setFromQuaternion(worldQuaternion, 'XYZ')
671
+ return [localRotation.x, localRotation.y, localRotation.z]
672
+ }
673
+
543
674
  const applyTransition = (result: TransitionResult) => {
544
675
  // Alignment guides are floor-only; clear them when the cursor moves
545
676
  // onto a wall / ceiling / item surface (only those paths call this).
@@ -555,7 +686,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
555
686
  if (cursorGroupRef.current) {
556
687
  cursorGroupRef.current.position.set(c.x, c.y, c.z)
557
688
  if (result.cursorRotation) {
558
- cursorGroupRef.current.rotation.set(...result.cursorRotation)
689
+ cursorGroupRef.current.rotation.set(
690
+ ...worldRotationToBuildingLocal(result.cursorRotation),
691
+ )
559
692
  } else {
560
693
  cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
561
694
  }
@@ -575,7 +708,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
575
708
  if (cursorGroupRef.current) {
576
709
  cursorGroupRef.current.position.set(c.x, c.y, c.z)
577
710
  if (result.cursorRotation) {
578
- cursorGroupRef.current.rotation.set(...result.cursorRotation)
711
+ cursorGroupRef.current.rotation.set(
712
+ ...worldRotationToBuildingLocal(result.cursorRotation),
713
+ )
579
714
  } else {
580
715
  cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
581
716
  }
@@ -600,6 +735,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
600
735
  Object.assign(draft, result.nodeUpdate)
601
736
  // One-time setup: put node in the right parent so it renders correctly
602
737
  useScene.getState().updateNode(draft.id, result.nodeUpdate)
738
+ disableDraftRaycastNow()
603
739
  }
604
740
 
605
741
  const previewBounds = expandBoundsToGrid(
@@ -775,7 +911,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
775
911
  }
776
912
  }
777
913
  } else if (cursorGroupRef.current) {
914
+ // No registered mesh yet (a just-created draft renders next tick), so
915
+ // fall back to the level-local grid position lifted onto its storey.
778
916
  cursorGroupRef.current.position.copy(gridPosition.current)
917
+ cursorGroupRef.current.position.y += getPlacementLevelY(draftNode.current)
779
918
  cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
780
919
  }
781
920
  }
@@ -858,7 +997,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
858
997
  const onGridMove = (event: GridEvent) => {
859
998
  releaseCommit = () => onGridClick(event)
860
999
  // 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) {
1000
+ if (
1001
+ shouldCreateFloorDraft(draftNode.current, asset.attachTo, placementState.current.surface)
1002
+ ) {
862
1003
  configRef.current.initDraft(gridPosition.current)
863
1004
  }
864
1005
 
@@ -875,6 +1016,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
875
1016
  // a drag over a deck-above-a-floor hop between the two surfaces).
876
1017
  const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
877
1018
  pointerSupportCapRef.current = pointed?.elevation ?? null
1019
+ pointerSupportSurfaceRef.current = pointed
878
1020
  const surfaceEvent: GridEvent =
879
1021
  pointed?.worldPoint && pointed.localPoint
880
1022
  ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
@@ -946,11 +1088,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
946
1088
  useAlignmentGuides.getState().clear()
947
1089
  }
948
1090
 
949
- const gridPos: [number, number, number] = [
1091
+ // `result.gridPosition[1]` is the LIVE `gridPosition.current.y` — seeded
1092
+ // from the draft's authored Y by `initDraft` (so a block-face / raised
1093
+ // construction-plane item keeps its height) and zeroed by
1094
+ // `detachItemSurfaceToFloor` / `faceHostStrategy.leave` when the item
1095
+ // comes back down. Freezing it at drag start instead left an item taken
1096
+ // off a shelf floating at the shelf's height.
1097
+ let gridPos: [number, number, number] = [
950
1098
  result.gridPosition[0] + alignX,
951
1099
  result.gridPosition[1],
952
1100
  result.gridPosition[2] + alignZ,
953
1101
  ]
1102
+ frozenSupportSlabIdRef.current = undefined
1103
+ if (draft && pointed?.sourceNodeId) {
1104
+ const effectiveNode = {
1105
+ ...draft,
1106
+ position: gridPos,
1107
+ parentId: useViewer.getState().selection.levelId ?? draft.parentId,
1108
+ } as ItemNode
1109
+ const frozenPatch = resolveFrozenFloorPlacementPatch(
1110
+ effectiveNode,
1111
+ useScene.getState().nodes,
1112
+ {
1113
+ position: gridPos,
1114
+ rotation: effectiveNode.rotation,
1115
+ elevation: pointed.elevation,
1116
+ preferredSlabId: pointed.supportSlabId,
1117
+ },
1118
+ )
1119
+ gridPos = frozenPatch.position
1120
+ frozenSupportSlabIdRef.current = frozenPatch.supportSlabId
1121
+ }
954
1122
 
955
1123
  // Play snap sound when grid position changes
956
1124
  if (
@@ -966,7 +1134,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
966
1134
  if (!draft && asset.attachTo) {
967
1135
  cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
968
1136
  }
969
- cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
1137
+ cursorGroupRef.current.position.set(
1138
+ cursorPosition[0],
1139
+ cursorPosition[1] + getPlacementLevelY(draft),
1140
+ cursorPosition[2],
1141
+ )
970
1142
  // Floor items only rotate on Y; keep the preview box (and the live
971
1143
  // transform the 2D floorplan mirrors) aligned with the draft's
972
1144
  // rotation. Without this the box stays at its seed rotation until a
@@ -1002,20 +1174,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1002
1174
  0,
1003
1175
  ]
1004
1176
 
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
1177
  // Carry the pointer surface cap into the commit so the persisted
1013
1178
  // supportSlabId reproduces the capped election (elects the aimed-at
1014
1179
  // lower slab — or the ground — instead of a deck hanging above).
1015
- const finalId = draftNode.commit(result.nodeUpdate, {
1180
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate, {
1016
1181
  supportElevationCap: pointerSupportCapRef.current,
1182
+ preferredSupportSlabId: pointerSupportSurfaceRef.current?.supportSlabId,
1183
+ pinSupport: pointerSupportSurfaceRef.current?.sourceNodeId != null,
1017
1184
  })
1018
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1185
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1019
1186
  draftNode.create(
1020
1187
  gridPosition.current,
1021
1188
  asset,
@@ -1208,18 +1375,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1208
1375
  if (!result) return
1209
1376
 
1210
1377
  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)
1378
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1218
1379
  if (result.dirtyNodeId) {
1219
1380
  useScene.getState().dirtyNodes.add(result.dirtyNodeId)
1220
1381
  }
1221
1382
 
1222
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1383
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1223
1384
  const nodes = useScene.getState().nodes
1224
1385
  const enterResult = wallStrategy.enter(
1225
1386
  getContext(),
@@ -1368,14 +1529,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1368
1529
  if (!result) return
1369
1530
 
1370
1531
  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)
1532
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1377
1533
 
1378
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1534
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1379
1535
  const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1380
1536
  if (enterResult) {
1381
1537
  applyTransition(enterResult)
@@ -1408,6 +1564,119 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1408
1564
  }
1409
1565
  }
1410
1566
 
1567
+ // ---- Face Host Handlers ----
1568
+
1569
+ const enterFaceHost = (event: NodeEvent): boolean => {
1570
+ const result = faceHostStrategy.enter(getContext(), event)
1571
+ if (!result) return false
1572
+ pendingFaceHostId = null
1573
+ event.stopPropagation()
1574
+ applyTransition(result)
1575
+ if (!draftNode.current) {
1576
+ ensureDraft(result)
1577
+ } else if (result.nodeUpdate.parentId) {
1578
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1579
+ disableDraftRaycastNow()
1580
+ }
1581
+ if (draftNode.current) useLiveTransforms.getState().clear(draftNode.current.id)
1582
+ return true
1583
+ }
1584
+
1585
+ const onFaceHostEnter = (event: NodeEvent) => {
1586
+ has3DPointerDrivenMoveRef.current = true
1587
+ enterFaceHost(event)
1588
+ }
1589
+
1590
+ const onFaceHostMove = (event: NodeEvent) => {
1591
+ has3DPointerDrivenMoveRef.current = true
1592
+ if (!cursorGroupRef.current) return
1593
+ const ctx = getContext()
1594
+ if (ctx.state.surface !== 'block-face' || !draftNode.current) {
1595
+ if (enterFaceHost(event)) releaseCommit = () => onFaceHostClick(event)
1596
+ return
1597
+ }
1598
+ const result = faceHostStrategy.move(ctx, event)
1599
+ if (!result) {
1600
+ event.stopPropagation()
1601
+ return
1602
+ }
1603
+
1604
+ event.stopPropagation()
1605
+ const draft = draftNode.current
1606
+ const nextFaceId = result.hostFaceId
1607
+ const faceSwitch = resolveFaceHostSwitch(
1608
+ currentFaceHostId(draft),
1609
+ nextFaceId,
1610
+ pendingFaceHostId,
1611
+ )
1612
+ pendingFaceHostId = faceSwitch.pendingFaceId
1613
+ if (!faceSwitch.accept) return
1614
+ releaseCommit = () => onFaceHostClick(event)
1615
+
1616
+ const posChanged =
1617
+ gridPosition.current.x !== result.gridPosition[0] ||
1618
+ gridPosition.current.y !== result.gridPosition[1] ||
1619
+ gridPosition.current.z !== result.gridPosition[2]
1620
+ if (posChanged) sfxEmitter.emit('sfx:grid-snap')
1621
+ gridPosition.current.set(...result.gridPosition)
1622
+ const cursor = worldToBuildingLocal(...result.cursorPosition)
1623
+ cursorGroupRef.current.position.copy(cursor)
1624
+ if (result.cursorRotation) {
1625
+ cursorGroupRef.current.rotation.set(...worldRotationToBuildingLocal(result.cursorRotation))
1626
+ }
1627
+
1628
+ if (draft && result.nodeUpdate) {
1629
+ Object.assign(draft, result.nodeUpdate)
1630
+ const mesh = sceneRegistry.nodes.get(draft.id)
1631
+ const rotation = result.nodeUpdate.rotation ?? draft.rotation
1632
+ if (mesh) applyFaceHostPreviewPose(mesh, result.gridPosition, rotation)
1633
+ useLiveNodeOverrides.getState().set(draft.id, {
1634
+ position: result.gridPosition,
1635
+ rotation,
1636
+ ...result.nodeUpdate,
1637
+ })
1638
+ }
1639
+ revalidate()
1640
+ }
1641
+
1642
+ const onFaceHostClick = (event: NodeEvent) => {
1643
+ const outcome = commitFaceHostClick({
1644
+ commitDraft,
1645
+ enterFaceHost,
1646
+ event,
1647
+ getContext,
1648
+ })
1649
+ if (!outcome) return
1650
+ const { committedId, wasAdopted } = outcome
1651
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1652
+ const enterResult = faceHostStrategy.enter(getContext(), event)
1653
+ if (enterResult) applyTransition(enterResult)
1654
+ else revalidate()
1655
+ })
1656
+ }
1657
+
1658
+ const onFaceHostLeave = (event: NodeEvent) => {
1659
+ pendingFaceHostId = null
1660
+ if (!shouldDetachFaceHostOnLeave(asset.attachTo)) {
1661
+ event.stopPropagation()
1662
+ return
1663
+ }
1664
+ const result = faceHostStrategy.leave(getContext())
1665
+ if (!result) return
1666
+ event.stopPropagation()
1667
+ const draft = draftNode.current
1668
+ if (draft) {
1669
+ useLiveNodeOverrides.getState().clearFields(draft.id, faceHostClearFields(draft))
1670
+ }
1671
+ if (draftNode.isAdopted) {
1672
+ applyTransition(result)
1673
+ if (draft) useScene.getState().updateNode(draft.id, result.nodeUpdate)
1674
+ } else {
1675
+ draftNode.destroy()
1676
+ Object.assign(placementState.current, result.stateUpdate)
1677
+ }
1678
+ }
1679
+
1411
1680
  // ---- Item Surface Handlers ----
1412
1681
 
1413
1682
  const detachItemSurfaceToFloor = (event: ItemEvent) => {
@@ -1449,7 +1718,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1449
1718
  levelId ? { parentId: levelId } : undefined,
1450
1719
  )
1451
1720
  if (cursorGroupRef.current) {
1452
- cursorGroupRef.current.position.set(...floorVisualPosition)
1721
+ cursorGroupRef.current.position.set(
1722
+ floorVisualPosition[0],
1723
+ floorVisualPosition[1] + getPlacementLevelY(draftNode.current),
1724
+ floorVisualPosition[2],
1725
+ )
1453
1726
  }
1454
1727
 
1455
1728
  const draft = draftNode.current
@@ -1613,6 +1886,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1613
1886
  // have to aim around the cursor preview to drop the item.
1614
1887
  if (event.node.id === draftNode.current?.id) {
1615
1888
  const ctx = getContext()
1889
+ if (ctx.state.surface === 'block-face') {
1890
+ const result = resolveFaceHostPreviewCommit(ctx)
1891
+ if (result) {
1892
+ event.stopPropagation()
1893
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1894
+ finishCommittedPlacement(committedId, wasAdopted, revalidate)
1895
+ return
1896
+ }
1897
+ }
1616
1898
  if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
1617
1899
  const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
1618
1900
  if (shelfNode && shelfNode.type === 'shelf') {
@@ -1620,13 +1902,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1620
1902
  const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
1621
1903
  if (result) {
1622
1904
  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, () => {
1905
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1906
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1630
1907
  const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
1631
1908
  if (enterResult) {
1632
1909
  applyTransition(enterResult)
@@ -1648,13 +1925,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1648
1925
  const result = itemSurfaceStrategy.click(ctx, synthetic)
1649
1926
  if (result) {
1650
1927
  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, () => {
1928
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1929
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1658
1930
  const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
1659
1931
  if (enterResult) {
1660
1932
  applyTransition(enterResult)
@@ -1679,13 +1951,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1679
1951
  const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
1680
1952
  if (result) {
1681
1953
  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, () => {
1954
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1955
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1689
1956
  const nodes = useScene.getState().nodes
1690
1957
  const enterResult = ceilingStrategy.enter(
1691
1958
  getContext(),
@@ -1710,15 +1977,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1710
1977
  if (!result) return
1711
1978
 
1712
1979
  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)
1980
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1720
1981
 
1721
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1982
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1722
1983
  // Try to set up next draft on the same surface
1723
1984
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
1724
1985
  if (enterResult) {
@@ -1841,15 +2102,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1841
2102
  if (!result) return
1842
2103
 
1843
2104
  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)
2105
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1851
2106
 
1852
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2107
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1853
2108
  const nodes = useScene.getState().nodes
1854
2109
  const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
1855
2110
  if (enterResult) {
@@ -1982,14 +2237,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1982
2237
  if (!result) return
1983
2238
 
1984
2239
  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)
2240
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1991
2241
 
1992
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2242
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1993
2243
  const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
1994
2244
  if (enterResult) {
1995
2245
  applyTransition(enterResult)
@@ -2018,7 +2268,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2018
2268
 
2019
2269
  // Roof-wall drafts live flat in the host face frame (yaw 0) —
2020
2270
  // manual rotation would skew them off the wall plane.
2021
- if (placementState.current.surface === 'roof-wall') return
2271
+ if (
2272
+ placementState.current.surface === 'roof-wall' ||
2273
+ placementState.current.surface === 'block-face'
2274
+ )
2275
+ return
2022
2276
 
2023
2277
  let rotationDir: 1 | -1 | 0 = 0
2024
2278
  if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
@@ -2034,9 +2288,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2034
2288
  const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
2035
2289
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
2036
2290
 
2037
- // Ref + cursor mesh + item mesh no store update during drag
2291
+ // Rotate the building-local cursor by the same delta as the host-local
2292
+ // draft. This preserves the host's composed yaw for items resting on a
2293
+ // table or shelf while still matching the draft exactly on the floor.
2038
2294
  if (cursorGroupRef.current) {
2039
- cursorGroupRef.current.rotation.y = newRotationY
2295
+ cursorGroupRef.current.rotation.y += newRotationY - currentRotation[1]
2040
2296
  }
2041
2297
  const mesh = sceneRegistry.nodes.get(draft.id)
2042
2298
  if (mesh) mesh.rotation.y = newRotationY
@@ -2053,8 +2309,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2053
2309
  draft.position = [x, gridPosition.current.y, z]
2054
2310
  if (cursorGroupRef.current) {
2055
2311
  if (surface === 'floor') {
2312
+ const visual = getFloorVisualPosition([x, gridPosition.current.y, z])
2056
2313
  cursorGroupRef.current.position.set(
2057
- ...getFloorVisualPosition([x, gridPosition.current.y, z]),
2314
+ visual[0],
2315
+ visual[1] + getPlacementLevelY(draft),
2316
+ visual[2],
2058
2317
  )
2059
2318
  } else {
2060
2319
  cursorGroupRef.current.position.x = x
@@ -2100,24 +2359,40 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2100
2359
  }
2101
2360
  }
2102
2361
 
2103
- // Update live transform for 2D floorplan with post-snap position
2362
+ // Keep both preview renderers on the rotated draft. The item renderer
2363
+ // consumes live node overrides, while the floor-plan renderer consumes
2364
+ // the live transform and placement-preview snapshot.
2365
+ useLiveNodeOverrides.getState().set(draft.id, { rotation: draft.rotation })
2104
2366
  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
- })
2367
+ const livePosition: [number, number, number] =
2368
+ surface === 'floor'
2369
+ ? [draft.position[0], draft.position[1], draft.position[2]]
2370
+ : cursorGroupRef.current
2371
+ ? [
2372
+ cursorGroupRef.current.position.x,
2373
+ cursorGroupRef.current.position.y,
2374
+ cursorGroupRef.current.position.z,
2375
+ ]
2376
+ : [draft.position[0], draft.position[1], draft.position[2]]
2377
+ useLiveTransforms.getState().set(draft.id, {
2378
+ ...currentLive,
2379
+ position: livePosition,
2380
+ rotation: cursorGroupRef.current?.rotation.y ?? newRotationY,
2381
+ })
2382
+
2383
+ const placementPreview = usePlacementPreview.getState()
2384
+ if (placementPreview.node?.id === draft.id) {
2385
+ const parentNode = draft.parentId
2386
+ ? (useScene.getState().nodes[draft.parentId as AnyNodeId] ?? null)
2387
+ : null
2388
+ placementPreview.set(
2389
+ {
2390
+ ...draft,
2391
+ position: [...draft.position],
2392
+ rotation: [...draft.rotation],
2393
+ },
2394
+ parentNode,
2395
+ )
2121
2396
  }
2122
2397
 
2123
2398
  revalidate()
@@ -2228,6 +2503,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2228
2503
  emitter.on('roof:move', onRoofWallMove)
2229
2504
  emitter.on('roof:click', onRoofWallClick)
2230
2505
  emitter.on('roof:leave', onRoofWallLeave)
2506
+ emitter.on('node:enter', onFaceHostEnter)
2507
+ emitter.on('node:move', onFaceHostMove)
2508
+ emitter.on('node:click', onFaceHostClick)
2509
+ emitter.on('node:leave', onFaceHostLeave)
2231
2510
  emitter.on('ceiling:enter', onCeilingEnter)
2232
2511
  emitter.on('ceiling:move', onCeilingMove)
2233
2512
  emitter.on('ceiling:click', onCeilingClick)
@@ -2250,11 +2529,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2250
2529
  if (placementState.current.surface !== 'floor') return
2251
2530
  onGridClick(event as unknown as GridEvent)
2252
2531
  }
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)
2532
+ emitter.on('node:click', commitFloorOnSurfaceClick as never)
2258
2533
  if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
2259
2534
 
2260
2535
  return () => {
@@ -2262,9 +2537,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2262
2537
  if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
2263
2538
  unsubDraftWatch()
2264
2539
  useAlignmentGuides.getState().clear()
2265
- // Clear live transform for any remaining draft
2540
+ // Clear every live preview channel before restoring or deleting the draft.
2266
2541
  if (draftNode.current) {
2267
2542
  useLiveTransforms.getState().clear(draftNode.current.id)
2543
+ useLiveNodeOverrides.getState().clearFields(draftNode.current.id, ['rotation'])
2268
2544
  }
2269
2545
  draftNode.destroy()
2270
2546
  useScene.temporal.getState().resume()
@@ -2282,6 +2558,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2282
2558
  emitter.off('roof:move', onRoofWallMove)
2283
2559
  emitter.off('roof:click', onRoofWallClick)
2284
2560
  emitter.off('roof:leave', onRoofWallLeave)
2561
+ emitter.off('node:enter', onFaceHostEnter)
2562
+ emitter.off('node:move', onFaceHostMove)
2563
+ emitter.off('node:click', onFaceHostClick)
2564
+ emitter.off('node:leave', onFaceHostLeave)
2285
2565
  emitter.off('ceiling:enter', onCeilingEnter)
2286
2566
  emitter.off('ceiling:move', onCeilingMove)
2287
2567
  emitter.off('ceiling:click', onCeilingClick)
@@ -2290,11 +2570,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2290
2570
  emitter.off('shelf:move', onShelfMove)
2291
2571
  emitter.off('shelf:click', onShelfClick)
2292
2572
  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)
2573
+ emitter.off('node:click', commitFloorOnSurfaceClick as never)
2298
2574
  emitter.off('tool:cancel', onCancel)
2299
2575
  window.removeEventListener('keydown', onKeyDown)
2300
2576
  window.removeEventListener('keyup', onKeyUp)
@@ -2312,6 +2588,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2312
2588
  gridSnapStep,
2313
2589
  updateDimensionGuides,
2314
2590
  updatePreviewGeometry,
2591
+ reconcileDraftRaycast,
2315
2592
  ])
2316
2593
 
2317
2594
  // Refresh wireframe when the grid step changes mid-placement so the green/red
@@ -2343,39 +2620,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2343
2620
  const draftParent = draft.parentId
2344
2621
  ? useScene.getState().nodes[draft.parentId as AnyNodeId]
2345
2622
  : undefined
2346
- if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
2623
+ if (
2624
+ draftParent?.type === 'item' ||
2625
+ draftParent?.type === 'shelf' ||
2626
+ (draftParent &&
2627
+ nodeRegistry.get(draftParent.type)?.capabilities.faceHost?.currentFaceId(draft))
2628
+ )
2629
+ return
2347
2630
  draft.parentId = viewerLevelId
2348
2631
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
2349
2632
  }, [viewerLevelId, draftNode, asset])
2350
2633
 
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
2634
  // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
2380
2635
  useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
2381
2636
 
@@ -2387,8 +2642,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2387
2642
  // moving existing node has no draft here, so the grid reads that case straight
2388
2643
  // off the node's mesh. Cleared when idle.
2389
2644
  const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2645
+ const surfaceWorldPointRef = useRef(new Vector3())
2390
2646
  const facingForwardRef = useRef(new Vector3(0, 0, 1))
2391
2647
  const facingQuatRef = useRef(new Quaternion())
2648
+ const ghostSurfaceQuatRef = useRef(new Quaternion())
2392
2649
  useFrame(() => {
2393
2650
  const ghost = cursorGroupRef.current
2394
2651
  if (!(asset && ghost)) {
@@ -2403,7 +2660,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2403
2660
  // the item's forward on the floor, and the triangle rides at the ghost's Y.
2404
2661
  let facingYaw = ghost.rotation.y
2405
2662
  let facingY = ghost.position.y
2406
- if (surf === 'wall' || surf === 'roof-wall') {
2663
+ if (surf === 'wall' || surf === 'roof-wall' || surf === 'block-face') {
2407
2664
  // Wall/roof-segment faces: the cursor group's yaw is the symmetric
2408
2665
  // wireframe yaw (π off the real facing for a wall, and a different frame
2409
2666
  // for a roof face), so derive the item's TRUE outward facing from the
@@ -2411,21 +2668,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2411
2668
  // surface. This keeps BOTH the grid normal and the triangle correct for
2412
2669
  // wall and roof-segment hosts alike, rather than the old quaternion read
2413
2670
  // 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
- }
2671
+ const mesh =
2672
+ surf === 'block-face' || !draftNode.current
2673
+ ? null
2674
+ : sceneRegistry.nodes.get(draftNode.current.id)
2675
+ ghost.getWorldQuaternion(ghostSurfaceQuatRef.current)
2676
+ const hostedQuaternion = mesh ? mesh.getWorldQuaternion(facingQuatRef.current) : null
2677
+ resolveItemPlacementSurfaceNormal(
2678
+ surf,
2679
+ ghostSurfaceQuatRef.current,
2680
+ hostedQuaternion,
2681
+ n,
2682
+ asset.attachTo,
2683
+ )
2684
+ const fwd = facingForwardRef.current.copy(n)
2685
+ if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
2421
2686
  // 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))
2687
+ // under the hosted plane the storey's floor, not world ground.
2688
+ facingY = getPlacementLevelY(draftNode.current)
2425
2689
  } else {
2426
- n.set(0, 1, 0)
2690
+ ghost.getWorldQuaternion(ghostSurfaceQuatRef.current)
2691
+ resolveItemPlacementSurfaceNormal(surf, ghostSurfaceQuatRef.current, null, n)
2427
2692
  }
2428
- publishPlacementSurface(ghost.position, n)
2693
+ // `publishPlacementSurface` is a WORLD-space contract (the grid reads it in
2694
+ // world space), but the ghost lives in the building-local tool group.
2695
+ publishPlacementSurface(ghost.getWorldPosition(surfaceWorldPointRef.current), n)
2429
2696
 
2430
2697
  if (shape.depth > 0) {
2431
2698
  useFacingPose.getState().set({
@@ -2493,8 +2760,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2493
2760
  gridPosition.current.z,
2494
2761
  ])
2495
2762
  mesh.position.y = visualPosition[1]
2496
- cursorGroupRef.current.position.y = visualPosition[1]
2497
- }
2763
+ cursorGroupRef.current.position.y =
2764
+ visualPosition[1] + getPlacementLevelY(draftNode.current)
2765
+ }
2766
+ } else if (placementState.current.surface === 'block-face') {
2767
+ const rotation = draftNode.current.rotation
2768
+ applyFaceHostPreviewPose(
2769
+ mesh,
2770
+ [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
2771
+ rotation,
2772
+ )
2498
2773
  }
2499
2774
  })
2500
2775