@pascal-app/editor 1.0.1 → 1.0.4

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 (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -13,11 +13,13 @@ import {
13
13
  import { useViewer } from '@pascal-app/viewer'
14
14
  import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
15
15
  import { useEffect, useMemo, useRef, useState } from 'react'
16
- import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
16
+ import { OrthographicCamera, Plane, type Ray, Vector2, Vector3 } from 'three'
17
17
  import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
18
18
  import { isHistoryShortcut } from '../../lib/history'
19
19
  import { sfxEmitter } from '../../lib/sfx-bus'
20
- import useEditor from '../../store/use-editor'
20
+ import { intersectSpatialDragPlane } from '../../lib/spatial-drag-plane'
21
+ import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input'
22
+ import useEditor, { isAngleSnapActive } from '../../store/use-editor'
21
23
  import useInteractionScope, {
22
24
  useActiveHandleDrag,
23
25
  useMovingNode,
@@ -28,7 +30,7 @@ import {
28
30
  classifyParticipant,
29
31
  collectParticipants,
30
32
  computeGroupBox,
31
- expandToComponent,
33
+ computeGroupPlanBox,
32
34
  levelFrame,
33
35
  rotateGroupPatches,
34
36
  type Vec3,
@@ -79,14 +81,6 @@ export function GroupRotateHandle() {
79
81
  [selectedIds, levelId, nodes],
80
82
  )
81
83
 
82
- // Gate on the explicit selection (so a single connected wall still gets the
83
- // per-node handles), but transform the full connected wall/fence component so
84
- // attached structure rotates rigidly as one piece.
85
- const fullIds = useMemo(
86
- () => expandToComponent(participantIds, nodes, levelId),
87
- [participantIds, levelId, nodes],
88
- )
89
-
90
84
  const shouldRender =
91
85
  participantIds.length >= 2 &&
92
86
  mode !== 'delete' &&
@@ -98,7 +92,13 @@ export function GroupRotateHandle() {
98
92
 
99
93
  if (!shouldRender) return null
100
94
  // Remount when the moving set changes so the rest pivot re-seeds cleanly.
101
- return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} meshEpoch={meshEpoch} />
95
+ return (
96
+ <GroupRotateHandleInner
97
+ ids={participantIds}
98
+ key={participantIds.join(',')}
99
+ meshEpoch={meshEpoch}
100
+ />
101
+ )
102
102
  }
103
103
 
104
104
  function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
@@ -125,22 +125,28 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
125
125
  const baseScale = zoom * ARROW_SCALE * 1.05
126
126
  const scale = (isHovered ? 1.12 : 1) * baseScale
127
127
 
128
- // World-space bounding box of the selected meshes. Levels are axis-aligned in
129
- // XZ, so world XZ coincides with each node's level-local placement — letting
130
- // us rotate `position` / `start` / `end` directly against the pivot without
131
- // per-node frame conversion.
132
- // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
133
- // - `corner` = front-right bbox corner at mid-height → where the gizmo sits
128
+ // Both rest points come from the level-frame box (the dashed boxes' box, also
129
+ // keyboard R/T's pivot), carried to world so they stay on it under a rotated
130
+ // building; the world mesh box only supplies heights.
131
+ // - `pivot` = box center, Y at the group's base → the rotation origin
132
+ // - `corner` = far box corner at mid-height → where the gizmo sits
134
133
  const rest = useMemo(() => {
135
134
  void meshEpoch
136
135
  const box = computeGroupBox(ids)
137
136
  if (!box) return null
138
- const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
139
- const corner = new Vector3(
140
- box.max.x + CORNER_OFFSET,
141
- (box.min.y + box.max.y) / 2,
142
- box.max.z + CORNER_OFFSET,
143
- )
137
+ const levelId = useViewer.getState().selection.levelId
138
+ const { matrix } = levelFrame(levelId)
139
+ const plan = computeGroupPlanBox(ids, levelId)
140
+ const pivot = plan
141
+ ? new Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4(
142
+ matrix,
143
+ )
144
+ : new Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2)
145
+ pivot.y = box.min.y
146
+ const corner = plan
147
+ ? new Vector3(plan.maxX + CORNER_OFFSET, 0, plan.maxZ + CORNER_OFFSET).applyMatrix4(matrix)
148
+ : new Vector3(box.max.x + CORNER_OFFSET, 0, box.max.z + CORNER_OFFSET)
149
+ corner.y = (box.min.y + box.max.y) / 2
144
150
  return { pivot, corner }
145
151
  }, [ids, meshEpoch])
146
152
 
@@ -163,6 +169,12 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
163
169
  if (event.button !== 0) return
164
170
  event.stopPropagation()
165
171
  suppressBoxSelectForPointer(event)
172
+ const spatialPointerId = getSpatialPointerId(event.nativeEvent)
173
+ const spatialRay = spatialPointerId ? event.ray.clone() : null
174
+ const pointerTarget = event.object as typeof event.object & {
175
+ releasePointerCapture?: (pointerId: number) => void
176
+ setPointerCapture?: (pointerId: number) => void
177
+ }
166
178
 
167
179
  frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
168
180
  const center = rest.pivot.clone()
@@ -213,11 +225,15 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
213
225
  )
214
226
  }
