@pascal-app/editor 1.0.0-beta.4 → 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 (283) 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/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  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 +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. 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
@@ -295,6 +334,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
295
334
  const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
296
335
  const { asset, draftNode } = config
297
336
  const unit = useViewer((state) => state.unit)
337
+ const metricNotation = useViewer((state) => state.metricNotation)
298
338
  const gridSnapStep = useEditor((s) => s.gridSnapStep)
299
339
  const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
300
340
  const [width, height, depth] = bounds.dimensions
@@ -426,17 +466,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
426
466
  ): [number, number, number] => {
427
467
  const draft = draftNode.current
428
468
  if (!(draft && !asset?.attachTo)) return position
429
- 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)
430
476
  return getFloorStackPreviewPosition({
431
477
  node: previewNode,
432
478
  position,
433
479
  rotation: previewNode.rotation,
434
- maxElevation: pointerSupportCapRef.current,
480
+ maxElevation: pointerSupportSurfaceRef.current?.sourceNodeId
481
+ ? null
482
+ : pointerSupportCapRef.current,
435
483
  })
436
484
  },
437
485
  [asset?.attachTo, draftNode],
438
486
  )
439
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
+
440
513
  useEffect(() => {
441
514
  if (!asset) return
442
515
  useScene.temporal.getState().pause()
@@ -451,6 +524,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
451
524
  // layer's frame.
452
525
  let alignmentCandidates: AlignmentAnchor[] | null = null
453
526
  let floorDragAnchor: [number, number] | null = null
527
+ let pendingFaceHostId: string | null = null
454
528
 
455
529
  // Reset placement state
456
530
  placementState.current = configRef.current.initialState ?? {
@@ -464,10 +538,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
464
538
  // No pointer surface known yet — fall back to the uncapped election
465
539
  // (an adopted move draft keeps its persisted host until the first move).
466
540
  pointerSupportCapRef.current = null
541
+ pointerSupportSurfaceRef.current = null
542
+ frozenSupportSlabIdRef.current = undefined
467
543
  if (!asset.attachTo && placementState.current.surface === 'floor') {
468
544
  gridPosition.current.y = 0
469
545
  if (cursorGroupRef.current) {
470
- cursorGroupRef.current.position.y = 0
546
+ cursorGroupRef.current.position.y = getPlacementLevelY(draftNode.current)
471
547
  }
472
548
  }
473
549
 
@@ -492,6 +568,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
492
568
  }
493
569
  : validators
494
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
+
495
578
  const finishCommittedPlacement = (
496
579
  committedId: string | null,
497
580
  wasAdopted: boolean,
@@ -516,6 +599,40 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
516
599
  useEditor.getState().setMode('select')
517
600
  }
518
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
+
519
636
  const revalidate = (): boolean => {
520
637
  const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
521
638
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
@@ -539,6 +656,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
539
656
  return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
540
657
  }
541
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
+
542
674
  const applyTransition = (result: TransitionResult) => {
543
675
  // Alignment guides are floor-only; clear them when the cursor moves
544
676
  // onto a wall / ceiling / item surface (only those paths call this).
@@ -554,7 +686,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
554
686
  if (cursorGroupRef.current) {
555
687
  cursorGroupRef.current.position.set(c.x, c.y, c.z)
556
688
  if (result.cursorRotation) {
557
- cursorGroupRef.current.rotation.set(...result.cursorRotation)
689
+ cursorGroupRef.current.rotation.set(
690
+ ...worldRotationToBuildingLocal(result.cursorRotation),
691
+ )
558
692
  } else {
559
693
  cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
560
694
  }
@@ -574,7 +708,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
574
708
  if (cursorGroupRef.current) {
575
709
  cursorGroupRef.current.position.set(c.x, c.y, c.z)
576
710
  if (result.cursorRotation) {
577
- cursorGroupRef.current.rotation.set(...result.cursorRotation)
711
+ cursorGroupRef.current.rotation.set(
712
+ ...worldRotationToBuildingLocal(result.cursorRotation),
713
+ )
578
714
  } else {
579
715
  cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
580
716
  }
@@ -599,6 +735,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
599
735
  Object.assign(draft, result.nodeUpdate)
600
736
  // One-time setup: put node in the right parent so it renders correctly
601
737
  useScene.getState().updateNode(draft.id, result.nodeUpdate)
738
+ disableDraftRaycastNow()
602
739
  }
603
740
 
604
741
  const previewBounds = expandBoundsToGrid(
@@ -774,7 +911,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
774
911
  }
775
912
  }
776
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.
777
916
  cursorGroupRef.current.position.copy(gridPosition.current)
917
+ cursorGroupRef.current.position.y += getPlacementLevelY(draftNode.current)
778
918
  cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
779
919
  }
780
920
  }
@@ -857,7 +997,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
857
997
  const onGridMove = (event: GridEvent) => {
858
998
  releaseCommit = () => onGridClick(event)
859
999
  // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
860
- if (draftNode.current === null && asset.attachTo === undefined) {
1000
+ if (
1001
+ shouldCreateFloorDraft(draftNode.current, asset.attachTo, placementState.current.surface)
1002
+ ) {
861
1003
  configRef.current.initDraft(gridPosition.current)
862
1004
  }
863
1005
 
@@ -874,6 +1016,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
874
1016
  // a drag over a deck-above-a-floor hop between the two surfaces).
875
1017
  const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
876
1018
  pointerSupportCapRef.current = pointed?.elevation ?? null
1019
+ pointerSupportSurfaceRef.current = pointed
877
1020
  const surfaceEvent: GridEvent =
878
1021
  pointed?.worldPoint && pointed.localPoint
879
1022
  ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
@@ -945,11 +1088,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
945
1088
  useAlignmentGuides.getState().clear()
946
1089
  }
947
1090
 
948
- 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] = [
949
1098
  result.gridPosition[0] + alignX,
950
1099
  result.gridPosition[1],
951
1100
  result.gridPosition[2] + alignZ,
952
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
+ }
953
1122
 
954
1123
  // Play snap sound when grid position changes
955
1124
  if (
@@ -965,7 +1134,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
965
1134
  if (!draft && asset.attachTo) {
966
1135
  cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
967
1136
  }
968
- 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
+ )
969
1142
  // Floor items only rotate on Y; keep the preview box (and the live
970
1143
  // transform the 2D floorplan mirrors) aligned with the draft's
971
1144
  // rotation. Without this the box stays at its seed rotation until a
@@ -1001,20 +1174,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1001
1174
  0,
1002
1175
  ]
