@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,14 +1,19 @@
1
- import type { AssetInput } from '@pascal-app/core'
1
+ import type { AssetInput, ItemNode } from '@pascal-app/core'
2
2
  import {
3
+ type AlignmentAnchor,
4
+ type AnyNode,
3
5
  type AnyNodeId,
4
6
  type CeilingEvent,
7
+ collectAlignmentAnchors,
5
8
  emitter,
6
9
  type GridEvent,
7
10
  getScaledDimensions,
8
11
  type ItemEvent,
12
+ movingFootprintAnchors,
13
+ type RoofEvent,
9
14
  resolveLevelId,
15
+ type ShelfEvent,
10
16
  sceneRegistry,
11
- spatialGridManager,
12
17
  useLiveTransforms,
13
18
  useScene,
14
19
  useSpatialQuery,
@@ -17,30 +22,61 @@ import {
17
22
  } from '@pascal-app/core'
18
23
  import { useViewer } from '@pascal-app/viewer'
19
24
  import { Html } from '@react-three/drei'
20
- import { useFrame } from '@react-three/fiber'
21
- import { useEffect, useMemo, useRef, useState } from 'react'
25
+ import { useFrame, useThree } from '@react-three/fiber'
26
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
27
  import {
23
- BufferGeometry,
28
+ Box3,
24
29
  Euler,
25
- Float32BufferAttribute,
26
30
  type Group,
27
31
  type LineSegments,
32
+ Matrix4,
28
33
  type Mesh,
34
+ type Object3D,
29
35
  PlaneGeometry,
30
36
  Quaternion,
37
+ Ray,
31
38
  Vector3,
32
39
  } from 'three'
33
40
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
34
41
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
42
+ import {
43
+ clearPlacementSurface,
44
+ publishPlacementSurface,
45
+ } from '../../../lib/active-placement-surface'
35
46
  import { EDITOR_LAYER } from '../../../lib/constants'
47
+ import { formatLinearMeasurement } from '../../../lib/measurements'
36
48
  import { sfxEmitter } from '../../../lib/sfx-bus'
37
- import useEditor from '../../../store/use-editor'
38
- import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
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'
58
+ import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
59
+ import {
60
+ createLineGeometry,
61
+ getBoxEdgePoints,
62
+ type PreviewBounds,
63
+ updateLineGeometry,
64
+ } from '../shared/placement-box-geometry'
65
+ import {
66
+ getDetachedAttachmentPreviewLift,
67
+ getGridAlignedDimensions,
68
+ snapToGrid,
69
+ snapToHalf,
70
+ snapUpToGridStep,
71
+ steppedRotation,
72
+ } from './placement-math'
39
73
  import {
40
74
  ceilingStrategy,
41
75
  checkCanPlace,
42
76
  floorStrategy,
43
77
  itemSurfaceStrategy,
78
+ roofWallStrategy,
79
+ shelfSurfaceStrategy,
44
80
  wallStrategy,
45
81
  } from './placement-strategies'
46
82
  import type { PlacementState, TransitionResult } from './placement-types'
@@ -48,30 +84,24 @@ import type { DraftNodeHandle } from './use-draft-node'
48
84
 
49
85
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
50
86
 
51
- function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
52
- if (unit === 'imperial') {
53
- const feet = value * 3.280_84
54
- const wholeFeet = Math.floor(feet)
55
- const inches = Math.round((feet - wholeFeet) * 12)
56
- if (inches === 12) return `${wholeFeet + 1}'0"`
57
- return `${wholeFeet}'${inches}"`
58
- }
59
- return `${Number.parseFloat(value.toFixed(2))}m`
60
- }
87
+ /** Figma-style alignment-snap threshold (meters), matching the 2D
88
+ * floor-plan overlay and the 3D registry move tool. */
89
+ const ALIGNMENT_THRESHOLD_M = 0.08
61
90
 
62
- type PreviewBounds = {
63
- min: [number, number, number]
64
- max: [number, number, number]
65
- dimensions: [number, number, number]
66
- center: [number, number, number]
67
- }
91
+ /** Right-click cancels an active placement — but the right button also orbits
92
+ * the camera (CameraControls ROTATE). Only a quick, near-stationary right
93
+ * press/release counts as a cancel; anything that moves past the pixel
94
+ * threshold or is held longer is treated as a camera orbit and left alone. */
95
+ const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
96
+ const RIGHT_CLICK_CANCEL_MAX_MS = 200
68
97
 
69
98
  /**
70
99
  * Expand `bounds` outward so each axis is rounded up to the active grid step.
71
100
  * The wireframe stays centered on the original bounds centre on each axis we
72
101
  * expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
73
- * `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
74
- * so the box still sits on the floor / attachment plane.
102
+ * `min.z = 0` (the mounted face flush with the wall plane) and extend into the
103
+ * room along +Z matching the body and the 2D footprint; the bottom (`min.y`)
104
+ * is preserved so the box still sits on the floor / attachment plane.
75
105
  *
76
106
  * Floor / ceiling / item-surface: X and Z expand; Y stays exact.
77
107
  * Wall / wall-side: X and Y expand; Z stays exact.
@@ -97,9 +127,9 @@ function expandBoundsToGrid(
97
127
  let maxZ: number
98
128
  let newCz: number
99
129
  if (attachTo === 'wall-side') {
100
- maxZ = 0
101
- minZ = -expandedD
102
- newCz = -expandedD / 2
130
+ minZ = 0
131
+ maxZ = expandedD
132
+ newCz = expandedD / 2
103
133
  } else {
104
134
  minZ = cz - expandedD / 2
105
135
  maxZ = cz + expandedD / 2
@@ -116,124 +146,32 @@ function expandBoundsToGrid(
116
146
 
117
147
  function getFallbackPreviewBounds(
118
148
  item: import('@pascal-app/core').ItemNode | null,
119
- asset: AssetInput,
120
- attachTo: AssetInput['attachTo'],
149
+ asset: AssetInput | null | undefined,
150
+ attachTo: AssetInput['attachTo'] | null | undefined,
121
151
  ): PreviewBounds {
122
- const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
152
+ const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
123
153
  return {
124
- min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
125
- max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
154
+ min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
155
+ max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
126
156
  dimensions: dims,
127
- center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
157
+ center: [0, dims[1] / 2, attachTo === 'wall-side' ? dims[2] / 2 : 0],
128
158
  }
129
159
  }
130
160
 
131
- function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
132
- const geometry = new BufferGeometry()
133
- geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
134
- return geometry
135
- }
136
-
137
- function getBoxEdgePoints(bounds: PreviewBounds): number[] {
138
- const [width, height, depth] = bounds.dimensions
139
- const [centerX, centerY, centerZ] = bounds.center
140
- const minX = centerX - width / 2
141
- const maxX = centerX + width / 2
142
- const minY = centerY - height / 2
143
- const maxY = centerY + height / 2
144
- const minZ = centerZ - depth / 2
145
- const maxZ = centerZ + depth / 2
146
-
147
- return [
148
- minX,
149
- minY,
150
- minZ,
151
- maxX,
152
- minY,
153
- minZ,
154
- maxX,
155
- minY,
156
- minZ,
157
- maxX,
158
- minY,
159
- maxZ,
160
- maxX,
161
- minY,
162
- maxZ,
163
- minX,
164
- minY,
165
- maxZ,
166
- minX,
167
- minY,
168
- maxZ,
169
- minX,
170
- minY,
171
- minZ,
172
-
173
- minX,
174
- maxY,
175
- minZ,
176
- maxX,
177
- maxY,
178
- minZ,
179
- maxX,
180
- maxY,
181
- minZ,
182
- maxX,
183
- maxY,
184
- maxZ,
185
- maxX,
186
- maxY,
187
- maxZ,
188
- minX,
189
- maxY,
190
- maxZ,
191
- minX,
192
- maxY,
193
- maxZ,
194
- minX,
195
- maxY,
196
- minZ,
197
-
198
- minX,
199
- minY,
200
- minZ,
201
- minX,
202
- maxY,
203
- minZ,
204
- maxX,
205
- minY,
206
- minZ,
207
- maxX,
208
- maxY,
209
- minZ,
210
- maxX,
211
- minY,
212
- maxZ,
213
- maxX,
214
- maxY,
215
- maxZ,
216
- minX,
217
- minY,
218
- maxZ,
219
- minX,
220
- maxY,
221
- maxZ,
222
- ]
223
- }
161
+ function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
162
+ const scaled = getScaledDimensions(item)
163
+ const aligned = getGridAlignedDimensions(scaled, item.asset.attachTo)
164
+ if (scaled[0] === aligned[0] && scaled[1] === aligned[1] && scaled[2] === aligned[2]) {
165
+ return item
166
+ }
224
167
 
225
- function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
226
- const geometry = ref.current?.geometry
227
- if (!geometry) return
168
+ const scaleAxis = (axis: 0 | 1 | 2) =>
169
+ scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis])
228
170
 
229
- const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
230
- if (!attribute || attribute.array.length !== points.length) {
231
- geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
232
- } else {
233
- attribute.set(points)
234
- attribute.needsUpdate = true
171
+ return {
172
+ ...item,
173
+ scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number],
235
174
  }
236
- geometry.computeBoundingSphere()
237
175
  }
238
176
 
239
177
  // Shared materials for placement cursor - we just change colors, not swap materials
@@ -274,6 +212,13 @@ export interface PlacementCoordinatorConfig {
274
212
  initialState?: PlacementState
275
213
  /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
276
214
  defaultScale?: [number, number, number]
215
+ /** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling
216
+ * duplicates) so the duplicate keeps its materials. */
217
+ slots?: ItemNode['slots']
218
+ /** Move-mode sessions keep the grabbed item offset from the first surface hit
219
+ * (floor / wall / ceiling / item-surface / shelf) instead of snapping the
220
+ * item's origin under the cursor. */
221
+ preserveDragOffset?: boolean
277
222
  }
278
223
 
279
224
  export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
@@ -285,13 +230,53 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
285
230
  const basePlaneRef = useRef<Mesh>(null!)
286
231
  const gridPosition = useRef(new Vector3(0, 0, 0))
287
232
  const lastRawPos = useRef(new Vector3(0, 0, 0))
233
+ const lastWallDirtyAtRef = useRef(new Map<string, number>())
288
234
  const placementState = useRef<PlacementState>(
289
- config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
235
+ config.initialState ?? {
236
+ surface: 'floor',
237
+ wallId: null,
238
+ roofSegmentId: null,
239
+ ceilingId: null,
240
+ surfaceItemId: null,
241
+ shelfId: null,
242
+ },
290
243
  )
291
- const shiftFreeRef = useRef(false)
244
+ const altFreeRef = useRef(false)
292
245
  const previewBoundsSignatureRef = useRef<string | null>(null)