215
227
 
216
- setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
217
- raycaster.setFromCamera(ndc, camera)
228
+ if (!spatialRay) {
229
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
230
+ raycaster.setFromCamera(ndc, camera)
231
+ }
218
232
  const hit = new Vector3()
219
- if (!raycaster.ray.intersectPlane(plane, hit)) return
233
+ if (!intersectSpatialDragPlane(spatialRay ?? raycaster.ray, plane, hit)) return
220
234
  const initialAngle = angleOf(hit)
235
+ if (spatialPointerId) pointerTarget.setPointerCapture?.(event.pointerId)
236
+ let altKey = event.nativeEvent.altKey
221
237
 
222
238
  document.body.style.cursor = 'grabbing'
223
239
  sfxEmitter.emit('sfx:item-pick')
@@ -230,15 +246,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
230
246
  })
231
247
  setIsDragging(true)
232
248
 
233
- const onMove = (e: PointerEvent) => {
234
- setNDC(e.clientX, e.clientY)
235
- raycaster.setFromCamera(ndc, camera)
249
+ const applyRay = (ray: Ray, freeRotation = false) => {
236
250
  const moveHit = new Vector3()
237
- if (!raycaster.ray.intersectPlane(plane, moveHit)) return
251
+ if (!intersectSpatialDragPlane(ray, plane, moveHit)) return
238
252
  let delta = angleOf(moveHit) - initialAngle
239
253
  while (delta > Math.PI) delta -= 2 * Math.PI
240
254
  while (delta < -Math.PI) delta += 2 * Math.PI
241
- if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
255
+ if (!freeRotation && isAngleSnapActive())
256
+ delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
242
257
 
243
258
  // Shared rigid-rotation math (also used by the keyboard group R/T);
244
259
  // see `rotateGroupPatches` for the orbit/yaw handedness contract.
@@ -286,8 +301,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
286
301
  })
287
302
  }
288
303
  }
304
+ const onMove = (e: PointerEvent) => {
305
+ setNDC(e.clientX, e.clientY)
306
+ raycaster.setFromCamera(ndc, camera)
307
+ applyRay(raycaster.ray, e.altKey)
308
+ }
289
309
 
