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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -3,45 +3,185 @@
3
3
  import '../../../three-types'
4
4
 
5
5
  import {
6
+ type AlignmentGuide,
6
7
  type AnyNode,
7
8
  type AnyNodeId,
9
+ analyzePortConnectivity,
10
+ bboxCornerAnchors,
8
11
  collectAlignmentAnchors,
12
+ createSceneApi,
9
13
  type EventSuffix,
10
14
  emitter,
15
+ footprintAABBFrom,
11
16
  type GridEvent,
17
+ getFloorPlacedFootprints,
12
18
  movingFootprintAnchors,
13
19
  type NodeEvent,
14
20
  nodeRegistry,
21
+ type ParentFrameSnapMatch,
22
+ type PortConnectivity,
15
23
  resolveAlignment,
24
+ resolveConnectivityUpdates,
25
+ resolveFacingIndicator,
26
+ resolveSupportSlabPatch,
16
27
  sceneRegistry,
17
28
  spatialGridManager,
29
+ useLiveNodeOverrides,
18
30
  useLiveTransforms,
19
31
  useScene,
20
32
  } from '@pascal-app/core'
21
- import { useAlignmentGuides } from '@pascal-app/editor'
22
33
  import { useViewer } from '@pascal-app/viewer'
34
+ import { useThree } from '@react-three/fiber'
23
35
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
24
36
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
25
37
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
26
38
  import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
27
39
  import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
40
+ import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
28
41
  import { sfxEmitter } from '../../../lib/sfx-bus'
29
- import useEditor from '../../../store/use-editor'
42
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
43
+
44
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
45
+ import useEditor, {
46
+ getActiveSnappingMode,
47
+ isAlignmentGuideActive,
48
+ isGridSnapActive,
49
+ isMagneticSnapActive,
50
+ } from '../../../store/use-editor'
51
+
52
+ import useFacingPose from '../../../store/use-facing-pose'
53
+ import { swallowNextClick } from '../../editor/node-arrow-handles'
30
54
  import { CursorSphere } from '../shared/cursor-sphere'
31
55
  import { DragBoundingBox } from '../shared/drag-bounding-box'
32
56
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
33
57
  import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
34
58
  import { PlacementBox } from '../shared/placement-box'
59
+ import { resolvePointerSupportSurface } from '../shared/pointer-support-cap'
35
60
 
36
61
  /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
37
62
  * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
38
63
  const snapToGridStep = (value: number) => {
64
+ if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value
39
65
  const step = useEditor.getState().gridSnapStep
40
66
  return Math.round(value / step) * step
41
67
  }
42
68
 
