@pascal-app/editor 0.9.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -10,6 +10,7 @@ import {
10
10
  getScaledDimensions,
11
11
  type ItemEvent,
12
12
  movingFootprintAnchors,
13
+ type RoofEvent,
13
14
  resolveLevelId,
14
15
  type ShelfEvent,
15
16
  sceneRegistry,
@@ -19,7 +20,6 @@ import {
19
20
  type WallEvent,
20
21
  type WallNode,
21
22
  } from '@pascal-app/core'
22
- import { useAlignmentGuides } from '@pascal-app/editor'
23
23
  import { useViewer } from '@pascal-app/viewer'
24
24
  import { Html } from '@react-three/drei'
25
25
  import { useFrame, useThree } from '@react-three/fiber'
@@ -39,10 +39,22 @@ import {
39
39
  } from 'three'
40
40
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
41
41
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
42
+ import {
43
+ clearPlacementSurface,
44
+ publishPlacementSurface,
45
+ } from '../../../lib/active-placement-surface'
42
46
  import { EDITOR_LAYER } from '../../../lib/constants'
47
+ import { formatLinearMeasurement } from '../../../lib/measurements'
43
48
  import { sfxEmitter } from '../../../lib/sfx-bus'
44
- import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
45
- import useEditor from '../../../store/use-editor'
49
+
50
+ import {
51
+ projectAlignmentGuidesWorldToActiveBuildingLocal,
52
+ resolveAlignmentForActiveBuilding,
53
+ } from '../../../lib/world-grid-snap'
54
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
55
+ import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
56
+
57
+ import useFacingPose from '../../../store/use-facing-pose'
46
58
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
47
59
  import {
48
60
  createLineGeometry,
@@ -50,12 +62,24 @@ import {
50
62
  type PreviewBounds,
51
63
  updateLineGeometry,
52
64
  } from '../shared/placement-box-geometry'
53
- import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
65
+ import {
66
+ resolvePointerSupportElevation,
67
+ resolvePointerSupportSurface,
68
+ } from '../shared/pointer-support-cap'
69
+ import {
70
+ getDetachedAttachmentPreviewLift,
71
+ getGridAlignedDimensions,
72
+ snapToGrid,
73
+ snapToHalf,
74
+ snapUpToGridStep,
75
+ steppedRotation,
76
+ } from './placement-math'
54
77
  import {
55
78
  ceilingStrategy,
56
79
  checkCanPlace,
57
80
  floorStrategy,
58
81
  itemSurfaceStrategy,
82
+ roofWallStrategy,
59
83
  shelfSurfaceStrategy,
60
84
  wallStrategy,
61
85
  } from './placement-strategies'
@@ -68,23 +92,20 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
68
92
  * floor-plan overlay and the 3D registry move tool. */
69
93
  const ALIGNMENT_THRESHOLD_M = 0.08
70
94
 
71
- function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
72
- if (unit === 'imperial') {
73
- const feet = value * 3.280_84
74
- const wholeFeet = Math.floor(feet)
75
- const inches = Math.round((feet - wholeFeet) * 12)
76
- if (inches === 12) return `${wholeFeet + 1}'0"`
77
- return `${wholeFeet}'${inches}"`
78
- }
79
- return `${Number.parseFloat(value.toFixed(2))}m`
80
- }
95
+ /** Right-click cancels an active placement but the right button also orbits
96
+ * the camera (CameraControls ROTATE). Only a quick, near-stationary right
97
+ * press/release counts as a cancel; anything that moves past the pixel
98
+ * threshold or is held longer is treated as a camera orbit and left alone. */
99
+ const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
100
+ const RIGHT_CLICK_CANCEL_MAX_MS = 200
81
101
 
82
102
  /**
83
103
  * Expand `bounds` outward so each axis is rounded up to the active grid step.
84
104
  * The wireframe stays centered on the original bounds centre on each axis we
85
105
  * expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
86
- * `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
87
- * so the box still sits on the floor / attachment plane.
106
+ * `min.z = 0` (the mounted face flush with the wall plane) and extend into the
107
+ * room along +Z matching the body and the 2D footprint; the bottom (`min.y`)
108
+ * is preserved so the box still sits on the floor / attachment plane.
88
109
  *
89
110
  * Floor / ceiling / item-surface: X and Z expand; Y stays exact.
90
111
  * Wall / wall-side: X and Y expand; Z stays exact.
@@ -110,9 +131,9 @@ function expandBoundsToGrid(
110
131
  let maxZ: number
111
132
  let newCz: number
112
133
  if (attachTo === 'wall-side') {
113
- maxZ = 0
114
- minZ = -expandedD
115
- newCz = -expandedD / 2
134
+ minZ = 0
135
+ maxZ = expandedD
136
+ newCz = expandedD / 2
116
137
  } else {
117
138
  minZ = cz - expandedD / 2
118
139
  maxZ = cz + expandedD / 2
@@ -134,10 +155,10 @@ function getFallbackPreviewBounds(
134
155
  ): PreviewBounds {
135
156
  const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
136
157
  return {
137
- min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
138
- max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
158
+ min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
159
+ max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
139
160
  dimensions: dims,
140
- center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
161
+ center: [0, dims[1] / 2, attachTo === 'wall-side' ? dims[2] / 2 : 0],
141
162
  }
142
163
  }
143
164
 
@@ -195,8 +216,13 @@ export interface PlacementCoordinatorConfig {
195
216
  initialState?: PlacementState
196
217
  /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
197
218
  defaultScale?: [number, number, number]
198
- /** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
199
- preserveFloorDragOffset?: boolean
219
+ /** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling
220
+ * duplicates) so the duplicate keeps its materials. */
221
+ slots?: ItemNode['slots']
222
+ /** Move-mode sessions keep the grabbed item offset from the first surface hit
223
+ * (floor / wall / ceiling / item-surface / shelf) instead of snapping the
224
+ * item's origin under the cursor. */
225
+ preserveDragOffset?: boolean
200
226
  }
201
227
 
202
228
  export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
@@ -213,13 +239,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
213
239
  config.initialState ?? {
214
240
  surface: 'floor',
215
241
  wallId: null,
242
+ roofSegmentId: null,
216
243
  ceilingId: null,
217
244
  surfaceItemId: null,
218
245
  shelfId: null,
219
246
  },
220
247
  )
221
- const shiftFreeRef = useRef(false)
248
+ const altFreeRef = useRef(false)
222
249
  const previewBoundsSignatureRef = useRef<string | null>(null)
250
+ // Footprint shape (depth along local +Z and [x, z] centre) of the current
251
+ // preview box, mirrored from the rendered dimension bounds so the per-frame
252
+ // surface publisher can position the forward-facing triangle without reading
253
+ // React state. Updated in the render body below.
254
+ const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
255
+ depth: 0,
256
+ center: [0, 0],
257
+ })
223
258
  // Goes true the first time a 3D pointer event drives this coordinator.
224
259
  // The per-frame mesh-position lerp below is only useful for that path;
225
260
  // when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
@@ -238,6 +273,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
238
273
  const raycastDisabledMeshRef = useRef<Object3D | null>(null)
239
274
  const restoreRaycastsRef = useRef<Array<() => void>>([])
240
275
  const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