290
310
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
311
+ let releaseSpatialCapture: (() => void) | null = null
291
312
  const clearLivePreviews = () => {
292
313
  const overrides = useLiveNodeOverrides.getState()
293
314
  const liveTransforms = useLiveTransforms.getState()
@@ -303,6 +324,10 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
303
324
  window.removeEventListener('pointerup', onUp)
304
325
  window.removeEventListener('pointercancel', onCancel)
305
326
  window.removeEventListener('keydown', onKeyDown, true)
327
+ window.removeEventListener('keyup', onKeyUp, true)
328
+ releaseSpatialCapture?.()
329
+ releaseSpatialCapture = null
330
+ if (spatialPointerId) pointerTarget.releasePointerCapture?.(event.pointerId)
306
331
  if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
307
332
  useScene.temporal.getState().resume()
308
333
  useViewer.getState().setInputDragging(false)
@@ -352,12 +377,19 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
352
377
  // Escape / ⌘Z abort the rotate — capture phase so they win over the global
353
378
  // use-keyboard arms (⌘Z must never history-jump under a live pointer).
354
379
  const onKeyDown = (e: KeyboardEvent) => {
380
+ if (e.key === 'Alt') {
381
+ altKey = true
382
+ return
383
+ }
355
384
  if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
356
385
  e.preventDefault()
357
386
  e.stopPropagation()
358
387
  swallowNextClick()
359
388
  onCancel()
360
389
  }
390
+ const onKeyUp = (e: KeyboardEvent) => {
391
+ if (e.key === 'Alt') altKey = false
392
+ }
361
393
 
362
394
  dragCleanupRef.current = () => {
363
395
  clearLivePreviews()
@@ -366,10 +398,22 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
366
398
  for (const id of affectedIds) {
367
399
  useLiveTransforms.getState().clear(id)
368
400
  }
369
- window.addEventListener('pointermove', onMove)
370
- window.addEventListener('pointerup', onUp)
371
- window.addEventListener('pointercancel', onCancel)
401
+ if (spatialPointerId && spatialRay) {
402
+ releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
403
+ onMove: (ray) => {
404
+ spatialRay.copy(ray)
405
+ applyRay(spatialRay, altKey)
406
+ },
407
+ onRelease: onUp,
408
+ onCancel,
409
+ })
410
+ } else {
411
+ window.addEventListener('pointermove', onMove)
412
+ window.addEventListener('pointerup', onUp)
413
+ window.addEventListener('pointercancel', onCancel)
414
+ }
372
415
  window.addEventListener('keydown', onKeyDown, true)
416
+ window.addEventListener('keyup', onKeyUp, true)
373
417
  }
374
418
 
375
419
  return createPortal(
@@ -398,6 +442,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
398
442
  onPointerDown={activate}
399
443
  onPointerEnter={onHoverEnter}
400
444
  onPointerLeave={onHoverLeave}
445
+ {...({ pointerEventsOrder: 10 } as Record<string, unknown>)}
401
446
  scale={baseScale}
402
447
  />
403
448
  <mesh
@@ -407,6 +452,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
407
452
  onPointerDown={activate}
408
453
  onPointerEnter={onHoverEnter}
409
454
  onPointerLeave={onHoverLeave}
455
+ {...({ pointerEventsOrder: 10 } as Record<string, unknown>)}
410
456
  renderOrder={1010}
411
457
  scale={scale}
412
458
  />
@@ -12,7 +12,7 @@ import { armGroupMove3d } from './group-move-3d'
12
12
  import {
13
13
  classifyParticipant,
14
14
  computeGroupBox,
15
- expandToComponent,
15
+ computeGroupPlanBox,
16
16
  levelFrame,
17
17
  } from './group-transform-shared'
18
18
  import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
@@ -24,8 +24,8 @@ const BOX_PAD = 0.06
24
24
 
25
25
  /**
26
26
  * 3D sibling of the 2D dashed group selection box: a dashed wireframe around
27
- * the multi-selection's transformable participants (expanded to the welded
28
- * wall/fence component) that doubles as the group's whole-volume drag
27
+ * the multi-selection's transformable participants that doubles as the
28
+ * group's whole-volume drag
29
29
  * handle — move cursor across it, press-drag anywhere on it slides the group,
30
30
  * a plain click picks it up. Holding a selection modifier passes the press
31
31
  * through so members inside can still be toggled. Rides the live drag delta
@@ -55,22 +55,30 @@ export function GroupSelectionBox3D() {
55
55
  const box = useMemo(() => {
56
56
  void meshEpoch
57
57
  if (participantIds.length === 0) return null
58
- const fullIds = expandToComponent(participantIds, nodes, levelId)
59
- const world = computeGroupBox(fullIds)
60
- if (!world) return null
61
- const size = new THREE.Vector3()
62
- world.getSize(size)
63
- const center = new THREE.Vector3()
64
- world.getCenter(center)
58
+ // Aligned with the building like the 2D box: level-frame footprint, world
59
+ // height span, turned by the level's rotation.
60
+ const world = computeGroupBox(participantIds)
61
+ const { matrix } = levelFrame(levelId)
62
+ const plan = computeGroupPlanBox(participantIds, levelId)
63
+ if (!(world && plan)) return null
64
+ const center = new THREE.Vector3(
65
+ (plan.minX + plan.maxX) / 2,
66
+ 0,
67
+ (plan.minZ + plan.maxZ) / 2,
68
+ ).applyMatrix4(matrix)
69
+ center.y = (world.min.y + world.max.y) / 2
70
+ const quaternion = new THREE.Quaternion()
71
+ matrix.decompose(new THREE.Vector3(), quaternion, new THREE.Vector3())
65
72
  return {
66
- size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [
67
- number,
68
- number,
69
- number,
70
- ],
73
+ size: [
74
+ plan.maxX - plan.minX + 2 * BOX_PAD,
75
+ world.max.y - world.min.y + 2 * BOX_PAD,
76
+ plan.maxZ - plan.minZ + 2 * BOX_PAD,
77
+ ] as [number, number, number],
71
78
  center,
79
+ quaternion,
72
80
  }
73
- }, [participantIds, nodes, levelId, meshEpoch])
81
+ }, [participantIds, levelId, meshEpoch])
74
82
 
75
83
  // Dashed wireframe. Built per box size (rare — selection / commit changes)
76
84
  // because LineDashedMaterial measures dashes along the line, so scaling a
@@ -156,7 +164,7 @@ export function GroupSelectionBox3D() {
156
164
  ]
157
165
 
158
166
  return (
159
- <group position={position}>
167
+ <group position={position} quaternion={box.quaternion}>
160
168
  <primitive object={edges} />
161
169
  {/* Invisible whole-volume hit target — the box IS the drag handle. */}
