@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
@@ -0,0 +1,130 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Html } from '@react-three/drei'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { useCallback, useMemo, useRef } from 'react'
8
+ import * as THREE from 'three'
9
+ import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
10
+ import useEditor from '../../store/use-editor'
11
+ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
12
+ import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
13
+ import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared'
14
+ import { NodeActionMenu } from './node-action-menu'
15
+ import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
16
+
17
+ // Matches the single-node FloatingActionMenu's zoom-compensation constants.
18
+ const REF_ORTHO_ZOOM = 50
19
+ const REF_CAMERA_DISTANCE = 12
20
+ const MIN_MENU_SCALE = 0.6
21
+ const MAX_MENU_SCALE = 1.4
22
+ // Clearance above the group's bbox top so the pill doesn't sit on the meshes.
23
+ const MENU_Y_OFFSET = 0.42
24
+
25
+ /**
26
+ * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 3D
27
+ * view — the group sibling of the single-node `FloatingActionMenu` (which is
28
+ * sole-selection only). Anchored above the selection's bounding-box center;
29
+ * every action targets the whole selection: Move picks the group up (it rides
30
+ * the cursor until a click places it), Duplicate clones the selection and
31
+ * picks the clones up, Delete removes everything selected.
32
+ */
33
+ export function GroupFloatingActionMenu() {
34
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
35
+ const levelId = useViewer((s) => s.selection.levelId)
36
+ const mode = useEditor((s) => s.mode)
37
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
38
+ const movingNode = useMovingNode()
39
+ const nodes = useScene((s) => s.nodes)
40
+ // Hard-hidden during any active interaction (drag, pick-up, reshape) so the
41
+ // pill never competes with the live action — same policy as the 1-node menu.
42
+ const scope = useInteractionScope((s) => s.scope)
43
+ const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
44
+
45
+ const groupRef = useRef<THREE.Group>(null)
46
+ const menuScaleRef = useRef<HTMLDivElement>(null)
47
+
48
+ const participantIds = useMemo(
49
+ () =>
50
+ selectedIds.length > 1
51
+ ? selectedIds.filter(
52
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
53
+ )
54
+ : [],
55
+ [selectedIds, levelId, nodes],
56
+ )
57
+
58
+ // World anchor above the group bbox. Depends on the scene (post-commit
59
+ // positions), not the camera, and the menu hides during drags — so a
60
+ // memo keyed on selection + nodes is enough, no per-frame box traversal.
61
+ const meshEpoch = useMeshSettleEpoch(nodes)
62
+ const anchor = useMemo(() => {
63
+ void meshEpoch
64
+ if (participantIds.length === 0) return null
65
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
66
+ const box = computeGroupBox(fullIds)
67
+ if (!box) return null
68
+ return new THREE.Vector3(
69
+ (box.min.x + box.max.x) / 2,
70
+ box.max.y + MENU_Y_OFFSET,
71
+ (box.min.z + box.max.z) / 2,
72
+ )
73
+ }, [participantIds, nodes, levelId, meshEpoch])
74
+
75
+ useFrame((state) => {
76
+ // Scale the HTML pill with camera zoom / distance so it feels anchored to
77
+ // the world — mirrors the single-node menu.
78
+ if (!(menuScaleRef.current && groupRef.current)) return
79
+ const raw =
80
+ state.camera instanceof THREE.OrthographicCamera
81
+ ? state.camera.zoom / REF_ORTHO_ZOOM
82
+ : REF_CAMERA_DISTANCE /
83
+ Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
84
+ const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
85
+ menuScaleRef.current.style.transform = `scale(${scale})`
86
+ })
87
+
88
+ const stopPointer = useCallback((event: React.PointerEvent) => {
89
+ event.stopPropagation()
90
+ }, [])
91
+ const handleMove = useCallback((event: React.MouseEvent) => {
92
+ event.stopPropagation()
93
+ startGroupPickUp()
94
+ }, [])
95
+ const handleDuplicate = useCallback((event: React.MouseEvent) => {
96
+ event.stopPropagation()
97
+ duplicateSelectionAndPickUp()
98
+ }, [])
99
+ const handleDelete = useCallback((event: React.MouseEvent) => {
100
+ event.stopPropagation()
101
+ deleteSelection()
102
+ }, [])
103
+
104
+ if (!anchor || mode === 'delete' || isFloorplanHovered || movingNode || menuStepBack) {
105
+ return null
106
+ }
107
+
108
+ return (
109
+ <group position={anchor} ref={groupRef}>
110
+ <Html
111
+ center
112
+ style={{
113
+ pointerEvents: 'auto',
114
+ touchAction: 'none',
115
+ }}
116
+ zIndexRange={[25, 0]}
117
+ >
118
+ <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
119
+ <NodeActionMenu
120
+ onDelete={handleDelete}
121
+ onDuplicate={handleDuplicate}
122
+ onMove={handleMove}
123
+ onPointerDown={stopPointer}
124
+ onPointerUp={stopPointer}
125
+ />
126
+ </div>
127
+ </Html>
128
+ </group>
129
+ )
130
+ }
@@ -0,0 +1,397 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ bboxCornerAnchors,
5
+ collectAlignmentAnchors,
6
+ pauseSceneHistory,
7
+ pauseSpaceDetection,
8
+ resolveAlignment,
9
+ resumeSceneHistory,
10
+ resumeSpaceDetection,
11
+ useLiveNodeOverrides,
12
+ useLiveTransforms,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three'
17
+ import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
18
+ import { isHistoryShortcut } from '../../lib/history'
19
+ import { sfxEmitter } from '../../lib/sfx-bus'
20
+ import useAlignmentGuides from '../../store/use-alignment-guides'
21
+ import useEditor, {
22
+ isAlignmentGuideActive,
23
+ isGridSnapActive,
24
+ isMagneticSnapActive,
25
+ } from '../../store/use-editor'
26
+ import useInteractionScope from '../../store/use-interaction-scope'
27
+ import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
28
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
29
+ import { startGroupPickUp } from './group-actions'
30
+ import {
31
+ classifyParticipant,
32
+ collectParticipants,
33
+ computeGroupBox,
34
+ expandToComponent,
35
+ levelFrame,
36
+ participantExtents,
37
+ rotateGroupSnapshots,
38
+ translateGroupPatches,
39
+ type Vec2,
40
+ } from './group-transform-shared'
41
+ import { swallowNextClick } from './handles/use-handle-drag'
42
+
43
+ // 3D sibling of the 2D floorplan group move (and successor of the removed
44
+ // group-move gizmo cross): pressing any selected element's body in a
45
+ // multi-selection and dragging past the threshold slides the whole selection
46
+ // on the ground plane; a plain click (no drag) enters the group pick-up,
47
+ // parity with the single-item click-to-move. Shares the group participant
48
+ // snapshot, welded junctions, snapping entry points, live override previews,
49
+ // and single-undo commit with the 2D session.
50
+
51
+ // Figma-style alignment-snap threshold (meters) — same pull distance as the
52
+ // single-node registry move.
53
+ const ALIGNMENT_THRESHOLD_M = 0.08
54
+ const DRAG_THRESHOLD_PX = 4
55
+
56
+ /**
57
+ * Arm a 3D group move from a pointer-down on `nodeId`. Returns false
58
+ * (attaching nothing) unless the node is a transformable member of a
59
+ * multi-selection.
60
+ */
61
+ export function armGroupMove3d(args: {
62
+ nodeId: AnyNodeId
63
+ clientX: number
64
+ clientY: number
65
+ pointerId: number
66
+ nativeEvent: PointerEvent
67
+ camera: Camera
68
+ raycaster: Raycaster
69
+ domElement: HTMLCanvasElement
70
+ }): boolean {
71
+ const { nodeId, clientX, clientY, pointerId, camera, raycaster, domElement } = args
72
+ const { selectedIds, levelId } = useViewer.getState().selection
73
+ if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false
74
+ const sceneNodes = useScene.getState().nodes
75
+ if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false
76
+
77
+ suppressBoxSelectForPointer(args.nativeEvent)
78
+
79
+ const ndc = new Vector2()
80
+ const setNDC = (x: number, y: number) => {
81
+ const rect = domElement.getBoundingClientRect()
82
+ ndc.set(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1)
83
+ }
84
+
85
+ type Session = {
86
+ starts: ReturnType<typeof collectParticipants>['starts']
87
+ links: ReturnType<typeof collectParticipants>['links']
88
+ affectedIds: AnyNodeId[]
89
+ candidates: ReturnType<typeof collectAlignmentAnchors>
90
+ restAnchors: ReturnType<typeof bboxCornerAnchors>
91
+ restCenter: Vec2
92
+ plane: Plane
93
+ startLocal: Vector3
94
+ frameInv: ReturnType<typeof levelFrame>['inverse']
95
+ lastDelta: Vec2 | null
96
+ }
97
+ let session: Session | null = null
98
+
99
+ const engage = (): Session | null => {
100
+ const nodes = useScene.getState().nodes
101
+ const participantIds = selectedIds.filter(
102
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
103
+ )
104
+ // Move the full connected wall/fence component, mirroring the 2D session.
105
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
106
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
107
+ if (starts.length === 0) return null
108
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
109
+
110
+ // Horizontal drag plane at the group's base; placements live in the level
111
+ // frame, so world-space plane hits convert through it (a rotated building
112
+ // would otherwise drift off-axis from the cursor).
113
+ const restBox = computeGroupBox(fullIds)
114
+ if (!restBox) return null
115
+ const plane = new Plane(new Vector3(0, 1, 0), -restBox.min.y)
116
+ const { inverse: frameInv } = levelFrame(levelId)
117
+
118
+ setNDC(clientX, clientY)
119
+ raycaster.setFromCamera(ndc, camera)
120
+ const hit = new Vector3()
121
+ if (!raycaster.ray.intersectPlane(plane, hit)) return null
122
+ const startLocal = hit.clone().applyMatrix4(frameInv)
123
+
124
+ // Alignment candidates — anchors of everything OUTSIDE the moving set,
125
+ // gathered once at drag start. The group aligns as one rigid footprint:
126
+ // its bbox corners + center are the moving anchors.
127
+ const movingIdSet = new Set<string>(affectedIds)
128
+ const staticNodes: Record<string, AnyNode> = {}
129
+ for (const [nid, n] of Object.entries(nodes)) {
130
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
131
+ }
132
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
133
+ const boxMin = restBox.min.clone().applyMatrix4(frameInv)
134
+ const boxMax = restBox.max.clone().applyMatrix4(frameInv)
135
+ const restAnchors = bboxCornerAnchors(
136
+ 'group-move',
137
+ Math.min(boxMin.x, boxMax.x),
138
+ Math.min(boxMin.z, boxMax.z),
139
+ Math.max(boxMin.x, boxMax.x),
140
+ Math.max(boxMin.z, boxMax.z),
141
+ )
142
+
143
+ for (const id of affectedIds) {
144
+ useLiveTransforms.getState().clear(id)
145
+ }
146
+
147
+ domElement.style.cursor = 'grabbing'
148
+ sfxEmitter.emit('sfx:item-pick')
149
+ swallowNextClick()
150
+ useViewer.getState().setInputDragging(true)
151
+ pauseSceneHistory(useScene)
152
+ useInteractionScope.getState().begin({
153
+ kind: 'handle-drag',
154
+ nodeId,
155
+ handle: GROUP_MOVE_DRAG_LABEL,
156
+ })
157
+ // Rotation pivot for mid-drag R/T — the participant DATA extents' center.
158
+ const ext = participantExtents(starts)
159
+ const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
160
+
161
+ return {
162
+ starts,
163
+ links,
164
+ affectedIds,
165
+ candidates,
166
+ restAnchors,
167
+ restCenter,
168
+ plane,
169
+ startLocal,
170
+ frameInv,
171
+ lastDelta: null,
172
+ }
173
+ }
174
+
175
+ const applyMove = (e: PointerEvent, s: Session) => {
176
+ setNDC(e.clientX, e.clientY)
177
+ raycaster.setFromCamera(ndc, camera)
178
+ const moveHit = new Vector3()
179
+ if (!raycaster.ray.intersectPlane(s.plane, moveHit)) return
180
+ const moveLocal = moveHit.applyMatrix4(s.frameInv)
181
+
182
+ // Snap the slide DELTA to the active grid step so the selection's internal
183
+ // layout stays intact. Mode-driven: the `handle-drag` scope resolves to
184
+ // the item snap context, so Shift cycles the mode mid-drag.
185
+ const step = useEditor.getState().gridSnapStep
186
+ const snap = isGridSnapActive() && step > 0
187
+ let dx = snap
188
+ ? Math.round((moveLocal.x - s.startLocal.x) / step) * step
189
+ : moveLocal.x - s.startLocal.x
190
+ let dz = snap
191
+ ? Math.round((moveLocal.z - s.startLocal.z) / step) * step
192
+ : moveLocal.z - s.startLocal.z
193
+
194
+ // Figma-style alignment layered on top: guides display in every mode
195
+ // except Off; the magnetic pull applies only in 'lines' mode.
196
+ if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
197
+ const result = resolveAlignment({
198
+ moving: s.restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
199
+ candidates: s.candidates,
200
+ threshold: ALIGNMENT_THRESHOLD_M,
201
+ })
202
+ if (result.snap && isMagneticSnapActive()) {
203
+ dx += result.snap.dx
204
+ dz += result.snap.dz
205
+ }
206
+ useAlignmentGuides.getState().set(result.guides)
207
+ } else {
208
+ useAlignmentGuides.getState().clear()
209
+ }
210
+
211
+ applyDelta(s, dx, dz)
212
+ }
213
+
214
+ const applyDelta = (s: Session, dx: number, dz: number) => {
215
+ // Ticker on each delta change — parity with the single-node move SFX.
216
+ if (!s.lastDelta || s.lastDelta[0] !== dx || s.lastDelta[1] !== dz) {
217
+ sfxEmitter.emit('sfx:grid-snap')
218
+ s.lastDelta = [dx, dz]
219
+ }
220
+
221
+ const entries = translateGroupPatches(s.starts, s.links, dx, dz)
222
+ const patchById = new Map(entries)
223
+ const liveTransforms = useLiveTransforms.getState()
224
+ for (const start of s.starts) {
225
+ if (start.kind === 'scalar') {
226
+ const patch = patchById.get(start.id)
227
+ if (patch) {
228
+ liveTransforms.set(start.id, {
229
+ position: patch.position as [number, number, number],
230
+ rotation: start.rotation,
231
+ })
232
+ }
233
+ }
234
+ useScene.getState().markDirty(start.id)
235
+ }
236
+ for (const l of s.links) {
237
+ useScene.getState().markDirty(l.id)
238
+ }
239
+ useLiveNodeOverrides.getState().setMany(entries)
240
+ // The 2D dashed group bbox rides the same delta in split view.
241
+ useFloorplanGroupDrag.getState().set([dx, dz])
242
+ }
243
+
244
+ // Mid-drag R/T: rotate the SNAPSHOTS around the rest pivot and re-apply the
245
+ // current delta — the carried group turns exactly like the idle keyboard
246
+ // rotate, and the commit stays a single updateNodes.
247
+ const rotateSession = (s: Session, direction: 1 | -1) => {
248
+ const rotated = rotateGroupSnapshots(
249
+ s.starts,
250
+ s.links,
251
+ { x: s.restCenter[0], z: s.restCenter[1] },
252
+ -direction * (Math.PI / 4),
253
+ )
254
+ s.starts = rotated.starts
255
+ s.links = rotated.links
256
+ const ext = participantExtents(rotated.starts)
257
+ if (ext) {
258
+ s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
259
+ }
260
+ sfxEmitter.emit('sfx:item-rotate')
261
+ applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
262
+ }
263
+
264
+ const clearLivePreviews = (s: Session) => {
265
+ const overrides = useLiveNodeOverrides.getState()
266
+ const liveTransforms = useLiveTransforms.getState()
267
+ for (const id of s.affectedIds) {
268
+ overrides.clear(id)
269
+ liveTransforms.clear(id)
270
+ useScene.getState().markDirty(id)
271
+ }
272
+ }
273
+
274
+ const removeListeners = () => {
275
+ window.removeEventListener('pointermove', onMove)
276
+ window.removeEventListener('pointerup', onUp, true)
277
+ window.removeEventListener('pointercancel', onPointerCancel)
278
+ window.removeEventListener('keydown', onKeyDown, true)
279
+ }
280
+
281
+ // History resume is NOT here — it pairs one-to-one with the
282
+ // `pauseSceneHistory` in `engage()`, on the commit and cancel paths.
283
+ const teardown = () => {
284
+ removeListeners()
285
+ if (domElement.style.cursor === 'grabbing') domElement.style.cursor = ''
286
+ useAlignmentGuides.getState().clear()
287
+ // Deferred so the canvas pointerup later in this same dispatch still sees
288
+ // the drag as active (see onUp).
289
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
290
+ useInteractionScope
291
+ .getState()
292
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
293
+ useFloorplanGroupDrag.getState().set(null)
294
+ }
295
+
296
+ const onMove = (e: PointerEvent) => {
297
+ if (e.pointerId !== pointerId) return
298
+ if (!session) {
299
+ if (Math.hypot(e.clientX - clientX, e.clientY - clientY) < DRAG_THRESHOLD_PX) return
300
+ session = engage()
301
+ if (!session) {
302
+ removeListeners()
303
+ return
304
+ }
305
+ }
306
+ applyMove(e, session)
307
+ }
308
+
309
+ // Registered in CAPTURE phase so this runs before the canvas handlers:
310
+ // `use-node-events` synthesizes a selection click on EVERY pointerup,
311
+ // suppressed only while `inputDragging` is set — so the gesture must keep
312
+ // it raised through this event's dispatch (released on a 0ms timer) or the
313
+ // release re-routes selection to whatever sits under the cursor. Plain
314
+ // stopPropagation would ALSO kill the window bubble listeners that clear
315
+ // the box-select pointer suppression, leaving the marquee dead.
316
+ const onUp = (e: PointerEvent) => {
317
+ if (e.pointerId !== pointerId) return
318
+ if (!session) {
319
+ // Plain click — enter the group pick-up, parity with the single-item
320
+ // click-to-move. Eat the click so the selection manager's click
321
+ // handling doesn't collapse the multi-selection underneath it.
322
+ removeListeners()
323
+ swallowNextClick()
324
+ useViewer.getState().setInputDragging(true)
325
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
326
+ startGroupPickUp()
327
+ return
328
+ }
329
+ swallowNextClick()
330
+ sfxEmitter.emit('sfx:item-place')
331
+ const overrides = useLiveNodeOverrides.getState()
332
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
333
+ for (const id of session.affectedIds) {
334
+ const patch = overrides.get(id)
335
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
336
+ }
337
+ // Resume before the commit so the single batched `updateNodes` is the one
338
+ // tracked set — collapsing the whole group move into one undo step.
339
+ // Group transforms move existing structure rigidly — the wall-driven room
340
+ // auto-detection must not re-create floors/ceilings for the walls' new
341
+ // positions (that belongs to wall building/editing). Paused around the
342
+ // commit; the sync rolls its baseline forward for paused changes.
343
+ pauseSpaceDetection()
344
+ resumeSceneHistory(useScene)
345
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
346
+ resumeSpaceDetection()
347
+ clearLivePreviews(session)
348
+ session = null
349
+ teardown()
350
+ }
351
+
352
+ const cancel = () => {
353
+ if (session) {
354
+ clearLivePreviews(session)
355
+ resumeSceneHistory(useScene)
356
+ session = null
357
+ }
358
+ teardown()
359
+ }
360
+
361
+ const onPointerCancel = (e: PointerEvent) => {
362
+ if (e.pointerId !== pointerId) return
363
+ cancel()
364
+ }
365
+
366
+ // Capture phase so the drag's Escape / R / T win over the global
367
+ // `use-keyboard` arms, which would otherwise act on stale store state
368
+ // (or clear the multi-selection) mid-session.
369
+ const onKeyDown = (e: KeyboardEvent) => {
370
+ const key = e.key.toLowerCase()
371
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
372
+ if (!session) return
373
+ e.preventDefault()
374
+ e.stopPropagation()
375
+ rotateSession(session, key === 'r' ? 1 : -1)
376
+ return
377
+ }
378
+ if (e.key === 'Delete' || e.key === 'Backspace') {
379
+ // Deleting mid-move: revert the session first, then let the global
380
+ // Delete arm remove the selection — no dangling carry.
381
+ cancel()
382
+ return
383
+ }
384
+ // ⌘Z mid-move cancels like Escape — never a history jump under a live pointer.
385
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
386
+ e.preventDefault()
387
+ e.stopPropagation()
388
+ swallowNextClick()
389
+ cancel()
390
+ }
391
+
392
+ window.addEventListener('pointermove', onMove)
393
+ window.addEventListener('pointerup', onUp, true)
394
+ window.addEventListener('pointercancel', onPointerCancel)
395
+ window.addEventListener('keydown', onKeyDown, true)
396
+ return true
397
+ }