276
+ // Level-local elevation of the surface the pointer ray actually points
277
+ // at (deck top, floor slab, or ground), refreshed on every grid move.
278
+ // Threaded into the floor-support election as its cap so the POINTER
279
+ // decides the target surface — without it the election lifts the ghost
280
+ // by the MAX overlapping slab and a deck above the aimed-at floor
281
+ // captures the item (and the grid-plane feedback makes it blink).
282
+ const pointerSupportCapRef = useRef<number | null>(null)
241
283
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
242
284
 
243
285
  // Live camera ref — the shelf-stickiness test reconstructs the cursor world
@@ -389,6 +431,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
389
431
  node: previewNode,
390
432
  position,
391
433
  rotation: previewNode.rotation,
434
+ maxElevation: pointerSupportCapRef.current,
392
435
  })
393
436
  },
394
437
  [asset?.attachTo, draftNode],
@@ -413,10 +456,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
413
456
  placementState.current = configRef.current.initialState ?? {
414
457
  surface: 'floor',
415
458
  wallId: null,
459
+ roofSegmentId: null,
416
460
  ceilingId: null,
417
461
  surfaceItemId: null,
418
462
  shelfId: null,
419
463
  }
464
+ // No pointer surface known yet — fall back to the uncapped election
465
+ // (an adopted move draft keeps its persisted host until the first move).
466
+ pointerSupportCapRef.current = null
420
467
  if (!asset.attachTo && placementState.current.surface === 'floor') {
421
468
  gridPosition.current.y = 0
422
469
  if (cursorGroupRef.current) {
@@ -437,7 +484,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
437
484
  })
438
485
 
439
486
  const getActiveValidators = () =>
440
- shiftFreeRef.current
487
+ altFreeRef.current
441
488
  ? {
442
489
  canPlaceOnFloor: () => ({ valid: true }),
443
490
  canPlaceOnWall: () => ({ valid: true }),
@@ -445,8 +492,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
445
492
  }
446
493
  : validators
447
494
 
495
+ const finishCommittedPlacement = (
496
+ committedId: string | null,
497
+ wasAdopted: boolean,
498
+ repeat: () => void,
499
+ ) => {
500
+ if (configRef.current.onCommitted()) {
501
+ repeat()
502
+ return
503
+ }
504
+
505
+ useAlignmentGuides.getState().clear()
506
+ useScene.temporal.getState().resume()
507
+ if (committedId) {
508
+ useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
509
+ }
510
+ if (!wasAdopted) {
511
+ useEditor.getState().setTool(null)
512
+ }
513
+ // A non-repeating placement is finished: return to select mode so the user
514
+ // lands on the just-placed (now selected) node instead of a tool-less build
515
+ // limbo. Repeat placements took the early return above and stay armed.
516
+ useEditor.getState().setMode('select')
517
+ }
518
+
448
519
  const revalidate = (): boolean => {
449
- const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
520
+ const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
450
521
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
451
522
  edgeMaterial.color.setHex(color)
452
523
  basePlaneMaterial.color.setHex(color)
@@ -462,10 +533,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
462
533
  return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
463
534
  }
464
535
 
536
+ const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
537
+ const buildingId = useViewer.getState().selection.buildingId
538
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
539
+ return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
540
+ }
541
+
465
542
  const applyTransition = (result: TransitionResult) => {
466
543
  // Alignment guides are floor-only; clear them when the cursor moves
467
544
  // onto a wall / ceiling / item surface (only those paths call this).
468
545
  useAlignmentGuides.getState().clear()
546
+ // Roof faces carry no grab anchor, but landing on one still counts as
547
+ // anchoring elsewhere — a later return to the grabbed wall must center
548
+ // under the cursor, not restore the stale grab offset.
549
+ if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
469
550
  Object.assign(placementState.current, result.stateUpdate)
470
551
  gridPosition.current.set(...result.gridPosition)
471
552
 
@@ -505,7 +586,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
505
586
  0,
506
587
  ]
507
588
 
508
- draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale)
589
+ draftNode.create(
590
+ gridPosition.current,
591
+ asset,
592
+ initRotation,
593
+ configRef.current.defaultScale,
594
+ configRef.current.slots,
595
+ )
509
596
 
510
597
  const draft = draftNode.current
511
598
  if (draft) {
@@ -529,11 +616,134 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
529
616
 
530
617
  // ---- Init draft ----
531
618
  configRef.current.initDraft(gridPosition.current)
532
- const preserveFloorDragOffset =
533
- configRef.current.preserveFloorDragOffset === true &&
534
- placementState.current.surface === 'floor' &&
535
- !asset.attachTo
536
- const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
619
+ const preserveDragOffset = configRef.current.preserveDragOffset === true
620
+ // The host the item was grabbed from + its pre-drag host-local position.
621
+ // Each surface's grab anchor preserves the grab offset only on THAT host,
622
+ // and only until the item anchors on ANY other host (another wall/ceiling/
623
+ // shelf, a roof face, or the floor after a host visit) `grabForgotten`
624
+ // then latches and every host, the original included, centers the item
625
+ // under the cursor. Merely passing through empty space (wall/ceiling items
626
+ // hide between surfaces) does NOT forget the grab.
627
+ const grabWallId =
628
+ placementState.current.surface === 'wall' ? placementState.current.wallId : null
629
+ const grabCeilingId =
630
+ placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null
631
+ const grabSurfaceHostId =
632
+ placementState.current.surface === 'item-surface'
633
+ ? placementState.current.surfaceItemId
634
+ : placementState.current.surface === 'shelf-surface'
635
+ ? placementState.current.shelfId
636
+ : null
637
+ const grabStartPosition: [number, number, number] | null = draftNode.current
638
+ ? [
639
+ draftNode.current.position[0],
640
+ draftNode.current.position[1],
641
+ draftNode.current.position[2],
642
+ ]
643
+ : null
644
+ let grabForgotten = grabStartPosition === null
645
+ // Anchoring on `hostId`: returns whether the grab offset still applies
646
+ // there, and latches `grabForgotten` the first time it doesn't.
647
+ const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => {
648
+ const preserve = !grabForgotten && hostId !== null && hostId === grabHostId
649
+ if (!preserve) grabForgotten = true
650
+ return preserve
651
+ }
652
+ // Nulled when the item returns to the floor FROM a host (see
653
+ // `detachItemSurfaceToFloor`): the floor grab offset only survives until
654
+ // the item anchors elsewhere, like every other surface's grab anchor.
655
+ let relativeFloorStart =
656
+ preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
657
+ ? gridPosition.current.clone()
658
+ : null
659
+
660
+ // Grab anchors for the non-floor surfaces. Each captures the cursor's
661
+ // surface-local position and the item's stored position on the first move
662
+ // for a given host, then offsets every later move by
663
+ // `start + (raw - anchor)` — mirroring the floor path and the door/window
664
+ // move tools so the item tracks the grabbed point instead of teleporting
665
+ // its origin under the cursor. Reset on host change (re-seeded from the
666
+ // item's then-current position) by the surface leave handlers.
667
+ let wallDragAnchor: {
668
+ wallId: string
669
+ rawX: number
670
+ rawY: number
671
+ startX: number
672
+ startY: number
673
+ } | null = null
674
+ let ceilingDragAnchor: {
675
+ ceilingId: string
676
+ rawX: number
677
+ rawZ: number
678
+ startX: number
679
+ startZ: number
680
+ } | null = null
681
+ let hostSurfaceDragAnchor: {
682
+ hostId: string
683
+ rawX: number
684
+ rawZ: number
685
+ startX: number
686
+ startZ: number
687
+ } | null = null
688
+
689
+ // Item-surface / shelf moves snap from a WORLD cursor hit projected into the
690
+ // host's local frame. Re-project the offset-corrected local point back to
691
+ // world so the strategy (which re-derives both the stored position and the
692
+ // visual cursor from `event.position`) stays self-consistent.
693
+ const resolveHostSurfaceWorld = (
694
+ hostId: string,
695
+ worldPos: readonly [number, number, number],
696
+ ): [number, number, number] | null => {
697
+ const draft = draftNode.current
698
+ const hostMesh = sceneRegistry.nodes.get(hostId)
699
+ if (!(preserveDragOffset && draft && hostMesh)) return null
700
+ const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
701
+ if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
702
+ // Grab offset survives only on the host the item was grabbed from and
703
+ // only until it anchors elsewhere — any other shelf/table centers the
704
+ // item under the cursor (same rule as the wall grab anchor).
705
+ const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId)
706
+ hostSurfaceDragAnchor = {
707
+ hostId,
708
+ rawX: rawLocal.x,
709
+ rawZ: rawLocal.z,
710
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
711
+ startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
712
+ }
713
+ }
714
+ const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
715
+ const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
716
+ const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
717
+ return [world.x, world.y, world.z]
718
+ }
719
+
720
+ // Floor grab-offset: the item tracks the grabbed point instead of snapping
721
+ // its origin under the cursor. The offset is computed in building-local space
722
+ // (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
723
+ // (`event.position`), so the corrected local point is re-projected to a
724
+ // corrected world point and both frames carry the offset to stay consistent.
725
+ const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
726
+ if (relativeFloorStart === null) return event
727
+ const rawX = event.localPosition[0]
728
+ const rawZ = event.localPosition[2]
729
+ const anchor = floorDragAnchor ?? [rawX, rawZ]
730
+ floorDragAnchor = anchor
731
+ const correctedLocal: [number, number, number] = [
732
+ relativeFloorStart.x + (rawX - anchor[0]),
733
+ event.localPosition[1],
734
+ relativeFloorStart.z + (rawZ - anchor[1]),
735
+ ]
736
+ const correctedWorld = buildingLocalToWorld(
737
+ correctedLocal[0],
738
+ correctedLocal[1],
739
+ correctedLocal[2],
740
+ )
741
+ return {
742
+ ...event,
743
+ position: [correctedWorld.x, event.position[1], correctedWorld.z],
744
+ localPosition: correctedLocal,
745
+ }
746
+ }
537
747
 