1003
1176
 
1004
- // Clear live transform before commit
1005
- if (draftNode.current) {
1006
- useLiveTransforms.getState().clear(draftNode.current.id)
1007
- }
1008
-
1009
- const committedId = draftNode.current?.id ?? null
1010
- const wasAdopted = draftNode.isAdopted
1011
1177
  // Carry the pointer surface cap into the commit so the persisted
1012
1178
  // supportSlabId reproduces the capped election (elects the aimed-at
1013
1179
  // lower slab — or the ground — instead of a deck hanging above).
1014
- const finalId = draftNode.commit(result.nodeUpdate, {
1180
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate, {
1015
1181
  supportElevationCap: pointerSupportCapRef.current,
1182
+ preferredSupportSlabId: pointerSupportSurfaceRef.current?.supportSlabId,
1183
+ pinSupport: pointerSupportSurfaceRef.current?.sourceNodeId != null,
1016
1184
  })
1017
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1185
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1018
1186
  draftNode.create(
1019
1187
  gridPosition.current,
1020
1188
  asset,
@@ -1129,19 +1297,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1129
1297
  }
1130
1298
  const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
1131
1299
  const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
1132
- const wallMesh = sceneRegistry.nodes.get(event.node.id)
1133
- // Derive the world cursor from the corrected wall-local point so the
1134
- // visual cursor (world) and the stored position (wall-local) agree; if
1135
- // the wall mesh is somehow absent, keep the raw world hit unchanged.
1136
- const correctedWorld = wallMesh
1137
- ? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
1138
- : null
1139
1300
  wallMoveEvent = {
1140
1301
  ...event,
1141
1302
  localPosition: [correctedX, correctedY, event.localPosition[2]],
1142
- position: correctedWorld
1143
- ? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
1144
- : event.position,
1145
1303
  }
1146
1304
  }