43
- /** 90° steps, matching the GLB item placement rotation. */
44
- const ROTATION_STEP = Math.PI / 2
69
+ /** 45° steps, matching the GLB item placement rotation. */
70
+ const ROTATION_STEP = Math.PI / 4
71
+
72
+ /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
73
+ const PORT_SNAP_RADIUS_M = 0.5
74
+ const VALID_COLOR = 0x22_c5_5e
75
+ const INVALID_COLOR = 0xef_44_44
76
+
77
+ type DragBoundsOverride = {
78
+ size: [number, number, number]
79
+ center?: [number, number, number]
80
+ centerY?: number
81
+ }
82
+
83
+ function offsetPlanPositionByLocalCenter(
84
+ position: [number, number, number],
85
+ center: [number, number, number],
86
+ rotationY: number,
87
+ ): [number, number, number] {
88
+ const cos = Math.cos(rotationY)
89
+ const sin = Math.sin(rotationY)
90
+ return [
91
+ position[0] + center[0] * cos + center[2] * sin,
92
+ position[1] + center[1],
93
+ position[2] - center[0] * sin + center[2] * cos,
94
+ ]
95
+ }
96
+
97
+ /**
98
+ * Alignment anchors for the moving node. When the kind declares
99
+ * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
100
+ * run whose modules extend past the node origin), the anchors come from that
101
+ * declared box instead of the origin-centred footprint.
102
+ */
103
+ function movingDragBoundsAnchors(
104
+ node: AnyNode,
105
+ bounds: DragBoundsOverride | null,
106
+ x: number,
107
+ z: number,
108
+ rotationY: number,
109
+ ) {
110
+ if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY)
111
+ const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY)
112
+ const aabb = footprintAABBFrom(center, bounds.size, rotationY)
113
+ return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
114
+ }
115
+
116
+ function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide {
117
+ return {
118
+ axis: match.axis,
119
+ coord: match.axis === 'x' ? match.from.x : match.from.z,
120
+ from: match.from,
121
+ to: match.to,
122
+ anchor: match.from,
123
+ movingAnchorKind: 'corner',
124
+ candidateAnchorKind: 'corner',
125
+ candidateNodeId: match.candidateNodeId,
126
+ distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z),
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Magnetic port snap for a dragged node: if one of the node's own ports
132
+ * (read live from `def.ports`) lands within `radius` of a matching scene
133
+ * port at the candidate XZ, return the node XZ that mates them exactly.
134
+ *
135
+ * Pure core: ports come through `nodeRegistry` so this stays layer-clean.
136
+ * Ports are level-local meters — the same frame as the cursor's
137
+ * `localPosition`, so no extra transform is needed. The dragged node's
138
+ * ports move rigidly with its position, so a port at candidate `(x,z)`
139
+ * sits at `portStored + (candidate - nodeStored)`. We pick the closest
140
+ * (own-port, target-port) pair and shift the node so they coincide in XZ.
141
+ */
142
+ function resolvePortSnap(
143
+ node: AnyNode,
144
+ candidate: [number, number],
145
+ config: { systems?: readonly string[]; radius?: number },
146
+ ): [number, number] | null {
147
+ const nodePos = (node as { position?: [number, number, number] }).position
148
+ if (!nodePos) return null
149
+ const ownPorts = nodeRegistry.get(node.type)?.ports?.(node)
150
+ if (!ownPorts || ownPorts.length === 0) return null
151
+
152
+ const radius = config.radius ?? PORT_SNAP_RADIUS_M
153
+ const radiusSq = radius * radius
154
+ const { systems } = config
155
+ const dragDx = candidate[0] - nodePos[0]
156
+ const dragDz = candidate[1] - nodePos[2]
157
+
158
+ const nodes = useScene.getState().nodes
159
+ let bestDistSq = radiusSq
160
+ let snap: [number, number] | null = null
161
+
162
+ for (const node2 of Object.values(nodes)) {
163
+ if (!node2 || node2.id === node.id) continue
164
+ const targets = nodeRegistry.get(node2.type)?.ports?.(node2)
165
+ if (!targets) continue
166
+ for (const target of targets) {
167
+ if (systems && target.system !== undefined && !systems.includes(target.system)) continue
168
+ for (const own of ownPorts) {
169
+ // Own port at the candidate position = stored port + drag delta.
170
+ const ownX = own.position[0] + dragDx
171
+ const ownZ = own.position[2] + dragDz
172
+ const dx = target.position[0] - ownX
173
+ const dz = target.position[2] - ownZ
174
+ const distSq = dx * dx + dz * dz
175
+ if (distSq <= bestDistSq) {
176
+ bestDistSq = distSq
177
+ // Shift the node so this own port lands on the target (XZ only).
178
+ snap = [candidate[0] + dx, candidate[1] + dz]
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return snap
184
+ }
45
185
 
46
186
  /** Figma-style alignment-snap threshold (meters), matching the 2D
47
187
  * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
@@ -94,6 +234,24 @@ const CLICK_TRIGGER_KINDS = [
94
234
  ] as const
95
235
 
96
236
  export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
237
+ // Live camera ref — the pointer-surface cap reconstructs the cursor world
238
+ // ray (camera → grid hit) to find which walking surface is aimed at.
239
+ const camera = useThree((s) => s.camera)
240
+ const cameraRef = useRef(camera)
241
+ cameraRef.current = camera
242
+ // Level-local elevation of the surface the pointer ray points at,
243
+ // refreshed per grid move. Caps the floor-support election so a deck
244
+ // hanging above the aimed-at floor never lifts the dragged node.
245
+ const supportCapRef = useRef<number | null>(null)
246
+ // Kinds whose `position` lives in a host parent's local frame declare
247
+ // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
248
+ // plan-frame cursor through the capability's hooks and previews via
249
+ // `useLiveNodeOverrides` so the parent's composite geometry re-flows.
250
+ const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null
251
+ const frameParent = useMemo(
252
+ () => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null,
253
+ [parentFrame, node],
254
+ )
97
255
  const originalPosition: [number, number, number] = useMemo(
98
256
  () =>
99
257
  'position' in node && Array.isArray((node as { position?: unknown }).position)
@@ -119,7 +277,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
119
277
  return 0
120
278
  }, [node])
121
279
  const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
122
- const previousSnapRef = useRef<[number, number] | null>(null)
280
+ const previousSnapRef = useRef<string | null>(null)
123
281
  /**
124
282
  * The latest snapped cursor position from `grid:move`. We commit at
125
283
  * THIS position regardless of which event variant fires the click —
@@ -144,28 +302,108 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
144
302
  // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
145
303
  // and committed to the scene on drop.
146
304
  const rotationRef = useRef(originalRotationY)
305
+ // Snapshot of which ducts / fittings are mated to this node's ports at
306
+ // drag-start (duct fittings only). Drives the "connected ductwork follows"
307
+ // behaviour: connected nodes preview through `useLiveNodeOverrides` during
308
+ // the drag and commit alongside the moved node on drop. Null for kinds with
309
+ // no ports, so every other movable kind is unaffected.
310
+ const connectivityRef = useRef<PortConnectivity | null>(null)
311
+ // Node ids this drag has pushed live overrides onto — cleared on
312
+ // commit / cancel / unmount so a follow-on drag starts clean.
313
+ const overriddenIdsRef = useRef<AnyNodeId[]>([])
147
314
 
148
- // Shelf placement shows the same green/red footprint box GLB items use
149
- // (instead of the vertical-arrow cursor) and refuses an invalid drop unless
150
- // Shift forces it. The footprint comes from the kind's `floorPlaced`
151
- // capability so this stays generic if we ever opt other kinds in.
152
- const isShelf = node.type === 'shelf'
153
- const boxDimensions = useMemo(
315
+ // Colliding floor kinds (item / shelf / column) show the same green/red
316
+ // footprint box GLB items use (instead of the vertical-arrow cursor) and
317
+ // refuse an invalid drop unless Alt forces it. The gate + footprint both come
318
+ // from the kind's declarative `floorPlaced` capability, so opting a new kind
319
+ // in is just `collides: true` — no change here.
320
+ // Parent-frame kinds skip the world-frame floor-collision box — their
321
+ // position isn't in the level frame the spatial grid indexes.
322
+ const collides =
323
+ !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
324
+ // Snapshot the scene once at drag-start — bounds depend on `node` (locked
325
+ // for the lifetime of this tool) and any sibling state the kind reads. If a
326
+ // future kind needs live sibling state mid-drag, switch to a subscribed
327
+ // selector; for v1 (elevator shaft, cabinet run) start-time is correct and
328
+ // avoids subscribing the whole `nodes` map.
329
+ const dragBounds = useMemo(
330
+ (): DragBoundsOverride | null =>
331
+ (nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as
332
+ | DragBoundsOverride
333
+ | undefined) ?? null,
334
+ [node],
335
+ )
336
+ // Collision extents: the declared drag bounds (composite kinds — a cabinet
337
+ // run spans its modules) win over the single-node footprint.
338
+ const resolvedFootprint = useMemo(
154
339
  () =>
155
- isShelf
156
- ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
157
- null)
158
- : null,
159
- [isShelf, node],
340
+ dragBounds?.size ??
341
+ nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
342
+ null,
343
+ [dragBounds, node],
344
+ )
345
+ const boxDimensions = useMemo(
346
+ () => (collides ? resolvedFootprint : null),
347
+ [collides, resolvedFootprint],
160
348
  )
161
349
  const [valid, setValid] = useState(true)
162
- const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
350
+ const previewRotationY = useCallback(
351
+ (rotationY = rotationRef.current) =>
352
+ parentFrame && frameParent
353
+ ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
354
+ : rotationY,
355
+ [parentFrame, frameParent],
356
+ )
357
+ const visualPositionFor = useCallback(
358
+ (position: [number, number, number], rotationY = rotationRef.current) => {
359
+ if (parentFrame && frameParent) {
360
+ return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
361
+ }
362
+ return getFloorStackPreviewPosition({
363
+ node,
364
+ position,
365
+ rotation: (() => {
366
+ const r = (node as { rotation?: unknown }).rotation
367
+ return Array.isArray(r)
368
+ ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0]
369
+ : rotationY
370
+ })(),
371
+ maxElevation: supportCapRef.current,
372
+ })
373
+ },
374
+ [parentFrame, frameParent, node],
375
+ )
376
+ const canonicalPositionFromPlan = useCallback(
377
+ (planX: number, localY: number, planZ: number): [number, number, number] =>
378
+ parentFrame && frameParent
379
+ ? parentFrame.planToLocal(frameParent, planX, localY, planZ, useScene.getState().nodes)
380
+ : [planX, localY, planZ],
381
+ [parentFrame, frameParent],
382
+ )
383
+ const originalPlanPosition = useMemo(
384
+ () => visualPositionFor(originalPosition, originalRotationY),
385
+ [originalPosition, originalRotationY, visualPositionFor],
386
+ )
387
+ const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY))
163
388
  const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
164
389
  useFreshPlacementVisibility({ node })
165
- // Mirrors of `valid` / Shift for the event handlers inside the effect, which
390
+ // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
391
+ // cursor instead of preserving the grab offset — small connector-like
392
+ // nodes read an offset drag as "lagging behind the mouse".
393
+ const cursorAttached = nodeRegistry.get(node.type)?.capabilities?.movable?.cursorAttached === true
394
+ // Kinds that declare `movable.portSnap` (duct terminals) magnetically
395
+ // mate one of their own ports onto a nearby scene port while dragging —
396
+ // a register collar drops onto a duct run end. Reads `def.ports` through
397
+ // the core registry, so it stays layer-clean (no @pascal-app/nodes import).
398
+ const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
399
+ // Kind-owned magnetic snap for the generic 3D move path. Cabinets use this
400
+ // to settle a dragged run flush against a wall without forking the move tool.
401
+ const groupMoveSnapConfig =
402
+ nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
403
+ // Mirrors of `valid` / Alt for the event handlers inside the effect, which
166
404
  // can't read React state without stale closures.
167
405
  const validRef = useRef(true)
168
- const shiftRef = useRef(false)
406
+ const altRef = useRef(false)
169
407
 
170
408
  const exitMoveMode = useCallback(() => {
171
409
  useEditor.getState().setMovingNode(null)
@@ -177,14 +415,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
177
415
  dragAnchorRef.current = null
178
416
  hasMovedRef.current = false
179
417
  rotationRef.current = originalRotationY
180
- shiftRef.current = false
418
+ altRef.current = false
181
419
  validRef.current = true
420
+ // No pointer surface known yet — uncapped election (the node keeps its
421
+ // persisted host / committed elevation until the first grid move).
422
+ supportCapRef.current = null
182
423
  // Re-sync the box transform to the (possibly new) node. `node` changes
183
424
  // without this component remounting whenever a positioned preset re-arms a
184
425
  // fresh clone after a drop, or the user picks a different catalog tile —
185
426
  // and `useState` only honours its initial value, so without this the box
186
427
  // would keep the previous clone's rotation/position until the next R/T.
187
- setCursorRotationY(originalRotationY)
428
+ setCursorRotationY(previewRotationY(originalRotationY))
188
429
  lastCursorRef.current = originalPosition
189
430
  let committed = false
190
431
  const isNew = isFreshPlacement
@@ -195,15 +436,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
195
436
  ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
196
437
  : y
197
438
 
198
- const getVisualPosition = (
199
- position: [number, number, number],
200
- rotationY = rotationRef.current,
201
- ): [number, number, number] => {
202
- return getFloorStackPreviewPosition({
203
- node,
204
- position,
205
- rotation: toCommitRotation(rotationY),
206
- })
439
+ const getVisualPosition = visualPositionFor
440
+ const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
441
+ const object = sceneRegistry.nodes.get(node.id)
442
+ if (!object) return
443
+ object.position.set(...position)
444
+ object.rotation.y = rotationY
207
445
  }
208
446
  const markMovedNodeDirty = () => {
209
447
  if (useScene.getState().nodes[node.id]) {
@@ -211,15 +449,69 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
211
449
  }
212
450
  }
213
451
 
452
+ // Connectivity follow (duct fittings): the moved node with its live drag
453
+ // transform, so `def.ports` recomputes for `resolveConnectivityUpdates`.
454
+ // Uses the logical (un-stacked) position + Y rotation that commit writes,
455
+ // not the floor-lifted visual position.
456
+ const buildPreviewNode = (position: [number, number, number], rotationY: number): AnyNode =>
457
+ ({
458
+ ...(node as Record<string, unknown>),
459
+ position,
460
+ rotation: toCommitRotation(rotationY),
461
+ }) as AnyNode
462
+
463
+ // Resolve the patches that keep connected ductwork attached and preview
464
+ // them through `useLiveNodeOverrides` (transient — no history churn;
465
+ // GeometrySystem merges overrides via getEffectiveNode). Each connected
466
+ // node is re-dirtied so its geometry rebuilds against the new override.
467
+ const previewConnectivity = (position: [number, number, number], rotationY: number) => {
468
+ const connectivity = connectivityRef.current
469
+ if (!connectivity) return
470
+ const updates = resolveConnectivityUpdates(
471
+ connectivity,
472
+ buildPreviewNode(position, rotationY),
473
+ )
474
+ if (updates.length === 0) return
475
+ useLiveNodeOverrides
476
+ .getState()
477
+ .setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
478
+ overriddenIdsRef.current = updates.map((u) => u.id)
479
+ for (const u of updates) {
480
+ if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
481
+ }
482
+ }
483
+
484
+ const clearConnectivityOverrides = () => {
485
+ for (const id of overriddenIdsRef.current) {
486
+ useLiveNodeOverrides.getState().clear(id)
487
+ if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
488
+ }
489
+ }
490
+
491
+ const syncParentFramePreview = (position: [number, number, number]) => {
492
+ if (!frameParent) return
493
+ useLiveNodeOverrides.getState().set(node.id, {
494
+ position,
495
+ rotation: rotationRef.current,
496
+ })
497
+ useScene.getState().markDirty(frameParent.id as AnyNodeId)
498
+ }
499
+
500
+ const clearParentFramePreview = () => {
501
+ if (!frameParent) return
502
+ useLiveNodeOverrides.getState().clear(node.id)
503
+ useScene.getState().markDirty(frameParent.id as AnyNodeId)
504
+ }
505
+
214
506
  setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
215
507
 
216
508
  // Re-run the floor-collision check at the live cursor + rotation and push
217
- // the result to the box colour. Shift forces a valid (green) override so
218
- // the user can drop on top of an existing item on purpose. Only shelves
219
- // show the box, so this no-ops for every other movable kind.
509
+ // the result to the box colour. Alt (free place) forces a valid (green)
510
+ // override so the user can drop on top of an existing item on purpose. Only
511
+ // shelves show the box, so this no-ops for every other movable kind.
220
512
  const recomputeValidity = () => {
221
513
  if (!boxDimensions) return
222
- if (shiftRef.current) {
514
+ if (altRef.current) {
223
515
  validRef.current = true
224
516
  setValid(true)
225
517
  return
@@ -230,14 +522,42 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
230
522
  setValid(true)
231
523
  return
232
524
  }
233
- const [x, y, z] = lastCursorRef.current
234
- const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
235
- levelId,
236
- [x, y, z],
237
- boxDimensions,
238
- [0, rotationRef.current, 0],
239
- [node.id],
240
- )
525
+ const livePosition = lastCursorRef.current
526
+ const liveRotation = previewRotationY(rotationRef.current)
527
+ const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
528
+ const effectiveNode = {
529
+ ...(node as Record<string, unknown>),
530
+ position: livePosition,
531
+ rotation: Array.isArray((node as { rotation?: unknown }).rotation)
532
+ ? [
533
+ ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0,
534
+ rotationRef.current,
535
+ ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0,
536
+ ]
537
+ : rotationRef.current,
538
+ } as AnyNode
539
+ const footprints = floorPlaced
540
+ ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes })
541
+ : []
542
+ const resolvedFootprints: Array<{
543
+ position: [number, number, number]
544
+ dimensions: [number, number, number]
545
+ rotation: [number, number, number]
546
+ }> = footprints.map((footprint) => ({
547
+ position: footprint.position ?? livePosition,
548
+ dimensions: footprint.dimensions,
549
+ rotation: footprint.rotation,
550
+ }))
551
+ const { valid: placeable } =
552
+ resolvedFootprints.length > 0
553
+ ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id])
554
+ : spatialGridManager.canPlaceOnFloor(
555
+ levelId,
556
+ getVisualPosition(livePosition),
557
+ boxDimensions,
558
+ [0, liveRotation, 0],
559
+ [node.id],
560
+ )
241
561
  validRef.current = placeable
242
562
  setValid(placeable)
243
563
  }
@@ -276,16 +596,40 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
276
596
  useViewer.getState().selection.levelId ?? node.parentId,
277
597
  )
278
598
 
599
+ // Connectivity snapshot (existing port-bearing nodes only — fresh
600
+ // placements aren't connected to anything yet). Records which ducts /
601
+ // fittings are mated to this node's ports so they can follow the drag.
602
+ connectivityRef.current = null
603
+ overriddenIdsRef.current = []
604
+ if (!isNew && nodeRegistry.get(node.type)?.ports) {
605
+ const snapshot = analyzePortConnectivity(node, useScene.getState().nodes)
606
+ if (snapshot.connections.length > 0) connectivityRef.current = snapshot
607
+ }
608
+
279
609
  const onGridMove = (event: GridEvent) => {
280
- const rawX = event.localPosition[0]
281
- const rawZ = event.localPosition[2]
610
+ // The pointer decides the target surface AND the cursor plan point,
611
+ // both resolved from the true camera ray in one place. The event's
612
+ // own hit can't be used directly: its plane rides at the ghost's
613
+ // last height, so whenever that plane sits on a different storey
614
+ // than the aimed-at surface the hit XZ is perspective-skewed along
615
+ // the ray — electing at that skewed point is what made a drag over
616
+ // a deck-above-a-floor hop between the two surfaces (each hop moved
617
+ // the plane, which re-skewed the next hit, which flipped the
618
+ // election back). Cap and XZ from the same ray ∩ pointed-surface
619
+ // test are plane-height independent, so the elected surface is a
620
+ // single fixed point per pointer ray.
621
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
622
+ supportCapRef.current = pointed?.elevation ?? null
623
+ const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0]
624
+ const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2]
282
625
  revealFreshPlacement()
283
626
 
284
627
  const resolved = resolvePlanarCursorPosition({
285
628
  cursor: [rawX, rawZ],
286
- original: [originalPosition[0], originalPosition[2]],
629
+ original: [originalPlanPosition[0], originalPlanPosition[2]],
287
630
  anchor: dragAnchorRef.current,
288
- mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
631
+ mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
632
+ // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
289
633
  snap: snapToGridStep,
290
634
  })
291
635
  dragAnchorRef.current = resolved.anchor
@@ -293,17 +637,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
293
637
 
294
638
  // Figma-style alignment snap layered on top of grid snap: when the
295
639
  // moving item's edge lines up (on X or Z) with another item's edge,
296
- // snap and publish a guide. The guide connects to the nearest real
297
- // corner of the candidate (resolver tie-break), so the dot always sits
298
- // on an actual point. Alt bypasses.
299
- const bypass = event.nativeEvent?.altKey === true
300
- if (!bypass && alignmentCandidates.length > 0) {
640
+ // publish a guide. The guide connects to the nearest real corner of the
641
+ // candidate (resolver tie-break), so the dot always sits on an actual
642
+ // point. Alignment "lines" are DISPLAYED in every mode except Off
643
+ // (isAlignmentGuideActive); the magnetic pull toward them applies only in
644
+ // 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
645
+ const magnetic = isMagneticSnapActive()
646
+ if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
301
647
  const result = resolveAlignment({
302
- moving: movingFootprintAnchors(node, x, z, rotationRef.current),
648
+ moving: movingDragBoundsAnchors(
649
+ node,
650
+ dragBounds,
651
+ x,
652
+ z,
653
+ previewRotationY(rotationRef.current),
654
+ ),
303
655
  candidates: alignmentCandidates,
304
656
  threshold: ALIGNMENT_THRESHOLD_M,
305
657
  })
306
- if (result.snap) {
658
+ if (result.snap && magnetic) {
307
659
  x += result.snap.dx
308
660
  z += result.snap.dz
309
661
  }
@@ -312,7 +664,74 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
312
664
  useAlignmentGuides.getState().clear()
313
665
  }
314
666
 
315
- const position: [number, number, number] = [x, originalPosition[1], z]
667
+ // Kind-owned attachment snap (cabinet wall): an attach behavior like
668
+ // door/window wall placement, not an alignment guide — active in every
669
+ // snapping mode except Off.
670
+ if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
671
+ const snappedPosition = groupMoveSnapConfig({
672
+ node,
673
+ candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
674
+ movingIds: [node.id as AnyNodeId],
675
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
676
+ levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
677
+ })
678
+ if (snappedPosition) {
679
+ const snappedPlanPosition = getVisualPosition(snappedPosition)
680
+ x = snappedPlanPosition[0]
681
+ z = snappedPlanPosition[2]
682
+ useAlignmentGuides.getState().clear()
683
+ }
684
+ }
685
+
686
+ // Magnetic port snap (duct terminals): mate a collar onto a nearby
687
+ // duct run end. Takes precedence over grid / alignment snap; Alt
688
+ // bypasses. Only kinds that opted in via `movable.portSnap`.
689
+ if (magnetic && portSnapConfig) {
690
+ // Build the preview node at the ORIGINAL position but with the LIVE
691
+ // rotation so `def.ports` reflects any mid-drag R/T rotation. Without
692
+ // this the snap solver mates the pre-rotation collar and commit then
693
+ // writes the rotated node offset from the port it visually snapped to.
694
+ const snapNode = buildPreviewNode(originalPosition, rotationRef.current)
695
+ const mated = resolvePortSnap(snapNode, [x, z], portSnapConfig)
696
+ if (mated) {
697
+ x = mated[0]
698
+ z = mated[1]
699
+ useAlignmentGuides.getState().clear()
700
+ }
701
+ }
702
+
703
+ let position = canonicalPositionFromPlan(x, originalPosition[1], z)
704
+ if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
705
+ const preSnapPosition = position
706
+ const snappedPosition = parentFrame.magneticSnap(
707
+ node,
708
+ frameParent,
709
+ position,
710
+ useScene.getState().nodes,
711
+ )
712
+ if (
713
+ snappedPosition[0] !== position[0] ||
714
+ snappedPosition[1] !== position[1] ||
715
+ snappedPosition[2] !== position[2]
716
+ ) {
717
+ position = snappedPosition
718
+ const snappedPlanPosition = getVisualPosition(position)
719
+ x = snappedPlanPosition[0]
720
+ z = snappedPlanPosition[2]
721
+ }
722
+ if (isAlignmentGuideActive() && parentFrame.magneticSnapMatches) {
723
+ const guides = parentFrame
724
+ .magneticSnapMatches(
725
+ node,
726
+ frameParent,
727
+ preSnapPosition,
728
+ snappedPosition,
729
+ useScene.getState().nodes,
730
+ )
731
+ .map(alignmentGuideFromParentFrameMatch)
732
+ if (guides.length > 0) useAlignmentGuides.getState().set(guides)
733
+ }
734
+ }
316
735
  const visualPosition = getVisualPosition(position)
317
736
  hasMovedRef.current = true
318
737
  setCursorPosition(visualPosition)
@@ -320,7 +739,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
320
739
  recomputeValidity()
321
740
 
322
741
  // Pure imperative: move the mesh via its registered Object3D ref.
323
- sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition)
742
+ applyMeshPose(position)
324
743
  // Publish to `useLiveTransforms` so the 2D floor plan can mirror
325
744
  // the drag in real-time (the floor-plan layer subscribes to this
326
745
  // store and overrides the node's rendered position when an entry
@@ -331,16 +750,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
331
750
  // the absolute world plan position here. Polygon-based kinds
332
751
  // (slab / ceiling / fence) follow a different delta contract —
333
752
  // their floor-plan move-targets handle the override themselves.
753
+ // The pointer surface cap rides along so FloorElevationSystem's
754
+ // per-frame Y agrees with this tool's preview.
334
755
  useLiveTransforms.getState().set(node.id, {
335
756
  position,
336
757
  rotation: rotationRef.current,
758
+ supportElevationCap: supportCapRef.current ?? undefined,
337
759
  })
760
+ syncParentFramePreview(position)
338
761
  markMovedNodeDirty()
762
+ // Carry connected ductwork along (preview only — committed on drop).
763
+ previewConnectivity(position, rotationRef.current)
339
764
 
765
+ const nextSnapKey = movementSfxStepKey({
766
+ coords: [x, z],
767
+ gridSnapActive: isGridSnapActive(),
768
+ gridStep: useEditor.getState().gridSnapStep,
769
+ })
340
770
  const prev = previousSnapRef.current
341
- if (!prev || prev[0] !== x || prev[1] !== z) {
771
+ if (prev !== nextSnapKey) {
342
772
  sfxEmitter.emit('sfx:grid-snap')
343
- previousSnapRef.current = [x, z]
773
+ previousSnapRef.current = nextSnapKey
344
774
  }
345
775
  }
346
776
 
@@ -360,24 +790,48 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
360
790
  * AND scene updated) — never the original.
361
791
  */
362
792
  const commitAtCursor = (event: ClickTriggerEvent) => {
793
+ // One physical click can reach here twice: node clicks (`slab:click`,
794
+ // `item:click`, …) are synthesized on *pointerup* (`use-node-events`),
795
+ // while `grid:click` rides the browser's native *click* event from a
796
+ // canvas DOM listener (`use-grid-events`) that deliberately ignores
797
+ // stopPropagation — and this effect stays subscribed until React
798
+ // re-renders after `exitMoveMode`. Without this guard the second pass
799
+ // finds the fresh draft already deleted and takes the orphan re-create
800
+ // path below, minting a hidden ghost copy and replaying the SFX.
801
+ if (committed) return
363
802
  // Ignore a commit that fires before the cursor has moved into place —
364
803
  // it's the stray trailing click of whatever armed this move, not a
365
804
  // deliberate drop. Prevents preset re-arm from double-placing.
366
805
  if (!hasMovedRef.current) return
367
806
  // Refuse a drop on an invalid (red) footprint, matching the GLB item
368
- // tool — unless Shift is held to force placement. Other kinds carry no
369
- // validity box (`validRef` stays true), so they're never blocked.
370
- if (!validRef.current && !shiftRef.current) return
807
+ // tool — unless Alt (free place) is held to force placement. Other kinds
808
+ // carry no validity box (`validRef` stays true), so they're never blocked.
809
+ if (!validRef.current && !altRef.current) return
371
810
  const position: [number, number, number] = [...lastCursorRef.current]
372
811
 
373
812
  const rotation = toCommitRotation(rotationRef.current)
374
- const visualPosition = getVisualPosition(position)
375
813
  let committedId = node.id as AnyNodeId
376
814
 
377
815
  if (useScene.getState().nodes[node.id]) {
816
+ const effectiveNode = {
817
+ ...(node as Record<string, unknown>),
818
+ position,
819
+ rotation,
820
+ } as AnyNode
378
821
  const data = {
379
822
  position,
380
823
  rotation,
824
+ // The pointer cap makes the persisted host reproduce the capped
825
+ // election — a drop under a deck stores the aimed-at lower slab
826
+ // (or the ground), not the deck hanging above.
827
+ ...resolveSupportSlabPatch(
828
+ effectiveNode,
829
+ {
830
+ ...useScene.getState().nodes,
831
+ [node.id]: effectiveNode,
832
+ },
833
+ { maxElevation: supportCapRef.current },
834
+ ),
381
835
  ...(isNew
382
836
  ? {
383
837
  metadata: stripPlacementMetadataFlags(node.metadata),
@@ -393,8 +847,38 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
393
847
  committedId = finalId
394
848
  }
395
849
  } else {
850
+ // Fold the connected-ductwork follow-updates into the SAME
851
+ // batch as the moved node so the whole thing is one undo step.
852
+ const connectivityUpdates = connectivityRef.current
853
+ ? resolveConnectivityUpdates(
854
+ connectivityRef.current,
855
+ buildPreviewNode(position, rotationRef.current),
856
+ ).filter((u) => useScene.getState().nodes[u.id])
857
+ : []
396
858
  useScene.temporal.getState().resume()
397
- useScene.getState().updateNode(node.id, data)
859
+ useScene
860
+ .getState()
861
+ .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
862
+ // Kind-owned derived-state maintenance after a parent-frame move
863
+ // (cabinet run re-flow + linked corner-run re-anchor). Runs in the
864
+ // resumed window so its writes are undoable alongside the move.
865
+ if (parentFrame?.onCommit && frameParent) {
866
+ const liveNode = useScene.getState().nodes[node.id as AnyNodeId]
867
+ const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId]
868
+ if (liveNode && liveParent) {
869
+ parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene))
870
+ const committedNodes = useScene.getState().nodes
871
+ const committedParent = committedNodes[frameParent.id as AnyNodeId]
872
+ if (committedParent) {
873
+ useScene
874
+ .getState()
875
+ .updateNode(
876
+ committedParent.id,
877
+ resolveSupportSlabPatch(committedParent, committedNodes),
878
+ )
879
+ }
880
+ }
881
+ }
398
882
  useScene.temporal.getState().pause()
399
883
  committed = true
400
884
  }
@@ -408,9 +892,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
408
892
  metadata: {},
409
893
  position,
410
894
  rotation,
411
- })
895
+ }) as AnyNode
896
+ const committedNode = def.schema.parse({
897
+ ...reparsed,
898
+ parentId: node.parentId,
899
+ ...resolveSupportSlabPatch(
900
+ { ...reparsed, parentId: node.parentId } as AnyNode,
901
+ {
902
+ ...useScene.getState().nodes,
903
+ [reparsed.id]: reparsed,
904
+ },
905
+ { maxElevation: supportCapRef.current },
906
+ ),
907
+ }) as AnyNode
412
908
  useScene.temporal.getState().resume()
413
- useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
909
+ useScene.getState().createNode(committedNode, node.parentId as AnyNodeId)
414
910
  useScene.temporal.getState().pause()
415
911
  committed = true
416
912
  }