538
748
  // Sync cursor to the draft mesh's world position and rotation
539
749
  if (draftNode.current) {
@@ -544,8 +754,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
544
754
  const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
545
755
  if (cursorGroupRef.current) {
546
756
  cursorGroupRef.current.position.copy(localPos)
547
- if (draftNode.current.asset.attachTo) {
548
- // Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
757
+ if (
758
+ draftNode.current.asset.attachTo ||
759
+ placementState.current?.surface === 'item-surface'
760
+ ) {
761
+ // Wall/ceiling items AND items hosted on another item: the mesh is parented
762
+ // to a rotated host, so the box's building-local yaw must come from the mesh's
763
+ // world rotation, not the node's host-local `rotation[1]` (which would leave the
764
+ // box rotated by the host's yaw relative to the item).
549
765
  const q = new Quaternion()
550
766
  mesh.getWorldQuaternion(q)
551
767
  cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
@@ -647,6 +863,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
647
863
 
648
864
  has3DPointerDrivenMoveRef.current = true
649
865
 
866
+ // The pointer decides the target surface AND the floor point: cap
867
+ // the floor-support election at the elevation of the surface the
868
+ // camera ray actually hits, and re-aim the event at the ray's
869
+ // crossing of that surface's plane. The grid event's own hit can't
870
+ // be used directly — its plane rides at the ghost's last height, so
871
+ // its Y is a feedback loop (the under-deck blink) and its XZ is
872
+ // perspective-skewed along the ray whenever the plane sits on a
873
+ // different storey than the pointed surface (the skew is what made
874
+ // a drag over a deck-above-a-floor hop between the two surfaces).
875
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
876
+ pointerSupportCapRef.current = pointed?.elevation ?? null
877
+ const surfaceEvent: GridEvent =
878
+ pointed?.worldPoint && pointed.localPoint
879
+ ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
880
+ : event
881
+
650
882
  // Shelf stickiness: while hosting on a shelf, ignore floor events while
651
883
  // the cursor ray still points at the shelf volume (the ray merely slipped
652
884
  // off a board / through a gap and hit the floor behind). Detach to the
@@ -654,26 +886,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
654
886
  // item oscillates between the shelf row and the floor on every micro-move.
655
887
  if (placementState.current.surface === 'shelf-surface') {
656
888
  if (cursorRayIntersectsActiveShelf(event.position)) return
657
- detachItemSurfaceToFloor(event as unknown as ItemEvent)
658
- }
659
-
660
- const floorEvent =
661
- relativeFloorStart !== null
662
- ? (() => {
663
- const rawX = event.localPosition[0]
664
- const rawZ = event.localPosition[2]
665
- const anchor = floorDragAnchor ?? [rawX, rawZ]
666
- floorDragAnchor = anchor
667
- return {
668
- ...event,
669
- localPosition: [
670
- relativeFloorStart.x + (rawX - anchor[0]),
671
- event.localPosition[1],
672
- relativeFloorStart.z + (rawZ - anchor[1]),
673
- ] as [number, number, number],
674
- }
675
- })()
676
- : event
889
+ // Land at the pointed surface's plan point — the raw grid hit is
890
+ // still skewed by the plane riding at the shelf-surface height.
891
+ detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
892
+ }
893
+
894
+ const floorEvent = applyFloorGrabOffset(surfaceEvent)
677
895
 
678
896
  lastRawPos.current.set(
679
897
  floorEvent.localPosition[0],
@@ -689,12 +907,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
689
907
  // item's edge, snap and publish a guide. The guide connects to the
690
908
  // nearest real corner of the candidate (resolver tie-break), so the dot
691
909
  // always sits on an actual point. The delta is applied to BOTH the grid
692
- // and cursor positions below. Alt bypasses.
910
+ // and cursor positions below. Alt is force-place only (it does NOT bypass
911
+ // snapping — 'off' mode is the no-snap bypass); the active snapping mode
912
+ // governs whether alignment runs at all ('off' / 'angles' disable
913
+ // magnetic alignment, 'lines' enables it, matching the wall/fence flow).
693
914
  const draft = draftNode.current
694
915
  let alignX = 0
695
916
  let alignZ = 0
696
- const bypassAlign = floorEvent.nativeEvent?.altKey === true
697
- if (!bypassAlign && draft) {
917
+ // Alignment "lines" are DISPLAYED in every snapping mode except Off
918
+ // (isAlignmentGuideActive); the magnetic pull toward them is applied only
919
+ // in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
920
+ // bypass.
921
+ if (isAlignmentGuideActive() && draft) {
698
922
  alignmentCandidates ??= collectAlignmentAnchors(
699
923
  useScene.getState().nodes,
700
924
  draft.id,
@@ -710,11 +934,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
710
934
  candidates: alignmentCandidates,
711
935
  threshold: ALIGNMENT_THRESHOLD_M,
712
936
  })
713
- if (ar.snap) {
937
+ if (ar.snap && isMagneticSnapActive()) {
714
938
  alignX = ar.snap.dx
715
939
  alignZ = ar.snap.dz
716
940
  }
717
- useAlignmentGuides.getState().set(ar.guides)
941
+ useAlignmentGuides
942
+ .getState()
943
+ .set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
718
944
  } else {
719
945
  useAlignmentGuides.getState().clear()
720
946
  }
@@ -736,6 +962,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
736
962
  previousGridPos = [...gridPos]
737
963
  gridPosition.current.set(...gridPos)
738
964
  const cursorPosition = getFloorVisualPosition(gridPos)
965
+ if (!draft && asset.attachTo) {
966
+ cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
967
+ }
739
968
  cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
740
969
  // Floor items only rotate on Y; keep the preview box (and the live
741
970
  // transform the 2D floorplan mirrors) aligned with the draft's
@@ -745,11 +974,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
745
974
 
746
975
  if (draft) draft.position = gridPos
747
976
 
748
- // Publish live transform for 2D floorplan
977
+ // Publish live transform for 2D floorplan (and the pointer surface
978
+ // cap, so FloorElevationSystem's per-frame Y agrees with the ghost).
749
979
  if (draft) {
750
980
  useLiveTransforms.getState().set(draft.id, {
751
981
  position: gridPos,
752
982
  rotation: cursorGroupRef.current.rotation.y,
983
+ supportElevationCap: pointerSupportCapRef.current ?? undefined,
753
984
  })
754
985
  }
755
986
 
@@ -775,9 +1006,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
775
1006
  useLiveTransforms.getState().clear(draftNode.current.id)
776
1007
  }
777
1008
 
778
- draftNode.commit(result.nodeUpdate)
779
- if (configRef.current.onCommitted()) {
780
- draftNode.create(gridPosition.current, asset, currentRotation)
1009
+ const committedId = draftNode.current?.id ?? null
1010
+ const wasAdopted = draftNode.isAdopted
1011
+ // Carry the pointer surface cap into the commit so the persisted
1012
+ // supportSlabId reproduces the capped election (elects the aimed-at
1013
+ // lower slab — or the ground — instead of a deck hanging above).
1014
+ const finalId = draftNode.commit(result.nodeUpdate, {
1015
+ supportElevationCap: pointerSupportCapRef.current,
1016
+ })
1017
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1018
+ draftNode.create(
1019
+ gridPosition.current,
1020
+ asset,
1021
+ currentRotation,
1022
+ configRef.current.defaultScale,
1023
+ configRef.current.slots,
1024
+ )
781
1025
  const previewBounds = expandBoundsToGrid(
782
1026
  getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
783
1027
  asset.attachTo,
@@ -786,7 +1030,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
786
1030
  updatePreviewGeometry(previewBounds)
787
1031
  updateDimensionGuides(previewBounds)
788
1032
  revalidate()
789
- }
1033
+ })
790
1034
  }
791
1035
 
792
1036
  // ---- Wall Handlers ----
@@ -861,7 +1105,46 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
861
1105
  return
862
1106
  }
863
1107
 
864
- const result = wallStrategy.move(ctx, event, getActiveValidators())
1108
+ let wallMoveEvent = event
1109
+ if (preserveDragOffset && draftNode.current) {
1110
+ const rawX = event.localPosition[0]
1111
+ const rawY = event.localPosition[1]
1112
+ if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
1113
+ // Preserve the grab offset only on the wall the item was grabbed
1114
+ // from (no teleport under the pointer at grab time). Any OTHER host
1115
+ // centers the item under the cursor — a host change is already a
1116
+ // jump, so tracking the pointer exactly is the expected feel, while
1117
+ // re-seeding from the carried-over position (the old behavior)
1118
+ // baked an arbitrary along-wall offset into the whole stay on that
1119
+ // wall. Once anchored elsewhere the grab is forgotten for good, so
1120
+ // re-entering the original wall centers under the cursor too.
1121
+ const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
1122
+ wallDragAnchor = {
1123
+ wallId: event.node.id,
1124
+ rawX,
1125
+ rawY,
1126
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
1127
+ startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
1128
+ }
1129
+ }
1130
+ const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
1131
+ 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
+ wallMoveEvent = {
1140
+ ...event,
1141
+ localPosition: [correctedX, correctedY, event.localPosition[2]],
1142
+ position: correctedWorld
1143
+ ? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
1144
+ : event.position,
1145
+ }
1146
+ }
1147
+ const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
865
1148
  if (!result) return