246
+ // Footprint shape (depth along local +Z and [x, z] centre) of the current
247
+ // preview box, mirrored from the rendered dimension bounds so the per-frame
248
+ // surface publisher can position the forward-facing triangle without reading
249
+ // React state. Updated in the render body below.
250
+ const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
251
+ depth: 0,
252
+ center: [0, 0],
253
+ })
254
+ // Goes true the first time a 3D pointer event drives this coordinator.
255
+ // The per-frame mesh-position lerp below is only useful for that path;
256
+ // when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
257
+ // writing scene.position directly), the lerp fights React's render and
258
+ // pulls the rendered item back toward its pre-move position. Gating
259
+ // the lerp on this flag keeps 3D placement smooth without hijacking
260
+ // 2D drags that share the same draft.
261
+ const has3DPointerDrivenMoveRef = useRef(false)
262
+ // The draft mesh's raycast is disabled while placing so the cursor ray
263
+ // passes through it to the surface beneath (grid / item / shelf). Without
264
+ // this, the ray hits the moving draft first and the surface strategy keeps
265
+ // re-deriving the host point from the draft's own (just-moved) geometry —
266
+ // on a multi-row shelf this oscillates the chosen row, jittering the item.
267
+ // Mirrors MoveRegistryNodeTool. Reconciled per-frame (the draft mesh can be
268
+ // recreated mid-session) and restored on unmount.
269
+ const raycastDisabledMeshRef = useRef<Object3D | null>(null)
270
+ const restoreRaycastsRef = useRef<Array<() => void>>([])
271
+ const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
293
272
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
294
273
 
274
+ // Live camera ref — the shelf-stickiness test reconstructs the cursor world
275
+ // ray (camera → grid hit) to check it still points at the shelf volume.
276
+ const camera = useThree((s) => s.camera)
277
+ const cameraRef = useRef(camera)
278
+ cameraRef.current = camera
279
+
295
280
  // Store config callbacks in refs to avoid re-running effect when they change
296
281
  const configRef = useRef(config)
297
282
  configRef.current = config
@@ -300,7 +285,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
300
285
  const { asset, draftNode } = config
301
286
  const unit = useViewer((state) => state.unit)
302
287
  const gridSnapStep = useEditor((s) => s.gridSnapStep)
303
- const updatePreviewGeometry = (bounds: PreviewBounds) => {
288
+ const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
304
289
  const [width, height, depth] = bounds.dimensions
305
290
  const [centerX, centerY, centerZ] = bounds.center
306
291
  const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
@@ -317,9 +302,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
317
302
  const oldBasePlaneGeometry = basePlaneRef.current.geometry
318
303
  basePlaneRef.current.geometry = nextBasePlaneGeometry
319
304
  oldBasePlaneGeometry.dispose()
320
- }
305
+ }, [])
321
306
 
322
- const updateDimensionGuides = (bounds: PreviewBounds) => {
307
+ const updateDimensionGuides = useCallback((bounds: PreviewBounds) => {
323
308
  setDimensionBounds((current) => {
324
309
  if (
325
310
  current &&
@@ -421,7 +406,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
421
406
  applyPoints(measurementWidthRef, widthPoints)
422
407
  applyPoints(measurementDepthRef, depthPoints)
423
408
  applyPoints(measurementHeightRef, heightPoints)
424
- }
409
+ }, [])
410
+
411
+ const getFloorVisualPosition = useCallback(
412
+ (
413
+ position: [number, number, number],
414
+ nodeUpdate?: Partial<ItemNode>,
415
+ ): [number, number, number] => {
416
+ const draft = draftNode.current
417
+ if (!(draft && !asset?.attachTo)) return position
418
+ const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode)
419
+ return getFloorStackPreviewPosition({
420
+ node: previewNode,
421
+ position,
422
+ rotation: previewNode.rotation,
423
+ })
424
+ },
425
+ [asset?.attachTo, draftNode],
426
+ )
425
427
 
426
428
  useEffect(() => {
427
429
  if (!asset) return
@@ -429,16 +431,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
429
431
 
430
432
  const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
431
433
 
434
+ // Lazily-gathered alignment candidates — the corner anchors of every
435
+ // OTHER floor-placed node, excluding the draft. Computed on the first
436
+ // floor move (once the draft id exists) and reused for the rest of the
437
+ // drag; the scene graph is stable during placement. Coords are
438
+ // building-local, matching the draft's grid position and the guide
439
+ // layer's frame.
440
+ let alignmentCandidates: AlignmentAnchor[] | null = null
441
+ let floorDragAnchor: [number, number] | null = null
442
+
432
443
  // Reset placement state
433
444
  placementState.current = configRef.current.initialState ?? {
434
445
  surface: 'floor',
435
446
  wallId: null,
447
+ roofSegmentId: null,
436
448
  ceilingId: null,
437
449
  surfaceItemId: null,
450
+ shelfId: null,
438
451
  }
439
452
  if (!asset.attachTo && placementState.current.surface === 'floor') {
440
453
  gridPosition.current.y = 0
441
- cursorGroupRef.current.position.y = 0
454
+ if (cursorGroupRef.current) {
455
+ cursorGroupRef.current.position.y = 0
456
+ }
442
457
  }
443
458
 
444
459
  // ---- Helpers ----
@@ -449,11 +464,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
449
464
  draftItem: draftNode.current,
450
465
  gridPosition: gridPosition.current,
451
466
  state: { ...placementState.current },
452
- currentCursorRotationY: cursorGroupRef.current.rotation.y,
467
+ currentCursorRotationY:
468
+ cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
453
469
  })
454
470
 
455
471
  const getActiveValidators = () =>
456
- shiftFreeRef.current
472
+ altFreeRef.current
457
473
  ? {
458
474
  canPlaceOnFloor: () => ({ valid: true }),
459
475
  canPlaceOnWall: () => ({ valid: true }),
@@ -461,8 +477,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
461
477
  }
462
478
  : validators
463
479
 
480
+ const finishCommittedPlacement = (
481
+ committedId: string | null,
482
+ wasAdopted: boolean,
483
+ repeat: () => void,
484
+ ) => {
485
+ if (configRef.current.onCommitted()) {
486
+ repeat()
487
+ return
488
+ }
489
+
490
+ useAlignmentGuides.getState().clear()
491
+ useScene.temporal.getState().resume()
492
+ if (committedId) {
493
+ useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
494
+ }
495
+ if (!wasAdopted) {
496
+ useEditor.getState().setTool(null)
497
+ }
498
+ // A non-repeating placement is finished: return to select mode so the user
499
+ // lands on the just-placed (now selected) node instead of a tool-less build
500
+ // limbo. Repeat placements took the early return above and stay armed.
501
+ useEditor.getState().setMode('select')
502
+ }
503
+
464
504
  const revalidate = (): boolean => {
465
- const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
505
+ const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
466
506
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
467
507
  edgeMaterial.color.setHex(color)
468
508
  basePlaneMaterial.color.setHex(color)
@@ -478,13 +518,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
478
518
  return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
479
519
  }
480
520
 
521
+ const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
522
+ const buildingId = useViewer.getState().selection.buildingId
523
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
524
+ return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
525
+ }
526
+
481
527
  const applyTransition = (result: TransitionResult) => {
528
+ // Alignment guides are floor-only; clear them when the cursor moves
529
+ // onto a wall / ceiling / item surface (only those paths call this).
530
+ useAlignmentGuides.getState().clear()
531
+ // Roof faces carry no grab anchor, but landing on one still counts as
532
+ // anchoring elsewhere — a later return to the grabbed wall must center
533
+ // under the cursor, not restore the stale grab offset.
534
+ if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
482
535
  Object.assign(placementState.current, result.stateUpdate)
483
536
  gridPosition.current.set(...result.gridPosition)
484
537
 
485
538
  const c = worldToBuildingLocal(...result.cursorPosition)
486
- cursorGroupRef.current.position.set(c.x, c.y, c.z)
487
- cursorGroupRef.current.rotation.y = result.cursorRotationY
539
+ if (cursorGroupRef.current) {
540
+ cursorGroupRef.current.position.set(c.x, c.y, c.z)
541
+ if (result.cursorRotation) {
542
+ cursorGroupRef.current.rotation.set(...result.cursorRotation)
543
+ } else {
544
+ cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
545
+ }
546
+ }
488
547
 
489
548
  const draft = draftNode.current
490
549
  if (draft) {
@@ -497,14 +556,27 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
497
556
  const ensureDraft = (result: TransitionResult) => {
498
557
  gridPosition.current.set(...result.gridPosition)
499
558
  const c = worldToBuildingLocal(...result.cursorPosition)
500
- cursorGroupRef.current.position.set(c.x, c.y, c.z)
501
- cursorGroupRef.current.rotation.y = result.cursorRotationY
559
+ if (cursorGroupRef.current) {
560
+ cursorGroupRef.current.position.set(c.x, c.y, c.z)
561
+ if (result.cursorRotation) {
562
+ cursorGroupRef.current.rotation.set(...result.cursorRotation)
563
+ } else {
564
+ cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
565
+ }
566
+ }
567
+
568
+ const initRotation: [number, number, number] = result.cursorRotation ?? [
569
+ 0,
570
+ result.cursorRotationY,
571
+ 0,
572
+ ]
502
573
 
503
574
  draftNode.create(
504
575
  gridPosition.current,
505
576
  asset,
506
- [0, result.cursorRotationY, 0],
577
+ initRotation,
507
578
  configRef.current.defaultScale,
579
+ configRef.current.slots,
508
580
  )
509
581
 
510
582
  const draft = draftNode.current
@@ -514,6 +586,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
514
586
  useScene.getState().updateNode(draft.id, result.nodeUpdate)
515
587
  }
516
588
 
589
+ const previewBounds = expandBoundsToGrid(
590
+ getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
591
+ asset.attachTo,
592
+ gridSnapStep,
593
+ )
594
+ updatePreviewGeometry(previewBounds)
595
+ updateDimensionGuides(previewBounds)
596
+
517
597
  if (!revalidate()) {
518
598
  draftNode.destroy()
519
599
  }
@@ -521,6 +601,134 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
521
601
 
522
602
  // ---- Init draft ----
523
603
  configRef.current.initDraft(gridPosition.current)
604
+ const preserveDragOffset = configRef.current.preserveDragOffset === true
605
+ // The host the item was grabbed from + its pre-drag host-local position.
606
+ // Each surface's grab anchor preserves the grab offset only on THAT host,
607
+ // and only until the item anchors on ANY other host (another wall/ceiling/
608
+ // shelf, a roof face, or the floor after a host visit) — `grabForgotten`
609
+ // then latches and every host, the original included, centers the item
610
+ // under the cursor. Merely passing through empty space (wall/ceiling items
611
+ // hide between surfaces) does NOT forget the grab.
612
+ const grabWallId =
613
+ placementState.current.surface === 'wall' ? placementState.current.wallId : null
614
+ const grabCeilingId =
615
+ placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null
616
+ const grabSurfaceHostId =
617
+ placementState.current.surface === 'item-surface'
618
+ ? placementState.current.surfaceItemId
619
+ : placementState.current.surface === 'shelf-surface'
620
+ ? placementState.current.shelfId
621
+ : null
622
+ const grabStartPosition: [number, number, number] | null = draftNode.current
623
+ ? [
624
+ draftNode.current.position[0],
625
+ draftNode.current.position[1],
626
+ draftNode.current.position[2],
627
+ ]
628
+ : null
629
+ let grabForgotten = grabStartPosition === null
630
+ // Anchoring on `hostId`: returns whether the grab offset still applies
631
+ // there, and latches `grabForgotten` the first time it doesn't.
632
+ const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => {
633
+ const preserve = !grabForgotten && hostId !== null && hostId === grabHostId
634
+ if (!preserve) grabForgotten = true
635
+ return preserve
636
+ }
637
+ // Nulled when the item returns to the floor FROM a host (see
638
+ // `detachItemSurfaceToFloor`): the floor grab offset only survives until
639
+ // the item anchors elsewhere, like every other surface's grab anchor.
640
+ let relativeFloorStart =
641
+ preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
642
+ ? gridPosition.current.clone()
643
+ : null
644
+
645
+ // Grab anchors for the non-floor surfaces. Each captures the cursor's
646
+ // surface-local position and the item's stored position on the first move
647
+ // for a given host, then offsets every later move by
648
+ // `start + (raw - anchor)` — mirroring the floor path and the door/window
649
+ // move tools so the item tracks the grabbed point instead of teleporting
650
+ // its origin under the cursor. Reset on host change (re-seeded from the
651
+ // item's then-current position) by the surface leave handlers.
652
+ let wallDragAnchor: {
653
+ wallId: string
654
+ rawX: number
655
+ rawY: number
656
+ startX: number
657
+ startY: number
658
+ } | null = null
659
+ let ceilingDragAnchor: {
660
+ ceilingId: string
661
+ rawX: number
662
+ rawZ: number
663
+ startX: number
664
+ startZ: number
665
+ } | null = null
666
+ let hostSurfaceDragAnchor: {
667
+ hostId: string
668
+ rawX: number
669
+ rawZ: number
670
+ startX: number
671
+ startZ: number
672
+ } | null = null
673
+
674
+ // Item-surface / shelf moves snap from a WORLD cursor hit projected into the
675
+ // host's local frame. Re-project the offset-corrected local point back to
676
+ // world so the strategy (which re-derives both the stored position and the
677
+ // visual cursor from `event.position`) stays self-consistent.
678
+ const resolveHostSurfaceWorld = (
679
+ hostId: string,
680
+ worldPos: readonly [number, number, number],
681
+ ): [number, number, number] | null => {
682
+ const draft = draftNode.current
683
+ const hostMesh = sceneRegistry.nodes.get(hostId)
684
+ if (!(preserveDragOffset && draft && hostMesh)) return null
685
+ const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
686
+ if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
687
+ // Grab offset survives only on the host the item was grabbed from and
688
+ // only until it anchors elsewhere — any other shelf/table centers the
689
+ // item under the cursor (same rule as the wall grab anchor).
690
+ const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId)
691
+ hostSurfaceDragAnchor = {
692
+ hostId,
693
+ rawX: rawLocal.x,
694
+ rawZ: rawLocal.z,
695
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
696
+ startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
697
+ }
698
+ }
699
+ const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
700
+ const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
701
+ const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
702
+ return [world.x, world.y, world.z]
703
+ }
704
+
705
+ // Floor grab-offset: the item tracks the grabbed point instead of snapping
706
+ // its origin under the cursor. The offset is computed in building-local space
707
+ // (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
708
+ // (`event.position`), so the corrected local point is re-projected to a
709
+ // corrected world point and both frames carry the offset to stay consistent.
710
+ const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
711
+ if (relativeFloorStart === null) return event
712
+ const rawX = event.localPosition[0]
713
+ const rawZ = event.localPosition[2]
714
+ const anchor = floorDragAnchor ?? [rawX, rawZ]
715
+ floorDragAnchor = anchor
716
+ const correctedLocal: [number, number, number] = [
717
+ relativeFloorStart.x + (rawX - anchor[0]),
718
+ event.localPosition[1],
719
+ relativeFloorStart.z + (rawZ - anchor[1]),
720
+ ]
721
+ const correctedWorld = buildingLocalToWorld(
722
+ correctedLocal[0],
723
+ correctedLocal[1],
724
+ correctedLocal[2],
725
+ )
726
+ return {
727
+ ...event,
728
+ position: [correctedWorld.x, event.position[1], correctedWorld.z],
729
+ localPosition: correctedLocal,
730
+ }
731
+ }
524
732
 
