@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,744 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ bboxCornerAnchors,
7
+ collectAlignmentAnchors,
8
+ DEFAULT_ANGLE_STEP,
9
+ type FloorplanGeometry,
10
+ type FloorplanPalette,
11
+ pauseSceneHistory,
12
+ pauseSpaceDetection,
13
+ resumeSceneHistory,
14
+ resumeSpaceDetection,
15
+ useLiveNodeOverrides,
16
+ useLiveTransforms,
17
+ useScene,
18
+ } from '@pascal-app/core'
19
+ import { useViewer } from '@pascal-app/viewer'
20
+ import { memo, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useState } from 'react'
21
+ import { create } from 'zustand'
22
+ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
23
+ import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
24
+ import { clientToPlan } from '../../lib/floorplan/plan-coords'
25
+ import { isHistoryShortcut } from '../../lib/history'
26
+ import { formatLinearMeasurement } from '../../lib/measurements'
27
+ import { sfxEmitter } from '../../lib/sfx-bus'
28
+ import useAlignmentGuides from '../../store/use-alignment-guides'
29
+ import useEditor, {
30
+ isAlignmentGuideActive,
31
+ isAngleSnapActive,
32
+ isGridSnapActive,
33
+ isMagneticSnapActive,
34
+ } from '../../store/use-editor'
35
+ import useFloorplanMode from '../../store/use-floorplan-mode'
36
+ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
37
+ import {
38
+ classifyParticipant,
39
+ collectParticipants,
40
+ computeGroupBox,
41
+ expandToComponent,
42
+ levelFrame,
43
+ participantExtents,
44
+ rotateGroupPatches,
45
+ rotateGroupSnapshots,
46
+ translateGroupPatches,
47
+ type Vec2,
48
+ } from '../editor/group-transform-shared'
49
+ import { swallowNextClick } from '../editor/handles/use-handle-drag'
50
+ import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
51
+ import { useFloorplanSceneRotation } from './floorplan-render-context'
52
+ import { FloorplanDimensionRenderer } from './renderers/floorplan-dimension-renderer'
53
+
54
+ // 2D sibling of the 3D body-drag group move (`group-move-3d.ts`): dragging
55
+ // any selected element of a multi-selection slides the whole selection
56
+ // rigidly (Photoshop semantics). Both share the participant snapshot +
57
+ // translate math, the live preview channel (`useLiveNodeOverrides.setMany` +
58
+ // welded `LinkedNeighbor` endpoints), the snapping entry points (grid step
59
+ // via `isGridSnapActive`, Figma alignment via the shared resolver), and the
60
+ // single-undo commit (history pause → one `updateNodes` → resume).
61
+
62
+ // Same engage threshold as the layer's Cmd-drag direct move.
63
+ const DRAG_THRESHOLD_PX = 4
64
+
65
+ // Live plan-frame drag delta / rotation, published so the dashed group bbox
66
+ // overlay rides along without re-rendering the whole registry layer per tick.
67
+ type FloorplanGroupDragState = {
68
+ delta: Vec2 | null
69
+ rotation: { pivotX: number; pivotZ: number; angle: number } | null
70
+ set: (delta: Vec2 | null) => void
71
+ setRotation: (rotation: FloorplanGroupDragState['rotation']) => void
72
+ }
73
+ export const useFloorplanGroupDrag = create<FloorplanGroupDragState>((set) => ({
74
+ delta: null,
75
+ rotation: null,
76
+ set: (delta) => set({ delta }),
77
+ setRotation: (rotation) => set({ rotation }),
78
+ }))
79
+
80
+ /**
81
+ * Try to start a 2D group move for a pointer-down on `nodeId`. Returns false
82
+ * (attaching nothing) unless the node is a transformable member of a
83
+ * multi-selection — the caller falls through to its single-node behavior.
84
+ *
85
+ * `immediate` engages the session on pointer-down (move-handle dot semantics:
86
+ * the dot is an explicit move control); otherwise the session arms and only
87
+ * engages once the pointer travels past the drag threshold, and a plain
88
+ * click falls through to `onClickFallthrough` (the collapse-to-single
89
+ * selection click).
90
+ */
91
+ export function startFloorplanGroupMove(
92
+ nodeId: AnyNodeId,
93
+ event: { clientX: number; clientY: number; pointerId: number },
94
+ opts: { immediate?: boolean; onClickFallthrough?: () => void } = {},
95
+ ): boolean {
96
+ const { selectedIds, levelId } = useViewer.getState().selection
97
+ if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false
98
+ const sceneNodes = useScene.getState().nodes
99
+ // The pressed element itself must transform — dragging a selected door /
100
+ // window (which rides its host wall) keeps today's single-node behavior.
101
+ if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false
102
+ const startPlan = clientToPlan(event.clientX, event.clientY)
103
+ if (!startPlan) return false
104
+
105
+ const startX = event.clientX
106
+ const startY = event.clientY
107
+ const pointerId = event.pointerId
108
+
109
+ type Session = {
110
+ starts: ReturnType<typeof collectParticipants>['starts']
111
+ links: ReturnType<typeof collectParticipants>['links']
112
+ affectedIds: AnyNodeId[]
113
+ candidates: ReturnType<typeof collectAlignmentAnchors>
114
+ restAnchors: ReturnType<typeof bboxCornerAnchors>
115
+ restCenter: Vec2
116
+ lastDelta: Vec2 | null
117
+ }
118
+ let session: Session | null = null
119
+
120
+ const engage = (): Session | null => {
121
+ const nodes = useScene.getState().nodes
122
+ const participantIds = selectedIds.filter(
123
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
124
+ )
125
+ // Move the full connected wall/fence component, mirroring the 3D gizmo.
126
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
127
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
128
+ if (starts.length === 0) return null
129
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
130
+
131
+ // Alignment candidates — anchors of everything OUTSIDE the moving set
132
+ // (selection + welded neighbours), gathered once at drag start.
133
+ const movingIdSet = new Set<string>(affectedIds)
134
+ const staticNodes: Record<string, AnyNode> = {}
135
+ for (const [nid, n] of Object.entries(nodes)) {
136
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
137
+ }
138
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
139
+
140
+ // The group aligns as one rigid footprint: its bbox corners + center are
141
+ // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays
142
+ // mounted under every view mode); plan coords are level-frame, so convert.
143
+ const restBox = computeGroupBox(fullIds)
144
+ const { inverse: frameInv } = levelFrame(levelId)
145
+ const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null
146
+ const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null
147
+ const restAnchors =
148
+ boxMin && boxMax
149
+ ? bboxCornerAnchors(
150
+ 'group-move',
151
+ Math.min(boxMin.x, boxMax.x),
152
+ Math.min(boxMin.z, boxMax.z),
153
+ Math.max(boxMin.x, boxMax.x),
154
+ Math.max(boxMin.z, boxMax.z),
155
+ )
156
+ : []
157
+
158
+ for (const id of affectedIds) {
159
+ useLiveTransforms.getState().clear(id)
160
+ }
161
+
162
+ document.body.style.cursor = 'grabbing'
163
+ sfxEmitter.emit('sfx:item-pick')
164
+ swallowNextClick()
165
+ useViewer.getState().setInputDragging(true)
166
+ pauseSceneHistory(useScene)
167
+ useInteractionScope.getState().begin({
168
+ kind: 'handle-drag',
169
+ nodeId,
170
+ handle: GROUP_MOVE_DRAG_LABEL,
171
+ })
172
+ // Rotation pivot for mid-drag R/T — the participant DATA extents' center
173
+ // (stable across the drag; rotations re-seed around the same point).
174
+ const ext = participantExtents(starts)
175
+ const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
176
+
177
+ return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null }
178
+ }
179
+
180
+ const applyMove = (e: PointerEvent, s: Session) => {
181
+ const plan = clientToPlan(e.clientX, e.clientY)
182
+ if (!plan) return
183
+ // Snap the slide DELTA to the active grid step so the selection's
184
+ // internal layout stays intact — grid-aligned members stay aligned.
185
+ // Mode-driven like the 3D group move: the `handle-drag` scope resolves
186
+ // to the item snap context, so Shift cycles the mode mid-drag.
187
+ const step = useEditor.getState().gridSnapStep
188
+ const snap = isGridSnapActive() && step > 0
189
+ let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
190
+ let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
191
+
192
+ // Figma-style alignment layered on top: guides display in every mode
193
+ // except Off; the magnetic pull applies only in 'lines' mode.
194
+ if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
195
+ const proposed: [number, number] = [startPlan[0] + dx, startPlan[1] + dz]
196
+ const aligned = applyFloorplanAlignment(
197
+ proposed,
198
+ s.restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
199
+ s.candidates,
200
+ { applySnap: isMagneticSnapActive() },
201
+ )
202
+ dx = aligned.point[0] - startPlan[0]
203
+ dz = aligned.point[1] - startPlan[1]
204
+ } else {
205
+ useAlignmentGuides.getState().clear()
206
+ }
207
+
208
+ applyDelta(s, dx, dz)
209
+ }
210
+
211
+ const applyDelta = (s: Session, dx: number, dz: number) => {
212
+ // Ticker on each delta change — parity with the 3D group move's SFX.
213
+ if (!s.lastDelta || s.lastDelta[0] !== dx || s.lastDelta[1] !== dz) {
214
+ sfxEmitter.emit('sfx:grid-snap')
215
+ s.lastDelta = [dx, dz]
216
+ }
217
+
218
+ const entries = translateGroupPatches(s.starts, s.links, dx, dz)
219
+ const patchById = new Map(entries)
220
+ const liveTransforms = useLiveTransforms.getState()
221
+ for (const start of s.starts) {
222
+ if (start.kind === 'scalar') {
223
+ const patch = patchById.get(start.id)
224
+ if (patch) {
225
+ liveTransforms.set(start.id, {
226
+ position: patch.position as [number, number, number],
227
+ rotation: start.rotation,
228
+ })
229
+ }
230
+ }
231
+ useScene.getState().markDirty(start.id)
232
+ }
233
+ for (const l of s.links) {
234
+ useScene.getState().markDirty(l.id)
235
+ }
236
+ useLiveNodeOverrides.getState().setMany(entries)
237
+ useFloorplanGroupDrag.getState().set([dx, dz])
238
+ }
239
+
240
+ // Mid-drag R/T: rotate the SNAPSHOTS around the rest pivot and re-apply the
241
+ // current delta — the carried group turns exactly like the idle keyboard
242
+ // rotate, and the commit stays a single updateNodes.
243
+ const rotateSession = (s: Session, direction: 1 | -1) => {
244
+ const rotated = rotateGroupSnapshots(
245
+ s.starts,
246
+ s.links,
247
+ { x: s.restCenter[0], z: s.restCenter[1] },
248
+ -direction * (Math.PI / 4),
249
+ )
250
+ s.starts = rotated.starts
251
+ s.links = rotated.links
252
+ const ext = participantExtents(rotated.starts)
253
+ if (ext) {
254
+ s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
255
+ }
256
+ sfxEmitter.emit('sfx:item-rotate')
257
+ applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
258
+ }
259
+
260
+ const clearLivePreviews = (s: Session) => {
261
+ const overrides = useLiveNodeOverrides.getState()
262
+ const liveTransforms = useLiveTransforms.getState()
263
+ for (const id of s.affectedIds) {
264
+ overrides.clear(id)
265
+ liveTransforms.clear(id)
266
+ useScene.getState().markDirty(id)
267
+ }
268
+ }
269
+
270
+ const removeListeners = () => {
271
+ window.removeEventListener('pointermove', onMove)
272
+ window.removeEventListener('pointerup', onUp, true)
273
+ window.removeEventListener('pointercancel', onPointerCancel)
274
+ window.removeEventListener('keydown', onKeyDown, true)
275
+ }
276
+
277
+ // History resume is NOT here — it pairs exactly one-to-one with the
278
+ // `pauseSceneHistory` in `engage()`, on the commit and cancel paths.
279
+ const teardown = () => {
280
+ removeListeners()
281
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
282
+ useAlignmentGuides.getState().clear()
283
+ // Deferred so a canvas pointerup later in this same dispatch still sees
284
+ // the drag as active (split view — see onUp).
285
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
286
+ useInteractionScope
287
+ .getState()
288
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
289
+ useFloorplanGroupDrag.getState().set(null)
290
+ }
291
+
292
+ const onMove = (e: PointerEvent) => {
293
+ if (e.pointerId !== pointerId) return
294
+ if (!session) {
295
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return
296
+ session = engage()
297
+ if (!session) {
298
+ removeListeners()
299
+ return
300
+ }
301
+ }
302
+ applyMove(e, session)
303
+ }
304
+
305
+ // Capture-phase registration: in split view the release can land over the
306
+ // 3D canvas, whose use-node-events synthesizes a selection click on every
307
+ // pointerup — suppressed while `inputDragging` is raised, which teardown
308
+ // therefore lowers on a 0ms timer (after this event's dispatch).
309
+ const onUp = (e: PointerEvent) => {
310
+ if (e.pointerId !== pointerId) return
311
+ if (!session) {
312
+ removeListeners()
313
+ opts.onClickFallthrough?.()
314
+ return
315
+ }
316
+ // Eat the click that follows pointer-up so the background click handler
317
+ // doesn't clear the multi-selection the drag is preserving.
318
+ swallowNextClick()
319
+ sfxEmitter.emit('sfx:item-place')
320
+ const overrides = useLiveNodeOverrides.getState()
321
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
322
+ for (const id of session.affectedIds) {
323
+ const patch = overrides.get(id)
324
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
325
+ }
326
+ // Resume before the commit so the single batched `updateNodes` is the one
327
+ // tracked set — collapsing the whole group move into one undo step.
328
+ // Group transforms move existing structure rigidly — the wall-driven room
329
+ // auto-detection must not re-create floors/ceilings for the walls' new
330
+ // positions (that belongs to wall building/editing). Paused around the
331
+ // commit; the sync rolls its baseline forward for paused changes.
332
+ pauseSpaceDetection()
333
+ resumeSceneHistory(useScene)
334
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
335
+ resumeSpaceDetection()
336
+ clearLivePreviews(session)
337
+ session = null
338
+ teardown()
339
+ }
340
+
341
+ const cancel = () => {
342
+ if (session) {
343
+ clearLivePreviews(session)
344
+ resumeSceneHistory(useScene)
345
+ session = null
346
+ }
347
+ teardown()
348
+ }
349
+
350
+ const onPointerCancel = (e: PointerEvent) => {
351
+ if (e.pointerId !== pointerId) return
352
+ cancel()
353
+ }
354
+
355
+ // Capture phase so the drag's Escape wins over the global `use-keyboard`
356
+ // Escape arm (registered earlier on window in the bubble phase), which
357
+ // would otherwise clear the multi-selection mid-cancel.
358
+ const onKeyDown = (e: KeyboardEvent) => {
359
+ const key = e.key.toLowerCase()
360
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
361
+ if (!session) return
362
+ e.preventDefault()
363
+ e.stopPropagation()
364
+ rotateSession(session, key === 'r' ? 1 : -1)
365
+ return
366
+ }
367
+ if (e.key === 'Delete' || e.key === 'Backspace') {
368
+ // Deleting mid-move: revert the session first, then let the global
369
+ // Delete arm remove the selection — no dangling carry.
370
+ cancel()
371
+ return
372
+ }
373
+ // ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
374
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
375
+ e.preventDefault()
376
+ e.stopPropagation()
377
+ swallowNextClick()
378
+ cancel()
379
+ }
380
+
381
+ window.addEventListener('pointermove', onMove)
382
+ window.addEventListener('pointerup', onUp, true)
383
+ window.addEventListener('pointercancel', onPointerCancel)
384
+ window.addEventListener('keydown', onKeyDown, true)
385
+
386
+ if (opts.immediate) {
387
+ session = engage()
388
+ if (!session) {
389
+ removeListeners()
390
+ return false
391
+ }
392
+ }
393
+ return true
394
+ }
395
+
396
+ /**
397
+ * 2D group rotate, driven from the dashed selection box's corner handles —
398
+ * the floor-plan sibling of the 3D `GroupRotateHandle`. The group spins
399
+ * rigidly around its data-extents center; 15° increments by default, Shift
400
+ * for free rotation, one `updateNodes` on release (one undo step).
401
+ */
402
+ export function startFloorplanGroupRotate(event: {
403
+ clientX: number
404
+ clientY: number
405
+ pointerId: number
406
+ }): boolean {
407
+ const { selectedIds, levelId } = useViewer.getState().selection
408
+ if (selectedIds.length < 2) return false
409
+ const nodes = useScene.getState().nodes
410
+ const participantIds = selectedIds.filter(
411
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
412
+ )
413
+ if (participantIds.length === 0) return false
414
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
415
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
416
+ if (starts.length === 0) return false
417
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
418
+ const ext = participantExtents(starts)
419
+ if (!ext) return false
420
+ const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 }
421
+ const startPlan = clientToPlan(event.clientX, event.clientY)
422
+ if (!startPlan) return false
423
+ // Bearing around the pivot in the plan frame — the same atan2 x→z sense
424
+ // `rotateGroupPatches` orbits in.
425
+ const angleOf = (p: readonly [number, number]) => Math.atan2(p[1] - pivot.z, p[0] - pivot.x)
426
+ const initialAngle = angleOf(startPlan)
427
+ const pointerId = event.pointerId
428
+
429
+ for (const id of affectedIds) {
430
+ useLiveTransforms.getState().clear(id)
431
+ }
432
+ document.body.style.cursor = 'grabbing'
433
+ sfxEmitter.emit('sfx:item-pick')
434
+ swallowNextClick()
435
+ useViewer.getState().setInputDragging(true)
436
+ pauseSceneHistory(useScene)
437
+ useInteractionScope.getState().begin({
438
+ kind: 'handle-drag',
439
+ nodeId: (participantIds[0] ?? '') as AnyNodeId,
440
+ handle: GROUP_ROTATE_DRAG_LABEL,
441
+ })
442
+
443
+ const onMove = (e: PointerEvent) => {
444
+ if (e.pointerId !== pointerId) return
445
+ const plan = clientToPlan(e.clientX, e.clientY)
446
+ if (!plan) return
447
+ let delta = angleOf([plan[0], plan[1]]) - initialAngle
448
+ while (delta > Math.PI) delta -= 2 * Math.PI
449
+ while (delta < -Math.PI) delta += 2 * Math.PI
450
+ if (isAngleSnapActive()) {
451
+ delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
452
+ }
453
+
454
+ const entries = rotateGroupPatches(starts, links, pivot, delta)
455
+ const patchById = new Map(entries)
456
+ const liveTransforms = useLiveTransforms.getState()
457
+ for (const start of starts) {
458
+ if (start.kind === 'scalar') {
459
+ const patch = patchById.get(start.id)
460
+ if (patch) {
461
+ liveTransforms.set(start.id, {
462
+ position: patch.position as [number, number, number],
463
+ rotation: patch.rotation as number,
464
+ })
465
+ }
466
+ }
467
+ useScene.getState().markDirty(start.id)
468
+ }
469
+ for (const l of links) {
470
+ useScene.getState().markDirty(l.id)
471
+ }
472
+ useLiveNodeOverrides.getState().setMany(entries)
473
+ // The dashed box spins with the group for live feedback.
474
+ useFloorplanGroupDrag.getState().setRotation({ pivotX: pivot.x, pivotZ: pivot.z, angle: delta })
475
+ }
476
+
477
+ const clearLivePreviews = () => {
478
+ const overrides = useLiveNodeOverrides.getState()
479
+ const liveTransforms = useLiveTransforms.getState()
480
+ for (const id of affectedIds) {
481
+ overrides.clear(id)
482
+ liveTransforms.clear(id)
483
+ useScene.getState().markDirty(id)
484
+ }
485
+ }
486
+
487
+ const removeListeners = () => {
488
+ window.removeEventListener('pointermove', onMove)
489
+ window.removeEventListener('pointerup', onUp, true)
490
+ window.removeEventListener('pointercancel', onPointerCancel)
491
+ window.removeEventListener('keydown', onKeyDown, true)
492
+ }
493
+
494
+ // History resume pairs one-to-one with the pause above, on the commit and
495
+ // cancel paths only.
496
+ const teardown = () => {
497
+ removeListeners()
498
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
499
+ // Deferred so a canvas pointerup later in this same dispatch still sees
500
+ // the drag as active (split view — see onUp).
501
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
502
+ useInteractionScope
503
+ .getState()
504
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_ROTATE_DRAG_LABEL)
505
+ useFloorplanGroupDrag.getState().setRotation(null)
506
+ }
507
+
508
+ // Capture registration + deferred inputDragging — see the drag session.
509
+ const onUp = (e: PointerEvent) => {
510
+ if (e.pointerId !== pointerId) return
511
+ swallowNextClick()
512
+ sfxEmitter.emit('sfx:item-place')
513
+ const overrides = useLiveNodeOverrides.getState()
514
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
515
+ for (const id of affectedIds) {
516
+ const patch = overrides.get(id)
517
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
518
+ }
519
+ // One tracked set = one undo step; rigid rotations must not re-create
520
+ // the room's auto floors/ceilings (see the move sessions).
521
+ pauseSpaceDetection()
522
+ resumeSceneHistory(useScene)
523
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
524
+ resumeSpaceDetection()
525
+ clearLivePreviews()
526
+ teardown()
527
+ }
528
+
529
+ const cancel = () => {
530
+ clearLivePreviews()
531
+ resumeSceneHistory(useScene)
532
+ teardown()
533
+ }
534
+
535
+ const onPointerCancel = (e: PointerEvent) => {
536
+ if (e.pointerId !== pointerId) return
537
+ cancel()
538
+ }
539
+
540
+ // Capture phase so Escape wins over the global `use-keyboard` arm.
541
+ const onKeyDown = (e: KeyboardEvent) => {
542
+ if (e.key === 'Delete' || e.key === 'Backspace') {
543
+ // Deleting mid-rotate: revert first, then let the global Delete arm run.
544
+ cancel()
545
+ return
546
+ }
547
+ // ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
548
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
549
+ e.preventDefault()
550
+ e.stopPropagation()
551
+ swallowNextClick()
552
+ cancel()
553
+ }
554
+
555
+ window.addEventListener('pointermove', onMove)
556
+ window.addEventListener('pointerup', onUp, true)
557
+ window.addEventListener('pointercancel', onPointerCancel)
558
+ window.addEventListener('keydown', onKeyDown, true)
559
+ return true
560
+ }
561
+
562
+ const GROUP_BOX_CURSOR_STYLE = { cursor: 'move' } as const
563
+ // Curved-arrow rotate cursor (no native CSS equivalent) — black glyph with a
564
+ // white halo so it reads on light and dark plans; falls back to `grab`.
565
+ const ROTATE_CURSOR_SVG = encodeURIComponent(
566
+ '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"><g fill="none" stroke="#fff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g><g fill="none" stroke="#000" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g></svg>',
567
+ )
568
+ const GROUP_BOX_ROTATE_CURSOR_STYLE = {
569
+ cursor: `url("data:image/svg+xml,${ROTATE_CURSOR_SVG}") 11 11, grab`,
570
+ } as const
571
+
572
+ /**
573
+ * Dashed bounding box around the current multi-selection's transformable
574
+ * participants (expanded to the welded wall/fence component) — shows what a
575
+ * group drag will carry along, and IS the group's drag handle: press anywhere
576
+ * inside it to slide the group, click to pick it up. Holding a selection
577
+ * modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under
578
+ * the box can still be toggled in and out. Rides the live drag delta so it
579
+ * tracks the group mid-gesture. Mounted inside the floor-plan scene `<g>`, so
580
+ * plan coords render directly.
581
+ */
582
+ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionBox({
583
+ palette,
584
+ unitsPerPixel,
585
+ onPointerDown,
586
+ onRotatePointerDown,
587
+ }: {
588
+ palette: FloorplanPalette | undefined
589
+ unitsPerPixel: number
590
+ onPointerDown?: (event: ReactPointerEvent<SVGGElement>) => void
591
+ onRotatePointerDown?: (event: ReactPointerEvent<SVGGElement>) => void
592
+ }) {
593
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
594
+ const levelId = useViewer((s) => s.selection.levelId)
595
+ const unit = useViewer((s) => s.unit)
596
+ const metricNotation = useViewer((s) => s.metricNotation)
597
+ const nodes = useScene((s) => s.nodes)
598
+ const delta = useFloorplanGroupDrag((s) => s.delta)
599
+ const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
600
+ const movingNode = useMovingNode()
601
+ const mode = useEditor((s) => s.mode)
602
+ const floorplanMode = useFloorplanMode((s) => s.mode)
603
+ const sceneRotationDeg = useFloorplanSceneRotation()
604
+
605
+ // While a selection modifier is held the box steps aside so clicks reach
606
+ // the entries underneath (toggle membership) instead of starting a drag.
607
+ const [modifierHeld, setModifierHeld] = useState(false)
608
+ useEffect(() => {
609
+ const update = (e: KeyboardEvent) => setModifierHeld(e.metaKey || e.ctrlKey || e.shiftKey)
610
+ const clear = () => setModifierHeld(false)
611
+ window.addEventListener('keydown', update)
612
+ window.addEventListener('keyup', update)
613
+ window.addEventListener('blur', clear)
614
+ return () => {
615
+ window.removeEventListener('keydown', update)
616
+ window.removeEventListener('keyup', update)
617
+ window.removeEventListener('blur', clear)
618
+ }
619
+ }, [])
620
+
621
+ // `meshEpoch` re-runs the measurement once the meshes settle after a scene
622
+ // change (undo/redo included) — `computeGroupBox` reads mesh world bounds,
623
+ // which lag the `nodes` commit by a frame or two.
624
+ const meshEpoch = useMeshSettleEpoch(nodes)
625
+ const box = useMemo(() => {
626
+ void meshEpoch
627
+ if (selectedIds.length < 2 || !levelId) return null
628
+ const participantIds = selectedIds.filter(
629
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
630
+ )
631
+ if (participantIds.length === 0) return null
632
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
633
+ const world = computeGroupBox(fullIds)
634
+ if (!world) return null
635
+ const { inverse } = levelFrame(levelId)
636
+ const min = world.min.clone().applyMatrix4(inverse)
637
+ const max = world.max.clone().applyMatrix4(inverse)
638
+ return {
639
+ x: Math.min(min.x, max.x),
640
+ z: Math.min(min.z, max.z),
641
+ width: Math.abs(max.x - min.x),
642
+ depth: Math.abs(max.z - min.z),
643
+ }
644
+ }, [selectedIds, levelId, nodes, meshEpoch])
645
+
646
+ if (!box || movingNode || mode === 'delete') return null
647
+
648
+ const pad = 6 * unitsPerPixel
649
+ const stroke = palette?.selectedStroke ?? '#3b82f6'
650
+ const interactive = !modifierHeld && !!onPointerDown
651
+ const dimensionOffset = pad + 0.28
652
+ const widthDimension = {
653
+ kind: 'dimension',
654
+ start: [box.x, box.z + box.depth],
655
+ end: [box.x + box.width, box.z + box.depth],
656
+ offsetNormal: [0, 1],
657
+ offsetDistance: dimensionOffset,
658
+ extensionOvershoot: 0.08,
659
+ text: formatLinearMeasurement(box.width, unit, metricNotation),
660
+ stroke,
661
+ } satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
662
+ const depthDimension = {
663
+ kind: 'dimension',
664
+ start: [box.x + box.width, box.z],
665
+ end: [box.x + box.width, box.z + box.depth],
666
+ offsetNormal: [1, 0],
667
+ offsetDistance: dimensionOffset,
668
+ extensionOvershoot: 0.08,
669
+ text: formatLinearMeasurement(box.depth, unit, metricNotation),
670
+ stroke,
671
+ } satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
672
+ // Mid-gesture the box rides the live delta (group move) or spins around the
673
+ // rotation pivot (corner rotate) — SVG rotate() is degrees around a plan
674
+ // point, and positive matches the atan2 x→z sense on the y-down plan.
675
+ const transform = liveRotation
676
+ ? `rotate(${(liveRotation.angle * 180) / Math.PI} ${liveRotation.pivotX} ${liveRotation.pivotZ})`
677
+ : delta
678
+ ? `translate(${delta[0]} ${delta[1]})`
679
+ : undefined
680
+ return (
681
+ <g
682
+ data-group-selection-box
683
+ onPointerDown={interactive ? onPointerDown : undefined}
684
+ pointerEvents={interactive ? 'auto' : 'none'}
685
+ style={interactive ? GROUP_BOX_CURSOR_STYLE : undefined}
686
+ transform={transform}
687
+ >
688
+ <rect
689
+ fill="transparent"
690
+ height={box.depth + 2 * pad}
691
+ stroke={stroke}
692
+ strokeDasharray={`${4 * unitsPerPixel} ${3 * unitsPerPixel}`}
693
+ strokeWidth={1.2 * unitsPerPixel}
694
+ width={box.width + 2 * pad}
695
+ x={box.x - pad}
696
+ y={box.z - pad}
697
+ />
698
+ {floorplanMode === 'default' ? (
699
+ <g pointerEvents="none">
700
+ <FloorplanDimensionRenderer
701
+ geometry={widthDimension}
702
+ sceneRotationDeg={sceneRotationDeg}
703
+ />
704
+ <FloorplanDimensionRenderer
705
+ geometry={depthDimension}
706
+ sceneRotationDeg={sceneRotationDeg}
707
+ />
708
+ </g>
709
+ ) : null}
710
+ {/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
711
+ drag a corner to spin the group (15° steps, Shift = free). */}
712
+ {interactive && onRotatePointerDown
713
+ ? (
714
+ [
715
+ [box.x - pad, box.z - pad],
716
+ [box.x + box.width + pad, box.z - pad],
717
+ [box.x + box.width + pad, box.z + box.depth + pad],
718
+ [box.x - pad, box.z + box.depth + pad],
719
+ ] as Array<[number, number]>
720
+ ).map(([cx, cz], index) => (
721
+ <g
722
+ data-group-rotate-handle
723
+ key={`corner-${index}`}
724
+ onPointerDown={(event) => {
725
+ event.stopPropagation()
726
+ onRotatePointerDown(event)
727
+ }}
728
+ style={GROUP_BOX_ROTATE_CURSOR_STYLE}
729
+ >
730
+ <circle cx={cx} cy={cz} fill="transparent" r={8 * unitsPerPixel} />
731
+ <circle
732
+ cx={cx}
733
+ cy={cz}
734
+ fill="#ffffff"
735
+ r={3.2 * unitsPerPixel}
736
+ stroke={stroke}
737
+ strokeWidth={1.2 * unitsPerPixel}
738
+ />
739
+ </g>
740
+ ))
741
+ : null}
742
+ </g>
743
+ )
744
+ })