866
1149
 
867
1150
  event.stopPropagation()
@@ -918,10 +1201,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
918
1201
  }
919
1202
  }
920
1203
 
921
- // Publish live transform for 2D floorplan
1204
+ // Publish live transform for the 2D floorplan. The floorplan resolves a
1205
+ // 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
+ }
922
1219
  useLiveTransforms.getState().set(draft.id, {
923
1220
  position: result.cursorPosition,
924
- rotation: result.cursorRotationY,
1221
+ rotation: liveRotation,
925
1222
  })
926
1223
  }
927
1224
  }
@@ -935,12 +1232,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
935
1232
  if (draftNode.current) {
936
1233
  useLiveTransforms.getState().clear(draftNode.current.id)
937
1234
  }
938
- draftNode.commit(result.nodeUpdate)
1235
+ const committedId = draftNode.current?.id ?? null
1236
+ const wasAdopted = draftNode.isAdopted
1237
+ const finalId = draftNode.commit(result.nodeUpdate)
939
1238
  if (result.dirtyNodeId) {
940
1239
  useScene.getState().dirtyNodes.add(result.dirtyNodeId)
941
1240
  }
942
1241
 
943
- if (configRef.current.onCommitted()) {
1242
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
944
1243
  const nodes = useScene.getState().nodes
945
1244
  const enterResult = wallStrategy.enter(
946
1245
  getContext(),
@@ -954,10 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
954
1253
  } else {
955
1254
  revalidate()
956
1255
  }
957
- }
1256
+ })
958
1257
  }
959
1258
 
960
1259
  const onWallLeave = (event: WallEvent) => {
1260
+ wallDragAnchor = null
961
1261
  const result = wallStrategy.leave(getContext())
962
1262
  if (!result) return
963
1263
 
@@ -987,16 +1287,174 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
987
1287
  }
988
1288
  }
989
1289
 