525
733
  // Sync cursor to the draft mesh's world position and rotation
526
734
  if (draftNode.current) {
@@ -529,12 +737,28 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
529
737
  const worldPos = new Vector3()
530
738
  mesh.getWorldPosition(worldPos)
531
739
  const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
532
- cursorGroupRef.current.position.copy(localPos)
533
- // Extract world Y rotation (handles wall-parented items correctly)
534
- const q = new Quaternion()
535
- mesh.getWorldQuaternion(q)
536
- cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
537
- } else {
740
+ if (cursorGroupRef.current) {
741
+ cursorGroupRef.current.position.copy(localPos)
742
+ if (
743
+ draftNode.current.asset.attachTo ||
744
+ placementState.current?.surface === 'item-surface'
745
+ ) {
746
+ // Wall/ceiling items AND items hosted on another item: the mesh is parented
747
+ // to a rotated host, so the box's building-local yaw must come from the mesh's
748
+ // world rotation, not the node's host-local `rotation[1]` (which would leave the
749
+ // box rotated by the host's yaw relative to the item).
750
+ const q = new Quaternion()
751
+ mesh.getWorldQuaternion(q)
752
+ cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
753
+ } else {
754
+ // Floor items: the cursor group lives in building-local space, so use the
755
+ // node's local Y rotation — the same value onGridMove applies. The world
756
+ // quaternion would double-count any building rotation, leaving the initial
757
+ // box mis-rotated until the first cursor move.
758
+ cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
759
+ }
760
+ }
761
+ } else if (cursorGroupRef.current) {
538
762
  cursorGroupRef.current.position.copy(gridPosition.current)
539
763
  cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
540
764
  }
@@ -542,44 +766,185 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
542
766
 
543
767
  revalidate()
544
768
 
769
+ // ---- Press-drag commit-on-release ----
770
+ // When the move was engaged by the press-drag move cross (vs. click-to-
771
+ // place), commit on pointer-up instead of waiting for a click. Each surface
772
+ // move handler records how to commit at the current cursor; the release
773
+ // replays it. Captured once at setup — a fresh coordinator mounts per move.
774
+ const dragMode = useEditor.getState().placementDragMode
775
+ let releaseCommit: (() => void) | null = null
776
+ // Eat the click the browser fires after pointer-up so the surface
777
+ // `:click` handlers don't commit a second time.
778
+ const swallowNextClick = () => {
779
+ const swallow = (e: Event) => {
780
+ e.stopPropagation()
781
+ e.preventDefault()
782
+ }
783
+ window.addEventListener('click', swallow, { capture: true, once: true })
784
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
785
+ }
786
+ const onReleaseCommit = () => {
787
+ if (!releaseCommit) return
788
+ const commit = releaseCommit
789
+ releaseCommit = null
790
+ swallowNextClick()
791
+ commit()
792
+ }
793
+
545
794
  // ---- Floor Handlers ----
546
795
 
547
796
  let previousGridPos: [number, number, number] | null = null
548
797
 
798
+ // Scratch objects reused by the stickiness test (runs per grid:move).
799
+ const stickyRay = new Ray()
800
+ const stickyBox = new Box3()
801
+ const stickyMat = new Matrix4()
802
+ const stickyCamPos = new Vector3()
803
+
804
+ // True while the cursor ray still points at the active shelf's volume.
805
+ // Used to keep an item hosted on a shelf "sticky": from an angled camera
806
+ // the cursor ray slips off the shelf's thin boards / through its gaps and
807
+ // lands on the floor *behind* the shelf, which would otherwise thrash the
808
+ // placement between the shelf row and the floor on every micro-move. We
809
+ // reconstruct the world ray (camera → grid hit point) and test it against
810
+ // the shelf's bounding box — so a ray that passes *through* the shelf but
811
+ // lands behind it still counts as "on the shelf". Only a ray that misses
812
+ // the shelf box entirely means the user genuinely moved off it. A simple
813
+ // footprint test on the floor hit point can't distinguish those.
814
+ const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
815
+ const shelfId = placementState.current.shelfId
816
+ if (!shelfId) return false
817
+ const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
818
+ const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
819
+ | { width?: number; depth?: number; height?: number }
820
+ | undefined
821
+ if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
822
+
823
+ cameraRef.current.getWorldPosition(stickyCamPos)
824
+ stickyRay.origin.copy(stickyCamPos)
825
+ stickyRay.direction
826
+ .set(
827
+ gridWorldPoint[0] - stickyCamPos.x,
828
+ gridWorldPoint[1] - stickyCamPos.y,
829
+ gridWorldPoint[2] - stickyCamPos.z,
830
+ )
831
+ .normalize()
832
+
833
+ // Into shelf-local space, then test the shelf's local AABB (origin at the
834
+ // base: y ∈ [0, height]) with a small margin.
835
+ stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
836
+ const m = 0.08
837
+ stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
838
+ stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
839
+ return stickyRay.intersectsBox(stickyBox)
840
+ }
841
+
549
842
  const onGridMove = (event: GridEvent) => {
843
+ releaseCommit = () => onGridClick(event)
550
844
  // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
551
845
  if (draftNode.current === null && asset.attachTo === undefined) {
552
846
  configRef.current.initDraft(gridPosition.current)
553
847
  }
554
848
 
555
- lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
556
- const result = floorStrategy.move(getContext(), event)
849
+ has3DPointerDrivenMoveRef.current = true
850
+
851
+ // Shelf stickiness: while hosting on a shelf, ignore floor events while
852
+ // the cursor ray still points at the shelf volume (the ray merely slipped
853
+ // off a board / through a gap and hit the floor behind). Detach to the
854
+ // floor only once the ray misses the shelf entirely — without this the
855
+ // item oscillates between the shelf row and the floor on every micro-move.
856
+ if (placementState.current.surface === 'shelf-surface') {
857
+ if (cursorRayIntersectsActiveShelf(event.position)) return
858
+ detachItemSurfaceToFloor(event as unknown as ItemEvent)
859
+ }
860
+
861
+ const floorEvent = applyFloorGrabOffset(event)
862
+
863
+ lastRawPos.current.set(
864
+ floorEvent.localPosition[0],
865
+ floorEvent.localPosition[1],
866
+ floorEvent.localPosition[2],
867
+ )
868
+ if (!cursorGroupRef.current) return
869
+ const result = floorStrategy.move(getContext(), floorEvent)
557
870
  if (!result) return
558
871
 
872
+ // Figma-style alignment snap layered on top of the floor strategy's
873
+ // grid snap: when the draft's edge lines up (on X or Z) with another
874
+ // item's edge, snap and publish a guide. The guide connects to the
875
+ // nearest real corner of the candidate (resolver tie-break), so the dot
876
+ // always sits on an actual point. The delta is applied to BOTH the grid
877
+ // and cursor positions below. Alt is force-place only (it does NOT bypass
878
+ // snapping — 'off' mode is the no-snap bypass); the active snapping mode
879
+ // governs whether alignment runs at all ('off' / 'angles' disable
880
+ // magnetic alignment, 'lines' enables it, matching the wall/fence flow).
881
+ const draft = draftNode.current
882
+ let alignX = 0
883
+ let alignZ = 0
884
+ // Alignment "lines" are DISPLAYED in every snapping mode except Off
885
+ // (isAlignmentGuideActive); the magnetic pull toward them is applied only
886
+ // in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
887
+ // bypass.
888
+ if (isAlignmentGuideActive() && draft) {
889
+ alignmentCandidates ??= collectAlignmentAnchors(
890
+ useScene.getState().nodes,
891
+ draft.id,
892
+ useViewer.getState().selection.levelId,
893
+ )
894
+ const ar = resolveAlignmentForActiveBuilding({
895
+ moving: movingFootprintAnchors(
896
+ draft as unknown as AnyNode,
897
+ result.gridPosition[0],
898
+ result.gridPosition[2],
899
+ cursorGroupRef.current.rotation.y,
900
+ ),
901
+ candidates: alignmentCandidates,
902
+ threshold: ALIGNMENT_THRESHOLD_M,
903
+ })
904
+ if (ar.snap && isMagneticSnapActive()) {
905
+ alignX = ar.snap.dx
906
+ alignZ = ar.snap.dz
907
+ }
908
+ useAlignmentGuides
909
+ .getState()
910
+ .set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
911
+ } else {
912
+ useAlignmentGuides.getState().clear()
913
+ }
914
+
915
+ const gridPos: [number, number, number] = [
916
+ result.gridPosition[0] + alignX,
917
+ result.gridPosition[1],
918
+ result.gridPosition[2] + alignZ,
919
+ ]
920
+
559
921
  // Play snap sound when grid position changes
560
922
  if (
561
923
  previousGridPos &&
562
- (result.gridPosition[0] !== previousGridPos[0] ||
563
- result.gridPosition[2] !== previousGridPos[2])
924
+ (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
564
925
  ) {
565
926
  sfxEmitter.emit('sfx:grid-snap')
566
927
  }
567
928
 
568
- previousGridPos = [...result.gridPosition]
569
- gridPosition.current.set(...result.gridPosition)
570
- cursorGroupRef.current.position.set(
571
- result.cursorPosition[0],
572
- result.cursorPosition[1],
573
- result.cursorPosition[2],
574
- )
929
+ previousGridPos = [...gridPos]
930
+ gridPosition.current.set(...gridPos)
931
+ const cursorPosition = getFloorVisualPosition(gridPos)
932
+ if (!draft && asset.attachTo) {
933
+ cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
934
+ }
935
+ cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
936
+ // Floor items only rotate on Y; keep the preview box (and the live
937
+ // transform the 2D floorplan mirrors) aligned with the draft's
938
+ // rotation. Without this the box stays at its seed rotation until a
939
+ // manual R/T, so a moved already-rotated item shows an axis-aligned box.
940
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
575
941
 
576
- const draft = draftNode.current
577
- if (draft) draft.position = result.gridPosition
942
+ if (draft) draft.position = gridPos
578
943
 
579
944
  // Publish live transform for 2D floorplan
580
945
  if (draft) {
581
946
  useLiveTransforms.getState().set(draft.id, {
582
- position: result.gridPosition,
947
+ position: gridPos,
583
948
  rotation: cursorGroupRef.current.rotation.y,
584
949
  })
585
950
  }
@@ -588,27 +953,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
588
953
  }
589
954
 
590
955
  const onGridClick = (event: GridEvent) => {
956
+ // Drop alignment guides on click — the move commits (guides done) or
957
+ // placement re-arms (the next move republishes them).
958
+ useAlignmentGuides.getState().clear()
591
959
  const result = floorStrategy.click(getContext(), event, getActiveValidators())
592
960
  if (!result) return
593
961
 
594
962
  // Preserve cursor rotation for the next draft
595
- const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
963
+ const currentRotation: [number, number, number] = [
964
+ 0,
965
+ cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
966
+ 0,
967
+ ]
596
968
 
597
969
  // Clear live transform before commit
598
970
  if (draftNode.current) {
599
971
  useLiveTransforms.getState().clear(draftNode.current.id)
600
972
  }
601
973
 
602
- draftNode.commit(result.nodeUpdate)
603
- if (configRef.current.onCommitted()) {
604
- draftNode.create(gridPosition.current, asset, currentRotation)
974
+ const committedId = draftNode.current?.id ?? null
975
+ const wasAdopted = draftNode.isAdopted
976
+ const finalId = draftNode.commit(result.nodeUpdate)
977
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
978
+ draftNode.create(
979
+ gridPosition.current,
980
+ asset,
981
+ currentRotation,
982
+ configRef.current.defaultScale,
983
+ configRef.current.slots,
984
+ )
985
+ const previewBounds = expandBoundsToGrid(
986
+ getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
987
+ asset.attachTo,
988
+ gridSnapStep,
989
+ )
990
+ updatePreviewGeometry(previewBounds)
991
+ updateDimensionGuides(previewBounds)
605
992
  revalidate()
606
- }
993
+ })
607
994
  }