162
170
  <mesh
@@ -1,9 +1,24 @@
1
1
  import { beforeAll, describe, expect, test } from 'bun:test'
2
- import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
6
+ CeilingNode,
7
+ LevelNode,
8
+ nodeRegistry,
9
+ registerNode,
10
+ SlabNode,
11
+ sceneRegistry,
12
+ useScene,
13
+ WallNode,
14
+ } from '@pascal-app/core'
15
+ import { BoxGeometry, Group, Mesh } from 'three'
3
16
  import { z } from 'zod'
4
17
  import {
5
18
  classifyParticipant,
6
19
  collectParticipants,
20
+ computeGroupPlanBox,
21
+ groupPlanBounds,
7
22
  planBoundsCenter,
8
23
  rotateGroupPatches,
9
24
  rotateGroupSnapshots,
@@ -472,3 +487,113 @@ describe('group transform participants', () => {
472
487
  ])
473
488
  })
474
489
  })
490
+
491
+ describe('group plan bounds', () => {
492
+ const withNodes = (nodes: AnyNode[], run: () => void) => {
493
+ const saved = useScene.getState().nodes
494
+ useScene.setState({ nodes: Object.fromEntries(nodes.map((n) => [n.id, n])) as typeof saved })
495
+ try {
496
+ run()
497
+ } finally {
498
+ useScene.setState({ nodes: saved })
499
+ }
500
+ }
501
+
502
+ test('a placed object measures its built mesh in the level frame, or falls back to its anchor', () => {
503
+ const level = new Group()
504
+ level.position.set(10, 0, -4)
505
+ level.rotation.y = Math.PI / 5
506
+ const item = new Mesh(new BoxGeometry(1, 1, 1))
507
+ item.position.set(2, 0.5, 0)
508
+ level.add(item)
509
+ level.updateWorldMatrix(true, true)
510
+ const registry = sceneRegistry.nodes as unknown as Map<string, unknown>
511
+ registry.set('item_plan_bounds_test', item)
512
+ const start = {
513
+ id: 'item_plan_bounds_test' as AnyNodeId,
514
+ kind: 'vec3' as const,
515
+ position: [2, 0, 0] as [number, number, number],
516
+ rotation: [0, 0, 0] as [number, number, number],
517
+ }
518
+ try {
519
+ const frameInv = level.matrixWorld.clone().invert()
520
+ const built = groupPlanBounds([start], frameInv)
521
+ expect(built?.minX).toBeCloseTo(1.5)
522
+ expect(built?.maxX).toBeCloseTo(2.5)
523
+ expect(built?.minZ).toBeCloseTo(-0.5)
524
+ expect(built?.maxZ).toBeCloseTo(0.5)
525
+ // Unbuilt while the 3D scene is paused: the placeholder must not count.
526
+ item.geometry.userData.placeholder = true
527
+ expect(groupPlanBounds([start], frameInv)).toEqual({ minX: 2, minZ: 0, maxX: 2, maxZ: 0 })
528
+ } finally {
529
+ registry.delete('item_plan_bounds_test')
530
+ }
531
+ })
532
+
533
+ test('hugs the selected wall outlines; a connected wall stays outside', () => {
534
+ const level = LevelNode.parse({ children: [] })
535
+ const bottom = WallNode.parse({
536
+ parentId: level.id,
537
+ start: [0, 0],
538
+ end: [2.4, 0],
539
+ thickness: 0.2,
540
+ })
541
+ const side = WallNode.parse({
542
+ parentId: level.id,
543
+ start: [2.4, 0],
544
+ end: [2.4, 5.3],
545
+ thickness: 0.2,
546
+ })
547
+ const neighbour = WallNode.parse({
548
+ parentId: level.id,
549
+ start: [2.4, 5.3],
550
+ end: [8, 5.3],
551
+ thickness: 0.2,
552
+ })
553
+ withNodes([level, bottom, side, neighbour], () => {
554
+ const plan = computeGroupPlanBox([bottom.id, side.id], level.id)
555
+ expect(plan?.minX).toBeCloseTo(0)
556
+ expect(plan?.maxX).toBeCloseTo(2.5)
557
+ expect(plan?.minZ).toBeCloseTo(-0.1)
558
+ expect(plan?.maxZ).toBeLessThan(5.5)
559
+ expect(plan?.maxX).toBeLessThan(8)
560
+ })
561
+ })
562
+
563
+ test('two walls with the room floor and ceiling: the box is the drawn outlines, never the meshes', () => {
564
+ // A user's selection whose box came out 13 m wide in 2D-only view.
565
+ const level = LevelNode.parse({ id: 'level_vvkg048zziz9ktiy', children: [] })
566
+ const room: [number, number][] = [
567
+ [-3.6221682640435353, -9.037533979512114],
568
+ [-6.21504662384804, -8.29671159099654],
569
+ [-5.712152486161543, -6.5365821090938],
570
+ [-5.712152486161543, -5],
571
+ [-8.223564365687542, -5],
572
+ [-8.223564365687542, -6.5365821090938],
573
+ [-10.712152486161543, -6.5365821090938],
574
+ [-11.712152486161543, -8.5365821090938],
575
+ [-4.119274126357038, -10.777404497609373],
576
+ ]
577
+ const wall15 = WallNode.parse({
578
+ parentId: level.id,
579
+ start: [-10.712152486161543, -6.5365821090938],
580
+ end: [-11.712152486161543, -8.5365821090938],
581
+ })
582
+ const wall12 = WallNode.parse({
583
+ parentId: level.id,
584
+ start: [-11.712152486161543, -8.5365821090938],
585
+ end: [-6.712152486161543, -10.0365821090938],
586
+ })
587
+ const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 })
588
+ const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 })
589
+ withNodes([level, wall15, wall12, slab, ceiling], () => {
590
+ const plan = computeGroupPlanBox([wall15.id, ceiling.id, slab.id, wall12.id], level.id)!
591
+ expect(plan.minX).toBeLessThan(-11.71)
592
+ expect(plan.minX).toBeGreaterThan(-11.95)
593
+ expect(plan.maxX).toBeCloseTo(-3.62, 1)
594
+ expect(plan.minZ).toBeCloseTo(-10.78, 1)
595
+ expect(plan.maxZ).toBeCloseTo(-5, 1)
596
+ expect(plan.maxX - plan.minX).toBeLessThan(8.4)
597
+ })
598
+ })
599
+ })
@@ -1,11 +1,15 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ calculateLevelMiters,
5
+ getWallPlanFootprint,
4
6
  nodeRegistry,
