@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
@@ -0,0 +1,1064 @@
1
+ 'use client'
2
+
3
+ import '../../../three-types'
4
+
5
+ import {
6
+ type AlignmentGuide,
7
+ type AnyNode,
8
+ type AnyNodeId,
9
+ analyzePortConnectivity,
10
+ bboxCornerAnchors,
11
+ collectAlignmentAnchors,
12
+ createSceneApi,
13
+ type EventSuffix,
14
+ emitter,
15
+ footprintAABBFrom,
16
+ type GridEvent,
17
+ getFloorPlacedFootprints,
18
+ movingFootprintAnchors,
19
+ type NodeEvent,
20
+ nodeRegistry,
21
+ type ParentFrameSnapMatch,
22
+ type PortConnectivity,
23
+ resolveAlignment,
24
+ resolveConnectivityUpdates,
25
+ resolveFacingIndicator,
26
+ sceneRegistry,
27
+ spatialGridManager,
28
+ useLiveNodeOverrides,
29
+ useLiveTransforms,
30
+ useScene,
31
+ } from '@pascal-app/core'
32
+ import { useViewer } from '@pascal-app/viewer'
33
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
34
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
35
+ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
36
+ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
37
+ import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
38
+ import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
39
+ import { sfxEmitter } from '../../../lib/sfx-bus'
40
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
41
+
42
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
43
+ import useEditor, {
44
+ getActiveSnappingMode,
45
+ isAlignmentGuideActive,
46
+ isGridSnapActive,
47
+ isMagneticSnapActive,
48
+ } from '../../../store/use-editor'
49
+
50
+ import useFacingPose from '../../../store/use-facing-pose'
51
+ import { swallowNextClick } from '../../editor/node-arrow-handles'
52
+ import { CursorSphere } from '../shared/cursor-sphere'
53
+ import { DragBoundingBox } from '../shared/drag-bounding-box'
54
+ import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
55
+ import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
56
+ import { PlacementBox } from '../shared/placement-box'
57
+
58
+ /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
59
+ * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
60
+ const snapToGridStep = (value: number) => {
61
+ if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value
62
+ const step = useEditor.getState().gridSnapStep
63
+ return Math.round(value / step) * step
64
+ }
65
+
66
+ /** 45° steps, matching the GLB item placement rotation. */
67
+ const ROTATION_STEP = Math.PI / 4
68
+
69
+ /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
70
+ const PORT_SNAP_RADIUS_M = 0.5
71
+ const VALID_COLOR = 0x22_c5_5e
72
+ const INVALID_COLOR = 0xef_44_44
73
+
74
+ type DragBoundsOverride = {
75
+ size: [number, number, number]
76
+ center?: [number, number, number]
77
+ centerY?: number
78
+ }
79
+
80
+ function offsetPlanPositionByLocalCenter(
81
+ position: [number, number, number],
82
+ center: [number, number, number],
83
+ rotationY: number,
84
+ ): [number, number, number] {
85
+ const cos = Math.cos(rotationY)
86
+ const sin = Math.sin(rotationY)
87
+ return [
88
+ position[0] + center[0] * cos + center[2] * sin,
89
+ position[1] + center[1],
90
+ position[2] - center[0] * sin + center[2] * cos,
91
+ ]
92
+ }
93
+
94
+ /**
95
+ * Alignment anchors for the moving node. When the kind declares
96
+ * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
97
+ * run whose modules extend past the node origin), the anchors come from that
98
+ * declared box instead of the origin-centred footprint.
99
+ */
100
+ function movingDragBoundsAnchors(
101
+ node: AnyNode,
102
+ bounds: DragBoundsOverride | null,
103
+ x: number,
104
+ z: number,
105
+ rotationY: number,
106
+ ) {
107
+ if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY)
108
+ const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY)
109
+ const aabb = footprintAABBFrom(center, bounds.size, rotationY)
110
+ return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
111
+ }
112
+
113
+ function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide {
114
+ return {
115
+ axis: match.axis,
116
+ coord: match.axis === 'x' ? match.from.x : match.from.z,
117
+ from: match.from,
118
+ to: match.to,
119
+ anchor: match.from,
120
+ movingAnchorKind: 'corner',
121
+ candidateAnchorKind: 'corner',
122
+ candidateNodeId: match.candidateNodeId,
123
+ distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z),
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Magnetic port snap for a dragged node: if one of the node's own ports
129
+ * (read live from `def.ports`) lands within `radius` of a matching scene
130
+ * port at the candidate XZ, return the node XZ that mates them exactly.
131
+ *
132
+ * Pure core: ports come through `nodeRegistry` so this stays layer-clean.
133
+ * Ports are level-local meters — the same frame as the cursor's
134
+ * `localPosition`, so no extra transform is needed. The dragged node's
135
+ * ports move rigidly with its position, so a port at candidate `(x,z)`
136
+ * sits at `portStored + (candidate - nodeStored)`. We pick the closest
137
+ * (own-port, target-port) pair and shift the node so they coincide in XZ.
138
+ */
139
+ function resolvePortSnap(
140
+ node: AnyNode,
141
+ candidate: [number, number],
142
+ config: { systems?: readonly string[]; radius?: number },
143
+ ): [number, number] | null {
144
+ const nodePos = (node as { position?: [number, number, number] }).position
145
+ if (!nodePos) return null
146
+ const ownPorts = nodeRegistry.get(node.type)?.ports?.(node)
147
+ if (!ownPorts || ownPorts.length === 0) return null
148
+
149
+ const radius = config.radius ?? PORT_SNAP_RADIUS_M
150
+ const radiusSq = radius * radius
151
+ const { systems } = config
152
+ const dragDx = candidate[0] - nodePos[0]
153
+ const dragDz = candidate[1] - nodePos[2]
154
+
155
+ const nodes = useScene.getState().nodes
156
+ let bestDistSq = radiusSq
157
+ let snap: [number, number] | null = null
158
+
159
+ for (const node2 of Object.values(nodes)) {
160
+ if (!node2 || node2.id === node.id) continue
161
+ const targets = nodeRegistry.get(node2.type)?.ports?.(node2)
162
+ if (!targets) continue
163
+ for (const target of targets) {
164
+ if (systems && target.system !== undefined && !systems.includes(target.system)) continue
165
+ for (const own of ownPorts) {
166
+ // Own port at the candidate position = stored port + drag delta.
167
+ const ownX = own.position[0] + dragDx
168
+ const ownZ = own.position[2] + dragDz
169
+ const dx = target.position[0] - ownX
170
+ const dz = target.position[2] - ownZ
171
+ const distSq = dx * dx + dz * dz
172
+ if (distSq <= bestDistSq) {
173
+ bestDistSq = distSq
174
+ // Shift the node so this own port lands on the target (XZ only).
175
+ snap = [candidate[0] + dx, candidate[1] + dz]
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return snap
181
+ }
182
+
183
+ /** Figma-style alignment-snap threshold (meters), matching the 2D
184
+ * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
185
+ * without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
186
+ const ALIGNMENT_THRESHOLD_M = 0.08
187
+
188
+ /**
189
+ * Generic move tool for any registry-backed kind.
190
+ *
191
+ * Imperative-only motion during drag:
192
+ * - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
193
+ * directly. The node's store data is unchanged → the renderer doesn't
194
+ * re-render → R3F doesn't reapply `position={node.position}` → the
195
+ * imperative mutation sticks. Movement is smooth, framerate-locked,
196
+ * and React-free.
197
+ *
198
+ * Store update happens only on commit (single undoable action).
199
+ *
200
+ * Cancel imperatively snaps the mesh back to its original position and
201
+ * resumes history without ever having touched the store mid-drag.
202
+ *
203
+ * **Commit triggers**: the tool listens for `grid:click` *and* the
204
+ * common node click events (shelf / item / slab / ceiling / wall /
205
+ * fence / column / roof / stair). A click on the grid plane fires
206
+ * `grid:click`; a click on the moved node itself (or any other 3D
207
+ * geometry the ray happens to land on) fires the corresponding node
208
+ * click event. Without the node-click listeners, clicking on the
209
+ * cursor's own mesh during a move would silently drop the commit —
210
+ * the user perceives "click did nothing" because the click hit the
211
+ * vertical face of e.g. a shelf instead of the grid plane below it.
212
+ *
213
+ * The latest cursor position from `grid:move` is stored in a ref so
214
+ * any of these click variants commit at the same spot the cursor was
215
+ * indicating.
216
+ */
217
+ type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
218
+
219
+ const CLICK_TRIGGER_KINDS = [
220
+ 'shelf',
221
+ 'item',
222
+ 'slab',
223
+ 'ceiling',
224
+ 'wall',
225
+ 'fence',
226
+ 'column',
227
+ 'roof',
228
+ 'roof-segment',
229
+ 'stair',
230
+ 'stair-segment',
231
+ ] as const
232
+
233
+ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
234
+ // Kinds whose `position` lives in a host parent's local frame declare
235
+ // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
236
+ // plan-frame cursor through the capability's hooks and previews via
237
+ // `useLiveNodeOverrides` so the parent's composite geometry re-flows.
238
+ const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null
239
+ const frameParent = useMemo(
240
+ () => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null,
241
+ [parentFrame, node],
242
+ )
243
+ const originalPosition: [number, number, number] = useMemo(
244
+ () =>
245
+ 'position' in node && Array.isArray((node as { position?: unknown }).position)
246
+ ? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
247
+ : [0, 0, 0],
248
+ [node],
249
+ )
250
+ /**
251
+ * Y-axis rotation of the node at move-start. Captured so the
252
+ * imperative drag preview (and the `useLiveTransforms` mirror) keeps
253
+ * the original orientation — otherwise hardcoding `rotation: 0` in
254
+ * `useLiveTransforms.set` would override `node.rotation[1]` during
255
+ * the drag, the shelf would visually un-rotate to 0, then snap back
256
+ * to its true rotation on commit (when the live transform clears).
257
+ * The user reads that snap as "reverts to a weird position".
258
+ */
259
+ const originalRotationY: number = useMemo(() => {
260
+ if ('rotation' in node) {
261
+ const r = (node as { rotation?: unknown }).rotation
262
+ if (typeof r === 'number') return r
263
+ if (Array.isArray(r)) return (r as [number, number, number])[1] ?? 0
264
+ }
265
+ return 0
266
+ }, [node])
267
+ const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
268
+ const previousSnapRef = useRef<string | null>(null)
269
+ /**
270
+ * The latest snapped cursor position from `grid:move`. We commit at
271
+ * THIS position regardless of which event variant fires the click —
272
+ * a `grid:click` carries the same coords, but a node-click (e.g.
273
+ * `shelf:click`) carries the hit point on the clicked node's mesh,
274
+ * which can be slightly off-cursor when the user clicks the vertical
275
+ * face of the moved node itself. Reading from the ref keeps the
276
+ * commit position consistent with the visible cursor.
277
+ */
278
+ const lastCursorRef = useRef<[number, number, number]>(originalPosition)
279
+ const dragAnchorRef = useRef<[number, number] | null>(null)
280
+ /**
281
+ * Becomes true on the first `grid:move` after this move arms. Commits are
282
+ * ignored until then so a click that *armed* this move (e.g. the trailing
283
+ * `click` event of the click that just committed the previous copy, when a
284
+ * preset placement immediately re-arms the next one) can't auto-drop a
285
+ * second copy at the spot. Every real placement moves the cursor into
286
+ * position before the drop click, so this never blocks a legitimate commit.
287
+ */
288
+ const hasMovedRef = useRef(false)
289
+ // Live Y-rotation during the drag, seeded from the node's current rotation
290
+ // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
291
+ // and committed to the scene on drop.
292
+ const rotationRef = useRef(originalRotationY)
293
+ // Snapshot of which ducts / fittings are mated to this node's ports at
294
+ // drag-start (duct fittings only). Drives the "connected ductwork follows"
295
+ // behaviour: connected nodes preview through `useLiveNodeOverrides` during
296
+ // the drag and commit alongside the moved node on drop. Null for kinds with
297
+ // no ports, so every other movable kind is unaffected.
298
+ const connectivityRef = useRef<PortConnectivity | null>(null)
299
+ // Node ids this drag has pushed live overrides onto — cleared on
300
+ // commit / cancel / unmount so a follow-on drag starts clean.
301
+ const overriddenIdsRef = useRef<AnyNodeId[]>([])
302
+
303
+ // Colliding floor kinds (item / shelf / column) show the same green/red
304
+ // footprint box GLB items use (instead of the vertical-arrow cursor) and
305
+ // refuse an invalid drop unless Alt forces it. The gate + footprint both come
306
+ // from the kind's declarative `floorPlaced` capability, so opting a new kind
307
+ // in is just `collides: true` — no change here.
308
+ // Parent-frame kinds skip the world-frame floor-collision box — their
309
+ // position isn't in the level frame the spatial grid indexes.
310
+ const collides =
311
+ !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
312
+ // Snapshot the scene once at drag-start — bounds depend on `node` (locked
313
+ // for the lifetime of this tool) and any sibling state the kind reads. If a
314
+ // future kind needs live sibling state mid-drag, switch to a subscribed
315
+ // selector; for v1 (elevator shaft, cabinet run) start-time is correct and
316
+ // avoids subscribing the whole `nodes` map.
317
+ const dragBounds = useMemo(
318
+ (): DragBoundsOverride | null =>
319
+ (nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as
320
+ | DragBoundsOverride
321
+ | undefined) ?? null,
322
+ [node],
323
+ )
324
+ // Collision extents: the declared drag bounds (composite kinds — a cabinet
325
+ // run spans its modules) win over the single-node footprint.
326
+ const resolvedFootprint = useMemo(
327
+ () =>
328
+ dragBounds?.size ??
329
+ nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
330
+ null,
331
+ [dragBounds, node],
332
+ )
333
+ const boxDimensions = useMemo(
334
+ () => (collides ? resolvedFootprint : null),
335
+ [collides, resolvedFootprint],
336
+ )
337
+ const [valid, setValid] = useState(true)
338
+ const previewRotationY = useCallback(
339
+ (rotationY = rotationRef.current) =>
340
+ parentFrame && frameParent
341
+ ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
342
+ : rotationY,
343
+ [parentFrame, frameParent],
344
+ )
345
+ const visualPositionFor = useCallback(
346
+ (position: [number, number, number], rotationY = rotationRef.current) => {
347
+ if (parentFrame && frameParent) {
348
+ return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
349
+ }
350
+ return getFloorStackPreviewPosition({
351
+ node,
352
+ position,
353
+ rotation: (() => {
354
+ const r = (node as { rotation?: unknown }).rotation
355
+ return Array.isArray(r)
356
+ ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0]
357
+ : rotationY
358
+ })(),
359
+ })
360
+ },
361
+ [parentFrame, frameParent, node],
362
+ )
363
+ const canonicalPositionFromPlan = useCallback(
364
+ (planX: number, localY: number, planZ: number): [number, number, number] =>
365
+ parentFrame && frameParent
366
+ ? parentFrame.planToLocal(frameParent, planX, localY, planZ, useScene.getState().nodes)
367
+ : [planX, localY, planZ],
368
+ [parentFrame, frameParent],
369
+ )
370
+ const originalPlanPosition = useMemo(
371
+ () => visualPositionFor(originalPosition, originalRotationY),
372
+ [originalPosition, originalRotationY, visualPositionFor],
373
+ )
374
+ const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY))
375
+ const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
376
+ useFreshPlacementVisibility({ node })
377
+ // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
378
+ // cursor instead of preserving the grab offset — small connector-like
379
+ // nodes read an offset drag as "lagging behind the mouse".
380
+ const cursorAttached = nodeRegistry.get(node.type)?.capabilities?.movable?.cursorAttached === true
381
+ // Kinds that declare `movable.portSnap` (duct terminals) magnetically
382
+ // mate one of their own ports onto a nearby scene port while dragging —
383
+ // a register collar drops onto a duct run end. Reads `def.ports` through
384
+ // the core registry, so it stays layer-clean (no @pascal-app/nodes import).
385
+ const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
386
+ // Kind-owned magnetic snap for the generic 3D move path. Cabinets use this
387
+ // to settle a dragged run flush against a wall without forking the move tool.
388
+ const groupMoveSnapConfig =
389
+ nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
390
+ // Mirrors of `valid` / Alt for the event handlers inside the effect, which
391
+ // can't read React state without stale closures.
392
+ const validRef = useRef(true)
393
+ const altRef = useRef(false)
394
+
395
+ const exitMoveMode = useCallback(() => {
396
+ useEditor.getState().setMovingNode(null)
397
+ }, [])
398
+
399
+ useEffect(() => {
400
+ useScene.temporal.getState().pause()
401
+ previousSnapRef.current = null
402
+ dragAnchorRef.current = null
403
+ hasMovedRef.current = false
404
+ rotationRef.current = originalRotationY
405
+ altRef.current = false
406
+ validRef.current = true
407
+ // Re-sync the box transform to the (possibly new) node. `node` changes
408
+ // without this component remounting whenever a positioned preset re-arms a
409
+ // fresh clone after a drop, or the user picks a different catalog tile —
410
+ // and `useState` only honours its initial value, so without this the box
411
+ // would keep the previous clone's rotation/position until the next R/T.
412
+ setCursorRotationY(previewRotationY(originalRotationY))
413
+ lastCursorRef.current = originalPosition
414
+ let committed = false
415
+ const isNew = isFreshPlacement
416
+
417
+ const baseRotation = (node as { rotation?: unknown }).rotation
418
+ const toCommitRotation = (y: number): number | [number, number, number] =>
419
+ Array.isArray(baseRotation)
420
+ ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
421
+ : y
422
+
423
+ const getVisualPosition = visualPositionFor
424
+ const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
425
+ const object = sceneRegistry.nodes.get(node.id)
426
+ if (!object) return
427
+ object.position.set(...position)
428
+ object.rotation.y = rotationY
429
+ }
430
+ const markMovedNodeDirty = () => {
431
+ if (useScene.getState().nodes[node.id]) {
432
+ useScene.getState().markDirty(node.id as AnyNodeId)
433
+ }
434
+ }
435
+
436
+ // Connectivity follow (duct fittings): the moved node with its live drag
437
+ // transform, so `def.ports` recomputes for `resolveConnectivityUpdates`.
438
+ // Uses the logical (un-stacked) position + Y rotation that commit writes,
439
+ // not the floor-lifted visual position.
440
+ const buildPreviewNode = (position: [number, number, number], rotationY: number): AnyNode =>
441
+ ({
442
+ ...(node as Record<string, unknown>),
443
+ position,
444
+ rotation: toCommitRotation(rotationY),
445
+ }) as AnyNode
446
+
447
+ // Resolve the patches that keep connected ductwork attached and preview
448
+ // them through `useLiveNodeOverrides` (transient — no history churn;
449
+ // GeometrySystem merges overrides via getEffectiveNode). Each connected
450
+ // node is re-dirtied so its geometry rebuilds against the new override.
451
+ const previewConnectivity = (position: [number, number, number], rotationY: number) => {
452
+ const connectivity = connectivityRef.current
453
+ if (!connectivity) return
454
+ const updates = resolveConnectivityUpdates(
455
+ connectivity,
456
+ buildPreviewNode(position, rotationY),
457
+ )
458
+ if (updates.length === 0) return
459
+ useLiveNodeOverrides
460
+ .getState()
461
+ .setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
462
+ overriddenIdsRef.current = updates.map((u) => u.id)
463
+ for (const u of updates) {
464
+ if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
465
+ }
466
+ }
467
+
468
+ const clearConnectivityOverrides = () => {
469
+ for (const id of overriddenIdsRef.current) {
470
+ useLiveNodeOverrides.getState().clear(id)
471
+ if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
472
+ }
473
+ }
474
+
475
+ const syncParentFramePreview = (position: [number, number, number]) => {
476
+ if (!frameParent) return
477
+ useLiveNodeOverrides.getState().set(node.id, {
478
+ position,
479
+ rotation: rotationRef.current,
480
+ })
481
+ useScene.getState().markDirty(frameParent.id as AnyNodeId)
482
+ }
483
+
484
+ const clearParentFramePreview = () => {
485
+ if (!frameParent) return
486
+ useLiveNodeOverrides.getState().clear(node.id)
487
+ useScene.getState().markDirty(frameParent.id as AnyNodeId)
488
+ }
489
+
490
+ setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
491
+
492
+ // Re-run the floor-collision check at the live cursor + rotation and push
493
+ // the result to the box colour. Alt (free place) forces a valid (green)
494
+ // override so the user can drop on top of an existing item on purpose. Only
495
+ // shelves show the box, so this no-ops for every other movable kind.
496
+ const recomputeValidity = () => {
497
+ if (!boxDimensions) return
498
+ if (altRef.current) {
499
+ validRef.current = true
500
+ setValid(true)
501
+ return
502
+ }
503
+ const levelId = useViewer.getState().selection.levelId ?? node.parentId
504
+ if (!levelId) {
505
+ validRef.current = true
506
+ setValid(true)
507
+ return
508
+ }
509
+ const livePosition = lastCursorRef.current
510
+ const liveRotation = previewRotationY(rotationRef.current)
511
+ const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
512
+ const effectiveNode = {
513
+ ...(node as Record<string, unknown>),
514
+ position: livePosition,
515
+ rotation: Array.isArray((node as { rotation?: unknown }).rotation)
516
+ ? [
517
+ ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0,
518
+ rotationRef.current,
519
+ ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0,
520
+ ]
521
+ : rotationRef.current,
522
+ } as AnyNode
523
+ const footprints = floorPlaced
524
+ ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes })
525
+ : []
526
+ const resolvedFootprints: Array<{
527
+ position: [number, number, number]
528
+ dimensions: [number, number, number]
529
+ rotation: [number, number, number]
530
+ }> = footprints.map((footprint) => ({
531
+ position: footprint.position ?? livePosition,
532
+ dimensions: footprint.dimensions,
533
+ rotation: footprint.rotation,
534
+ }))
535
+ const { valid: placeable } =
536
+ resolvedFootprints.length > 0
537
+ ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id])
538
+ : spatialGridManager.canPlaceOnFloor(
539
+ levelId,
540
+ getVisualPosition(livePosition),
541
+ boxDimensions,
542
+ [0, liveRotation, 0],
543
+ [node.id],
544
+ )
545
+ validRef.current = placeable
546
+ setValid(placeable)
547
+ }
548
+ recomputeValidity()
549
+
550
+ // Disable raycast on the moved node's meshes for the duration of
551
+ // the drag. As the shelf follows the cursor, the cursor ray would
552
+ // otherwise hit the moved mesh first → only `${kind}:move` fires →
553
+ // `grid:move` stops updating `lastCursorRef` → clicks would commit
554
+ // at the stale (initial) position. With raycast disabled, the ray
555
+ // passes through the moved mesh and continues to the grid plane,
556
+ // so `grid:move` keeps firing and the cursor tracks correctly.
557
+ // We restore the original raycast on cleanup.
558
+ const mesh = sceneRegistry.nodes.get(node.id)
559
+ const restoreRaycasts: Array<() => void> = []
560
+ if (mesh) {
561
+ mesh.traverse((child) => {
562
+ const original = child.raycast
563
+ child.raycast = () => {}
564
+ restoreRaycasts.push(() => {
565
+ child.raycast = original
566
+ })
567
+ })
568
+ }
569
+
570
+ // Static alignment candidates — anchors of every OTHER alignable object
571
+ // (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
572
+ // gathered once at drag start (the scene graph is stable during an
573
+ // imperative move). Level-scoped so a node directly below on another
574
+ // floor doesn't snap (alignment is XZ-only). Coords are building-local,
575
+ // the same frame as `event.localPosition` and the rendered cursor, so
576
+ // the guide dots line up with the cursor.
577
+ const alignmentCandidates = collectAlignmentAnchors(
578
+ useScene.getState().nodes,
579
+ node.id,
580
+ useViewer.getState().selection.levelId ?? node.parentId,
581
+ )
582
+
583
+ // Connectivity snapshot (existing port-bearing nodes only — fresh
584
+ // placements aren't connected to anything yet). Records which ducts /
585
+ // fittings are mated to this node's ports so they can follow the drag.
586
+ connectivityRef.current = null
587
+ overriddenIdsRef.current = []
588
+ if (!isNew && nodeRegistry.get(node.type)?.ports) {
589
+ const snapshot = analyzePortConnectivity(node, useScene.getState().nodes)
590
+ if (snapshot.connections.length > 0) connectivityRef.current = snapshot
591
+ }
592
+
593
+ const onGridMove = (event: GridEvent) => {
594
+ const rawX = event.localPosition[0]
595
+ const rawZ = event.localPosition[2]
596
+ revealFreshPlacement()
597
+
598
+ const resolved = resolvePlanarCursorPosition({
599
+ cursor: [rawX, rawZ],
600
+ original: [originalPlanPosition[0], originalPlanPosition[2]],
601
+ anchor: dragAnchorRef.current,
602
+ mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
603
+ // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
604
+ snap: snapToGridStep,
605
+ })
606
+ dragAnchorRef.current = resolved.anchor
607
+ let [x, z] = resolved.point
608
+
609
+ // Figma-style alignment snap layered on top of grid snap: when the
610
+ // moving item's edge lines up (on X or Z) with another item's edge,
611
+ // publish a guide. The guide connects to the nearest real corner of the
612
+ // candidate (resolver tie-break), so the dot always sits on an actual
613
+ // point. Alignment "lines" are DISPLAYED in every mode except Off
614
+ // (isAlignmentGuideActive); the magnetic pull toward them applies only in
615
+ // 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
616
+ const magnetic = isMagneticSnapActive()
617
+ if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
618
+ const result = resolveAlignment({
619
+ moving: movingDragBoundsAnchors(
620
+ node,
621
+ dragBounds,
622
+ x,
623
+ z,
624
+ previewRotationY(rotationRef.current),
625
+ ),
626
+ candidates: alignmentCandidates,
627
+ threshold: ALIGNMENT_THRESHOLD_M,
628
+ })
629
+ if (result.snap && magnetic) {
630
+ x += result.snap.dx
631
+ z += result.snap.dz
632
+ }
633
+ useAlignmentGuides.getState().set(result.guides)
634
+ } else {
635
+ useAlignmentGuides.getState().clear()
636
+ }
637
+
638
+ // Kind-owned attachment snap (cabinet → wall): an attach behavior like
639
+ // door/window wall placement, not an alignment guide — active in every
640
+ // snapping mode except Off.
641
+ if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
642
+ const snappedPosition = groupMoveSnapConfig({
643
+ node,
644
+ candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
645
+ movingIds: [node.id as AnyNodeId],
646
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
647
+ levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
648
+ })
649
+ if (snappedPosition) {
650
+ const snappedPlanPosition = getVisualPosition(snappedPosition)
651
+ x = snappedPlanPosition[0]
652
+ z = snappedPlanPosition[2]
653
+ useAlignmentGuides.getState().clear()
654
+ }
655
+ }
656
+
657
+ // Magnetic port snap (duct terminals): mate a collar onto a nearby
658
+ // duct run end. Takes precedence over grid / alignment snap; Alt
659
+ // bypasses. Only kinds that opted in via `movable.portSnap`.
660
+ if (magnetic && portSnapConfig) {
661
+ // Build the preview node at the ORIGINAL position but with the LIVE
662
+ // rotation so `def.ports` reflects any mid-drag R/T rotation. Without
663
+ // this the snap solver mates the pre-rotation collar and commit then
664
+ // writes the rotated node offset from the port it visually snapped to.
665
+ const snapNode = buildPreviewNode(originalPosition, rotationRef.current)
666
+ const mated = resolvePortSnap(snapNode, [x, z], portSnapConfig)
667
+ if (mated) {
668
+ x = mated[0]
669
+ z = mated[1]
670
+ useAlignmentGuides.getState().clear()
671
+ }
672
+ }
673
+
674
+ let position = canonicalPositionFromPlan(x, originalPosition[1], z)
675
+ if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
676
+ const preSnapPosition = position
677
+ const snappedPosition = parentFrame.magneticSnap(
678
+ node,
679
+ frameParent,
680
+ position,
681
+ useScene.getState().nodes,
682
+ )
683
+ if (
684
+ snappedPosition[0] !== position[0] ||
685
+ snappedPosition[1] !== position[1] ||
686
+ snappedPosition[2] !== position[2]
687
+ ) {
688
+ position = snappedPosition
689
+ const snappedPlanPosition = getVisualPosition(position)
690
+ x = snappedPlanPosition[0]
691
+ z = snappedPlanPosition[2]
692
+ }
693
+ if (isAlignmentGuideActive() && parentFrame.magneticSnapMatches) {
694
+ const guides = parentFrame
695
+ .magneticSnapMatches(
696
+ node,
697
+ frameParent,
698
+ preSnapPosition,
699
+ snappedPosition,
700
+ useScene.getState().nodes,
701
+ )
702
+ .map(alignmentGuideFromParentFrameMatch)
703
+ if (guides.length > 0) useAlignmentGuides.getState().set(guides)
704
+ }
705
+ }
706
+ const visualPosition = getVisualPosition(position)
707
+ hasMovedRef.current = true
708
+ setCursorPosition(visualPosition)
709
+ lastCursorRef.current = position
710
+ recomputeValidity()
711
+
712
+ // Pure imperative: move the mesh via its registered Object3D ref.
713
+ applyMeshPose(position)
714
+ // Publish to `useLiveTransforms` so the 2D floor plan can mirror
715
+ // the drag in real-time (the floor-plan layer subscribes to this
716
+ // store and overrides the node's rendered position when an entry
717
+ // is set). Without this the 2D representation stays at the
718
+ // committed scene position until the move ends.
719
+ //
720
+ // For position-based kinds (shelf, item, column, spawn) we write
721
+ // the absolute world plan position here. Polygon-based kinds
722
+ // (slab / ceiling / fence) follow a different delta contract —
723
+ // their floor-plan move-targets handle the override themselves.
724
+ useLiveTransforms.getState().set(node.id, {
725
+ position,
726
+ rotation: rotationRef.current,
727
+ })
728
+ syncParentFramePreview(position)
729
+ markMovedNodeDirty()
730
+ // Carry connected ductwork along (preview only — committed on drop).
731
+ previewConnectivity(position, rotationRef.current)
732
+
733
+ const nextSnapKey = movementSfxStepKey({
734
+ coords: [x, z],
735
+ gridSnapActive: isGridSnapActive(),
736
+ gridStep: useEditor.getState().gridSnapStep,
737
+ })
738
+ const prev = previousSnapRef.current
739
+ if (prev !== nextSnapKey) {
740
+ sfxEmitter.emit('sfx:grid-snap')
741
+ previousSnapRef.current = nextSnapKey
742
+ }
743
+ }
744
+
745
+ /** Commit the move at the latest cursor position. Shared by every
746
+ * click variant — grid plane, the moved node itself, or any other
747
+ * 3D surface the user happens to click on during the move.
748
+ *
749
+ * Order is deliberate: write scene FIRST, then clear
750
+ * `useLiveTransforms`. If we cleared the live transform first,
751
+ * `ParametricNodeRenderer` would re-render with
752
+ * `position = liveTransform?.position ?? node.position` → undefined
753
+ * → original `node.position` (the scene write hasn't happened yet),
754
+ * briefly snapping the mesh back to its starting spot before the
755
+ * next render lands the new position. Writing scene first means
756
+ * every render shows either the live drag position (liveTransform
757
+ * still set) or the new committed position (liveTransform cleared
758
+ * AND scene updated) — never the original.
759
+ */
760
+ const commitAtCursor = (event: ClickTriggerEvent) => {
761
+ // One physical click can reach here twice: node clicks (`slab:click`,
762
+ // `item:click`, …) are synthesized on *pointerup* (`use-node-events`),
763
+ // while `grid:click` rides the browser's native *click* event from a
764
+ // canvas DOM listener (`use-grid-events`) that deliberately ignores
765
+ // stopPropagation — and this effect stays subscribed until React
766
+ // re-renders after `exitMoveMode`. Without this guard the second pass
767
+ // finds the fresh draft already deleted and takes the orphan re-create
768
+ // path below, minting a hidden ghost copy and replaying the SFX.
769
+ if (committed) return
770
+ // Ignore a commit that fires before the cursor has moved into place —
771
+ // it's the stray trailing click of whatever armed this move, not a
772
+ // deliberate drop. Prevents preset re-arm from double-placing.
773
+ if (!hasMovedRef.current) return
774
+ // Refuse a drop on an invalid (red) footprint, matching the GLB item
775
+ // tool — unless Alt (free place) is held to force placement. Other kinds
776
+ // carry no validity box (`validRef` stays true), so they're never blocked.
777
+ if (!validRef.current && !altRef.current) return
778
+ const position: [number, number, number] = [...lastCursorRef.current]
779
+
780
+ const rotation = toCommitRotation(rotationRef.current)
781
+ let committedId = node.id as AnyNodeId
782
+
783
+ if (useScene.getState().nodes[node.id]) {
784
+ const data = {
785
+ position,
786
+ rotation,
787
+ ...(isNew
788
+ ? {
789
+ metadata: stripPlacementMetadataFlags(node.metadata),
790
+ visible: true,
791
+ }
792
+ : null),
793
+ } as Partial<AnyNode>
794
+
795
+ if (isNew) {
796
+ const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data)
797
+ if (finalId) {
798
+ committed = true
799
+ committedId = finalId
800
+ }
801
+ } else {
802
+ // Fold the connected-ductwork follow-updates into the SAME
803
+ // batch as the moved node so the whole thing is one undo step.
804
+ const connectivityUpdates = connectivityRef.current
805
+ ? resolveConnectivityUpdates(
806
+ connectivityRef.current,
807
+ buildPreviewNode(position, rotationRef.current),
808
+ ).filter((u) => useScene.getState().nodes[u.id])
809
+ : []
810
+ useScene.temporal.getState().resume()
811
+ useScene
812
+ .getState()
813
+ .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
814
+ // Kind-owned derived-state maintenance after a parent-frame move
815
+ // (cabinet run re-flow + linked corner-run re-anchor). Runs in the
816
+ // resumed window so its writes are undoable alongside the move.
817
+ if (parentFrame?.onCommit && frameParent) {
818
+ const liveNode = useScene.getState().nodes[node.id as AnyNodeId]
819
+ const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId]
820
+ if (liveNode && liveParent) {
821
+ parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene))
822
+ }
823
+ }
824
+ useScene.temporal.getState().pause()
825
+ committed = true
826
+ }
827
+ } else if (node.parentId) {
828
+ // Orphan re-create path: re-parse via the registry's schema.
829
+ const def = nodeRegistry.get(node.type)
830
+ if (def) {
831
+ const reparsed = def.schema.parse({
832
+ ...(node as Record<string, unknown>),
833
+ id: undefined,
834
+ metadata: {},
835
+ position,
836
+ rotation,
837
+ })
838
+ useScene.temporal.getState().resume()
839
+ useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
840
+ useScene.temporal.getState().pause()
841
+ committed = true
842
+ }
843
+ }
844
+
845
+ // Clear after the scene write so React reconciles against the new
846
+ // canonical position, then restamp the lifted presentation Y for the
847
+ // current frame.
848
+ useLiveTransforms.getState().clear(node.id)
849
+ // Connected ductwork is now committed to the store — drop its live
850
+ // overrides so the renderers read the canonical path/position.
851
+ clearConnectivityOverrides()
852
+ clearParentFramePreview()
853
+ applyMeshPose(position)
854
+
855
+ useAlignmentGuides.getState().clear()
856
+ if (isNew && committed) {
857
+ useViewer.getState().setSelection({ selectedIds: [committedId] })
858
+ }
859
+
860
+ sfxEmitter.emit('sfx:item-place')
861
+ useEditor.getState().setMovingNodeOrigin('3d')
862
+ exitMoveMode()
863
+
864
+ // Stop further propagation so other listeners (e.g. a selection
865
+ // change on the clicked node) don't fire during the commit click.
866
+ const native = (event as { nativeEvent?: unknown }).nativeEvent
867
+ if (
868
+ native &&
869
+ typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
870
+ ) {
871
+ ;(native as { stopPropagation: () => void }).stopPropagation()
872
+ }
873
+ const direct = (event as { stopPropagation?: () => void }).stopPropagation
874
+ if (typeof direct === 'function') direct.call(event)
875
+ }
876
+
877
+ // R / T rotate the dragged node about Y in 45° steps — matching the GLB
878
+ // item placement keys (and the "Rotate" hints the move HUD shows). Applied
879
+ // imperatively + mirrored to the live transform; committed on drop.
880
+ const onKeyDown = (e: KeyboardEvent) => {
881
+ // Hold Alt (free place) to force placement on an invalid (red) footprint,
882
+ // matching the GLB item tool. Recolour the box to green while held.
883
+ if (e.key === 'Alt') {
884
+ altRef.current = true
885
+ recomputeValidity()
886
+ return
887
+ }
888
+ if (e.metaKey || e.ctrlKey || e.altKey) return
889
+ let delta = 0
890
+ if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
891
+ else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
892
+ else return
893
+ e.preventDefault()
894
+ sfxEmitter.emit('sfx:item-rotate')
895
+ rotationRef.current += delta
896
+ setCursorRotationY(previewRotationY(rotationRef.current))
897
+ const position = lastCursorRef.current
898
+ const visualPosition = getVisualPosition(position)
899
+ setCursorPosition(visualPosition)
900
+ applyMeshPose(position)
901
+ useLiveTransforms.getState().set(node.id, {
902
+ position,
903
+ rotation: rotationRef.current,
904
+ })
905
+ syncParentFramePreview(position)
906
+ markMovedNodeDirty()
907
+ // Rotating the fitting swings its collars — connected ducts follow.
908
+ previewConnectivity(position, rotationRef.current)
909
+ // Rotation changes the footprint's collision span — re-check validity.
910
+ recomputeValidity()
911
+ }
912
+ const onKeyUp = (e: KeyboardEvent) => {
913
+ if (e.key === 'Alt') {
914
+ altRef.current = false
915
+ recomputeValidity()
916
+ }
917
+ }
918
+ window.addEventListener('keydown', onKeyDown)
919
+ window.addEventListener('keyup', onKeyUp)
920
+
921
+ emitter.on('grid:move', onGridMove)
922
+ emitter.on('grid:click', commitAtCursor)
923
+
924
+ const onPlacementDragPointerUp = (event: PointerEvent) => {
925
+ if (!useEditor.getState().placementDragMode) return
926
+ if (event.button !== 0) return
927
+ swallowNextClick()
928
+ if (!hasMovedRef.current) {
929
+ exitMoveMode()
930
+ return
931
+ }
932
+ commitAtCursor({
933
+ nativeEvent: event,
934
+ stopPropagation: () => event.stopPropagation(),
935
+ } as unknown as ClickTriggerEvent)
936
+ }
937
+ window.addEventListener('pointerup', onPlacementDragPointerUp)
938
+
939
+ // Listen on every common kind's click event too. mitt's typing keeps
940
+ // `${kind}:click` as a fixed union so the cast is safe at runtime —
941
+ // we're just routing them through the shared commit path.
942
+ type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
943
+ type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
944
+ for (const kind of CLICK_TRIGGER_KINDS) {
945
+ const key = `${kind}:click` as ClickKey
946
+ emitter.on(key, commitAtCursor as never)
947
+ }
948
+
949
+ const onCancel = () => {
950
+ useLiveTransforms.getState().clear(node.id)
951
+ clearConnectivityOverrides()
952
+ clearParentFramePreview()
953
+ if (isNew) {
954
+ useScene.getState().deleteNode(node.id as AnyNodeId)
955
+ } else {
956
+ applyMeshPose(originalPosition, originalRotationY)
957
+ markMovedNodeDirty()
958
+ }
959
+ useAlignmentGuides.getState().clear()
960
+ useScene.temporal.getState().resume()
961
+ markToolCancelConsumed()
962
+ exitMoveMode()
963
+ }
964
+ emitter.on('tool:cancel', onCancel)
965
+
966
+ return () => {
967
+ window.removeEventListener('keydown', onKeyDown)
968
+ window.removeEventListener('keyup', onKeyUp)
969
+ emitter.off('grid:move', onGridMove)
970
+ emitter.off('grid:click', commitAtCursor)
971
+ window.removeEventListener('pointerup', onPlacementDragPointerUp)
972
+ for (const kind of CLICK_TRIGGER_KINDS) {
973
+ const key = `${kind}:click` as ClickKey
974
+ emitter.off(key, commitAtCursor as never)
975
+ }
976
+ emitter.off('tool:cancel', onCancel)
977
+ // Restore the moved meshes' raycast so they're hoverable / selectable
978
+ // again after the drag ends.
979
+ for (const restore of restoreRaycasts) restore()
980
+ // Drop any alignment guides this drag published — covers Esc / mid-drag
981
+ // unmount / commit paths uniformly.
982
+ useAlignmentGuides.getState().clear()
983
+ const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
984
+ if (!(committed || isNew || finalisedBy2D)) {
985
+ useLiveTransforms.getState().clear(node.id)
986
+ clearConnectivityOverrides()
987
+ clearParentFramePreview()
988
+ applyMeshPose(originalPosition, originalRotationY)
989
+ markMovedNodeDirty()
990
+ }
991
+ useScene.temporal.getState().resume()
992
+ }
993
+ }, [
994
+ boxDimensions,
995
+ dragBounds,
996
+ canonicalPositionFromPlan,
997
+ parentFrame,
998
+ frameParent,
999
+ cursorAttached,
1000
+ portSnapConfig,
1001
+ groupMoveSnapConfig,
1002
+ exitMoveMode,
1003
+ isFreshPlacement,
1004
+ node,
1005
+ originalPosition,
1006
+ originalPlanPosition,
1007
+ originalRotationY,
1008
+ previewRotationY,
1009
+ revealFreshPlacement,
1010
+ useAbsoluteCursorPlacement,
1011
+ visualPositionFor,
1012
+ ])
1013
+
1014
+ // Forward-facing triangle for the footprint-box branch (item / shelf / column
1015
+ // — anything that renders `<PlacementBox>`). Published to the editor-side
1016
+ // overlay; the `<DragBoundingBox>` branch (e.g. stair, which has no centred
1017
+ // footprint) publishes its own. The box is centred on `cursorPosition`, so
1018
+ // the footprint centre is the origin. Clears on unmount.
1019
+ const facing = resolveFacingIndicator(node.type)
1020
+ useEffect(() => {
1021
+ if (!previewVisible || !facing || !boxDimensions) return
1022
+ useFacingPose.getState().set({
1023
+ position: cursorPosition,
1024
+ rotationY: cursorRotationY,
1025
+ depth: boxDimensions[2],
1026
+ center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined,
1027
+ reversed: facing.reversed,
1028
+ })
1029
+ }, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY])
1030
+ useEffect(() => () => useFacingPose.getState().clear(), [])
1031
+
1032
+ if (!previewVisible) return null
1033
+
1034
+ if (boxDimensions && !dragBounds?.center) {
1035
+ return (
1036
+ <PlacementBox
1037
+ dimensions={boxDimensions}
1038
+ position={cursorPosition}
1039
+ rotationY={cursorRotationY}
1040
+ valid={valid}
1041
+ />
1042
+ )
1043
+ }
1044
+
1045
+ const dragCenterPosition =
1046
+ dragBounds?.center && dragBounds.size
1047
+ ? offsetPlanPositionByLocalCenter(cursorPosition, dragBounds.center, cursorRotationY)
1048
+ : cursorPosition
1049
+
1050
+ return (
1051
+ <>
1052
+ <CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
1053
+ <DragBoundingBox
1054
+ center={dragBounds?.center}
1055
+ centerY={dragBounds?.centerY}
1056
+ color={boxDimensions ? (valid ? VALID_COLOR : INVALID_COLOR) : undefined}
1057
+ nodeId={node.id}
1058
+ position={cursorPosition}
1059
+ rotationY={cursorRotationY}
1060
+ size={dragBounds?.size}
1061
+ />
1062
+ </>
1063
+ )
1064
+ }