608
995
 
609
996
  // ---- Wall Handlers ----
610
997
 
611
998
  const onWallEnter = (event: WallEvent) => {
999
+ has3DPointerDrivenMoveRef.current = true
612
1000
  const nodes = useScene.getState().nodes
613
1001
  const result = wallStrategy.enter(
614
1002
  getContext(),
@@ -634,6 +1022,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
634
1022
  }
635
1023
 
636
1024
  const onWallMove = (event: WallEvent) => {
1025
+ releaseCommit = () => onWallClick(event)
1026
+ has3DPointerDrivenMoveRef.current = true
1027
+ if (!cursorGroupRef.current) return
637
1028
  const ctx = getContext()
638
1029
 
639
1030
  if (ctx.state.surface !== 'wall') {
@@ -674,7 +1065,46 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
674
1065
  return
675
1066
  }
676
1067
 
677
- const result = wallStrategy.move(ctx, event, getActiveValidators())
1068
+ let wallMoveEvent = event
1069
+ if (preserveDragOffset && draftNode.current) {
1070
+ const rawX = event.localPosition[0]
1071
+ const rawY = event.localPosition[1]
1072
+ if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
1073
+ // Preserve the grab offset only on the wall the item was grabbed
1074
+ // from (no teleport under the pointer at grab time). Any OTHER host
1075
+ // centers the item under the cursor — a host change is already a
1076
+ // jump, so tracking the pointer exactly is the expected feel, while
1077
+ // re-seeding from the carried-over position (the old behavior)
1078
+ // baked an arbitrary along-wall offset into the whole stay on that
1079
+ // wall. Once anchored elsewhere the grab is forgotten for good, so
1080
+ // re-entering the original wall centers under the cursor too.
1081
+ const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
1082
+ wallDragAnchor = {
1083
+ wallId: event.node.id,
1084
+ rawX,
1085
+ rawY,
1086
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
1087
+ startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
1088
+ }
1089
+ }
1090
+ const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
1091
+ const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
1092
+ const wallMesh = sceneRegistry.nodes.get(event.node.id)
1093
+ // Derive the world cursor from the corrected wall-local point so the
1094
+ // visual cursor (world) and the stored position (wall-local) agree; if
1095
+ // the wall mesh is somehow absent, keep the raw world hit unchanged.
1096
+ const correctedWorld = wallMesh
1097
+ ? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
1098
+ : null
1099
+ wallMoveEvent = {
1100
+ ...event,
1101
+ localPosition: [correctedX, correctedY, event.localPosition[2]],
1102
+ position: correctedWorld
1103
+ ? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
1104
+ : event.position,
1105
+ }
1106
+ }
1107
+ const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
678
1108
  if (!result) return
679
1109
 
680
1110
  event.stopPropagation()
@@ -722,13 +1152,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
722
1152
  }
723
1153
  // Mark parent wall dirty so it rebuilds geometry — only when position changed
724
1154
  if (result.dirtyNodeId && posChanged) {
725
- useScene.getState().dirtyNodes.add(result.dirtyNodeId)
1155
+ const now = globalThis.performance?.now?.() ?? Date.now()
1156
+ const last = lastWallDirtyAtRef.current.get(result.dirtyNodeId) ?? 0
1157
+ // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
1158
+ if (now - last > 120) {
1159
+ lastWallDirtyAtRef.current.set(result.dirtyNodeId, now)
1160
+ useScene.getState().dirtyNodes.add(result.dirtyNodeId)
1161
+ }
726
1162
  }
727
1163
 
728
- // Publish live transform for 2D floorplan
1164
+ // Publish live transform for the 2D floorplan. The floorplan resolves a
1165
+ // wall item's footprint (and its wall-side depth offset) from this
1166
+ // rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
1167
+ // yaw, which is π off from the plan rotation on a wall face — feeding it
1168
+ // raw flips the footprint to the far side of the wall during placement.
1169
+ // Publish the plan rotation (wall angle + the item's wall-local yaw) so
1170
+ // the preview matches what the committed node resolves to.
1171
+ let liveRotation = result.cursorRotationY
1172
+ const liveWallId = placementState.current.wallId
1173
+ const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
1174
+ if (liveWall?.type === 'wall') {
1175
+ const w = liveWall as WallNode
1176
+ const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
1177
+ liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
1178
+ }
729
1179
  useLiveTransforms.getState().set(draft.id, {
730
1180
  position: result.cursorPosition,
731
- rotation: result.cursorRotationY,
1181
+ rotation: liveRotation,
732
1182
  })
733
1183
  }
734
1184
  }
@@ -742,12 +1192,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
742
1192
  if (draftNode.current) {
743
1193
  useLiveTransforms.getState().clear(draftNode.current.id)
744
1194
  }
745
- draftNode.commit(result.nodeUpdate)
1195
+ const committedId = draftNode.current?.id ?? null
1196
+ const wasAdopted = draftNode.isAdopted
1197
+ const finalId = draftNode.commit(result.nodeUpdate)
746
1198
  if (result.dirtyNodeId) {
747
1199
  useScene.getState().dirtyNodes.add(result.dirtyNodeId)
748
1200
  }
749
1201
 
750
- if (configRef.current.onCommitted()) {
1202
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
751
1203
  const nodes = useScene.getState().nodes
752
1204
  const enterResult = wallStrategy.enter(
753
1205
  getContext(),
@@ -761,10 +1213,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
761
1213
  } else {
762
1214
  revalidate()
763
1215
  }
764
- }
1216
+ })
765
1217
  }
766
1218
 
767
1219
  const onWallLeave = (event: WallEvent) => {
1220
+ wallDragAnchor = null
768
1221
  const result = wallStrategy.leave(getContext())
769
1222
  if (!result) return
770
1223
 
@@ -794,28 +1247,215 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
794
1247
  }
795
1248
  }
796
1249
 