5
7
  resolveBuildingForLevel,
6
8
  sceneRegistry,
9
+ useScene,
10
+ type WallNode,
7
11
  } from '@pascal-app/core'
8
- import { Box3, Matrix4 } from 'three'
12
+ import { Box3, type BufferGeometry, Matrix4, type Object3D, Vector3 } from 'three'
9
13
 
10
14
  // Shared plumbing for the group transform gizmos (rotate + move). Both operate
11
15
  // on the same multi-selection: classify each participant by how its placement
@@ -242,48 +246,6 @@ export function collectParticipants(
242
246
  return { starts, links }
243
247
  }
244
248
 
245
- // Grow a selection to the full connected component of walls/fences: any
246
- // endpoint node transitively reachable through shared junctions from a selected
247
- // endpoint node joins in, so the whole rigid structure transforms as one piece
248
- // (rather than tearing/stretching at the boundary). Non-endpoint selections
249
- // (items, columns) pass through unchanged.
250
- export function expandToComponent(
251
- selectedIds: string[],
252
- sceneNodes: Record<string, AnyNode | undefined>,
253
- levelId: string | null,
254
- ): string[] {
255
- const endpoints: { id: string; start: Vec2; end: Vec2 }[] = []
256
- for (const [id, node] of Object.entries(sceneNodes)) {
257
- if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') {
258
- const n = node as AnyNode & { start: Vec2; end: Vec2 }
259
- endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] })
260
- }
261
- }
262
- const included = new Set(selectedIds)
263
- if (!endpoints.some((e) => included.has(e.id))) return selectedIds
264
-
265
- let changed = true
266
- while (changed) {
267
- changed = false
268
- for (const e of endpoints) {
269
- if (included.has(e.id)) continue
270
- const touches = endpoints.some(
271
- (o) =>
272
- included.has(o.id) &&
273
- (nearPoint(e.start, o.start) ||
274
- nearPoint(e.start, o.end) ||
275
- nearPoint(e.end, o.start) ||
276
- nearPoint(e.end, o.end)),
277
- )
278
- if (touches) {
279
- included.add(e.id)
280
- changed = true
281
- }
282
- }
283
- }
284
- return Array.from(included)
285
- }
286
-
287
249
  // Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live