@@ -420,11 +916,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
420
916
  // canonical position, then restamp the lifted presentation Y for the
421
917
  // current frame.
422
918
  useLiveTransforms.getState().clear(node.id)
423
- const mesh = sceneRegistry.nodes.get(node.id)
424
- if (mesh) {
425
- mesh.position.set(...visualPosition)
426
- mesh.rotation.y = rotationRef.current
427
- }
919
+ // Connected ductwork is now committed to the store — drop its live
920
+ // overrides so the renderers read the canonical path/position.
921
+ clearConnectivityOverrides()
922
+ clearParentFramePreview()
923
+ applyMeshPose(position)
428
924
 
429
925
  useAlignmentGuides.getState().clear()
430
926
  if (isNew && committed) {
@@ -448,14 +944,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
448
944
  if (typeof direct === 'function') direct.call(event)
449
945
  }
450
946
 
451
- // R / T rotate the dragged node about Y in 90° steps — matching the GLB
947
+ // R / T rotate the dragged node about Y in 45° steps — matching the GLB
452
948
  // item placement keys (and the "Rotate" hints the move HUD shows). Applied
453
949
  // imperatively + mirrored to the live transform; committed on drop.
454
950
  const onKeyDown = (e: KeyboardEvent) => {
455
- // Hold Shift to force placement on an invalid (red) footprint, matching
456
- // the GLB item tool. Recolour the box to green while held.
457
- if (e.key === 'Shift') {
458
- shiftRef.current = true
951
+ // Hold Alt (free place) to force placement on an invalid (red) footprint,
952
+ // matching the GLB item tool. Recolour the box to green while held.
953
+ if (e.key === 'Alt') {
954
+ altRef.current = true
459
955
  recomputeValidity()
460
956
  return
461
957
  }
@@ -467,26 +963,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
467
963
  e.preventDefault()
468
964
  sfxEmitter.emit('sfx:item-rotate')
469
965
  rotationRef.current += delta
470
- setCursorRotationY(rotationRef.current)
966
+ setCursorRotationY(previewRotationY(rotationRef.current))
471
967
  const position = lastCursorRef.current
472
968
  const visualPosition = getVisualPosition(position)
473
969
  setCursorPosition(visualPosition)
474
- const m = sceneRegistry.nodes.get(node.id)
475
- if (m) {
476
- m.position.set(...visualPosition)
477
- m.rotation.y = rotationRef.current
478
- }
970
+ applyMeshPose(position)
479
971
  useLiveTransforms.getState().set(node.id, {
480
972
  position,
481
973
  rotation: rotationRef.current,
974
+ supportElevationCap: supportCapRef.current ?? undefined,
482
975
  })
976
+ syncParentFramePreview(position)
483
977
  markMovedNodeDirty()
978
+ // Rotating the fitting swings its collars — connected ducts follow.
979
+ previewConnectivity(position, rotationRef.current)
484
980
  // Rotation changes the footprint's collision span — re-check validity.
485
981
  recomputeValidity()
486
982
  }
487
983
  const onKeyUp = (e: KeyboardEvent) => {
488
- if (e.key === 'Shift') {
489
- shiftRef.current = false
984
+ if (e.key === 'Alt') {
985
+ altRef.current = false
490
986
  recomputeValidity()
491
987
  }
492
988
  }
@@ -496,6 +992,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
496
992
  emitter.on('grid:move', onGridMove)
497
993
  emitter.on('grid:click', commitAtCursor)
498
994
 
995
+ const onPlacementDragPointerUp = (event: PointerEvent) => {
996
+ if (!useEditor.getState().placementDragMode) return
997
+ if (event.button !== 0) return
998
+ swallowNextClick()
999
+ if (!hasMovedRef.current) {
1000
+ exitMoveMode()
1001
+ return
1002
+ }
1003
+ commitAtCursor({
1004
+ nativeEvent: event,
1005
+ stopPropagation: () => event.stopPropagation(),
1006
+ } as unknown as ClickTriggerEvent)
1007
+ }
1008
+ window.addEventListener('pointerup', onPlacementDragPointerUp)
1009
+
499
1010
  // Listen on every common kind's click event too. mitt's typing keeps
500
1011
  // `${kind}:click` as a fixed union so the cast is safe at runtime —
501
1012
  // we're just routing them through the shared commit path.
@@ -508,14 +1019,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
508
1019
 
509
1020
  const onCancel = () => {
510
1021
  useLiveTransforms.getState().clear(node.id)
1022
+ clearConnectivityOverrides()
1023
+ clearParentFramePreview()
511
1024
  if (isNew) {
512
1025
  useScene.getState().deleteNode(node.id as AnyNodeId)
513
1026
  } else {
514
- const m = sceneRegistry.nodes.get(node.id)
515
- if (m) {
516
- m.position.set(...getVisualPosition(originalPosition, originalRotationY))
517
- m.rotation.y = originalRotationY
518
- }
1027
+ applyMeshPose(originalPosition, originalRotationY)
519
1028
  markMovedNodeDirty()
520
1029
  }
521
1030
  useAlignmentGuides.getState().clear()
@@ -530,6 +1039,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
530
1039
  window.removeEventListener('keyup', onKeyUp)
531
1040
  emitter.off('grid:move', onGridMove)
532
1041
  emitter.off('grid:click', commitAtCursor)
1042
+ window.removeEventListener('pointerup', onPlacementDragPointerUp)
533
1043
  for (const kind of CLICK_TRIGGER_KINDS) {
534
1044
  const key = `${kind}:click` as ClickKey
535
1045
  emitter.off(key, commitAtCursor as never)
@@ -544,39 +1054,55 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
544
1054
  const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
545
1055
  if (!(committed || isNew || finalisedBy2D)) {
546
1056
  useLiveTransforms.getState().clear(node.id)
547
- sceneRegistry.nodes
548
- .get(node.id)
549
- ?.position.set(...getVisualPosition(originalPosition, originalRotationY))
1057
+ clearConnectivityOverrides()
1058
+ clearParentFramePreview()
1059
+ applyMeshPose(originalPosition, originalRotationY)
550
1060
  markMovedNodeDirty()
551
1061
  }
552
1062
  useScene.temporal.getState().resume()
553
1063
  }
554
1064
  }, [
555
1065
  boxDimensions,
1066
+ dragBounds,
1067
+ canonicalPositionFromPlan,
1068
+ parentFrame,
1069
+ frameParent,
1070
+ cursorAttached,
1071
+ portSnapConfig,
1072
+ groupMoveSnapConfig,
556
1073
  exitMoveMode,
557
1074
  isFreshPlacement,
558
1075
  node,
559
1076
  originalPosition,
1077
+ originalPlanPosition,
560
1078
  originalRotationY,
1079
+ previewRotationY,
561
1080
  revealFreshPlacement,
562
1081
  useAbsoluteCursorPlacement,
1082
+ visualPositionFor,
563
1083
  ])
564
1084
 
565
- // Snapshot the scene once at drag-start bounds depend on `node` (locked
566
- // for the lifetime of this tool) and any sibling state the kind reads. If a
567
- // future kind needs live sibling state mid-drag, switch to a subscribed
568
- // selector; for v1 (elevator shaft height from level set) start-time is
569
- // correct and avoids subscribing the whole `nodes` map.
570
- const dragBounds = useMemo(
571
- () =>
572
- nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ??
573
- null,
574
- [node],
575
- )
1085
+ // Forward-facing triangle for the footprint-box branch (item / shelf / column
1086
+ // anything that renders `<PlacementBox>`). Published to the editor-side
1087
+ // overlay; the `<DragBoundingBox>` branch (e.g. stair, which has no centred
1088
+ // footprint) publishes its own. The box is centred on `cursorPosition`, so
1089
+ // the footprint centre is the origin. Clears on unmount.
1090
+ const facing = resolveFacingIndicator(node.type)
1091
+ useEffect(() => {
1092
+ if (!previewVisible || !facing || !boxDimensions) return
1093
+ useFacingPose.getState().set({
1094
+ position: cursorPosition,
1095
+ rotationY: cursorRotationY,
1096
+ depth: boxDimensions[2],
1097
+ center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined,
1098
+ reversed: facing.reversed,
1099
+ })
1100
+ }, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY])
1101
+ useEffect(() => () => useFacingPose.getState().clear(), [])
576
1102
 
577
1103
  if (!previewVisible) return null
578
1104
 
579
- if (boxDimensions) {
1105
+ if (boxDimensions && !dragBounds?.center) {
580
1106
  return (
581
1107
  <PlacementBox
582
1108
  dimensions={boxDimensions}
@@ -587,11 +1113,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
587
1113
  )
588
1114
  }
589
1115
 
1116
+ const dragCenterPosition =
1117
+ dragBounds?.center && dragBounds.size
1118
+ ? offsetPlanPositionByLocalCenter(cursorPosition, dragBounds.center, cursorRotationY)
1119
+ : cursorPosition
1120
+
590
1121
  return (
591
1122
  <>
592
- <CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
1123
+ <CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
593
1124
  <DragBoundingBox
1125
+ center={dragBounds?.center}
594
1126
  centerY={dragBounds?.centerY}
1127
+ color={boxDimensions ? (valid ? VALID_COLOR : INVALID_COLOR) : undefined}
595
1128
  nodeId={node.id}
596
1129
  position={cursorPosition}
597
1130
  rotationY={cursorRotationY}