1250
+ // ---- Roof Wall Handlers ----
1251
+ // Wall-attach items also host on the vertical wall faces a roof
1252
+ // segment generates (base walls + coplanar gable ends). Unlike walls,
1253
+ // crossing between segments inside ONE roof never re-fires
1254
+ // `roof:enter` (events come from the roof group), so the move handler
1255
+ // re-enters whenever the strategy reports a segment change.
1256
+
1257
+ const enterRoofWall = (event: RoofEvent): boolean => {
1258
+ const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1259
+ if (!result) return false
1260
+
1261
+ event.stopPropagation()
1262
+ applyTransition(result)
1263
+
1264
+ if (!draftNode.current) {
1265
+ ensureDraft(result)
1266
+ } else if (result.nodeUpdate.parentId) {
1267
+ // Existing draft (move mode): reparent to the segment
1268
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1269
+ }
1270
+ return true
1271
+ }
1272
+
1273
+ const onRoofWallEnter = (event: RoofEvent) => {
1274
+ has3DPointerDrivenMoveRef.current = true
1275
+ enterRoofWall(event)
1276
+ }
1277
+
1278
+ const onRoofWallMove = (event: RoofEvent) => {
1279
+ releaseCommit = () => onRoofWallClick(event)
1280
+ has3DPointerDrivenMoveRef.current = true
1281
+ if (!cursorGroupRef.current) return
1282
+ const ctx = getContext()
1283
+
1284
+ if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
1285
+ enterRoofWall(event)
1286
+ return
1287
+ }
1288
+
1289
+ const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
1290
+ if (!result) {
1291
+ // Different segment under the pointer (or no placeable face) —
1292
+ // try a fresh enter; a null resolve leaves the draft where it is.
1293
+ enterRoofWall(event)
1294
+ return
1295
+ }
1296
+
1297
+ event.stopPropagation()
1298
+
1299
+ const posChanged =
1300
+ gridPosition.current.x !== result.gridPosition[0] ||
1301
+ gridPosition.current.y !== result.gridPosition[1] ||
1302
+ gridPosition.current.z !== result.gridPosition[2]
1303
+
1304
+ if (posChanged) {
1305
+ sfxEmitter.emit('sfx:grid-snap')
1306
+ }
1307
+
1308
+ gridPosition.current.set(...result.gridPosition)
1309
+ const wc = worldToBuildingLocal(...result.cursorPosition)
1310
+ cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
1311
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
1312
+
1313
+ const draft = draftNode.current
1314
+ if (draft && result.nodeUpdate) {
1315
+ if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
1316
+ if ('rotation' in result.nodeUpdate)
1317
+ draft.rotation = result.nodeUpdate.rotation as [number, number, number]
1318
+ }
1319
+
1320
+ const placeable = revalidate()
1321
+
1322
+ if (draft && placeable) {
1323
+ draft.position = result.gridPosition
1324
+ const mesh = sceneRegistry.nodes.get(draft.id)
1325
+ if (mesh) {
1326
+ mesh.position.copy(gridPosition.current)
1327
+ // Wall-side items sit on the outer surface: mirror ItemSystem's
1328
+ // push (z = thickness/2 off the face frame's mid-plane) so the
1329
+ // drag preview doesn't sink into the wall until commit.
1330
+ if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
1331
+ const segment =
1332
+ useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
1333
+ if (segment?.type === 'roof-segment') {
1334
+ mesh.position.z = (segment.wallThickness ?? 0.1) / 2
1335
+ }
1336
+ }
1337
+ const rot = result.nodeUpdate?.rotation
1338
+ if (rot) mesh.rotation.y = rot[1]
1339
+ }
1340
+ // The 2D floor-plan live frame is wall-local; a segment-local
1341
+ // value would render garbage — clear instead of publishing.
1342
+ useLiveTransforms.getState().clear(draft.id)
1343
+ }
1344
+ }
1345
+
1346
+ const onRoofWallClick = (event: RoofEvent) => {
1347
+ const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
1348
+ if (!result) return
1349
+
1350
+ event.stopPropagation()
1351
+ if (draftNode.current) {
1352
+ useLiveTransforms.getState().clear(draftNode.current.id)
1353
+ }
1354
+ const committedId = draftNode.current?.id ?? null
1355
+ const wasAdopted = draftNode.isAdopted
1356
+ const finalId = draftNode.commit(result.nodeUpdate)
1357
+
1358
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1359
+ const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1360
+ if (enterResult) {
1361
+ applyTransition(enterResult)
1362
+ } else {
1363
+ revalidate()
1364
+ }
1365
+ })
1366
+ }
1367
+
1368
+ const onRoofWallLeave = (event: RoofEvent) => {
1369
+ const result = roofWallStrategy.leave(getContext())
1370
+ if (!result) return
1371
+
1372
+ event.stopPropagation()
1373
+
1374
+ if (draftNode.isAdopted) {
1375
+ // Move mode: keep draft alive, reparent to level
1376
+ applyTransition(result)
1377
+ const draft = draftNode.current
1378
+ if (draft) {
1379
+ useScene.getState().updateNode(draft.id, {
1380
+ parentId: result.nodeUpdate.parentId as string,
1381
+ roofSegmentId: undefined,
1382
+ })
1383
+ }
1384
+ } else {
1385
+ // Create mode: destroy transient and reset state
1386
+ draftNode.destroy()
1387
+ Object.assign(placementState.current, result.stateUpdate)
1388
+ }
1389
+ }
1390
+
797
1391
  // ---- Item Surface Handlers ----
798
1392
 
799
1393
  const detachItemSurfaceToFloor = (event: ItemEvent) => {
1394
+ hostSurfaceDragAnchor = null
1395
+ // Coming back from a host: forget the floor grab too, so the item
1396
+ // centers under the cursor instead of restoring the pre-drag offset —
1397
+ // and landing on the floor is "anchoring elsewhere", so a later return
1398
+ // to the grabbed host centers as well.
1399
+ relativeFloorStart = null
1400
+ floorDragAnchor = null
1401
+ grabForgotten = true
800
1402
  const buildingLocalPoint = worldToBuildingLocal(
801
1403
  event.position[0],
802
1404
  event.position[1],
803
1405
  event.position[2],
804
1406
  )
805
- const wx = Math.round(buildingLocalPoint.x * 2) / 2
806
- const wz = Math.round(buildingLocalPoint.z * 2) / 2
1407
+ // Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
1408
+ const wx = snapToHalf(buildingLocalPoint.x)
1409
+ const wz = snapToHalf(buildingLocalPoint.z)
807
1410
  const floorPos: [number, number, number] = [wx, 0, wz]
808
1411
 
809
- Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
1412
+ Object.assign(placementState.current, {
1413
+ surface: 'floor',
1414
+ surfaceItemId: null,
1415
+ shelfId: null,
1416
+ })
810
1417
  gridPosition.current.set(wx, 0, wz)
811
- cursorGroupRef.current.position.set(wx, 0, wz)
1418
+ const levelId = useViewer.getState().selection.levelId as AnyNodeId | null
1419
+ const floorVisualPosition = getFloorVisualPosition(
1420
+ floorPos,
1421
+ levelId ? { parentId: levelId } : undefined,
1422
+ )
1423
+ if (cursorGroupRef.current) {
1424
+ cursorGroupRef.current.position.set(...floorVisualPosition)
1425
+ }
812
1426
 
813
1427
  const draft = draftNode.current
814
1428
  if (draft) {
1429
+ // The stored yaw is still HOST-local — the surface enter counter-
1430
+ // rotated it against the host's world yaw so the item wouldn't spin
1431
+ // when attaching. Re-express the same world yaw in the level frame
1432
+ // before re-parenting, or the item visibly spins by the host's
1433
+ // rotation the moment it returns to the floor.
1434
+ let rotation = draft.rotation
1435
+ const draftMesh = sceneRegistry.nodes.get(draft.id)
1436
+ if (draftMesh) {
1437
+ const quat = new Quaternion()
1438
+ draftMesh.getWorldQuaternion(quat)
1439
+ const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
1440
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
1441
+ let levelYaw = 0
1442
+ if (levelMesh) {
1443
+ levelMesh.getWorldQuaternion(quat)
1444
+ levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
1445
+ }
1446
+ rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]]
1447
+ draft.rotation = rotation
1448
+ }
815
1449
  draft.position = floorPos
1450
+ // Sync the ref's parent too (the store-only write left the ref
1451
+ // pointing at the host, so `getFloorPlacedElevation` bailed on its
1452
+ // parent-must-be-a-level guard and the item + grid stopped following
1453
+ // slab elevations for the rest of the drag).
1454
+ if (levelId) draft.parentId = levelId
816
1455
  useScene.getState().updateNode(draft.id, {
817
1456
  parentId: useViewer.getState().selection.levelId as string,
818
1457
  position: floorPos,
1458
+ rotation,
819
1459
  })
820
1460
  }
821
1461
 
@@ -824,6 +1464,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
824
1464
 
825
1465
  const onItemEnter = (event: ItemEvent) => {
826
1466
  if (event.node.id === draftNode.current?.id) return
1467
+ has3DPointerDrivenMoveRef.current = true
827
1468
  const result = itemSurfaceStrategy.enter(getContext(), event)
828
1469
  if (!result) return
829
1470
 
@@ -840,6 +1481,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
840
1481
 
841
1482
  const onItemMove = (event: ItemEvent) => {
842
1483
  if (event.node.id === draftNode.current?.id) return
1484
+ releaseCommit = () => onItemClick(event)
1485
+ has3DPointerDrivenMoveRef.current = true
1486
+ if (!cursorGroupRef.current) return
843
1487
  const ctx = getContext()
844
1488
 
845
1489
  if (ctx.state.surface !== 'item-surface') {
@@ -881,8 +1525,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
881
1525
  return
882
1526
  }
883
1527
 
884
- lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
885
- const result = itemSurfaceStrategy.move(ctx, event)
1528
+ const surfaceWorld =
1529
+ ctx.state.surfaceItemId !== null
1530
+ ? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
1531
+ : null
1532
+ const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
1533
+ lastRawPos.current.set(
1534
+ itemMoveEvent.position[0],
1535
+ itemMoveEvent.position[1],
1536
+ itemMoveEvent.position[2],
1537
+ )
1538
+ const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
886
1539
  if (!result) return
887
1540
 
888
1541
  event.stopPropagation()
@@ -923,7 +1576,108 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
923
1576
  }
924
1577
 
925
1578
  const onItemClick = (event: ItemEvent) => {
926
- if (event.node.id === draftNode.current?.id) return
1579
+ // Click on the draft item itself. R3F dispatches click events to
1580
+ // the closest intersected mesh only — when the draft is hovering
1581
+ // on a host (shelf / table / etc.) the draft's mesh is *above*
1582
+ // the host's mesh, so the host's `${kind}:click` never fires.
1583
+ // If we're currently hosting on a shelf-surface, treat the
1584
+ // self-click as a commit on the active shelf so the user doesn't
1585
+ // have to aim around the cursor preview to drop the item.
1586
+ if (event.node.id === draftNode.current?.id) {
1587
+ const ctx = getContext()
1588
+ if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
1589
+ const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
1590
+ if (shelfNode && shelfNode.type === 'shelf') {
1591
+ const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
1592
+ const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
1593
+ if (result) {
1594
+ event.stopPropagation()
1595
+ if (draftNode.current) {
1596
+ useLiveTransforms.getState().clear(draftNode.current.id)
1597
+ }
1598
+ const committedId = draftNode.current?.id ?? null
1599
+ const wasAdopted = draftNode.isAdopted
1600
+ const finalId = draftNode.commit(result.nodeUpdate)
1601
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1602
+ const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
1603
+ if (enterResult) {
1604
+ applyTransition(enterResult)
1605
+ } else {
1606
+ revalidate()
1607
+ }
1608
+ })
1609
+ return
1610
+ }
1611
+ }
1612
+ }
1613
+ // Same self-click forwarding for item-surface hosts (tables,
1614
+ // counters) — the draft mesh sits on top of the host mesh, so
1615
+ // the host's own click event is blocked by the cursor preview.
1616
+ if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
1617
+ const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
1618
+ if (hostNode && hostNode.type === 'item') {
1619
+ const synthetic = { ...event, node: hostNode } as ItemEvent
1620
+ const result = itemSurfaceStrategy.click(ctx, synthetic)
1621
+ if (result) {
1622
+ event.stopPropagation()
1623
+ if (draftNode.current) {
1624
+ useLiveTransforms.getState().clear(draftNode.current.id)
1625
+ }
1626
+ const committedId = draftNode.current?.id ?? null
1627
+ const wasAdopted = draftNode.isAdopted
1628
+ const finalId = draftNode.commit(result.nodeUpdate)
1629
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1630
+ const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
1631
+ if (enterResult) {
1632
+ applyTransition(enterResult)
1633
+ } else {
1634
+ revalidate()
1635
+ }
1636
+ })
1637
+ return
1638
+ }
1639
+ }
1640
+ }
1641
+ // Ceiling-hosted draft: when placing a ceiling-attached item the
1642
+ // draft hangs below the ceiling and intercepts the click ray
1643
+ // before the ceiling-grid mesh does — so `ceiling:click` never
1644
+ // fires and the user's commit click is dropped. Forward the
1645
+ // self-click to `ceilingStrategy.click` so placement commits the
1646
+ // same way it would from a click on the ceiling itself.
1647
+ if (ctx.state.surface === 'ceiling' && ctx.state.ceilingId) {
1648
+ const ceilingNode = useScene.getState().nodes[ctx.state.ceilingId as AnyNodeId]
1649
+ if (ceilingNode && ceilingNode.type === 'ceiling') {
1650
+ const synthetic = { ...event, node: ceilingNode } as unknown as CeilingEvent
1651
+ const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
1652
+ if (result) {
1653
+ event.stopPropagation()
1654
+ if (draftNode.current) {
1655
+ useLiveTransforms.getState().clear(draftNode.current.id)
1656
+ }
1657
+ const committedId = draftNode.current?.id ?? null
1658
+ const wasAdopted = draftNode.isAdopted
1659
+ const finalId = draftNode.commit(result.nodeUpdate)
1660
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1661
+ const nodes = useScene.getState().nodes
1662
+ const enterResult = ceilingStrategy.enter(
1663
+ getContext(),
1664
+ synthetic,
1665
+ resolveLevelId,
1666
+ nodes,
1667
+ )
1668
+ if (enterResult) {
1669
+ applyTransition(enterResult)
1670
+ } else {
1671
+ revalidate()
1672
+ }
1673
+ })
1674
+ return
1675
+ }
1676
+ }
1677
+ }
1678
+ return
1679
+ }
1680
+
927
1681
  const result = itemSurfaceStrategy.click(getContext(), event)