288
250
  // preview and for the single batched `updateNodes` on commit.
289
251
  export type GroupPatch = readonly [AnyNodeId, Record<string, unknown>]
@@ -389,9 +351,24 @@ export function rotateGroupSnapshots(
389
351
 
390
352
  export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number }
391
353
 
392
- // Level-frame XZ extents of the participant DATA — the mesh-free sibling of
393
- // `computeGroupBox`, used when meshes aren't mounted yet.
394
- function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null {
354
+ const planCorner = new Vector3()
355
+ const planMatrix = new Matrix4()
356
+
357
+ /**
358
+ * The selection's footprint in the level frame — the plan's own coordinates,
359
+ * measured from node data wherever the plan draws from data: walls by their
360
+ * mitered outline, polygon hosts (slab, ceiling, zone) by their polygon, fences
361
+ * by their run. Only placed objects (items, columns, stairs…) measure their
362
+ * meshes: bounds go through `frameInv × matrixWorld` (a world box can't be
363
+ * carried into a rotated building's frame), placeholders — unbuilt while the
364
+ * 3D scene is paused in 2D-only view — are skipped, and a node with no built
365
+ * mesh falls back to its anchor.
366
+ */
367
+ export function groupPlanBounds(
368
+ starts: ParticipantStart[],
369
+ frameInv: Matrix4,
370
+ ): GroupPlanBounds | null {
371
+ const nodes = useScene.getState().nodes
395
372
  let minX = Number.POSITIVE_INFINITY
396
373
  let minZ = Number.POSITIVE_INFINITY
397
374
  let maxX = Number.NEGATIVE_INFINITY
@@ -402,43 +379,72 @@ function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null
402
379
  maxX = Math.max(maxX, x)
403
380
  maxZ = Math.max(maxZ, z)
404
381
  }
382
+ const miters = new Map<string | null, ReturnType<typeof calculateLevelMiters>>()
383
+ const wallOutline = (wall: WallNode) => {
384
+ const levelId = wall.parentId ?? null
385
+ let levelMiters = miters.get(levelId)
386
+ if (!levelMiters) {
387
+ levelMiters = calculateLevelMiters(
388
+ Object.values(nodes).filter(
389
+ (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
390
+ ),
391
+ )
392
+ miters.set(levelId, levelMiters)
393
+ }
394
+ return getWallPlanFootprint(wall, levelMiters)
395
+ }
396
+ const measureMeshes = (id: string) => {
397
+ const obj = sceneRegistry.nodes.get(id as AnyNodeId)
398
+ if (!obj) return false
399
+ obj.updateWorldMatrix(true, true)
400
+ let found = false
401
+ obj.traverse((child: Object3D) => {
402
+ const geometry = (child as Object3D & { geometry?: BufferGeometry }).geometry
403
+ if (!child.visible || !geometry || geometry.userData.placeholder) return
404
+ // Always fresh: geometries rebuilt in place keep a stale cached box.
405
+ geometry.computeBoundingBox()
406
+ const bounds = geometry.boundingBox
407
+ if (!bounds || bounds.isEmpty()) return
408
+ planMatrix.multiplyMatrices(frameInv, child.matrixWorld)
409
+ for (let corner = 0; corner < 8; corner++) {
410
+ planCorner
411
+ .set(
412
+ corner & 1 ? bounds.max.x : bounds.min.x,
413
+ corner & 2 ? bounds.max.y : bounds.min.y,
414
+ corner & 4 ? bounds.max.z : bounds.min.z,
415
+ )
416
+ .applyMatrix4(planMatrix)
417
+ reach(planCorner.x, planCorner.z)
418
+ }
419
+ found = true
420
+ })
421
+ return found
422
+ }
405
423
  for (const s of starts) {
424
+ const node = nodes[s.id]
406
425
  if (s.kind === 'endpoint') {
426
+ const outline = node?.type === 'wall' ? wallOutline(node) : []
427
+ if (outline.length > 0) {
428
+ for (const point of outline) reach(point.x, point.y)
429
+ continue
430
+ }
407
431
  reach(s.start[0], s.start[1])
408
432
  reach(s.end[0], s.end[1])
433
+ const path = (node as { path?: unknown } | undefined)?.path
434
+ if (isVec2Array(path)) for (const [x, z] of path) reach(x, z)
409
435
  } else if (s.kind === 'polygon') {
410
- for (const [x, z] of s.polygon) {
411
- reach(x, z)
412
- }
413
- } else {
436
+ for (const [x, z] of s.polygon) reach(x, z)
437
+ } else if (!measureMeshes(s.id)) {
414
438
  reach(s.position[0], s.position[2])
415
439
  }
416
440
  }
417
- if (!Number.isFinite(minX)) return null
418
- return { minX, minZ, maxX, maxZ }
441
+ return Number.isFinite(minX) ? { minX, minZ, maxX, maxZ } : null
419
442
  }
420
443
 
421
- // The one footprint every group transform measures itself against: the
422
- // selection's mounted meshes (world box, converted into the level frame) with
423
- // the participant DATA extents as the fallback when the meshes aren't up yet
424
- // (Duplicate picks up its clones a frame before their renderers mount). Anchor
425
- // points alone sit metres inside a wide selection's real footprint, so a
426
- // gesture that pivots on the data extents orbits a different point than the
427
- // idle keyboard rotate and the rotate gizmos, which both use the mesh box.
428
- export function groupPlanBounds(
429
- box: Box3 | null,
430
- starts: ParticipantStart[],
431
- frameInv: Matrix4,
432
- ): GroupPlanBounds | null {
433
- if (!box) return participantExtents(starts)
434
- const min = box.min.clone().applyMatrix4(frameInv)
435
- const max = box.max.clone().applyMatrix4(frameInv)
436
- return {
437
- minX: Math.min(min.x, max.x),
438
- minZ: Math.min(min.z, max.z),
439
- maxX: Math.max(min.x, max.x),
440
- maxZ: Math.max(min.z, max.z),
441
- }
444
+ /** `groupPlanBounds` for a selection: the dashed boxes and gizmos measure this. */
445
+ export function computeGroupPlanBox(ids: string[], levelId: string | null): GroupPlanBounds | null {
446
+ const { starts } = collectParticipants(ids, useScene.getState().nodes, levelId)
447
+ return groupPlanBounds(starts, levelFrame(levelId).inverse)
442
448
  }
443
449
 
444
450
  export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [