@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,857 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AlignmentAnchor,
5
+ type AnyNode,
6
+ type AnyNodeId,
7
+ bboxAnchors,
8
+ bboxCornerAnchors,
9
+ emitter,
10
+ type FloorplanMoveTargetSession,
11
+ nodeRegistry,
12
+ pauseSceneHistory,
13
+ resumeSceneHistory,
14
+ useLiveNodeOverrides,
15
+ useLiveTransforms,
16
+ useScene,
17
+ } from '@pascal-app/core'
18
+ import { useViewer } from '@pascal-app/viewer'
19
+ import { useEffect } from 'react'
20
+ import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
21
+ import { isHistoryShortcut } from '../../lib/history'
22
+ import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
23
+ import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
24
+ import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
25
+ import { sfxEmitter } from '../../lib/sfx-bus'
26
+ import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
27
+ import useAlignmentGuides from '../../store/use-alignment-guides'
28
+ import useEditor, {
29
+ isAlignmentGuideActive,
30
+ isGridSnapActive,
31
+ isMagneticSnapActive,
32
+ } from '../../store/use-editor'
33
+ import { useMovingNode } from '../../store/use-interaction-scope'
34
+ import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
35
+
36
+ // Figma-style alignment snap threshold. Meters in world space; 8cm gives
37
+ // a comfortable "magnetic" pull at default zoom without fighting the
38
+ // grid snap. Held fixed for v1 — a future revision can scale this with
39
+ // the SVG's units-per-pixel so the feel stays constant across zoom.
40
+ const ALIGNMENT_THRESHOLD_M = 0.08
41
+
42
+ /**
43
+ * Cursor-driven placement for registered kinds in the floor plan.
44
+ *
45
+ * Activates when `useEditor.movingNode` is set to a node whose kind is
46
+ * registered with `def.floorplan`. Two dispatch paths:
47
+ *
48
+ * 1. **`def.floorplanMoveTarget` present** (door / window / item):
49
+ * kind-specific 2D move handler with wall / ceiling / slab
50
+ * anchor logic. Pointer events feed `session.apply` which writes
51
+ * directly to `useScene`; pointer-up does the single-undo dance
52
+ * (revert→resume→re-apply) if `canCommit()` is true.
53
+ * 2. **Fallback — generic free-floating translate**: imperatively
54
+ * translates the rendered SVG entry on pointer-move, commits via
55
+ * `updateNode` on pointer-up. Used by shelf / spawn / fence /
56
+ * etc. whose move is "translate position on X/Z plane".
57
+ *
58
+ * Lives outside the `floorplan-panel.tsx` monolith. Coordinate
59
+ * conversion routes through the scene `<g>`'s `getScreenCTM` so
60
+ * cursor → meters accounts for pan / zoom / building rotation.
61
+ */
62
+ export function FloorplanRegistryMoveOverlay() {
63
+ const movingNode = useMovingNode()
64
+ const setMovingNode = useEditor((s) => s.setMovingNode)
65
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
66
+
67
+ const def = movingNode ? nodeRegistry.get(movingNode.type) : null
68
+ const isActive = !!movingNode && !!def?.floorplan
69
+ const hasMoveTarget = !!def?.floorplanMoveTarget
70
+
71
+ useEffect(() => {
72
+ if (!isActive || !movingNode) return
73
+
74
+ const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
75
+ if (!scene) return
76
+
77
+ const toMeters = (clientX: number, clientY: number): [number, number] | null => {
78
+ const svg = scene.ownerSVGElement
79
+ if (!svg) return null
80
+ const ctm = scene.getScreenCTM()
81
+ if (!ctm) return null
82
+ const pt = svg.createSVGPoint()
83
+ pt.x = clientX
84
+ pt.y = clientY
85
+ const m = pt.matrixTransform(ctm.inverse())
86
+ return [m.x, m.y]
87
+ }
88
+
89
+ const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
90
+ // The scene's `<g>` only covers painted SVG elements, so hovers over
91
+ // empty grid background often target the parent SVG. Bounds keep the
92
+ // cursor active anywhere inside the floor-plan viewport.
93
+ const svg = scene.ownerSVGElement
94
+ if (!svg) return false
95
+ const rect = svg.getBoundingClientRect()
96
+ return (
97
+ clientX >= rect.left &&
98
+ clientX <= rect.right &&
99
+ clientY >= rect.top &&
100
+ clientY <= rect.bottom
101
+ )
102
+ }
103
+
104
+ // ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
105
+ if (hasMoveTarget && def?.floorplanMoveTarget) {
106
+ const sceneNodes = useScene.getState().nodes
107
+ const session: FloorplanMoveTargetSession = (
108
+ def.floorplanMoveTarget as (a: {
109
+ node: AnyNode
110
+ nodes: Record<AnyNodeId, AnyNode>
111
+ }) => FloorplanMoveTargetSession
112
+ )({ node: movingNode, nodes: sceneNodes })
113
+
114
+ // Capture snapshots of every affected node BEFORE the first apply
115
+ // so the single-undo dance has a clean baseline to revert to.
116
+ const snapshots = session.affectedIds
117
+ .map((id) => sceneNodes[id])
118
+ .filter((n): n is AnyNode => !!n)
119
+ .map((n) => snapshotNode(n))
120
+
121
+ pauseSceneHistory(useScene)
122
+ let historyPaused = true
123
+
124
+ const clearLivePreviews = () => {
125
+ const liveTransforms = useLiveTransforms.getState()
126
+ const liveOverrides = useLiveNodeOverrides.getState()
127
+ const scene = useScene.getState()
128
+ for (const id of session.affectedIds) {
129
+ liveTransforms.clear(id)
130
+ liveOverrides.clear(id)
131
+ scene.markDirty(id)
132
+ }
133
+ }
134
+
135
+ // The registry action menu's Move button portals to `document.body`,
136
+ // so the trigger click's pointer-up happens OUTSIDE the floor-plan
137
+ // scene and never reaches `onPointerUp` here. That means: the very
138
+ // first window-pointer-up the overlay sees is the user's intended
139
+ // commit click. No "click-to-enter" gesture to detect — the older
140
+ // flow used an orange "Move" dot rendered inside the slab itself,
141
+ // where the trigger click DID hit the overlay's listener and had
142
+ // to be consumed. That legacy flow is gone in the registry layer;
143
+ // all entries use the action menu now.
144
+ let hasMovedSinceStart = false
145
+ // Last resolved position of the moved node — drives the move "tick" SFX.
146
+ // Parity with the 3D move, which emits on any change of the resolved
147
+ // position (every snapping mode, not only grid).
148
+ let lastSnapKey: string | null = null
149
+ // Live cursor location — updated on EVERY pointermove (even over the 3D
150
+ // canvas) so R-key ownership can follow the pointer's CURRENT pane rather
151
+ // than the sticky `hasMovedSinceStart`. Without this, once the user touched
152
+ // the 2D pane the overlay claimed R forever and the 3D flip went dead.
153
+ let pointerOverFloorplan = false
154
+ const onPointerTrack = (event: PointerEvent) => {
155
+ pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
156
+ }
157
+
158
+ const onMove = (event: PointerEvent) => {
159
+ // Skip 3D-canvas / other-UI cursor moves so the overlay only
160
+ // tracks pointer events that actually correspond to a floor-plan
161
+ // location. The bounding-rect check (vs the legacy
162
+ // `target.closest('[data-floorplan-scene]')`) also picks up
163
+ // hovers over empty grid background — without it, the cursor
164
+ // only updated the shelf when it happened to brush over an
165
+ // existing SVG entry, leaving the move feeling "stuck" elsewhere.
166
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
167
+ const planPoint = toMeters(event.clientX, event.clientY)
168
+ if (!planPoint) return
169
+ hasMovedSinceStart = true
170
+ session.apply({
171
+ planPoint,
172
+ modifiers: {
173
+ shiftKey: event.shiftKey,
174
+ altKey: event.altKey,
175
+ ctrlKey: event.ctrlKey,
176
+ metaKey: event.metaKey,
177
+ },
178
+ })
179
+ // Move "tick" — same feedback the 3D move gives, which fires whenever the
180
+ // resolved position changes (any snapping mode, not just grid), so it
181
+ // ticks as the item lands on each new snapped/free position.
182
+ const movedId = session.affectedIds[0]
183
+ const moved = movedId ? useScene.getState().nodes[movedId] : undefined
184
+ const pos = (moved as { position?: [number, number, number] } | undefined)?.position
185
+ if (pos) {
186
+ const key = movementSfxStepKey({
187
+ coords: [pos[0], pos[2]],
188
+ gridSnapActive: isGridSnapActive(),
189
+ gridStep: useEditor.getState().gridSnapStep,
190
+ })
191
+ if (key !== lastSnapKey) {
192
+ lastSnapKey = key
193
+ sfxEmitter.emit('sfx:grid-snap')
194
+ }
195
+ }
196
+ }
197
+
198
+ const commitFinalStateOrRevert = () => {
199
+ const commitValid = session.canCommit()
200
+
201
+ // Claim ownership of the drag teardown so the 3D move tool's
202
+ // unmount-time cleanup skips its restore-from-snapshot — see
203
+ // `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
204
+ // `setMovingNode(null)`) so that by the time the 3D effect's
205
+ // cleanup runs the origin is observable in the store.
206
+ setMovingNodeOrigin('2d')
207
+
208
+ // Sessions with a `commit` hook own their atomic write (e.g.
209
+ // wall move emits creates + deletes + updates via the junction
210
+ // planner). For those we still do Phase 1 (revert to baseline)
211
+ // and Phase 2's resume — but Phase 2's write is delegated, and
212
+ // we skip the snapshot-diff finalUpdates path.
213
+ if (commitValid && session.commit) {
214
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
215
+ if (historyPaused) {
216
+ resumeSceneHistory(useScene)
217
+ historyPaused = false
218
+ }
219
+ session.commit()
220
+ sfxEmitter.emit('sfx:item-place')
221
+ useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
222
+ return
223
+ }
224
+
225
+ const sceneState = useScene.getState().nodes
226
+ const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
227
+ for (const snap of snapshots) {
228
+ const current = sceneState[snap.id]
229
+ if (!current) continue
230
+ const data: Record<string, unknown> = {}
231
+ let changed = false
232
+ for (const [key, before] of Object.entries(snap.data)) {
233
+ const after = (current as unknown as Record<string, unknown>)[key]
234
+ if (!deepEqual(before, after)) {
235
+ data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
236
+ changed = true
237
+ }
238
+ }
239
+ if (changed) finalUpdates.push({ id: snap.id, data })
240
+ }
241
+
242
+ for (const snap of snapshots) {
243
+ const current = sceneState[snap.id]
244
+ if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) {
245
+ continue
246
+ }
247
+ const existing = finalUpdates.find((update) => update.id === snap.id)
248
+ const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata)
249
+ if (existing) {
250
+ existing.data.metadata = metadata
251
+ existing.data.visible = true
252
+ } else {
253
+ finalUpdates.push({
254
+ id: snap.id,
255
+ data: { metadata, visible: true },
256
+ })
257
+ }
258
+ }
259
+
260
+ if (commitValid && finalUpdates.length > 0) {
261
+ // Single-undo dance:
262
+ // 1. Revert to baseline while history is still paused.
263
+ // 2. Resume history.
264
+ // 3. Re-apply the final state — recorded as one tracked change.
265
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
266
+ if (historyPaused) {
267
+ resumeSceneHistory(useScene)
268
+ historyPaused = false
269
+ }
270
+ useScene.getState().updateNodes(finalUpdates)
271
+ sfxEmitter.emit('sfx:item-place')
272
+ // Re-select the moved node(s) — mirrors the legacy 3D move
273
+ // tool. The action menu cleared selection on Move click so
274
+ // selection-gated affordances (slab/ceiling boundary editor,
275
+ // etc.) would unmount during the drag; restoring it here
276
+ // brings them back at the new position.
277
+ useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
278
+ } else {
279
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
280
+ if (historyPaused) {
281
+ resumeSceneHistory(useScene)
282
+ historyPaused = false
283
+ }
284
+ }
285
+ }
286
+
287
+ const onPointerUp = (event: PointerEvent) => {
288
+ if (event.button !== 0) return
289
+ // Bounding-rect check (see `isPointerOverFloorplanScene`) — same
290
+ // reason as `onMove`: commits should land for any pointer-up
291
+ // inside the SVG viewport, including empty grid background.
292
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
293
+ if (!hasMovedSinceStart) return
294
+
295
+ // Commit using the LAST pointermove's state — no re-apply at
296
+ // pointer-up coords. A previous version re-applied here to
297
+ // close a sub-pixel "drift" window when pointer-up fires
298
+ // without a preceding pointermove, but that re-apply also
299
+ // re-snaps: if the pointer-up coord crosses a grid boundary
300
+ // relative to the last pointermove, the snapped result flips
301
+ // to a different grid cell and the wall (or other moved node)
302
+ // visibly jumps from where it was painted during the drag to
303
+ // a different commit position. Trusting the last pointermove
304
+ // means "what you saw is what gets committed", which is the
305
+ // UX users expect — at the cost of a sub-pixel drift in the
306
+ // rare case where the OS fires pointerup with no preceding
307
+ // pointermove. Modern browsers reliably emit a final
308
+ // pointermove right before pointerup, so the trade-off lands
309
+ // on the side of WYSIWYG.
310
+
311
+ commitFinalStateOrRevert()
312
+ setMovingNode(null)
313
+
314
+ // Swallow the click event that follows this pointer-up — the
315
+ // floor-plan SVG's `handleBackgroundClick` would otherwise route
316
+ // it through `resolveFloorplanBackgroundSelection`, which clears
317
+ // the selection if the click resolved to empty space. We already
318
+ // set selection back to the moved node in `commitFinalStateOrRevert`;
319
+ // letting the background-click handler run would undo that for
320
+ // any commit click that doesn't happen to land directly on the
321
+ // node's hit-test geometry.
322
+ //
323
+ // The 3D mover doesn't need this because its grid-click fires
324
+ // via the emitter inside the R3F pointer event and can call
325
+ // `event.nativeEvent.stopPropagation()`; the 2D pointerup and
326
+ // the following click are separate DOM events, so we listen on
327
+ // window in the capture phase to intercept the click before any
328
+ // bubble-phase handler (the floor-plan SVG) sees it.
329
+ swallowNextClick()
330
+ }
331
+
332
+ const onKey = (event: KeyboardEvent) => {
333
+ // R flips a directional kind's facing mid-placement (door / window:
334
+ // front ↔ back). The session records the flip and re-runs its last
335
+ // apply so the 2D symbol updates immediately; kinds without a facing
336
+ // leave `flipSide` unset and R falls through to the global handler.
337
+ //
338
+ // Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
339
+ // move tool ALSO listens for R on `window`. We own R only while the
340
+ // cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
341
+ // 2D mover has actually engaged (`hasMovedSinceStart`); then we
342
+ // `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
343
+ // first) so the 3D tool can't also flip. When the cursor is over the 3D
344
+ // pane we yield — the 3D tool owns R there. (The old sticky
345
+ // `hasMovedSinceStart`-only gate made the overlay claim R forever after
346
+ // the first 2D move, killing the 3D flip.)
347
+ if (event.key === 'r' || event.key === 'R') {
348
+ // Yield Cmd/Ctrl+R to the browser reload instead of flipping the side.
349
+ if (event.metaKey || event.ctrlKey) return
350
+ if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
351
+ if (event.repeat) return
352
+ const t = event.target as HTMLElement | null
353
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
354
+ return
355
+ }
356
+ event.preventDefault()
357
+ event.stopImmediatePropagation()
358
+ session.flipSide()
359
+ sfxEmitter.emit('sfx:item-rotate')
360
+ return
361
+ }
362
+ if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
363
+ if (isHistoryShortcut(event)) {
364
+ // ⌘Z mid-move cancels like Escape — keep it from reaching the
365
+ // global undo arm (this handler is capture-phase, that one bubbles).
366
+ event.preventDefault()
367
+ event.stopImmediatePropagation()
368
+ }
369
+ // Claim teardown ownership so the 3D move tool's cleanup skips
370
+ // its own restore — without this, both sides would race to
371
+ // write the same baseline, harmless but wasteful.
372
+ setMovingNodeOrigin('2d')
373
+ if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
374
+ emitter.emit('tool:cancel')
375
+ useScene.getState().deleteNode(movingNode.id as AnyNodeId)
376
+ if (historyPaused) {
377
+ resumeSceneHistory(useScene)
378
+ historyPaused = false
379
+ }
380
+ clearLivePreviews()
381
+ useAlignmentGuides.getState().clear()
382
+ setMovingNode(null)
383
+ return
384
+ }
385
+ // Revert untracked, then resume — no history entry.
386
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
387
+ if (historyPaused) {
388
+ resumeSceneHistory(useScene)
389
+ historyPaused = false
390
+ }
391
+ // Clear any live previews the session wrote. Slab / ceiling
392
+ // 2D move stages a translation delta in `useLiveTransforms`;
393
+ // wall move publishes `{ start, end, ... }` to
394
+ // `useLiveNodeOverrides`. Either way, leaving them in place
395
+ // after Esc would freeze the 2D / 3D view at the cancelled
396
+ // position.
397
+ clearLivePreviews()
398
+ // Restore selection cleared by the action menu's Move click.
399
+ useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
400
+ setMovingNode(null)
401
+ }
402
+
403
+ window.addEventListener('pointermove', onPointerTrack)
404
+ window.addEventListener('pointermove', onMove)
405
+ window.addEventListener('pointerup', onPointerUp)
406
+ // Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
407
+ // listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
408
+ // then pre-empts it so only one handler flips.
409
+ window.addEventListener('keydown', onKey, true)
410
+ return () => {
411
+ window.removeEventListener('pointermove', onPointerTrack)
412
+ window.removeEventListener('pointermove', onMove)
413
+ window.removeEventListener('pointerup', onPointerUp)
414
+ window.removeEventListener('keydown', onKey, true)
415
+ // Unmount cleanup. `historyPaused === true` here means none of
416
+ // our terminal paths (commit, Esc) ran in this overlay — they
417
+ // each call `resumeSceneHistory` and flip the flag.
418
+ //
419
+ // If `movingNodeOrigin === '3d'`, a 3D move tool finalised
420
+ // while our overlay was still mounted (split view); the live
421
+ // scene IS the committed state and reverting would stomp it.
422
+ // Otherwise (origin is `null` or `'2d'`) we own the teardown
423
+ // and revert any untracked apply() writes back to baseline.
424
+ //
425
+ // The two prior scenarios this block guarded against:
426
+ // - mid-drag unmount with apply() writes still present
427
+ // - 3D mover committing via `draftNode.commit` just before
428
+ // our unmount
429
+ // are now distinguished by the origin flag — no scene-state
430
+ // diff heuristic required.
431
+ if (historyPaused) {
432
+ if (hasMovedSinceStart) {
433
+ const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
434
+ if (!finalisedBy3D) {
435
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
436
+ }
437
+ }
438
+ resumeSceneHistory(useScene)
439
+ }
440
+ // Belt-and-suspenders: clear any live previews on abnormal
441
+ // unmount paths too. Slab / ceiling sessions write to
442
+ // `useLiveTransforms`; wall sessions write to
443
+ // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
444
+ // tool's cleanup isn't there to clear them for us.
445
+ clearLivePreviews()
446
+ // Sessions that publish Figma-style alignment guides during `apply`
447
+ // (item / shelf / column) leave them in the store; this cleanup runs
448
+ // after every terminal path (commit + Esc both unmount via
449
+ // `setMovingNode(null)`), so clearing here drops any lingering guide.
450
+ useAlignmentGuides.getState().clear()
451
+ // Same belt-and-suspenders pattern for the wall bridge ghost
452
+ // previews — clear unconditionally so Esc / mid-drag unmount /
453
+ // 3D-takeover paths all end up with no stale ghosts left over.
454
+ // The wall session's `commit()` already clears them on the
455
+ // happy path; this just covers the rest.
456
+ useWallMoveGhosts.getState().clear()
457
+ }
458
+ }
459
+
460
+ // ── Path 2 — generic free-floating translate ────────────────────
461
+ const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
462
+ if (!entry) return
463
+ const sceneNodes = useScene.getState().nodes as Record<string, AnyNode>
464
+ const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes)
465
+ const relatedEntries = Array.from(relatedEntryIds)
466
+ .map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null)
467
+ .filter((value): value is SVGGElement => value != null)
468
+
469
+ // Polyline kinds (duct / pipe / lineset) carry a `path`, not a
470
+ // `position` — translating a `position` here would write a field their
471
+ // schema ignores and snap the run back. For those we move every path
472
+ // point by the cursor delta and commit the translated `path` instead.
473
+ // The reference origin is the path centre so the SVG `translate` delta
474
+ // matches the geometry's actual location (which isn't at [0,0,0]).
475
+ // Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
476
+ // here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
477
+ // moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
478
+ const rawPath = (movingNode as { path?: unknown }).path
479
+ const originalPath =
480
+ Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
481
+ ? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
482
+ : null
483
+ const originalPosition: [number, number, number] = originalPath
484
+ ? (() => {
485
+ let cx = 0
486
+ let cz = 0
487
+ for (const p of originalPath) {
488
+ cx += p[0]
489
+ cz += p[2]
490
+ }
491
+ const n = originalPath.length || 1
492
+ return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
493
+ })()
494
+ : (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
495
+ 0, 0, 0,
496
+ ]) as [number, number, number])
497
+ const isFreshPlacement = isFreshPlacementMetadata(
498
+ (movingNode as { metadata?: unknown }).metadata,
499
+ )
500
+
501
+ // SVG units in this floorplan map 1:1 to world meters, and the
502
+ // `<g data-node-id>` entry has no transform of its own when at rest,
503
+ // so its untransformed bbox IS the world-space footprint. Cache the
504
+ // moving entry's local bbox once (relative to originalPosition) and
505
+ // derive anchors at any proposed (sx, sz) by translating it.
506
+ const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries)
507
+ const candidateAnchors: AlignmentAnchor[] = []
508
+ const allEntries = scene.querySelectorAll('[data-node-id]')
509
+ for (const el of Array.from(allEntries)) {
510
+ const otherId = el.getAttribute('data-node-id')
511
+ if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue
512
+ const b = (el as SVGGraphicsElement).getBBox()
513
+ // Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
514
+ // lineset drawn as a line) has one zero dimension but is still a valid
515
+ // alignment target — its endpoints become line anchors.
516
+ if (b.width <= 0 && b.height <= 0) continue
517
+ candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
518
+ }
519
+
520
+ let lastSnapped: [number, number] | null = null
521
+ let dragAnchor: [number, number] | null = null
522
+
523
+ // Footprint bounding box drawn around the dragged entry — the 2D
524
+ // counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
525
+ // reads the same in both views. Green wireframe rect over the entry's
526
+ // own bbox, translated in lockstep with it. The entry stays visible the
527
+ // whole drag (no hide-until-move) so it never appears to vanish.
528
+ const SVG_NS = 'http://www.w3.org/2000/svg'
529
+ const boxEl = document.createElementNS(SVG_NS, 'rect')
530
+ boxEl.setAttribute('x', String(movingLocalBBox.x))
531
+ boxEl.setAttribute('y', String(movingLocalBBox.y))
532
+ boxEl.setAttribute('width', String(movingLocalBBox.width))
533
+ boxEl.setAttribute('height', String(movingLocalBBox.height))
534
+ boxEl.setAttribute('fill', 'none')
535
+ boxEl.setAttribute('stroke', '#22c55e')
536
+ boxEl.setAttribute('stroke-width', '1.5')
537
+ boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
538
+ boxEl.setAttribute('pointer-events', 'none')
539
+ scene.appendChild(boxEl)
540
+
541
+ const onMove = (event: PointerEvent) => {
542
+ // Same target guard as Path 1 — pointer must be over the floor
543
+ // plan scene; otherwise we'd react to 3D-canvas moves with garbage
544
+ // plan coords.
545
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
546
+ const m = toMeters(event.clientX, event.clientY)
547
+ if (!m) return
548
+
549
+ // 1) Grid snap baseline. Fresh catalog placement is absolute under
550
+ // the cursor; existing moves preserve the cursor's grab offset. Grid
551
+ // follows the active snapping mode (Shift cycles it); raw cursor in
552
+ // any non-grid mode.
553
+ const gridStep = useEditor.getState().gridSnapStep
554
+ const snap = (value: number) =>
555
+ isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
556
+ const resolved = resolvePlanarCursorPosition({
557
+ cursor: [m[0], m[1]],
558
+ original: [originalPosition[0], originalPosition[2]],
559
+ anchor: dragAnchor,
560
+ mode: isFreshPlacement ? 'absolute' : 'relative',
561
+ snap,
562
+ })
563
+ dragAnchor = resolved.anchor
564
+ const [gridX, gridZ] = resolved.point
565
+
566
+ // 2) Alignment snap layered on top. Treat the grid-snapped point
567
+ // as the "proposed" position so alignment competes from a stable
568
+ // base rather than the raw cursor jitter. Alignment "lines" are
569
+ // DISPLAYED in every mode except Off (isAlignmentGuideActive); the
570
+ // magnetic pull toward them applies only in 'lines' mode. Alt is
571
+ // force-place, not a snap bypass.
572
+ let finalX = gridX
573
+ let finalZ = gridZ
574
+ if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
575
+ // Translate the cached local bbox to the proposed pos to get the
576
+ // moving anchors at that location. The entry's untransformed
577
+ // bbox is in world meters relative to the node's origin, so a
578
+ // simple translate suffices.
579
+ const dxProposed = gridX - originalPosition[0]
580
+ const dzProposed = gridZ - originalPosition[2]
581
+ // Corner-only for the moving node so it aligns by its edges, never
582
+ // its centreline — matching the placement tools and Path 1 move
583
+ // sessions. Candidates keep their full 9-point set (we DO want to
584
+ // align to a neighbour's centre / edge-midpoints).
585
+ const movingAnchors = bboxCornerAnchors(
586
+ movingNode.id,
587
+ movingLocalBBox.x + dxProposed,
588
+ movingLocalBBox.y + dzProposed,
589
+ movingLocalBBox.x + movingLocalBBox.width + dxProposed,
590
+ movingLocalBBox.y + movingLocalBBox.height + dzProposed,
591
+ )
592
+ // Local-frame resolve (anchors come from the building-local
593
+ // SVG `getBBox()`). Guides land in the editor-local alignment
594
+ // store, which the 2D FloorplanAlignmentGuideLayer renders
595
+ // inside the rotated scene <g>. The 3D pipeline uses a
596
+ // separate store, so frames stay isolated per surface.
597
+ const result = resolveAlignmentForFloorplanView({
598
+ moving: movingAnchors,
599
+ candidates: candidateAnchors,
600
+ threshold: ALIGNMENT_THRESHOLD_M,
601
+ })
602
+ if (result.snap && isMagneticSnapActive()) {
603
+ finalX += result.snap.dx
604
+ finalZ += result.snap.dz
605
+ }
606
+ useAlignmentGuides.getState().set(result.guides)
607
+ } else {
608
+ useAlignmentGuides.getState().clear()
609
+ }
610
+
611
+ // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
612
+ // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
613
+ // alignment guide, so it runs in every snapping mode except Off.
614
+ const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
615
+ if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
616
+ const snappedPosition = groupMoveSnap({
617
+ node: movingNode,
618
+ candidatePosition: [finalX, originalPosition[1], finalZ],
619
+ movingIds: [movingNode.id as AnyNodeId],
620
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
621
+ levelId:
622
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
623
+ (movingNode.parentId as AnyNodeId | undefined) ??
624
+ null,
625
+ })
626
+ if (snappedPosition) {
627
+ finalX = snappedPosition[0]
628
+ finalZ = snappedPosition[2]
629
+ useAlignmentGuides.getState().clear()
630
+ }
631
+ }
632
+
633
+ const dx = finalX - originalPosition[0]
634
+ const dz = finalZ - originalPosition[2]
635
+ for (const relatedEntry of relatedEntries) {
636
+ relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
637
+ }
638
+ boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
639
+ lastSnapped = [finalX, finalZ]
640
+ }
641
+
642
+ const onPointerUp = (event: PointerEvent) => {
643
+ if (event.button !== 0) return
644
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
645
+
646
+ const snapped = lastSnapped
647
+ if (!snapped) return
648
+ const [sx, sz] = snapped
649
+ const [, oldY] = originalPosition
650
+ setMovingNodeOrigin('2d')
651
+ let selectedId = movingNode.id as AnyNodeId
652
+ if (originalPath) {
653
+ // Polyline kinds: shift every point by the committed delta and
654
+ // write `path`. Strip the fresh-placement flags on first drop.
655
+ const dx = sx - originalPosition[0]
656
+ const dz = sz - originalPosition[2]
657
+ const nextPath = originalPath.map(
658
+ ([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
659
+ )
660
+ useScene.getState().updateNode(
661
+ movingNode.id as AnyNodeId,
662
+ (isFreshPlacement
663
+ ? {
664
+ path: nextPath,
665
+ metadata: stripPlacementMetadataFlags(
666
+ (movingNode as { metadata?: unknown }).metadata,
667
+ ),
668
+ visible: true,
669
+ }
670
+ : { path: nextPath }) as Partial<AnyNode>,
671
+ )
672
+ useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
673
+ for (const relatedEntry of relatedEntries) {
674
+ relatedEntry.removeAttribute('transform')
675
+ }
676
+ useAlignmentGuides.getState().clear()
677
+ setMovingNode(null)
678
+ swallowNextClick()
679
+ return
680
+ }
681
+ if (isFreshPlacement) {
682
+ selectedId =
683
+ commitFreshPlacementSubtree(
684
+ movingNode.id as AnyNodeId,
685
+ {
686
+ position: [sx, oldY, sz],
687
+ metadata: stripPlacementMetadataFlags(
688
+ (movingNode as { metadata?: unknown }).metadata,
689
+ ),
690
+ visible: true,
691
+ } as Partial<AnyNode>,
692
+ ) ?? selectedId
693
+ } else {
694
+ useScene.getState().updateNode(
695
+ movingNode.id as AnyNodeId,
696
+ {
697
+ position: [sx, oldY, sz],
698
+ } as Partial<AnyNode>,
699
+ )
700
+ }
701
+ useViewer.getState().setSelection({ selectedIds: [selectedId] })
702
+ for (const relatedEntry of relatedEntries) {
703
+ relatedEntry.removeAttribute('transform')
704
+ }
705
+ useAlignmentGuides.getState().clear()
706
+ setMovingNode(null)
707
+ swallowNextClick()
708
+ }
709
+
710
+ const onKey = (event: KeyboardEvent) => {
711
+ if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
712
+ if (isHistoryShortcut(event)) {
713
+ // ⌘Z mid-move cancels like Escape — keep it from reaching the
714
+ // global undo arm (this handler is capture-phase, that one bubbles).
715
+ event.preventDefault()
716
+ event.stopImmediatePropagation()
717
+ }
718
+ setMovingNodeOrigin('2d')
719
+ if (isFreshPlacement) {
720
+ emitter.emit('tool:cancel')
721
+ const temporal = useScene.temporal.getState()
722
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
723
+ if (wasTracking) temporal.pause()
724
+ useScene.getState().deleteNode(movingNode.id as AnyNodeId)
725
+ if (wasTracking) temporal.resume()
726
+ }
727
+ for (const relatedEntry of relatedEntries) {
728
+ relatedEntry.removeAttribute('transform')
729
+ }
730
+ useAlignmentGuides.getState().clear()
731
+ setMovingNode(null)
732
+ }
733
+
734
+ window.addEventListener('pointermove', onMove)
735
+ window.addEventListener('pointerup', onPointerUp)
736
+ window.addEventListener('keydown', onKey, true)
737
+ return () => {
738
+ window.removeEventListener('pointermove', onMove)
739
+ window.removeEventListener('pointerup', onPointerUp)
740
+ window.removeEventListener('keydown', onKey, true)
741
+ for (const relatedEntry of relatedEntries) {
742
+ relatedEntry.removeAttribute('transform')
743
+ }
744
+ // Always un-hide on teardown so a committed copy shows and a
745
+ // never-revealed entry doesn't leak a hidden style onto a reused node.
746
+ for (const relatedEntry of relatedEntries) {
747
+ relatedEntry.style.visibility = ''
748
+ }
749
+ boxEl.remove()
750
+ useAlignmentGuides.getState().clear()
751
+ }
752
+ }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
753
+
754
+ return null
755
+ }
756
+
757
+ // ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
758
+ //
759
+ // Kept inline here to avoid a circular dependency through a shared
760
+ // utility module. If a third call site shows up, extract.
761
+
762
+ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
763
+
764
+ function snapshotNode(node: AnyNode): NodeSnapshot {
765
+ const data: Record<string, unknown> = {}
766
+ for (const [key, value] of Object.entries(node)) {
767
+ if (key === 'id' || key === 'type' || key === 'object') continue
768
+ data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
769
+ }
770
+ return { id: node.id, data }
771
+ }
772
+
773
+ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
774
+ return snapshots.map((s) => ({ id: s.id, data: s.data }))
775
+ }
776
+
777
+ function deepEqual(a: unknown, b: unknown): boolean {
778
+ if (a === b) return true
779
+ if (Array.isArray(a) && Array.isArray(b)) {
780
+ if (a.length !== b.length) return false
781
+ for (let i = 0; i < a.length; i++) {
782
+ if (!deepEqual(a[i], b[i])) return false
783
+ }
784
+ return true
785
+ }
786
+ if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
787
+ const aKeys = Object.keys(a as Record<string, unknown>)
788
+ const bKeys = Object.keys(b as Record<string, unknown>)
789
+ if (aKeys.length !== bKeys.length) return false
790
+ for (const key of aKeys) {
791
+ if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
792
+ return false
793
+ }
794
+ }
795
+ return true
796
+ }
797
+ return false
798
+ }
799
+
800
+ function collectFloorplanMoveEntryIds(
801
+ rootId: AnyNodeId,
802
+ nodes: Record<string, AnyNode>,
803
+ ): Set<AnyNodeId> {
804
+ const root = nodes[rootId]
805
+ if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') {
806
+ return new Set([rootId])
807
+ }
808
+
809
+ const ids = new Set<AnyNodeId>()
810
+ const queue: AnyNodeId[] = [rootId]
811
+ while (queue.length > 0) {
812
+ const id = queue.pop()!
813
+ if (ids.has(id)) continue
814
+ const node = nodes[id]
815
+ if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue
816
+ ids.add(id)
817
+ for (const childId of node.children ?? []) {
818
+ const child = nodes[childId as AnyNodeId]
819
+ if (child?.type === 'cabinet' || child?.type === 'cabinet-module') {
820
+ queue.push(childId as AnyNodeId)
821
+ }
822
+ }
823
+ }
824
+ return ids
825
+ }
826
+
827
+ function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect {
828
+ const first = entries[0]?.getBBox()
829
+ if (!first) return new DOMRect(0, 0, 0, 0)
830
+
831
+ let minX = first.x
832
+ let minY = first.y
833
+ let maxX = first.x + first.width
834
+ let maxY = first.y + first.height
835
+ for (const entry of entries.slice(1)) {
836
+ const box = entry.getBBox()
837
+ minX = Math.min(minX, box.x)
838
+ minY = Math.min(minY, box.y)
839
+ maxX = Math.max(maxX, box.x + box.width)
840
+ maxY = Math.max(maxY, box.y + box.height)
841
+ }
842
+ return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY))
843
+ }
844
+
845
+ function swallowNextClick() {
846
+ const swallowClick = (e: MouseEvent) => {
847
+ e.stopPropagation()
848
+ e.preventDefault()
849
+ window.removeEventListener('click', swallowClick, true)
850
+ }
851
+ window.addEventListener('click', swallowClick, true)
852
+ // Safety net: if no click fires (e.g. user dragged enough to suppress it),
853
+ // drop the listener on the next tick.
854
+ setTimeout(() => {
855
+ window.removeEventListener('click', swallowClick, true)
856
+ }, 0)
857
+ }