1290
+ // ---- Roof Wall Handlers ----
1291
+ // Wall-attach items also host on the vertical wall faces a roof
1292
+ // segment generates (base walls + coplanar gable ends). Unlike walls,
1293
+ // crossing between segments inside ONE roof never re-fires
1294
+ // `roof:enter` (events come from the roof group), so the move handler
1295
+ // re-enters whenever the strategy reports a segment change.
1296
+
1297
+ const enterRoofWall = (event: RoofEvent): boolean => {
1298
+ const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1299
+ if (!result) return false
1300
+
1301
+ event.stopPropagation()
1302
+ applyTransition(result)
1303
+
1304
+ if (!draftNode.current) {
1305
+ ensureDraft(result)
1306
+ } else if (result.nodeUpdate.parentId) {
1307
+ // Existing draft (move mode): reparent to the segment
1308
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1309
+ }
1310
+ return true
1311
+ }
1312
+
1313
+ const onRoofWallEnter = (event: RoofEvent) => {
1314
+ has3DPointerDrivenMoveRef.current = true
1315
+ enterRoofWall(event)
1316
+ }
1317
+
1318
+ const onRoofWallMove = (event: RoofEvent) => {
1319
+ releaseCommit = () => onRoofWallClick(event)
1320
+ has3DPointerDrivenMoveRef.current = true
1321
+ if (!cursorGroupRef.current) return
1322
+ const ctx = getContext()
1323
+
1324
+ if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
1325
+ enterRoofWall(event)
1326
+ return
1327
+ }
1328
+
1329
+ const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
1330
+ if (!result) {
1331
+ // Different segment under the pointer (or no placeable face) —
1332
+ // try a fresh enter; a null resolve leaves the draft where it is.
1333
+ enterRoofWall(event)
1334
+ return
1335
+ }
1336
+
1337
+ event.stopPropagation()
1338
+
1339
+ const posChanged =
1340
+ gridPosition.current.x !== result.gridPosition[0] ||
1341
+ gridPosition.current.y !== result.gridPosition[1] ||
1342
+ gridPosition.current.z !== result.gridPosition[2]
1343
+
1344
+ if (posChanged) {
1345
+ sfxEmitter.emit('sfx:grid-snap')
1346
+ }
1347
+
1348
+ gridPosition.current.set(...result.gridPosition)
1349
+ const wc = worldToBuildingLocal(...result.cursorPosition)
1350
+ cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
1351
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
1352
+
1353
+ const draft = draftNode.current
1354
+ if (draft && result.nodeUpdate) {
1355
+ if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
1356
+ if ('rotation' in result.nodeUpdate)
1357
+ draft.rotation = result.nodeUpdate.rotation as [number, number, number]
1358
+ }
1359
+
1360
+ const placeable = revalidate()
1361
+
1362
+ if (draft && placeable) {
1363
+ draft.position = result.gridPosition
1364
+ const mesh = sceneRegistry.nodes.get(draft.id)
1365
+ if (mesh) {
1366
+ mesh.position.copy(gridPosition.current)
1367
+ // Wall-side items sit on the outer surface: mirror ItemSystem's
1368
+ // push (z = thickness/2 off the face frame's mid-plane) so the
1369
+ // drag preview doesn't sink into the wall until commit.
1370
+ if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
1371
+ const segment =
1372
+ useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
1373
+ if (segment?.type === 'roof-segment') {
1374
+ mesh.position.z = (segment.wallThickness ?? 0.1) / 2
1375
+ }
1376
+ }
1377
+ const rot = result.nodeUpdate?.rotation
1378
+ if (rot) mesh.rotation.y = rot[1]
1379
+ }
1380
+ // The 2D floor-plan live frame is wall-local; a segment-local
1381
+ // value would render garbage — clear instead of publishing.
1382
+ useLiveTransforms.getState().clear(draft.id)
1383
+ }
1384
+ }
1385
+
1386
+ const onRoofWallClick = (event: RoofEvent) => {
1387
+ const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
1388
+ if (!result) return
1389
+
1390
+ 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)
1397
+
1398
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1399
+ const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1400
+ if (enterResult) {
1401
+ applyTransition(enterResult)
1402
+ } else {
1403
+ revalidate()
1404
+ }
1405
+ })
1406
+ }
1407
+
1408
+ const onRoofWallLeave = (event: RoofEvent) => {
1409
+ const result = roofWallStrategy.leave(getContext())
1410
+ if (!result) return
1411
+
1412
+ event.stopPropagation()
1413
+
1414
+ if (draftNode.isAdopted) {
1415
+ // Move mode: keep draft alive, reparent to level
1416
+ applyTransition(result)
1417
+ const draft = draftNode.current
1418
+ if (draft) {
1419
+ useScene.getState().updateNode(draft.id, {
1420
+ parentId: result.nodeUpdate.parentId as string,
1421
+ roofSegmentId: undefined,
1422
+ })
1423
+ }
1424
+ } else {
1425
+ // Create mode: destroy transient and reset state
1426
+ draftNode.destroy()
1427
+ Object.assign(placementState.current, result.stateUpdate)
1428
+ }
1429
+ }
1430
+
990
1431
  // ---- Item Surface Handlers ----
991
1432
 
992
1433
  const detachItemSurfaceToFloor = (event: ItemEvent) => {
1434
+ hostSurfaceDragAnchor = null
1435
+ // Landing back on the floor: refresh the pointer surface cap from
1436
+ // this event's world hit so the first floor position already targets
1437
+ // the aimed-at surface (not a deck above it).
1438
+ pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [
1439
+ event.position[0],
1440
+ event.position[1],
1441
+ event.position[2],
1442
+ ])
1443
+ // Coming back from a host: forget the floor grab too, so the item
1444
+ // centers under the cursor instead of restoring the pre-drag offset —
1445
+ // and landing on the floor is "anchoring elsewhere", so a later return
1446
+ // to the grabbed host centers as well.
1447
+ relativeFloorStart = null
1448
+ floorDragAnchor = null
1449
+ grabForgotten = true
993
1450
  const buildingLocalPoint = worldToBuildingLocal(
994
1451
  event.position[0],
995
1452
  event.position[1],
996
1453
  event.position[2],
997
1454
  )
998
- const wx = Math.round(buildingLocalPoint.x * 2) / 2
999
- const wz = Math.round(buildingLocalPoint.z * 2) / 2
1455
+ // Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
1456
+ const wx = snapToHalf(buildingLocalPoint.x)
1457
+ const wz = snapToHalf(buildingLocalPoint.z)
1000
1458
  const floorPos: [number, number, number] = [wx, 0, wz]
1001
1459
 
1002
1460
  Object.assign(placementState.current, {
@@ -1016,10 +1474,36 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1016
1474
 
1017
1475
  const draft = draftNode.current
1018
1476
  if (draft) {
1477
+ // The stored yaw is still HOST-local — the surface enter counter-
1478
+ // rotated it against the host's world yaw so the item wouldn't spin
1479
+ // when attaching. Re-express the same world yaw in the level frame
1480
+ // before re-parenting, or the item visibly spins by the host's
1481
+ // rotation the moment it returns to the floor.
1482
+ let rotation = draft.rotation
1483
+ const draftMesh = sceneRegistry.nodes.get(draft.id)
1484
+ if (draftMesh) {
1485
+ const quat = new Quaternion()
1486
+ draftMesh.getWorldQuaternion(quat)
1487
+ const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
1488
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
1489
+ let levelYaw = 0
1490
+ if (levelMesh) {
1491
+ levelMesh.getWorldQuaternion(quat)
1492
+ levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
1493
+ }
1494
+ rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]]
1495
+ draft.rotation = rotation
1496
+ }
1019
1497
  draft.position = floorPos
1498
+ // Sync the ref's parent too (the store-only write left the ref
1499
+ // pointing at the host, so `getFloorPlacedElevation` bailed on its
1500
+ // parent-must-be-a-level guard and the item + grid stopped following
1501
+ // slab elevations for the rest of the drag).
1502
+ if (levelId) draft.parentId = levelId
1020
1503
  useScene.getState().updateNode(draft.id, {
1021
1504
  parentId: useViewer.getState().selection.levelId as string,
1022
1505
  position: floorPos,
1506
+ rotation,
1023
1507
  })
1024
1508
  }
