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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -10,7 +10,6 @@ import {
10
10
  type FloorplanMoveTargetSession,
11
11
  nodeRegistry,
12
12
  pauseSceneHistory,
13
- resolveAlignment,
14
13
  resumeSceneHistory,
15
14
  useLiveNodeOverrides,
16
15
  useLiveTransforms,
@@ -19,11 +18,19 @@ import {
19
18
  import { useViewer } from '@pascal-app/viewer'
20
19
  import { useEffect } from 'react'
21
20
  import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
21
+ import { isHistoryShortcut } from '../../lib/history'
22
22
  import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
23
23
  import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
24
+ import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
24
25
  import { sfxEmitter } from '../../lib/sfx-bus'
26
+ import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
25
27
  import useAlignmentGuides from '../../store/use-alignment-guides'
26
- import useEditor from '../../store/use-editor'
28
+ import useEditor, {
29
+ isAlignmentGuideActive,
30
+ isGridSnapActive,
31
+ isMagneticSnapActive,
32
+ } from '../../store/use-editor'
33
+ import { useMovingNode } from '../../store/use-interaction-scope'
27
34
  import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
28
35
 
29
36
  // Figma-style alignment snap threshold. Meters in world space; 8cm gives
@@ -53,7 +60,7 @@ const ALIGNMENT_THRESHOLD_M = 0.08
53
60
  * cursor → meters accounts for pan / zoom / building rotation.
54
61
  */
55
62
  export function FloorplanRegistryMoveOverlay() {
56
- const movingNode = useEditor((s) => s.movingNode)
63
+ const movingNode = useMovingNode()
57
64
  const setMovingNode = useEditor((s) => s.setMovingNode)
58
65
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
59
66
 
@@ -114,6 +121,17 @@ export function FloorplanRegistryMoveOverlay() {
114
121
  pauseSceneHistory(useScene)
115
122
  let historyPaused = true
116
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
+
117
135
  // The registry action menu's Move button portals to `document.body`,
118
136
  // so the trigger click's pointer-up happens OUTSIDE the floor-plan
119
137
  // scene and never reaches `onPointerUp` here. That means: the very
@@ -124,6 +142,18 @@ export function FloorplanRegistryMoveOverlay() {
124
142
  // to be consumed. That legacy flow is gone in the registry layer;
125
143
  // all entries use the action menu now.
126
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
+ }
127
157
 
128
158
  const onMove = (event: PointerEvent) => {
129
159
  // Skip 3D-canvas / other-UI cursor moves so the overlay only
@@ -146,10 +176,31 @@ export function FloorplanRegistryMoveOverlay() {
146
176
  metaKey: event.metaKey,
147
177
  },
148
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
+ }
149
196
  }
150
197
 
151
198
  const commitFinalStateOrRevert = () => {
152
199
  const commitValid = session.canCommit()
200
+ const freshPlacement = isFreshPlacementMetadata(
201
+ (useScene.getState().nodes[movingNode.id] as { metadata?: unknown } | undefined)
202
+ ?.metadata,
203
+ )
153
204
 
154
205
  // Claim ownership of the drag teardown so the 3D move tool's
155
206
  // unmount-time cleanup skips its restore-from-snapshot — see
@@ -163,6 +214,29 @@ export function FloorplanRegistryMoveOverlay() {
163
214
  // planner). For those we still do Phase 1 (revert to baseline)
164
215
  // and Phase 2's resume — but Phase 2's write is delegated, and
165
216
  // we skip the snapshot-diff finalUpdates path.
217
+ if (commitValid && freshPlacement) {
218
+ session.commit?.()
219
+ const stagedNode = useScene.getState().nodes[movingNode.id]
220
+ const committedId = stagedNode
221
+ ? commitFreshPlacementSubtree(
222
+ movingNode.id as AnyNodeId,
223
+ {
224
+ metadata: stripPlacementMetadataFlags(stagedNode.metadata),
225
+ visible: true,
226
+ } as Partial<AnyNode>,
227
+ )
228
+ : null
229
+ if (historyPaused) {
230
+ resumeSceneHistory(useScene)
231
+ historyPaused = false
232
+ }
233
+ if (committedId) {
234
+ sfxEmitter.emit('sfx:item-place')
235
+ useViewer.getState().setSelection({ selectedIds: [committedId] })
236
+ }
237
+ return
238
+ }
239
+
166
240
  if (commitValid && session.commit) {
167
241
  useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
168
242
  if (historyPaused) {
@@ -283,7 +357,42 @@ export function FloorplanRegistryMoveOverlay() {
283
357
  }
284
358
 
285
359
  const onKey = (event: KeyboardEvent) => {
286
- if (event.key !== 'Escape') return
360
+ // R flips a directional kind's facing mid-placement (door / window:
361
+ // front ↔ back). The session records the flip and re-runs its last
362
+ // apply so the 2D symbol updates immediately; kinds without a facing
363
+ // leave `flipSide` unset and R falls through to the global handler.
364
+ //
365
+ // Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
366
+ // move tool ALSO listens for R on `window`. We own R only while the
367
+ // cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
368
+ // 2D mover has actually engaged (`hasMovedSinceStart`); then we
369
+ // `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
370
+ // first) so the 3D tool can't also flip. When the cursor is over the 3D
371
+ // pane we yield — the 3D tool owns R there. (The old sticky
372
+ // `hasMovedSinceStart`-only gate made the overlay claim R forever after
373
+ // the first 2D move, killing the 3D flip.)
374
+ if (event.key === 'r' || event.key === 'R') {
375
+ // Yield Cmd/Ctrl+R to the browser reload instead of flipping the side.
376
+ if (event.metaKey || event.ctrlKey) return
377
+ if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
378
+ if (event.repeat) return
379
+ const t = event.target as HTMLElement | null
380
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
381
+ return
382
+ }
383
+ event.preventDefault()
384
+ event.stopImmediatePropagation()
385
+ session.flipSide()
386
+ sfxEmitter.emit('sfx:item-rotate')
387
+ return
388
+ }
389
+ if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
390
+ if (isHistoryShortcut(event)) {
391
+ // ⌘Z mid-move cancels like Escape — keep it from reaching the
392
+ // global undo arm (this handler is capture-phase, that one bubbles).
393
+ event.preventDefault()
394
+ event.stopImmediatePropagation()
395
+ }
287
396
  // Claim teardown ownership so the 3D move tool's cleanup skips
288
397
  // its own restore — without this, both sides would race to
289
398
  // write the same baseline, harmless but wasteful.
@@ -295,12 +404,7 @@ export function FloorplanRegistryMoveOverlay() {
295
404
  resumeSceneHistory(useScene)
296
405
  historyPaused = false
297
406
  }
298
- const liveTransforms = useLiveTransforms.getState()
299
- const liveOverrides = useLiveNodeOverrides.getState()
300
- for (const id of session.affectedIds) {
301
- liveTransforms.clear(id)
302
- liveOverrides.clear(id)
303
- }
407
+ clearLivePreviews()
304
408
  useAlignmentGuides.getState().clear()
305
409
  setMovingNode(null)
306
410
  return
@@ -317,24 +421,24 @@ export function FloorplanRegistryMoveOverlay() {
317
421
  // `useLiveNodeOverrides`. Either way, leaving them in place
318
422
  // after Esc would freeze the 2D / 3D view at the cancelled
319
423
  // position.
320
- const liveTransforms = useLiveTransforms.getState()
321
- const liveOverrides = useLiveNodeOverrides.getState()
322
- for (const id of session.affectedIds) {
323
- liveTransforms.clear(id)
324
- liveOverrides.clear(id)
325
- }
424
+ clearLivePreviews()
326
425
  // Restore selection cleared by the action menu's Move click.
327
426
  useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
328
427
  setMovingNode(null)
329
428
  }
330
429
 
430
+ window.addEventListener('pointermove', onPointerTrack)
331
431
  window.addEventListener('pointermove', onMove)
332
432
  window.addEventListener('pointerup', onPointerUp)
333
- window.addEventListener('keydown', onKey)
433
+ // Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
434
+ // listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
435
+ // then pre-empts it so only one handler flips.
436
+ window.addEventListener('keydown', onKey, true)
334
437
  return () => {
438
+ window.removeEventListener('pointermove', onPointerTrack)
335
439
  window.removeEventListener('pointermove', onMove)
336
440
  window.removeEventListener('pointerup', onPointerUp)
337
- window.removeEventListener('keydown', onKey)
441
+ window.removeEventListener('keydown', onKey, true)
338
442
  // Unmount cleanup. `historyPaused === true` here means none of
339
443
  // our terminal paths (commit, Esc) ran in this overlay — they
340
444
  // each call `resumeSceneHistory` and flip the flag.
@@ -365,12 +469,7 @@ export function FloorplanRegistryMoveOverlay() {
365
469
  // `useLiveTransforms`; wall sessions write to
366
470
  // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
367
471
  // tool's cleanup isn't there to clear them for us.
368
- const liveTransforms = useLiveTransforms.getState()
369
- const liveOverrides = useLiveNodeOverrides.getState()
370
- for (const id of session.affectedIds) {
371
- liveTransforms.clear(id)
372
- liveOverrides.clear(id)
373
- }
472
+ clearLivePreviews()
374
473
  // Sessions that publish Figma-style alignment guides during `apply`
375
474
  // (item / shelf / column) leave them in the store; this cleanup runs
376
475
  // after every terminal path (commit + Esc both unmount via
@@ -388,12 +487,40 @@ export function FloorplanRegistryMoveOverlay() {
388
487
  // ── Path 2 — generic free-floating translate ────────────────────
389
488
  const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
390
489
  if (!entry) return
391
-
392
- const originalPosition = ((
393
- movingNode as unknown as {
394
- position?: [number, number, number]
395
- }
396
- ).position ?? [0, 0, 0]) as [number, number, number]
490
+ const sceneNodes = useScene.getState().nodes as Record<string, AnyNode>
491
+ const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes)
492
+ const relatedEntries = Array.from(relatedEntryIds)
493
+ .map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null)
494
+ .filter((value): value is SVGGElement => value != null)
495
+
496
+ // Polyline kinds (duct / pipe / lineset) carry a `path`, not a
497
+ // `position` — translating a `position` here would write a field their
498
+ // schema ignores and snap the run back. For those we move every path
499
+ // point by the cursor delta and commit the translated `path` instead.
500
+ // The reference origin is the path centre so the SVG `translate` delta
501
+ // matches the geometry's actual location (which isn't at [0,0,0]).
502
+ // Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
503
+ // here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
504
+ // moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
505
+ const rawPath = (movingNode as { path?: unknown }).path
506
+ const originalPath =
507
+ Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
508
+ ? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
509
+ : null
510
+ const originalPosition: [number, number, number] = originalPath
511
+ ? (() => {
512
+ let cx = 0
513
+ let cz = 0
514
+ for (const p of originalPath) {
515
+ cx += p[0]
516
+ cz += p[2]
517
+ }
518
+ const n = originalPath.length || 1
519
+ return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
520
+ })()
521
+ : (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
522
+ 0, 0, 0,
523
+ ]) as [number, number, number])
397
524
  const isFreshPlacement = isFreshPlacementMetadata(
398
525
  (movingNode as { metadata?: unknown }).metadata,
399
526
  )
@@ -403,20 +530,41 @@ export function FloorplanRegistryMoveOverlay() {
403
530
  // so its untransformed bbox IS the world-space footprint. Cache the
404
531
  // moving entry's local bbox once (relative to originalPosition) and
405
532
  // derive anchors at any proposed (sx, sz) by translating it.
406
- const movingLocalBBox = entry.getBBox()
533
+ const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries)
407
534
  const candidateAnchors: AlignmentAnchor[] = []
408
535
  const allEntries = scene.querySelectorAll('[data-node-id]')
409
536
  for (const el of Array.from(allEntries)) {
410
537
  const otherId = el.getAttribute('data-node-id')
411
- if (!otherId || otherId === movingNode.id) continue
538
+ if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue
412
539
  const b = (el as SVGGraphicsElement).getBBox()
413
- if (b.width <= 0 || b.height <= 0) continue
540
+ // Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
541
+ // lineset drawn as a line) has one zero dimension but is still a valid
542
+ // alignment target — its endpoints become line anchors.
543
+ if (b.width <= 0 && b.height <= 0) continue
414
544
  candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
415
545
  }
416
546
 
417
547
  let lastSnapped: [number, number] | null = null
418
548
  let dragAnchor: [number, number] | null = null
419
549
 
550
+ // Footprint bounding box drawn around the dragged entry — the 2D
551
+ // counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
552
+ // reads the same in both views. Green wireframe rect over the entry's
553
+ // own bbox, translated in lockstep with it. The entry stays visible the
554
+ // whole drag (no hide-until-move) so it never appears to vanish.
555
+ const SVG_NS = 'http://www.w3.org/2000/svg'
556
+ const boxEl = document.createElementNS(SVG_NS, 'rect')
557
+ boxEl.setAttribute('x', String(movingLocalBBox.x))
558
+ boxEl.setAttribute('y', String(movingLocalBBox.y))
559
+ boxEl.setAttribute('width', String(movingLocalBBox.width))
560
+ boxEl.setAttribute('height', String(movingLocalBBox.height))
561
+ boxEl.setAttribute('fill', 'none')
562
+ boxEl.setAttribute('stroke', '#22c55e')
563
+ boxEl.setAttribute('stroke-width', '1.5')
564
+ boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
565
+ boxEl.setAttribute('pointer-events', 'none')
566
+ scene.appendChild(boxEl)
567
+
420
568
  const onMove = (event: PointerEvent) => {
421
569
  // Same target guard as Path 1 — pointer must be over the floor
422
570
  // plan scene; otherwise we'd react to 3D-canvas moves with garbage
@@ -426,9 +574,12 @@ export function FloorplanRegistryMoveOverlay() {
426
574
  if (!m) return
427
575
 
428
576
  // 1) Grid snap baseline. Fresh catalog placement is absolute under
429
- // the cursor; existing moves preserve the cursor's grab offset.
577
+ // the cursor; existing moves preserve the cursor's grab offset. Grid
578
+ // follows the active snapping mode (Shift cycles it); raw cursor in
579
+ // any non-grid mode.
430
580
  const gridStep = useEditor.getState().gridSnapStep
431
- const snap = (value: number) => Math.round(value / gridStep) * gridStep
581
+ const snap = (value: number) =>
582
+ isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
432
583
  const resolved = resolvePlanarCursorPosition({
433
584
  cursor: [m[0], m[1]],
434
585
  original: [originalPosition[0], originalPosition[2]],
@@ -441,12 +592,13 @@ export function FloorplanRegistryMoveOverlay() {
441
592
 
442
593
  // 2) Alignment snap layered on top. Treat the grid-snapped point
443
594
  // as the "proposed" position so alignment competes from a stable
444
- // base rather than the raw cursor jitter. Alt bypasses alignment
445
- // entirely same affordance Path 1 advertises in its "No Snap"
446
- // hint chip.
595
+ // base rather than the raw cursor jitter. Alignment "lines" are
596
+ // DISPLAYED in every mode except Off (isAlignmentGuideActive); the
597
+ // magnetic pull toward them applies only in 'lines' mode. Alt is
598
+ // force-place, not a snap bypass.
447
599
  let finalX = gridX
448
600
  let finalZ = gridZ
449
- if (!event.altKey && candidateAnchors.length > 0) {
601
+ if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
450
602
  // Translate the cached local bbox to the proposed pos to get the
451
603
  // moving anchors at that location. The entry's untransformed
452
604
  // bbox is in world meters relative to the node's origin, so a
@@ -469,12 +621,12 @@ export function FloorplanRegistryMoveOverlay() {
469
621
  // store, which the 2D FloorplanAlignmentGuideLayer renders
470
622
  // inside the rotated scene <g>. The 3D pipeline uses a
471
623
  // separate store, so frames stay isolated per surface.
472
- const result = resolveAlignment({
624
+ const result = resolveAlignmentForFloorplanView({
473
625
  moving: movingAnchors,
474
626
  candidates: candidateAnchors,
475
627
  threshold: ALIGNMENT_THRESHOLD_M,
476
628
  })
477
- if (result.snap) {
629
+ if (result.snap && isMagneticSnapActive()) {
478
630
  finalX += result.snap.dx
479
631
  finalZ += result.snap.dz
480
632
  }
@@ -483,9 +635,34 @@ export function FloorplanRegistryMoveOverlay() {
483
635
  useAlignmentGuides.getState().clear()
484
636
  }
485
637
 
638
+ // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
639
+ // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
640
+ // alignment guide, so it runs in every snapping mode except Off.
641
+ const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
642
+ if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
643
+ const snappedPosition = groupMoveSnap({
644
+ node: movingNode,
645
+ candidatePosition: [finalX, originalPosition[1], finalZ],
646
+ movingIds: [movingNode.id as AnyNodeId],
647
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
648
+ levelId:
649
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
650
+ (movingNode.parentId as AnyNodeId | undefined) ??
651
+ null,
652
+ })
653
+ if (snappedPosition) {
654
+ finalX = snappedPosition[0]
655
+ finalZ = snappedPosition[2]
656
+ useAlignmentGuides.getState().clear()
657
+ }
658
+ }
659
+
486
660
  const dx = finalX - originalPosition[0]
487
661
  const dz = finalZ - originalPosition[2]
488
- entry.setAttribute('transform', `translate(${dx} ${dz})`)
662
+ for (const relatedEntry of relatedEntries) {
663
+ relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
664
+ }
665
+ boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
489
666
  lastSnapped = [finalX, finalZ]
490
667
  }
491
668
 
@@ -499,6 +676,35 @@ export function FloorplanRegistryMoveOverlay() {
499
676
  const [, oldY] = originalPosition
500
677
  setMovingNodeOrigin('2d')
501
678
  let selectedId = movingNode.id as AnyNodeId
679
+ if (originalPath) {
680
+ // Polyline kinds: shift every point by the committed delta and
681
+ // write `path`. Strip the fresh-placement flags on first drop.
682
+ const dx = sx - originalPosition[0]
683
+ const dz = sz - originalPosition[2]
684
+ const nextPath = originalPath.map(
685
+ ([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
686
+ )
687
+ useScene.getState().updateNode(
688
+ movingNode.id as AnyNodeId,
689
+ (isFreshPlacement
690
+ ? {
691
+ path: nextPath,
692
+ metadata: stripPlacementMetadataFlags(
693
+ (movingNode as { metadata?: unknown }).metadata,
694
+ ),
695
+ visible: true,
696
+ }
697
+ : { path: nextPath }) as Partial<AnyNode>,
698
+ )
699
+ useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
700
+ for (const relatedEntry of relatedEntries) {
701
+ relatedEntry.removeAttribute('transform')
702
+ }
703
+ useAlignmentGuides.getState().clear()
704
+ setMovingNode(null)
705
+ swallowNextClick()
706
+ return
707
+ }
502
708
  if (isFreshPlacement) {
503
709
  selectedId =
504
710
  commitFreshPlacementSubtree(
@@ -520,37 +726,54 @@ export function FloorplanRegistryMoveOverlay() {
520
726
  )
521
727
  }
522
728
  useViewer.getState().setSelection({ selectedIds: [selectedId] })
523
- entry.removeAttribute('transform')
729
+ for (const relatedEntry of relatedEntries) {
730
+ relatedEntry.removeAttribute('transform')
731
+ }
524
732
  useAlignmentGuides.getState().clear()
525
733
  setMovingNode(null)
526
734
  swallowNextClick()
527
735
  }
528
736
 
529
737
  const onKey = (event: KeyboardEvent) => {
530
- if (event.key === 'Escape') {
531
- setMovingNodeOrigin('2d')
532
- if (isFreshPlacement) {
533
- emitter.emit('tool:cancel')
534
- const temporal = useScene.temporal.getState()
535
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
536
- if (wasTracking) temporal.pause()
537
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
538
- if (wasTracking) temporal.resume()
539
- }
540
- entry.removeAttribute('transform')
541
- useAlignmentGuides.getState().clear()
542
- setMovingNode(null)
738
+ if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
739
+ if (isHistoryShortcut(event)) {
740
+ // ⌘Z mid-move cancels like Escape — keep it from reaching the
741
+ // global undo arm (this handler is capture-phase, that one bubbles).
742
+ event.preventDefault()
743
+ event.stopImmediatePropagation()
744
+ }
745
+ setMovingNodeOrigin('2d')
746
+ if (isFreshPlacement) {
747
+ emitter.emit('tool:cancel')
748
+ const temporal = useScene.temporal.getState()
749
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
750
+ if (wasTracking) temporal.pause()
751
+ useScene.getState().deleteNode(movingNode.id as AnyNodeId)
752
+ if (wasTracking) temporal.resume()
543
753
  }
754
+ for (const relatedEntry of relatedEntries) {
755
+ relatedEntry.removeAttribute('transform')
756
+ }
757
+ useAlignmentGuides.getState().clear()
758
+ setMovingNode(null)
544
759
  }
545
760
 
546
761
  window.addEventListener('pointermove', onMove)
547
762
  window.addEventListener('pointerup', onPointerUp)
548
- window.addEventListener('keydown', onKey)
763
+ window.addEventListener('keydown', onKey, true)
549
764
  return () => {
550
765
  window.removeEventListener('pointermove', onMove)
551
766
  window.removeEventListener('pointerup', onPointerUp)
552
- window.removeEventListener('keydown', onKey)
553
- entry.removeAttribute('transform')
767
+ window.removeEventListener('keydown', onKey, true)
768
+ for (const relatedEntry of relatedEntries) {
769
+ relatedEntry.removeAttribute('transform')
770
+ }
771
+ // Always un-hide on teardown so a committed copy shows and a
772
+ // never-revealed entry doesn't leak a hidden style onto a reused node.
773
+ for (const relatedEntry of relatedEntries) {
774
+ relatedEntry.style.visibility = ''
775
+ }
776
+ boxEl.remove()
554
777
  useAlignmentGuides.getState().clear()
555
778
  }
556
779
  }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
@@ -601,6 +824,51 @@ function deepEqual(a: unknown, b: unknown): boolean {
601
824
  return false
602
825
  }
603
826
 
827
+ function collectFloorplanMoveEntryIds(
828
+ rootId: AnyNodeId,
829
+ nodes: Record<string, AnyNode>,
830
+ ): Set<AnyNodeId> {
831
+ const root = nodes[rootId]
832
+ if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') {
833
+ return new Set([rootId])
834
+ }
835
+
836
+ const ids = new Set<AnyNodeId>()
837
+ const queue: AnyNodeId[] = [rootId]
838
+ while (queue.length > 0) {
839
+ const id = queue.pop()!
840
+ if (ids.has(id)) continue
841
+ const node = nodes[id]
842
+ if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue
843
+ ids.add(id)
844
+ for (const childId of node.children ?? []) {
845
+ const child = nodes[childId as AnyNodeId]
846
+ if (child?.type === 'cabinet' || child?.type === 'cabinet-module') {
847
+ queue.push(childId as AnyNodeId)
848
+ }
849
+ }
850
+ }
851
+ return ids
852
+ }
853
+
854
+ function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect {
855
+ const first = entries[0]?.getBBox()
856
+ if (!first) return new DOMRect(0, 0, 0, 0)
857
+
858
+ let minX = first.x
859
+ let minY = first.y
860
+ let maxX = first.x + first.width
861
+ let maxY = first.y + first.height
862
+ for (const entry of entries.slice(1)) {
863
+ const box = entry.getBBox()
864
+ minX = Math.min(minX, box.x)
865
+ minY = Math.min(minY, box.y)
866
+ maxX = Math.max(maxX, box.x + box.width)
867
+ maxY = Math.max(maxY, box.y + box.height)
868
+ }
869
+ return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY))
870
+ }
871
+
604
872
  function swallowNextClick() {
605
873
  const swallowClick = (e: MouseEvent) => {
606
874
  e.stopPropagation()
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createFloorplanRenderScaleReference } from './floorplan-render-context'
3
+
4
+ describe('floorplan render context', () => {
5
+ test('updates the live scale without changing the registry-facing reader', () => {
6
+ const scale = createFloorplanRenderScaleReference(0.02)
7
+ const read = scale.read
8
+
9
+ scale.update(0.01)
10
+
11
+ expect(scale.read).toBe(read)
12
+ expect(read()).toBe(0.01)
13
+ })
14
+
15
+ test('notifies selected-handle renderers when a hidden floorplan becomes visible', () => {
16
+ const scale = createFloorplanRenderScaleReference(30)
17
+ let renderedCurveHandleRadius = 8 * scale.read()
18
+ const unsubscribe = scale.subscribe(() => {
19
+ renderedCurveHandleRadius = 8 * scale.read()
20
+ })
21
+
22
+ scale.update(0.02)
23
+
24
+ expect(renderedCurveHandleRadius).toBe(0.16)
25
+ unsubscribe()
26
+ })
27
+ })