928
1682
  if (!result) return
929
1683
 
@@ -932,9 +1686,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
932
1686
  if (draftNode.current) {
933
1687
  useLiveTransforms.getState().clear(draftNode.current.id)
934
1688
  }
935
- draftNode.commit(result.nodeUpdate)
1689
+ const committedId = draftNode.current?.id ?? null
1690
+ const wasAdopted = draftNode.isAdopted
1691
+ const finalId = draftNode.commit(result.nodeUpdate)
936
1692
 
937
- if (configRef.current.onCommitted()) {
1693
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
938
1694
  // Try to set up next draft on the same surface
939
1695
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
940
1696
  if (enterResult) {
@@ -942,12 +1698,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
942
1698
  } else {
943
1699
  revalidate()
944
1700
  }
945
- }
1701
+ })
946
1702
  }
947
1703
 
948
1704
  // ---- Ceiling Handlers ----
949
1705
 
950
1706
  const onCeilingEnter = (event: CeilingEvent) => {
1707
+ has3DPointerDrivenMoveRef.current = true
951
1708
  const nodes = useScene.getState().nodes
952
1709
  const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
953
1710
  if (!result) return
@@ -967,6 +1724,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
967
1724
  }
968
1725
 
969
1726
  const onCeilingMove = (event: CeilingEvent) => {
1727
+ releaseCommit = () => onCeilingClick(event)
1728
+ has3DPointerDrivenMoveRef.current = true
1729
+ if (!cursorGroupRef.current) return
970
1730
  if (!draftNode.current && placementState.current.surface === 'ceiling') {
971
1731
  const nodes = useScene.getState().nodes
972
1732
  const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
@@ -977,8 +1737,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
977
1737
  return
978
1738
  }
979
1739
 
980
- lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
981
- const result = ceilingStrategy.move(getContext(), event)
1740
+ let ceilingMoveEvent = event
1741
+ if (preserveDragOffset && draftNode.current) {
1742
+ const rawX = event.localPosition[0]
1743
+ const rawZ = event.localPosition[2]
1744
+ if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
1745
+ // Same rule as the wall grab anchor: only the grabbed ceiling
1746
+ // preserves the offset, any other ceiling centers under the cursor.
1747
+ const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId)
1748
+ ceilingDragAnchor = {
1749
+ ceilingId: event.node.id,
1750
+ rawX,
1751
+ rawZ,
1752
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
1753
+ startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
1754
+ }
1755
+ }
1756
+ ceilingMoveEvent = {
1757
+ ...event,
1758
+ localPosition: [
1759
+ ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
1760
+ event.localPosition[1],
1761
+ ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
1762
+ ],
1763
+ }
1764
+ }
1765
+ lastRawPos.current.set(
1766
+ ceilingMoveEvent.localPosition[0],
1767
+ ceilingMoveEvent.localPosition[1],
1768
+ ceilingMoveEvent.localPosition[2],
1769
+ )
1770
+ const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
982
1771
  if (!result) return
983
1772
 
984
1773
  event.stopPropagation()
@@ -1005,9 +1794,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1005
1794
  const mesh = sceneRegistry.nodes.get(draft.id)
1006
1795
  if (mesh) mesh.position.copy(gridPosition.current)
1007
1796
 
1008
- // Publish live transform for 2D floorplan
1797
+ // Publish live transform for 2D floorplan. The item override in
1798
+ // `floorplan-registry-layer` treats `live.position` as building-local
1799
+ // plan coords (parentId forced to null so the resolver renders it
1800
+ // directly), so publish the building-local cursor — not the
1801
+ // world-space `result.cursorPosition`, which otherwise lands the 2D
1802
+ // visual off the cursor whenever the building isn't at the origin
1803
+ // with zero rotation.
1009
1804
  useLiveTransforms.getState().set(draft.id, {
1010
- position: result.cursorPosition,
1805
+ position: [cc.x, cc.y, cc.z],
1011
1806
  rotation: cursorGroupRef.current.rotation.y,
1012
1807
  })
1013
1808
  }
@@ -1022,9 +1817,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1022
1817
  if (draftNode.current) {
1023
1818
  useLiveTransforms.getState().clear(draftNode.current.id)
1024
1819
  }
1025
- draftNode.commit(result.nodeUpdate)
1820
+ const committedId = draftNode.current?.id ?? null
1821
+ const wasAdopted = draftNode.isAdopted
1822
+ const finalId = draftNode.commit(result.nodeUpdate)
1026
1823
 
1027
- if (configRef.current.onCommitted()) {
1824
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1028
1825
  const nodes = useScene.getState().nodes
1029
1826
  const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
1030
1827
  if (enterResult) {
@@ -1032,10 +1829,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1032
1829
  } else {
1033
1830
  revalidate()
1034
1831
  }
1035
- }
1832
+ })
1036
1833
  }
1037
1834
 
1038
1835
  const onCeilingLeave = (event: CeilingEvent) => {
1836
+ ceilingDragAnchor = null
1039
1837
  const result = ceilingStrategy.leave(getContext())
1040
1838
  if (!result) return
1041
1839
 
@@ -1065,12 +1863,119 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1065
1863
  }
1066
1864
  }
1067
1865
 
1866
+ // ---- Shelf Handlers ----
1867
+ //
1868
+ // Items can host on shelves the same way they host on tables and
1869
+ // counters (item-surface). The shelf's `surfaces.custom` exposes one
1870
+ // candidate Y per row; `shelfSurfaceStrategy` picks the closest one
1871
+ // to the cursor's local-Y so the user can target a specific row.
1872
+
1873
+ const onShelfEnter = (event: ShelfEvent) => {
1874
+ has3DPointerDrivenMoveRef.current = true
1875
+ const result = shelfSurfaceStrategy.enter(getContext(), event)
1876
+ if (!result) return
1877
+
1878
+ event.stopPropagation()
1879
+ applyTransition(result)
1880
+
1881
+ if (!draftNode.current) {
1882
+ ensureDraft(result)
1883
+ } else if (result.nodeUpdate.parentId) {
1884
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1885
+ }
1886
+ }
1887
+
1888
+ const onShelfMove = (event: ShelfEvent) => {
1889
+ releaseCommit = () => onShelfClick(event)
1890
+ has3DPointerDrivenMoveRef.current = true
1891
+ // A shelf event can fire before the cursor group mounts or after
1892
+ // teardown, leaving the ref null; bail before dereferencing it below.
1893
+ if (!cursorGroupRef.current) return
1894
+ const ctx = getContext()
1895
+ if (ctx.state.surface !== 'shelf-surface') {
1896
+ // Cursor entered via a move event without an enter — try
1897
+ // transitioning in so the user doesn't need to mouse out + back
1898
+ // in to start hosting.
1899
+ const enterResult = shelfSurfaceStrategy.enter(ctx, event)
1900
+ if (!enterResult) return
1901
+ event.stopPropagation()
1902
+ applyTransition(enterResult)
1903
+ if (!draftNode.current) {
1904
+ ensureDraft(enterResult)
1905
+ } else if (enterResult.nodeUpdate.parentId) {
1906
+ useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1907
+ }
1908
+ return
1909
+ }
1910
+ const shelfWorld =
1911
+ ctx.state.shelfId !== null
1912
+ ? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
1913
+ : null
1914
+ const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
1915
+ const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
1916
+ if (!result) return
1917
+
1918
+ event.stopPropagation()
1919
+
1920
+ gridPosition.current.set(...result.gridPosition)
1921
+ const ic = worldToBuildingLocal(...result.cursorPosition)
1922
+ cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
1923
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
1924
+
1925
+ const draft = draftNode.current
1926
+ if (draft) {
1927
+ draft.position = result.gridPosition
1928
+ const mesh = sceneRegistry.nodes.get(draft.id)
1929
+ if (mesh) mesh.position.set(...result.gridPosition)
1930
+ useLiveTransforms.getState().set(draft.id, {
1931
+ position: result.cursorPosition,
1932
+ rotation: result.cursorRotationY,
1933
+ })
1934
+ }
1935
+
1936
+ revalidate()
1937
+ }
1938
+
1939
+ const onShelfLeave = (event: ShelfEvent) => {
1940
+ if (placementState.current.surface !== 'shelf-surface') return
1941
+ if (event.node.id !== placementState.current.shelfId) return
1942
+ // Intentionally do NOT detach to the floor here. `shelf:leave` fires
1943
+ // constantly while hosting because the cursor ray slips off the shelf's
1944
+ // thin boards and through its gaps — detaching on each of those would
1945
+ // thrash the item between the shelf row and the floor. The grid handler
1946
+ // owns the real shelf→floor transition (see `isOverActiveShelfFootprint`
1947
+ // in `onGridMove`): it detaches only once the cursor is clearly off the
1948
+ // shelf footprint, which is the genuine "left the shelf" signal.
1949
+ event.stopPropagation()
1950
+ }
1951
+
1952
+ const onShelfClick = (event: ShelfEvent) => {
1953
+ const result = shelfSurfaceStrategy.click(getContext(), event)
1954
+ if (!result) return
1955
+
1956
+ event.stopPropagation()
1957
+ if (draftNode.current) {
1958
+ useLiveTransforms.getState().clear(draftNode.current.id)
1959
+ }
1960
+ const committedId = draftNode.current?.id ?? null
1961
+ const wasAdopted = draftNode.isAdopted
1962
+ const finalId = draftNode.commit(result.nodeUpdate)
1963
+
1964
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1965
+ const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
1966
+ if (enterResult) {
1967
+ applyTransition(enterResult)
1968
+ } else {
1969
+ revalidate()
1970
+ }
1971
+ })
1972
+ }
1973
+
1068
1974
  // ---- Keyboard rotation ----
1069
1975
 