1147
1305
  const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
@@ -1203,22 +1361,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1203
1361
 
1204
1362
  // Publish live transform for the 2D floorplan. The floorplan resolves a
1205
1363
  // wall item's footprint (and its wall-side depth offset) from this
1206
- // rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
1207
- // yaw, which is π off from the plan rotation on a wall face — feeding it
1208
- // raw flips the footprint to the far side of the wall during placement.
1209
- // Publish the plan rotation (wall angle + the item's wall-local yaw) so
1210
- // the preview matches what the committed node resolves to.
1211
- let liveRotation = result.cursorRotationY
1212
- const liveWallId = placementState.current.wallId
1213
- const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
1214
- if (liveWall?.type === 'wall') {
1215
- const w = liveWall as WallNode
1216
- const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
1217
- liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
1218
- }
1364
+ // rotation as a PLAN-space yaw which is exactly what the wall strategy
1365
+ // composes `cursorRotationY` as (wall yaw + the item's wall-local yaw).
1219
1366
  useLiveTransforms.getState().set(draft.id, {
1220
1367
  position: result.cursorPosition,
1221
- rotation: liveRotation,
1368
+ rotation: result.cursorRotationY,
1222
1369
  })
1223
1370
  }
1224
1371
  }
@@ -1228,18 +1375,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1228
1375
  if (!result) return
1229
1376
 
1230
1377
  event.stopPropagation()
1231
- // Clear live transform before commit
1232
- if (draftNode.current) {
1233
- useLiveTransforms.getState().clear(draftNode.current.id)
1234
- }
1235
- const committedId = draftNode.current?.id ?? null
1236
- const wasAdopted = draftNode.isAdopted
1237
- const finalId = draftNode.commit(result.nodeUpdate)
1378
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1238
1379
  if (result.dirtyNodeId) {
1239
1380
  useScene.getState().dirtyNodes.add(result.dirtyNodeId)
1240
1381
  }
1241
1382
 
1242
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1383
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1243
1384
  const nodes = useScene.getState().nodes
1244
1385
  const enterResult = wallStrategy.enter(
1245
1386
  getContext(),
@@ -1388,14 +1529,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1388
1529
  if (!result) return
1389
1530
 
1390
1531
  event.stopPropagation()
1391
- if (draftNode.current) {
1392
- useLiveTransforms.getState().clear(draftNode.current.id)
1393
- }
1394
- const committedId = draftNode.current?.id ?? null
1395
- const wasAdopted = draftNode.isAdopted
1396
- const finalId = draftNode.commit(result.nodeUpdate)
1532
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1397
1533
 
1398
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1534
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1399
1535
  const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1400
1536
  if (enterResult) {
1401
1537
  applyTransition(enterResult)
@@ -1428,6 +1564,119 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1428
1564
  }
1429
1565
  }
1430
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
+
1431
1680
  // ---- Item Surface Handlers ----
1432
1681
 
1433
1682
  const detachItemSurfaceToFloor = (event: ItemEvent) => {
@@ -1469,7 +1718,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1469
1718
  levelId ? { parentId: levelId } : undefined,
1470
1719
  )
1471
1720
  if (cursorGroupRef.current) {
1472
- cursorGroupRef.current.position.set(...floorVisualPosition)
1721
+ cursorGroupRef.current.position.set(
1722
+ floorVisualPosition[0],
1723
+ floorVisualPosition[1] + getPlacementLevelY(draftNode.current),
1724
+ floorVisualPosition[2],
1725
+ )
1473
1726
  }