1025
1509
 
@@ -1089,8 +1573,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1089
1573
  return
1090
1574
  }
1091
1575
 
1092
- lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
1093
- const result = itemSurfaceStrategy.move(ctx, event)
1576
+ const surfaceWorld =
1577
+ ctx.state.surfaceItemId !== null
1578
+ ? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
1579
+ : null
1580
+ const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
1581
+ lastRawPos.current.set(
1582
+ itemMoveEvent.position[0],
1583
+ itemMoveEvent.position[1],
1584
+ itemMoveEvent.position[2],
1585
+ )
1586
+ const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
1094
1587
  if (!result) return
1095
1588
 
1096
1589
  event.stopPropagation()
@@ -1150,15 +1643,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1150
1643
  if (draftNode.current) {
1151
1644
  useLiveTransforms.getState().clear(draftNode.current.id)
1152
1645
  }
1153
- draftNode.commit(result.nodeUpdate)
1154
- if (configRef.current.onCommitted()) {
1646
+ const committedId = draftNode.current?.id ?? null
1647
+ const wasAdopted = draftNode.isAdopted
1648
+ const finalId = draftNode.commit(result.nodeUpdate)
1649
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1155
1650
  const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
1156
1651
  if (enterResult) {
1157
1652
  applyTransition(enterResult)
1158
1653
  } else {
1159
1654
  revalidate()
1160
1655
  }
1161
- }
1656
+ })
1162
1657
  return
1163
1658
  }
1164
1659
  }
@@ -1176,15 +1671,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1176
1671
  if (draftNode.current) {
1177
1672
  useLiveTransforms.getState().clear(draftNode.current.id)
1178
1673
  }
1179
- draftNode.commit(result.nodeUpdate)
1180
- if (configRef.current.onCommitted()) {
1674
+ const committedId = draftNode.current?.id ?? null
1675
+ const wasAdopted = draftNode.isAdopted
1676
+ const finalId = draftNode.commit(result.nodeUpdate)
1677
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1181
1678
  const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
1182
1679
  if (enterResult) {
1183
1680
  applyTransition(enterResult)
1184
1681
  } else {
1185
1682
  revalidate()
1186
1683
  }
1187
- }
1684
+ })
1188
1685
  return
1189
1686
  }
1190
1687
  }
@@ -1205,8 +1702,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1205
1702
  if (draftNode.current) {
1206
1703
  useLiveTransforms.getState().clear(draftNode.current.id)
1207
1704
  }
1208
- draftNode.commit(result.nodeUpdate)
1209
- if (configRef.current.onCommitted()) {
1705
+ const committedId = draftNode.current?.id ?? null
1706
+ const wasAdopted = draftNode.isAdopted
1707
+ const finalId = draftNode.commit(result.nodeUpdate)
1708
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1210
1709
  const nodes = useScene.getState().nodes
1211
1710
  const enterResult = ceilingStrategy.enter(
1212
1711
  getContext(),
@@ -1219,7 +1718,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1219
1718
  } else {
1220
1719
  revalidate()
1221
1720
  }
1222
- }
1721
+ })
1223
1722
  return
1224
1723
  }
1225
1724
  }
@@ -1235,9 +1734,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1235
1734
  if (draftNode.current) {
1236
1735
  useLiveTransforms.getState().clear(draftNode.current.id)
1237
1736
  }
1238
- draftNode.commit(result.nodeUpdate)
1737
+ const committedId = draftNode.current?.id ?? null
1738
+ const wasAdopted = draftNode.isAdopted
1739
+ const finalId = draftNode.commit(result.nodeUpdate)
1239
1740
 
1240
- if (configRef.current.onCommitted()) {
1741
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1241
1742
  // Try to set up next draft on the same surface
1242
1743
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
1243
1744
  if (enterResult) {
@@ -1245,7 +1746,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1245
1746
  } else {
1246
1747
  revalidate()
1247
1748
  }
1248
- }
1749
+ })
1249
1750
  }
1250
1751
 
1251
1752
  // ---- Ceiling Handlers ----
@@ -1284,8 +1785,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1284
1785
  return
1285
1786
  }
1286
1787
 
1287
- lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
1288
- const result = ceilingStrategy.move(getContext(), event)
1788
+ let ceilingMoveEvent = event
1789
+ if (preserveDragOffset && draftNode.current) {
1790
+ const rawX = event.localPosition[0]
1791
+ const rawZ = event.localPosition[2]
1792
+ if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
1793
+ // Same rule as the wall grab anchor: only the grabbed ceiling
1794
+ // preserves the offset, any other ceiling centers under the cursor.
1795
+ const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId)
1796
+ ceilingDragAnchor = {
1797
+ ceilingId: event.node.id,
1798
+ rawX,
1799
+ rawZ,
1800
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
1801
+ startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
1802
+ }
1803
+ }
1804
+ ceilingMoveEvent = {
1805
+ ...event,
1806
+ localPosition: [
1807
+ ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
1808
+ event.localPosition[1],
1809
+ ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
1810
+ ],
1811
+ }
1812
+ }
1813
+ lastRawPos.current.set(
1814
+ ceilingMoveEvent.localPosition[0],
1815
+ ceilingMoveEvent.localPosition[1],
1816
+ ceilingMoveEvent.localPosition[2],
1817
+ )
1818
+ const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
1289
1819
  if (!result) return
1290
1820
 
1291
1821
  event.stopPropagation()
@@ -1335,9 +1865,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1335
1865
  if (draftNode.current) {
1336
1866
  useLiveTransforms.getState().clear(draftNode.current.id)
1337
1867
  }
1338
- draftNode.commit(result.nodeUpdate)
1868
+ const committedId = draftNode.current?.id ?? null
1869
+ const wasAdopted = draftNode.isAdopted
1870
+ const finalId = draftNode.commit(result.nodeUpdate)
1339
1871
 
1340
- if (configRef.current.onCommitted()) {
1872
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1341
1873
  const nodes = useScene.getState().nodes
1342
1874
  const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
1343
1875
  if (enterResult) {
@@ -1345,10 +1877,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1345
1877
  } else {
1346
1878
  revalidate()
1347
1879
  }
1348
- }
1880
+ })
1349
1881
  }
1350
1882
 
1351
1883
  const onCeilingLeave = (event: CeilingEvent) => {
1884
+ ceilingDragAnchor = null
1352
1885
  const result = ceilingStrategy.leave(getContext())
1353
1886
  if (!result) return
1354
1887
 
@@ -1422,7 +1955,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1422
1955
  }
1423
1956
  return
1424
1957
  }
1425
- const result = shelfSurfaceStrategy.move(ctx, event)
1958
+ const shelfWorld =
1959
+ ctx.state.shelfId !== null
1960
+ ? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
1961
+ : null
1962
+ const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
1963
+ const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
1426
1964
  if (!result) return
1427
1965
 
1428
1966
  event.stopPropagation()
@@ -1467,26 +2005,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1467
2005
  if (draftNode.current) {
1468
2006
  useLiveTransforms.getState().clear(draftNode.current.id)
1469
2007
  }
1470
- draftNode.commit(result.nodeUpdate)
2008
+ const committedId = draftNode.current?.id ?? null
2009
+ const wasAdopted = draftNode.isAdopted
2010
+ const finalId = draftNode.commit(result.nodeUpdate)
1471
2011
 