1070
- const ROTATION_STEP = Math.PI / 2
1071
1976
  const onKeyDown = (event: KeyboardEvent) => {
1072
- if (event.key === 'Shift') {
1073
- shiftFreeRef.current = true
1977
+ if (event.key === 'Alt') {
1978
+ altFreeRef.current = true
1074
1979
  revalidate()
1075
1980
  return
1076
1981
  }
@@ -1083,21 +1988,28 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1083
1988
  const draft = draftNode.current
1084
1989
  if (!draft) return
1085
1990
 
1086
- let rotationDelta = 0
1991
+ // Roof-wall drafts live flat in the host face frame (yaw 0) —
1992
+ // manual rotation would skew them off the wall plane.
1993
+ if (placementState.current.surface === 'roof-wall') return
1994
+
1995
+ let rotationDir: 1 | -1 | 0 = 0
1087
1996
  if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
1088
- rotationDelta = ROTATION_STEP
1997
+ rotationDir = 1
1089
1998
  else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
1090
- rotationDelta = -ROTATION_STEP
1999
+ rotationDir = -1
1091
2000
 
1092
- if (rotationDelta !== 0) {
2001
+ if (rotationDir !== 0) {
1093
2002
  event.preventDefault()
1094
2003
  sfxEmitter.emit('sfx:item-rotate')
1095
2004
  const currentRotation = draft.rotation
1096
- const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
2005
+ // Round to the nearest 45° then step, matching the placed-item R/T.
2006
+ const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
1097
2007
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
1098
2008
 
1099
2009
  // Ref + cursor mesh + item mesh — no store update during drag
1100
- cursorGroupRef.current.rotation.y = newRotationY
2010
+ if (cursorGroupRef.current) {
2011
+ cursorGroupRef.current.rotation.y = newRotationY
2012
+ }
1101
2013
  const mesh = sceneRegistry.nodes.get(draft.id)
1102
2014
  if (mesh) mesh.rotation.y = newRotationY
1103
2015
 
@@ -1111,11 +2023,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1111
2023
  const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
1112
2024
  gridPosition.current.set(x, gridPosition.current.y, z)
1113
2025
  draft.position = [x, gridPosition.current.y, z]
1114
- cursorGroupRef.current.position.x = x
1115
- cursorGroupRef.current.position.z = z
2026
+ if (cursorGroupRef.current) {
2027
+ if (surface === 'floor') {
2028
+ cursorGroupRef.current.position.set(
2029
+ ...getFloorVisualPosition([x, gridPosition.current.y, z]),
2030
+ )
2031
+ } else {
2032
+ cursorGroupRef.current.position.x = x
2033
+ cursorGroupRef.current.position.z = z
2034
+ }
2035
+ }
1116
2036
  if (mesh) {
1117
2037
  mesh.position.x = x
1118
2038
  mesh.position.z = z
2039
+ if (surface === 'floor') {
2040
+ mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1]
2041
+ }
1119
2042
  }
1120
2043
  } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
1121
2044
  const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
@@ -1135,7 +2058,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1135
2058
  worldSnapped.y,
1136
2059
  worldSnapped.z,
1137
2060
  )
1138
- cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
2061
+ const surfaceQuat = new Quaternion()
2062
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
2063
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
2064
+ if (cursorGroupRef.current) {
2065
+ cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
2066
+ // The box lives in building-local space while the mesh is parented to the host
2067
+ // item, so add the host's world yaw: the box must track the item's true
2068
+ // orientation, not its host-local `rotation[1]`.
2069
+ cursorGroupRef.current.rotation.y = newRotationY + surfaceWorldY
2070
+ }
1139
2071
  if (mesh) mesh.position.set(x, y, z)
1140
2072
  }
1141
2073
  }
@@ -1143,13 +2075,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1143
2075
  // Update live transform for 2D floorplan with post-snap position
1144
2076
  const currentLive = useLiveTransforms.getState().get(draft.id)
1145
2077
  if (currentLive) {
2078
+ const livePosition: [number, number, number] =
2079
+ surface === 'floor'
2080
+ ? [draft.position[0], draft.position[1], draft.position[2]]
2081
+ : cursorGroupRef.current
2082
+ ? [
2083
+ cursorGroupRef.current.position.x,
2084
+ cursorGroupRef.current.position.y,
2085
+ cursorGroupRef.current.position.z,
2086
+ ]
2087
+ : [draft.position[0], draft.position[1], draft.position[2]]
1146
2088
  useLiveTransforms.getState().set(draft.id, {
1147
2089
  ...currentLive,
1148
- position: [
1149
- cursorGroupRef.current.position.x,
1150
- cursorGroupRef.current.position.y,
1151
- cursorGroupRef.current.position.z,
1152
- ] as [number, number, number],
2090
+ position: livePosition,
1153
2091
  rotation: newRotationY,
1154
2092
  })
1155
2093
  }
@@ -1159,8 +2097,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1159
2097
  }
1160
2098
 
1161
2099
  const onKeyUp = (event: KeyboardEvent) => {
1162
- if (event.key === 'Shift') {
1163
- shiftFreeRef.current = false
2100
+ if (event.key === 'Alt') {
2101
+ altFreeRef.current = false
1164
2102
  revalidate()
1165
2103
  }
1166
2104
  }
@@ -1170,19 +2108,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1170
2108
 
1171
2109
  // ---- tool:cancel (Escape / programmatic) ----
1172
2110
  const onCancel = () => {
2111
+ useAlignmentGuides.getState().clear()
1173
2112
  if (configRef.current.onCancel) {
1174
2113
  configRef.current.onCancel()
1175
2114
  }
1176
2115
  }
1177
2116
  emitter.on('tool:cancel', onCancel)
1178
2117
 
1179
- // ---- Right-click cancel ----
1180
- const onContextMenu = (event: MouseEvent) => {
1181
- if (configRef.current.onCancel) {
1182
- event.preventDefault()
1183
- configRef.current.onCancel()
2118
+ // ---- Right-click cancel (quick click only, never a right-drag orbit) ----
2119
+ // The right button is also the camera-orbit control, so a contextmenu/up
2120
+ // alone can't tell "cancel placement" from "move the camera". Record the
2121
+ // right-button-down point + time and only cancel on release when the
2122
+ // pointer barely moved within a short window — a longer / further press is
2123
+ // an orbit and must leave the placement untouched.
2124
+ let rightDown: { x: number; y: number; t: number } | null = null
2125
+ const onRightPointerDown = (event: PointerEvent) => {
2126
+ if (event.button !== 2) return
2127
+ rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
2128
+ }
2129
+ const onRightPointerUp = (event: PointerEvent) => {
2130
+ if (event.button !== 2) return
2131
+ const down = rightDown
2132
+ rightDown = null
2133
+ if (!down || !configRef.current.onCancel) return
2134
+ const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
2135
+ const elapsed = performance.now() - down.t
2136
+ if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
2137
+ onCancel()
1184
2138
  }
1185
2139
  }
2140
+ // Suppress the OS context menu while placing; the cancel itself is decided
2141
+ // on pointerup above.
2142
+ const onContextMenu = (event: MouseEvent) => {
2143
+ if (configRef.current.onCancel) event.preventDefault()
2144
+ }
2145
+ window.addEventListener('pointerdown', onRightPointerDown, true)
2146
+ window.addEventListener('pointerup', onRightPointerUp, true)
1186
2147
  window.addEventListener('contextmenu', onContextMenu)
1187
2148
 
1188
2149
  // ---- Bounding box geometry ----
@@ -1235,14 +2196,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1235
2196
  emitter.on('wall:move', onWallMove)
1236
2197
  emitter.on('wall:click', onWallClick)
1237
2198
  emitter.on('wall:leave', onWallLeave)
2199
+ emitter.on('roof:enter', onRoofWallEnter)
2200
+ emitter.on('roof:move', onRoofWallMove)
2201
+ emitter.on('roof:click', onRoofWallClick)
2202
+ emitter.on('roof:leave', onRoofWallLeave)
1238
2203
  emitter.on('ceiling:enter', onCeilingEnter)
1239
2204
  emitter.on('ceiling:move', onCeilingMove)
1240
2205
  emitter.on('ceiling:click', onCeilingClick)
1241
2206
  emitter.on('ceiling:leave', onCeilingLeave)
2207
+ emitter.on('shelf:enter', onShelfEnter)
2208
+ emitter.on('shelf:move', onShelfMove)
2209
+ emitter.on('shelf:click', onShelfClick)
2210
+ emitter.on('shelf:leave', onShelfLeave)
2211
+
2212
+ // A floor placement commits at the tracked floor cursor (`gridPosition`),
2213
+ // which keeps following the floor even when the click ray lands on a wall
2214
+ // (grid:move uses a separate ground-plane raycast). Without this, a commit
2215
+ // click whose ray hits a wall fires only `wall:click` — whose handler
2216
+ // declines for a floor item — and the click is silently eaten (the user
2217
+ // has to click again until the ray happens to clear the wall). Route every
2218
+ // surface click to the floor commit too; `floorStrategy.click` guards on
2219
+ // `surface === 'floor'` (and a non-attach draft), so it no-ops while the
2220
+ // draft is actually resting on that surface.
2221
+ const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
2222
+ if (placementState.current.surface !== 'floor') return
2223
+ onGridClick(event as unknown as GridEvent)
2224
+ }
2225
+ emitter.on('wall:click', commitFloorOnSurfaceClick as never)
2226
+ emitter.on('item:click', commitFloorOnSurfaceClick as never)
2227
+ emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
2228
+ emitter.on('roof:click', commitFloorOnSurfaceClick as never)
2229
+ emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
2230
+ if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
1242
2231
 
1243
2232
  return () => {
1244
2233
  tearingDown = true
2234
+ if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
1245
2235
  unsubDraftWatch()
2236
+ useAlignmentGuides.getState().clear()
1246
2237
  // Clear live transform for any remaining draft
1247
2238
  if (draftNode.current) {
1248
2239
  useLiveTransforms.getState().clear(draftNode.current.id)
@@ -1259,16 +2250,41 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1259
2250
  emitter.off('wall:move', onWallMove)
1260
2251
  emitter.off('wall:click', onWallClick)
1261
2252
  emitter.off('wall:leave', onWallLeave)
2253
+ emitter.off('roof:enter', onRoofWallEnter)
2254
+ emitter.off('roof:move', onRoofWallMove)
2255
+ emitter.off('roof:click', onRoofWallClick)
2256
+ emitter.off('roof:leave', onRoofWallLeave)
1262
2257
  emitter.off('ceiling:enter', onCeilingEnter)
1263
2258
  emitter.off('ceiling:move', onCeilingMove)
1264
2259
  emitter.off('ceiling:click', onCeilingClick)
1265
2260
  emitter.off('ceiling:leave', onCeilingLeave)
2261
+ emitter.off('shelf:enter', onShelfEnter)
2262
+ emitter.off('shelf:move', onShelfMove)
2263
+ emitter.off('shelf:click', onShelfClick)
2264
+ emitter.off('shelf:leave', onShelfLeave)
2265
+ emitter.off('wall:click', commitFloorOnSurfaceClick as never)
2266
+ emitter.off('item:click', commitFloorOnSurfaceClick as never)
2267
+ emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
2268
+ emitter.off('roof:click', commitFloorOnSurfaceClick as never)
2269
+ emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
1266
2270
  emitter.off('tool:cancel', onCancel)
1267
2271
  window.removeEventListener('keydown', onKeyDown)
1268
2272
  window.removeEventListener('keyup', onKeyUp)
2273
+ window.removeEventListener('pointerdown', onRightPointerDown, true)
2274
+ window.removeEventListener('pointerup', onRightPointerUp, true)
1269
2275
  window.removeEventListener('contextmenu', onContextMenu)
1270
2276
  }
1271
- }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
2277
+ }, [
2278
+ asset,
2279
+ canPlaceOnFloor,
2280
+ canPlaceOnWall,
2281
+ canPlaceOnCeiling,
2282
+ draftNode,
2283
+ getFloorVisualPosition,
2284
+ gridSnapStep,
2285
+ updateDimensionGuides,
2286
+ updatePreviewGeometry,
2287
+ ])
1272
2288
 
1273
2289
  // Refresh wireframe when the grid step changes mid-placement so the green/red
1274
2290
  // box snaps to the new cell size right away.
@@ -1282,7 +2298,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1282
2298
  )
1283
2299
  updatePreviewGeometry(previewBounds)
1284
2300
  updateDimensionGuides(previewBounds)
1285
- }, [gridSnapStep, asset, draftNode])
2301
+ }, [gridSnapStep, asset, draftNode, updateDimensionGuides, updatePreviewGeometry])
1286
2302
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
1287
2303
  const viewerLevelId = useViewer((s) => s.selection.levelId)
1288
2304
  useEffect(() => {
@@ -1290,14 +2306,139 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1290
2306
  const draft = draftNode.current
1291
2307
  if (!(draft && viewerLevelId) || asset.attachTo) return
1292
2308
  if (draft.parentId === viewerLevelId) return
2309
+ // A non-attach item resting on a host surface (table / counter / shelf) is
2310
+ // intentionally parented to that host while it's moved — the surface move
2311
+ // handlers keep it hosted and the commit writes the host parent back. Only
2312
+ // free floor items get re-homed to the level here; yanking a hosted item
2313
+ // onto the level would re-interpret its host-local position in level space
2314
+ // and float the dragged mesh off the host toward the building origin.
2315
+ const draftParent = draft.parentId
2316
+ ? useScene.getState().nodes[draft.parentId as AnyNodeId]
2317
+ : undefined
2318
+ if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
1293
2319
  draft.parentId = viewerLevelId
1294
2320
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
1295
2321
  }, [viewerLevelId, draftNode, asset])
1296
2322
 
1297
- useFrame((_, delta) => {
1298
- if (!asset) return
1299
- if (!draftNode.current) return
1300
- const mesh = sceneRegistry.nodes.get(draftNode.current.id)
2323
+ // Disable raycasting on the live draft mesh (and restore it when the draft
2324
+ // changes or goes away) so the cursor ray passes through the item being
2325
+ // moved and lands on the surface beneath it.
2326
+ const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
2327
+ if (raycastDisabledMeshRef.current !== mesh) {
2328
+ // New draft root (or cleared): restore the prior mesh and reset tracking.
2329
+ for (const restore of restoreRaycastsRef.current) restore()
2330
+ restoreRaycastsRef.current = []
2331
+ raycastDisabledChildrenRef.current = new WeakSet()
2332
+ raycastDisabledMeshRef.current = mesh
2333
+ }
2334
+ if (!mesh) return
2335
+ // Disable any descendant not handled yet. Item drafts are GLB models whose
2336
+ // child meshes mount asynchronously (Suspense), so a one-shot traverse
2337
+ // misses them — those late children keep intercepting the ray and corrupt
2338
+ // the shelf-row hit the moment the item moves onto a row. Re-walking each
2339
+ // frame is cheap: the WeakSet makes it idempotent, so only new children pay.
2340
+ mesh.traverse((child) => {
2341
+ if (raycastDisabledChildrenRef.current.has(child)) return
2342
+ raycastDisabledChildrenRef.current.add(child)
2343
+ const original = child.raycast
2344
+ child.raycast = () => {}
2345
+ restoreRaycastsRef.current.push(() => {
2346
+ child.raycast = original
2347
+ })
2348
+ })
2349
+ }, [])
2350
+
2351
+ // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
2352
+ useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
2353
+
2354
+ // Publish the ghost's surface (contact point + normal) so the grid's snap
2355
+ // patch sits at the item's resolved height (e.g. a shelf top) and orients to
2356
+ // the surface (vertical in a wall plane), AND publish the forward-facing
2357
+ // triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
2358
+ // instead of drawing an inline triangle. Only this coordinator publishes — a
2359
+ // moving existing node has no draft here, so the grid reads that case straight
2360
+ // off the node's mesh. Cleared when idle.
2361
+ const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2362
+ const facingForwardRef = useRef(new Vector3(0, 0, 1))
2363
+ const facingQuatRef = useRef(new Quaternion())
2364
+ useFrame(() => {
2365
+ const ghost = cursorGroupRef.current
2366
+ if (!(asset && ghost)) {
2367
+ clearPlacementSurface()
2368
+ useFacingPose.getState().clear()
2369
+ return
2370
+ }
2371
+ const surf = placementState.current.surface
2372
+ const n = surfaceNormalRef.current
2373
+ const shape = facingShapeRef.current
2374
+ // Triangle yaw / Y default to the floor case: the cursor group's own yaw is
2375
+ // the item's forward on the floor, and the triangle rides at the ghost's Y.
2376
+ let facingYaw = ghost.rotation.y
2377
+ let facingY = ghost.position.y
2378
+ if (surf === 'wall' || surf === 'roof-wall') {
2379
+ // Wall/roof-segment faces: the cursor group's yaw is the symmetric
2380
+ // wireframe yaw (π off the real facing for a wall, and a different frame
2381
+ // for a roof face), so derive the item's TRUE outward facing from the
2382
+ // draft mesh's world orientation — its local +Z faces out of the host
2383
+ // surface. This keeps BOTH the grid normal and the triangle correct for
2384
+ // wall and roof-segment hosts alike, rather than the old quaternion read
2385
+ // that pointed the wrong way.
2386
+ const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
2387
+ if (mesh) {
2388
+ mesh.getWorldQuaternion(facingQuatRef.current)
2389
+ const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
2390
+ fwd.y = 0
2391
+ if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
2392
+ }
2393
+ // The forward triangle is a floor aid; drop it to the building-local floor
2394
+ // under the wall (the ghost Y is up on the wall).
2395
+ facingY = 0
2396
+ n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
2397
+ } else {
2398
+ n.set(0, 1, 0)
2399
+ }
2400
+ publishPlacementSurface(ghost.position, n)
2401
+
2402
+ if (shape.depth > 0) {
2403
+ useFacingPose.getState().set({
2404
+ position: [ghost.position.x, facingY, ghost.position.z],
2405
+ rotationY: facingYaw,
2406
+ depth: shape.depth,
2407
+ center: shape.center,
2408
+ })
2409
+ } else {
2410
+ useFacingPose.getState().clear()
2411
+ }
2412
+ })
2413
+ useEffect(
2414
+ () => () => {
2415
+ clearPlacementSurface()
2416
+ useFacingPose.getState().clear()
2417
+ },
2418
+ [],
2419
+ )
2420
+
2421
+ useFrame(() => {
2422
+ if (!asset) {
2423
+ reconcileDraftRaycast(null)
2424
+ return
2425
+ }
2426
+ if (!draftNode.current) {
2427
+ reconcileDraftRaycast(null)
2428
+ return
2429
+ }
2430
+ const mesh = sceneRegistry.nodes.get(draftNode.current.id) ?? null
2431
+ reconcileDraftRaycast(mesh)
2432
+ // mitt listeners outlive the cursor group's mount; bail if it's gone
2433
+ // (mount/teardown race, #323). Placed after reconcileDraftRaycast so the
2434
+ // draft's raycast is still restored during that window.
2435
+ if (!cursorGroupRef.current) return
2436
+ // The mesh-position lerp below only makes sense once this coordinator
2437
+ // owns the move via a 3D pointer event. Skip until then so that
2438
+ // external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
2439
+ // writing scene.position directly) aren't fought by useFrame pulling
2440
+ // the mesh back to its pre-move location.
2441
+ if (!has3DPointerDrivenMoveRef.current) return
1301
2442
  if (!mesh) return
1302
2443
 
1303
2444
  // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
@@ -1308,27 +2449,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1308
2449
  mesh.visible = true
1309
2450
 
1310
2451
  if (placementState.current.surface === 'floor') {
1311
- const distance = mesh.position.distanceToSquared(gridPosition.current)
1312
- if (distance > 1) {
1313
- mesh.position.copy(gridPosition.current)
1314
- } else {
1315
- mesh.position.lerp(gridPosition.current, delta * 20)
1316
- }
2452
+ // Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
2453
+ // active move visibly trail the cursor and — combined with React
2454
+ // re-renders momentarily pulling the mesh back toward its committed
2455
+ // position — read as a laggy snap-back on every move. Copying each frame
2456
+ // locks placement/move to the cursor and overrides any stray reset
2457
+ // within a single frame, so it feels precise instead of dragging.
2458
+ mesh.position.copy(gridPosition.current)
1317
2459
 
1318
2460
  // Adjust Y for slab elevation (floor items on top of slabs)
1319
2461
  if (!asset.attachTo) {
1320
- const nodes = useScene.getState().nodes
1321
- const levelId = resolveLevelId(draftNode.current, nodes)
1322
- const slabElevation = spatialGridManager.getSlabElevationForItem(
1323
- levelId,
1324
- [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
1325
- getGridAlignedDimensions(
1326
- getScaledDimensions(draftNode.current),
1327
- draftNode.current.asset.attachTo,
1328
- ),
1329
- draftNode.current.rotation,
1330
- )
1331
- mesh.position.y = slabElevation
2462
+ const visualPosition = getFloorVisualPosition([
2463
+ gridPosition.current.x,
2464
+ gridPosition.current.y,
2465
+ gridPosition.current.z,
2466
+ ])
2467
+ mesh.position.y = visualPosition[1]
2468
+ cursorGroupRef.current.position.y = visualPosition[1]
1332
2469
  }
1333
2470
  }
1334
2471
  })
@@ -1339,9 +2476,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1339
2476
  ? getScaledDimensions(initialDraft)
1340
2477
  : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
1341
2478
  const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
1342
- const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
2479
+ const wallSideZOffset = initialAttachTo === 'wall-side' ? dims[2] / 2 : 0
1343
2480
  const initialDimensionBounds = expandBoundsToGrid(
1344
- getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
2481
+ getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
1345
2482
  initialAttachTo,
1346
2483
  gridSnapStep,
1347
2484
  )
@@ -1354,6 +2491,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1354
2491
  initialDimensionBounds.dimensions[0],
1355
2492
  initialDimensionBounds.dimensions[1],
1356
2493
  initialDimensionBounds.dimensions[2],
2494
+ initialDimensionBounds,
1357
2495
  ],
1358
2496
  )
1359
2497
  const basePlaneGeometry = useMemo(() => {
@@ -1366,9 +2504,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1366
2504
  const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
1367
2505
  const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
1368
2506
  const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
1369
- const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
1370
- const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
1371
- const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
2507
+ // Feed the footprint shape to the per-frame surface publisher, which orients
2508
+ // and positions the forward-facing triangle via `useFacingPose`.
2509
+ facingShapeRef.current = {
2510
+ depth: currentDimensionBounds.dimensions[2],
2511
+ center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
2512
+ }
2513
+ const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
2514
+ const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
2515
+ const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
1372
2516
  const widthLabelPosition: [number, number, number] = [
1373
2517
  currentDimensionBounds.center[0],
1374
2518
  0.04,
@@ -1384,7 +2528,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1384
2528
  currentDimensionBounds.dimensions[1] / 2,
1385
2529
  currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
1386
2530
  ]
1387
-
1388
2531
  const measurementContent = (
1389
2532
  <>
1390
2533
  <lineSegments