1474
1727
 
1475
1728
  const draft = draftNode.current
@@ -1633,6 +1886,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1633
1886
  // have to aim around the cursor preview to drop the item.
1634
1887
  if (event.node.id === draftNode.current?.id) {
1635
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
+ }
1636
1898
  if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
1637
1899
  const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
1638
1900
  if (shelfNode && shelfNode.type === 'shelf') {
@@ -1640,13 +1902,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1640
1902
  const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
1641
1903
  if (result) {
1642
1904
  event.stopPropagation()
1643
- if (draftNode.current) {
1644
- useLiveTransforms.getState().clear(draftNode.current.id)
1645
- }
1646
- const committedId = draftNode.current?.id ?? null
1647
- const wasAdopted = draftNode.isAdopted
1648
- const finalId = draftNode.commit(result.nodeUpdate)
1649
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1905
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1906
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1650
1907
  const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
1651
1908
  if (enterResult) {
1652
1909
  applyTransition(enterResult)
@@ -1668,13 +1925,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1668
1925
  const result = itemSurfaceStrategy.click(ctx, synthetic)
1669
1926
  if (result) {
1670
1927
  event.stopPropagation()
1671
- if (draftNode.current) {
1672
- useLiveTransforms.getState().clear(draftNode.current.id)
1673
- }
1674
- const committedId = draftNode.current?.id ?? null
1675
- const wasAdopted = draftNode.isAdopted
1676
- const finalId = draftNode.commit(result.nodeUpdate)
1677
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1928
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1929
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1678
1930
  const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
1679
1931
  if (enterResult) {
1680
1932
  applyTransition(enterResult)
@@ -1699,13 +1951,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1699
1951
  const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
1700
1952
  if (result) {
1701
1953
  event.stopPropagation()
1702
- if (draftNode.current) {
1703
- useLiveTransforms.getState().clear(draftNode.current.id)
1704
- }
1705
- const committedId = draftNode.current?.id ?? null
1706
- const wasAdopted = draftNode.isAdopted
1707
- const finalId = draftNode.commit(result.nodeUpdate)
1708
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1954
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1955
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1709
1956
  const nodes = useScene.getState().nodes
1710
1957
  const enterResult = ceilingStrategy.enter(
1711
1958
  getContext(),
@@ -1730,15 +1977,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1730
1977
  if (!result) return
1731
1978
 
1732
1979
  event.stopPropagation()
1733
- // Clear live transform before commit
1734
- if (draftNode.current) {
1735
- useLiveTransforms.getState().clear(draftNode.current.id)
1736
- }
1737
- const committedId = draftNode.current?.id ?? null
1738
- const wasAdopted = draftNode.isAdopted
1739
- const finalId = draftNode.commit(result.nodeUpdate)
1980
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1740
1981
 
1741
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1982
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1742
1983
  // Try to set up next draft on the same surface
1743
1984
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
1744
1985
  if (enterResult) {
@@ -1861,15 +2102,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1861
2102
  if (!result) return
1862
2103
 
1863
2104
  event.stopPropagation()
1864
- // Clear live transform before commit
1865
- if (draftNode.current) {
1866
- useLiveTransforms.getState().clear(draftNode.current.id)
1867
- }
1868
- const committedId = draftNode.current?.id ?? null
1869
- const wasAdopted = draftNode.isAdopted
1870
- const finalId = draftNode.commit(result.nodeUpdate)
2105
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
1871
2106
 
1872
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2107
+ finishCommittedPlacement(committedId, wasAdopted, () => {
1873
2108
  const nodes = useScene.getState().nodes
1874
2109
  const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
1875
2110
  if (enterResult) {
@@ -2002,14 +2237,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2002
2237
  if (!result) return
2003
2238
 
2004
2239
  event.stopPropagation()
2005
- if (draftNode.current) {
2006
- useLiveTransforms.getState().clear(draftNode.current.id)
2007
- }
2008
- const committedId = draftNode.current?.id ?? null
2009
- const wasAdopted = draftNode.isAdopted
2010
- const finalId = draftNode.commit(result.nodeUpdate)
2240
+ const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
2011
2241
 
2012
- finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
2242
+ finishCommittedPlacement(committedId, wasAdopted, () => {
2013
2243
  const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
2014
2244
  if (enterResult) {
2015
2245
  applyTransition(enterResult)
@@ -2038,7 +2268,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2038
2268
 
2039
2269
  // Roof-wall drafts live flat in the host face frame (yaw 0) —
2040
2270
  // manual rotation would skew them off the wall plane.
2041
- if (placementState.current.surface === 'roof-wall') return
2271
+ if (
2272
+ placementState.current.surface === 'roof-wall' ||
2273
+ placementState.current.surface === 'block-face'
2274
+ )
2275
+ return
2042
2276
 
2043
2277
  let rotationDir: 1 | -1 | 0 = 0
2044
2278
  if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
@@ -2054,9 +2288,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2054
2288
  const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
2055
2289
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
2056
2290
 
2057
- // 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.
2058
2294
  if (cursorGroupRef.current) {
2059
- cursorGroupRef.current.rotation.y = newRotationY
2295
+ cursorGroupRef.current.rotation.y += newRotationY - currentRotation[1]
2060
2296
  }
2061
2297
  const mesh = sceneRegistry.nodes.get(draft.id)
2062
2298
  if (mesh) mesh.rotation.y = newRotationY
@@ -2073,8 +2309,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2073
2309
  draft.position = [x, gridPosition.current.y, z]
2074
2310
  if (cursorGroupRef.current) {
2075
2311
  if (surface === 'floor') {
2312
+ const visual = getFloorVisualPosition([x, gridPosition.current.y, z])
2076
2313
  cursorGroupRef.current.position.set(
2077
- ...getFloorVisualPosition([x, gridPosition.current.y, z]),
2314
+ visual[0],
2315
+ visual[1] + getPlacementLevelY(draft),
2316
+ visual[2],
2078
2317
  )
2079
2318
  } else {
2080
2319
  cursorGroupRef.current.position.x = x
@@ -2120,24 +2359,40 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2120
2359
  }
2121
2360
  }
2122
2361
 
2123
- // 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 })
2124
2366
  const currentLive = useLiveTransforms.getState().get(draft.id)
2125
- if (currentLive) {
2126
- const livePosition: [number, number, number] =
2127
- surface === 'floor'
2128
- ? [draft.position[0], draft.position[1], draft.position[2]]
2129
- : cursorGroupRef.current
2130
- ? [
2131
- cursorGroupRef.current.position.x,
2132
- cursorGroupRef.current.position.y,
2133
- cursorGroupRef.current.position.z,
2134
- ]
2135
- : [draft.position[0], draft.position[1], draft.position[2]]
2136
- useLiveTransforms.getState().set(draft.id, {
2137
- ...currentLive,
2138
- position: livePosition,
2139
- rotation: newRotationY,
2140
- })
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
+ )
2141
2396
  }
2142
2397
 
2143
2398
  revalidate()
@@ -2248,6 +2503,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2248
2503
  emitter.on('roof:move', onRoofWallMove)
2249
2504
  emitter.on('roof:click', onRoofWallClick)
2250
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)
2251
2510
  emitter.on('ceiling:enter', onCeilingEnter)
2252
2511
  emitter.on('ceiling:move', onCeilingMove)
2253
2512
  emitter.on('ceiling:click', onCeilingClick)
@@ -2270,11 +2529,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2270
2529
  if (placementState.current.surface !== 'floor') return
2271
2530
  onGridClick(event as unknown as GridEvent)
2272
2531
  }
2273
- emitter.on('wall:click', commitFloorOnSurfaceClick as never)
2274
- emitter.on('item:click', commitFloorOnSurfaceClick as never)
2275
- emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
2276
- emitter.on('roof:click', commitFloorOnSurfaceClick as never)
2277
- emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
2532
+ emitter.on('node:click', commitFloorOnSurfaceClick as never)
2278
2533
  if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
2279
2534
 
2280
2535
  return () => {
@@ -2282,9 +2537,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2282
2537
  if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
2283
2538
  unsubDraftWatch()
2284
2539
  useAlignmentGuides.getState().clear()
2285
- // Clear live transform for any remaining draft
2540
+ // Clear every live preview channel before restoring or deleting the draft.
2286
2541
  if (draftNode.current) {
2287
2542
  useLiveTransforms.getState().clear(draftNode.current.id)
2543
+ useLiveNodeOverrides.getState().clearFields(draftNode.current.id, ['rotation'])
2288
2544
  }
2289
2545
  draftNode.destroy()
2290
2546
  useScene.temporal.getState().resume()
@@ -2302,6 +2558,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2302
2558
  emitter.off('roof:move', onRoofWallMove)
2303
2559
  emitter.off('roof:click', onRoofWallClick)
2304
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)
2305
2565
  emitter.off('ceiling:enter', onCeilingEnter)
2306
2566
  emitter.off('ceiling:move', onCeilingMove)
2307
2567
  emitter.off('ceiling:click', onCeilingClick)
@@ -2310,11 +2570,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2310
2570
  emitter.off('shelf:move', onShelfMove)
2311
2571
  emitter.off('shelf:click', onShelfClick)
2312
2572
  emitter.off('shelf:leave', onShelfLeave)
2313
- emitter.off('wall:click', commitFloorOnSurfaceClick as never)
2314
- emitter.off('item:click', commitFloorOnSurfaceClick as never)
2315
- emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
2316
- emitter.off('roof:click', commitFloorOnSurfaceClick as never)
2317
- emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
2573
+ emitter.off('node:click', commitFloorOnSurfaceClick as never)
2318
2574
  emitter.off('tool:cancel', onCancel)
2319
2575
  window.removeEventListener('keydown', onKeyDown)
2320
2576
  window.removeEventListener('keyup', onKeyUp)
@@ -2332,6 +2588,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2332
2588
  gridSnapStep,
2333
2589
  updateDimensionGuides,
2334
2590
  updatePreviewGeometry,
2591
+ reconcileDraftRaycast,
2335
2592
  ])
2336
2593
 
2337
2594
  // Refresh wireframe when the grid step changes mid-placement so the green/red
@@ -2363,39 +2620,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2363
2620
  const draftParent = draft.parentId
2364
2621
  ? useScene.getState().nodes[draft.parentId as AnyNodeId]
2365
2622
  : undefined
2366
- 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
2367
2630
  draft.parentId = viewerLevelId
2368
2631
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
2369
2632
  }, [viewerLevelId, draftNode, asset])
2370
2633
 
2371
- // Disable raycasting on the live draft mesh (and restore it when the draft
2372
- // changes or goes away) so the cursor ray passes through the item being
2373
- // moved and lands on the surface beneath it.
2374
- const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
2375
- if (raycastDisabledMeshRef.current !== mesh) {
2376
- // New draft root (or cleared): restore the prior mesh and reset tracking.
2377
- for (const restore of restoreRaycastsRef.current) restore()
2378
- restoreRaycastsRef.current = []
2379
- raycastDisabledChildrenRef.current = new WeakSet()
2380
- raycastDisabledMeshRef.current = mesh
2381
- }
2382
- if (!mesh) return
2383
- // Disable any descendant not handled yet. Item drafts are GLB models whose
2384
- // child meshes mount asynchronously (Suspense), so a one-shot traverse
2385
- // misses them — those late children keep intercepting the ray and corrupt
2386
- // the shelf-row hit the moment the item moves onto a row. Re-walking each
2387
- // frame is cheap: the WeakSet makes it idempotent, so only new children pay.
2388
- mesh.traverse((child) => {
2389
- if (raycastDisabledChildrenRef.current.has(child)) return
2390
- raycastDisabledChildrenRef.current.add(child)
2391
- const original = child.raycast
2392
- child.raycast = () => {}
2393
- restoreRaycastsRef.current.push(() => {
2394
- child.raycast = original
2395
- })
2396
- })
2397
- }, [])
2398
-
2399
2634
  // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
2400
2635
  useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
2401
2636
 
@@ -2407,8 +2642,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2407
2642
  // moving existing node has no draft here, so the grid reads that case straight
2408
2643
  // off the node's mesh. Cleared when idle.
2409
2644
  const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2645
+ const surfaceWorldPointRef = useRef(new Vector3())
2410
2646
  const facingForwardRef = useRef(new Vector3(0, 0, 1))
2411
2647
  const facingQuatRef = useRef(new Quaternion())
2648
+ const ghostSurfaceQuatRef = useRef(new Quaternion())
2412
2649
  useFrame(() => {
2413
2650
  const ghost = cursorGroupRef.current
2414
2651
  if (!(asset && ghost)) {
@@ -2423,7 +2660,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2423
2660
  // the item's forward on the floor, and the triangle rides at the ghost's Y.
2424
2661
  let facingYaw = ghost.rotation.y
2425
2662
  let facingY = ghost.position.y
2426
- if (surf === 'wall' || surf === 'roof-wall') {
2663
+ if (surf === 'wall' || surf === 'roof-wall' || surf === 'block-face') {
2427
2664
  // Wall/roof-segment faces: the cursor group's yaw is the symmetric
2428
2665
  // wireframe yaw (π off the real facing for a wall, and a different frame
2429
2666
  // for a roof face), so derive the item's TRUE outward facing from the
@@ -2431,21 +2668,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2431
2668
  // surface. This keeps BOTH the grid normal and the triangle correct for
2432
2669
  // wall and roof-segment hosts alike, rather than the old quaternion read
2433
2670
  // that pointed the wrong way.
2434
- const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
2435
- if (mesh) {
2436
- mesh.getWorldQuaternion(facingQuatRef.current)
2437
- const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
2438
- fwd.y = 0
2439
- if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
2440
- }
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)
2441
2686
  // The forward triangle is a floor aid; drop it to the building-local floor
2442
- // under the wall (the ghost Y is up on the wall).
2443
- facingY = 0
2444
- 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)
2445
2689
  } else {
2446
- n.set(0, 1, 0)
2690
+ ghost.getWorldQuaternion(ghostSurfaceQuatRef.current)
2691
+ resolveItemPlacementSurfaceNormal(surf, ghostSurfaceQuatRef.current, null, n)
2447
2692
  }
2448
- 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)
2449
2696
 
2450
2697
  if (shape.depth > 0) {
2451
2698
  useFacingPose.getState().set({
@@ -2513,8 +2760,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2513
2760
  gridPosition.current.z,
2514
2761
  ])
2515
2762
  mesh.position.y = visualPosition[1]
2516
- cursorGroupRef.current.position.y = visualPosition[1]
2517
- }
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
+ )
2518
2773
  }
2519
2774
  })
2520
2775
 
@@ -2558,9 +2813,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2558
2813
  depth: currentDimensionBounds.dimensions[2],
2559
2814
  center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
2560
2815
  }
2561
- const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
2562
- const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
2563
- const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
2816
+ const widthLabel = formatLinearMeasurement(
2817
+ currentDimensionBounds.dimensions[0],
2818
+ unit,
2819
+ metricNotation,
2820
+ )
2821
+ const depthLabel = formatLinearMeasurement(
2822
+ currentDimensionBounds.dimensions[2],
2823
+ unit,
2824
+ metricNotation,
2825
+ )
2826
+ const heightLabel = formatLinearMeasurement(
2827
+ currentDimensionBounds.dimensions[1],
2828
+ unit,
2829
+ metricNotation,
2830
+ )
2564
2831
  const widthLabelPosition: [number, number, number] = [
2565
2832
  currentDimensionBounds.center[0],
2566
2833
  0.04,