1472
- if (configRef.current.onCommitted()) {
2012
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1473
2013
  const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
1474
2014
  if (enterResult) {
1475
2015
  applyTransition(enterResult)
1476
2016
  } else {
1477
2017
  revalidate()
1478
2018
  }
1479
- }
2019
+ })
1480
2020
  }
1481
2021
 
1482
2022
  // ---- Keyboard rotation ----
1483
2023
 
1484
- // 45° increments — matches the R-key rotation step for already-placed
1485
- // items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
1486
- const ROTATION_STEP = Math.PI / 4
1487
2024
  const onKeyDown = (event: KeyboardEvent) => {
1488
- if (event.key === 'Shift') {
1489
- shiftFreeRef.current = true
2025
+ if (event.key === 'Alt') {
2026
+ altFreeRef.current = true
1490
2027
  revalidate()
1491
2028
  return
1492
2029
  }
@@ -1499,17 +2036,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1499
2036
  const draft = draftNode.current
1500
2037
  if (!draft) return
1501
2038
 
1502
- let rotationDelta = 0
2039
+ // Roof-wall drafts live flat in the host face frame (yaw 0) —
2040
+ // manual rotation would skew them off the wall plane.
2041
+ if (placementState.current.surface === 'roof-wall') return
2042
+
2043
+ let rotationDir: 1 | -1 | 0 = 0
1503
2044
  if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
1504
- rotationDelta = ROTATION_STEP
2045
+ rotationDir = 1
1505
2046
  else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
1506
- rotationDelta = -ROTATION_STEP
2047
+ rotationDir = -1
1507
2048
 
1508
- if (rotationDelta !== 0) {
2049
+ if (rotationDir !== 0) {
1509
2050
  event.preventDefault()
1510
2051
  sfxEmitter.emit('sfx:item-rotate')
1511
2052
  const currentRotation = draft.rotation
1512
- const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
2053
+ // Round to the nearest 45° then step, matching the placed-item R/T.
2054
+ const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
1513
2055
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
1514
2056
 
1515
2057
  // Ref + cursor mesh + item mesh — no store update during drag
@@ -1564,8 +2106,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1564
2106
  worldSnapped.y,
1565
2107
  worldSnapped.z,
1566
2108
  )
2109
+ const surfaceQuat = new Quaternion()
2110
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
2111
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
1567
2112
  if (cursorGroupRef.current) {
1568
2113
  cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
2114
+ // The box lives in building-local space while the mesh is parented to the host
2115
+ // item, so add the host's world yaw: the box must track the item's true
2116
+ // orientation, not its host-local `rotation[1]`.
2117
+ cursorGroupRef.current.rotation.y = newRotationY + surfaceWorldY
1569
2118
  }
1570
2119
  if (mesh) mesh.position.set(x, y, z)
1571
2120
  }
@@ -1596,8 +2145,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1596
2145
  }
1597
2146
 
1598
2147
  const onKeyUp = (event: KeyboardEvent) => {
1599
- if (event.key === 'Shift') {
1600
- shiftFreeRef.current = false
2148
+ if (event.key === 'Alt') {
2149
+ altFreeRef.current = false
1601
2150
  revalidate()
1602
2151
  }
1603
2152
  }
@@ -1614,13 +2163,35 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1614
2163
  }
1615
2164
  emitter.on('tool:cancel', onCancel)
1616
2165
 
1617
- // ---- Right-click cancel ----
1618
- const onContextMenu = (event: MouseEvent) => {
1619
- if (configRef.current.onCancel) {
1620
- event.preventDefault()
1621
- configRef.current.onCancel()
2166
+ // ---- Right-click cancel (quick click only, never a right-drag orbit) ----
2167
+ // The right button is also the camera-orbit control, so a contextmenu/up
2168
+ // alone can't tell "cancel placement" from "move the camera". Record the
2169
+ // right-button-down point + time and only cancel on release when the
2170
+ // pointer barely moved within a short window — a longer / further press is
2171
+ // an orbit and must leave the placement untouched.
2172
+ let rightDown: { x: number; y: number; t: number } | null = null
2173
+ const onRightPointerDown = (event: PointerEvent) => {
2174
+ if (event.button !== 2) return
2175
+ rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
2176
+ }
2177
+ const onRightPointerUp = (event: PointerEvent) => {
2178
+ if (event.button !== 2) return
2179
+ const down = rightDown
2180
+ rightDown = null
2181
+ if (!down || !configRef.current.onCancel) return
2182
+ const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
2183
+ const elapsed = performance.now() - down.t
2184
+ if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
2185
+ onCancel()
1622
2186
  }
1623
2187
  }
2188
+ // Suppress the OS context menu while placing; the cancel itself is decided
2189
+ // on pointerup above.
2190
+ const onContextMenu = (event: MouseEvent) => {
2191
+ if (configRef.current.onCancel) event.preventDefault()
2192
+ }
2193
+ window.addEventListener('pointerdown', onRightPointerDown, true)
2194
+ window.addEventListener('pointerup', onRightPointerUp, true)
1624
2195
  window.addEventListener('contextmenu', onContextMenu)
1625
2196
 
1626
2197
  // ---- Bounding box geometry ----
@@ -1673,6 +2244,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1673
2244
  emitter.on('wall:move', onWallMove)
1674
2245
  emitter.on('wall:click', onWallClick)
1675
2246
  emitter.on('wall:leave', onWallLeave)
2247
+ emitter.on('roof:enter', onRoofWallEnter)
2248
+ emitter.on('roof:move', onRoofWallMove)
2249
+ emitter.on('roof:click', onRoofWallClick)
2250
+ emitter.on('roof:leave', onRoofWallLeave)
1676
2251
  emitter.on('ceiling:enter', onCeilingEnter)
1677
2252
  emitter.on('ceiling:move', onCeilingMove)
1678
2253
  emitter.on('ceiling:click', onCeilingClick)
@@ -1681,6 +2256,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1681
2256
  emitter.on('shelf:move', onShelfMove)
1682
2257
  emitter.on('shelf:click', onShelfClick)
1683
2258
  emitter.on('shelf:leave', onShelfLeave)
2259
+
2260
+ // A floor placement commits at the tracked floor cursor (`gridPosition`),
2261
+ // which keeps following the floor even when the click ray lands on a wall
2262
+ // (grid:move uses a separate ground-plane raycast). Without this, a commit
2263
+ // click whose ray hits a wall fires only `wall:click` — whose handler
2264
+ // declines for a floor item — and the click is silently eaten (the user
2265
+ // has to click again until the ray happens to clear the wall). Route every
2266
+ // surface click to the floor commit too; `floorStrategy.click` guards on
2267
+ // `surface === 'floor'` (and a non-attach draft), so it no-ops while the
2268
+ // draft is actually resting on that surface.
2269
+ const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
2270
+ if (placementState.current.surface !== 'floor') return
2271
+ onGridClick(event as unknown as GridEvent)
2272
+ }
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)
1684
2278
  if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
1685
2279
 
1686
2280
  return () => {
@@ -1704,6 +2298,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1704
2298
  emitter.off('wall:move', onWallMove)
1705
2299
  emitter.off('wall:click', onWallClick)
1706
2300
  emitter.off('wall:leave', onWallLeave)
2301
+ emitter.off('roof:enter', onRoofWallEnter)
2302
+ emitter.off('roof:move', onRoofWallMove)
2303
+ emitter.off('roof:click', onRoofWallClick)
2304
+ emitter.off('roof:leave', onRoofWallLeave)
1707
2305
  emitter.off('ceiling:enter', onCeilingEnter)
1708
2306
  emitter.off('ceiling:move', onCeilingMove)
1709
2307
  emitter.off('ceiling:click', onCeilingClick)
@@ -1712,9 +2310,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1712
2310
  emitter.off('shelf:move', onShelfMove)
1713
2311
  emitter.off('shelf:click', onShelfClick)
1714
2312
  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)
1715
2318
  emitter.off('tool:cancel', onCancel)
1716
2319
  window.removeEventListener('keydown', onKeyDown)
1717
2320
  window.removeEventListener('keyup', onKeyUp)
2321
+ window.removeEventListener('pointerdown', onRightPointerDown, true)
2322
+ window.removeEventListener('pointerup', onRightPointerUp, true)
1718
2323
  window.removeEventListener('contextmenu', onContextMenu)
1719
2324
  }
1720
2325
  }, [
@@ -1749,6 +2354,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1749
2354
  const draft = draftNode.current
1750
2355
  if (!(draft && viewerLevelId) || asset.attachTo) return
1751
2356
  if (draft.parentId === viewerLevelId) return
2357
+ // A non-attach item resting on a host surface (table / counter / shelf) is
2358
+ // intentionally parented to that host while it's moved — the surface move
2359
+ // handlers keep it hosted and the commit writes the host parent back. Only
2360
+ // free floor items get re-homed to the level here; yanking a hosted item
2361
+ // onto the level would re-interpret its host-local position in level space
2362
+ // and float the dragged mesh off the host toward the building origin.
2363
+ const draftParent = draft.parentId
2364
+ ? useScene.getState().nodes[draft.parentId as AnyNodeId]
2365
+ : undefined
2366
+ if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
1752
2367
  draft.parentId = viewerLevelId
1753
2368
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
1754
2369
  }, [viewerLevelId, draftNode, asset])
@@ -1784,7 +2399,74 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1784
2399
  // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
1785
2400
  useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
1786
2401
 
1787
- useFrame((_, delta) => {
2402
+ // Publish the ghost's surface (contact point + normal) so the grid's snap
2403
+ // patch sits at the item's resolved height (e.g. a shelf top) and orients to
2404
+ // the surface (vertical in a wall plane), AND publish the forward-facing
2405
+ // triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
2406
+ // instead of drawing an inline triangle. Only this coordinator publishes — a
2407
+ // moving existing node has no draft here, so the grid reads that case straight
2408
+ // off the node's mesh. Cleared when idle.
2409
+ const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2410
+ const facingForwardRef = useRef(new Vector3(0, 0, 1))
2411
+ const facingQuatRef = useRef(new Quaternion())
2412
+ useFrame(() => {
2413
+ const ghost = cursorGroupRef.current
2414
+ if (!(asset && ghost)) {
2415
+ clearPlacementSurface()
2416
+ useFacingPose.getState().clear()
2417
+ return
2418
+ }
2419
+ const surf = placementState.current.surface
2420
+ const n = surfaceNormalRef.current
2421
+ const shape = facingShapeRef.current
2422
+ // Triangle yaw / Y default to the floor case: the cursor group's own yaw is
2423
+ // the item's forward on the floor, and the triangle rides at the ghost's Y.
2424
+ let facingYaw = ghost.rotation.y
2425
+ let facingY = ghost.position.y
2426
+ if (surf === 'wall' || surf === 'roof-wall') {
2427
+ // Wall/roof-segment faces: the cursor group's yaw is the symmetric
2428
+ // wireframe yaw (π off the real facing for a wall, and a different frame
2429
+ // for a roof face), so derive the item's TRUE outward facing from the
2430
+ // draft mesh's world orientation — its local +Z faces out of the host
2431
+ // surface. This keeps BOTH the grid normal and the triangle correct for
2432
+ // wall and roof-segment hosts alike, rather than the old quaternion read
2433
+ // 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
+ }
2441
+ // 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))
2445
+ } else {
2446
+ n.set(0, 1, 0)
2447
+ }
2448
+ publishPlacementSurface(ghost.position, n)
2449
+
2450
+ if (shape.depth > 0) {
2451
+ useFacingPose.getState().set({
2452
+ position: [ghost.position.x, facingY, ghost.position.z],
2453
+ rotationY: facingYaw,
2454
+ depth: shape.depth,
2455
+ center: shape.center,
2456
+ })
2457
+ } else {
2458
+ useFacingPose.getState().clear()
2459
+ }
2460
+ })
2461
+ useEffect(
2462
+ () => () => {
2463
+ clearPlacementSurface()
2464
+ useFacingPose.getState().clear()
2465
+ },
2466
+ [],
2467
+ )
2468
+
2469
+ useFrame(() => {
1788
2470
  if (!asset) {
1789
2471
  reconcileDraftRaycast(null)
1790
2472
  return
@@ -1815,12 +2497,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1815
2497
  mesh.visible = true
1816
2498
 
1817
2499
  if (placementState.current.surface === 'floor') {
1818
- const distance = mesh.position.distanceToSquared(gridPosition.current)
1819
- if (distance > 1) {
1820
- mesh.position.copy(gridPosition.current)
1821
- } else {
1822
- mesh.position.lerp(gridPosition.current, delta * 20)
1823
- }
2500
+ // Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
2501
+ // active move visibly trail the cursor and — combined with React
2502
+ // re-renders momentarily pulling the mesh back toward its committed
2503
+ // position — read as a laggy snap-back on every move. Copying each frame
2504
+ // locks placement/move to the cursor and overrides any stray reset
2505
+ // within a single frame, so it feels precise instead of dragging.
2506
+ mesh.position.copy(gridPosition.current)
1824
2507
 
1825
2508
  // Adjust Y for slab elevation (floor items on top of slabs)
1826
2509
  if (!asset.attachTo) {
@@ -1841,7 +2524,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1841
2524
  ? getScaledDimensions(initialDraft)
1842
2525
  : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
1843
2526
  const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
1844
- const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
2527
+ const wallSideZOffset = initialAttachTo === 'wall-side' ? dims[2] / 2 : 0
1845
2528
  const initialDimensionBounds = expandBoundsToGrid(
1846
2529
  getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
1847
2530
  initialAttachTo,
@@ -1869,9 +2552,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1869
2552
  const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
1870
2553
  const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
1871
2554
  const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
1872
- const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
1873
- const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
1874
- const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
2555
+ // Feed the footprint shape to the per-frame surface publisher, which orients
2556
+ // and positions the forward-facing triangle via `useFacingPose`.
2557
+ facingShapeRef.current = {
2558
+ depth: currentDimensionBounds.dimensions[2],
2559
+ center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
2560
+ }
2561
+ const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
2562
+ const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
2563
+ const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
1875
2564
  const widthLabelPosition: [number, number, number] = [
1876
2565
  currentDimensionBounds.center[0],
1877
2566
  0.04,
@@ -1887,7 +2576,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1887
2576
  currentDimensionBounds.dimensions[1] / 2,
1888
2577
  currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
1889
2578
  ]
1890
-
1891
2579
  const measurementContent = (
1892
2580
  <>
1893
2581
  <lineSegments