@pascal-app/editor 1.0.3 → 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 (440) hide show
  1. package/package.json +11 -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 +202 -57
  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 +36 -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 +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,569 @@
1
+ import {
2
+ type AnyNode,
3
+ area,
4
+ type BuildingNode,
5
+ buildUnitReport,
6
+ type CameraControlFrameEvent,
7
+ containsPoint,
8
+ DEFAULT_LEVEL_HEIGHT,
9
+ emitter,
10
+ getLevelDisplayName,
11
+ type LevelNode,
12
+ type NodeEvent,
13
+ resolveSelectionProxyId,
14
+ sceneRegistry,
15
+ type UnitNode,
16
+ type ZoneNode,
17
+ } from '@pascal-app/core'
18
+ import { useViewer } from '@pascal-app/viewer'
19
+ import type { CameraControlsImpl } from '@react-three/drei'
20
+ import { useEffect } from 'react'
21
+ import {
22
+ Box3,
23
+ type Camera,
24
+ type Object3D,
25
+ type OrthographicCamera,
26
+ type PerspectiveCamera,
27
+ Sphere,
28
+ Vector3,
29
+ } from 'three'
30
+ import { nodeDisplayLabel } from './node-display-label'
31
+ import { roomFloorElevation } from './room-handle-drag'
32
+ import { RoomSelectionIndex, type RoomSelectionRecord, resolveRoomHit } from './room-selection'
33
+ import type { SelectionModifierKeys } from './selection-routing'
34
+
35
+ // One selection model for every viewer surface: the published viewer (baked
36
+ // GLB or parametric), its embed and the editor's Preview. Each renderer only
37
+ // turns a pointer hit into a `ViewerPick`; these rules decide what it selects,
38
+ // on `useViewer.selection`:
39
+ // levelId the current floor
40
+ // zoneId the selected room (or zone)
41
+ // selectedIds the selected element or item
42
+
43
+ export type ViewerNodes = Readonly<Record<string, AnyNode>>
44
+
45
+ /** The scene a viewer surface shows: its graph, and the 3D object standing for an id. */
46
+ export type ViewerSource = {
47
+ nodes: ViewerNodes
48
+ objectFor: (id: string) => Object3D | undefined
49
+ }
50
+
51
+ /** A pointer hit, whichever renderer took it: the node and the hit in its level's local XZ. */
52
+ export type ViewerPick = { nodeId: string; point: readonly [number, number] | null }
53
+
54
+ export type ViewerSelection = {
55
+ buildingId: BuildingNode['id'] | null
56
+ levelId: LevelNode['id'] | null
57
+ zoneId: ZoneNode['id'] | null
58
+ selectedIds: string[]
59
+ }
60
+
61
+ type ViewerTarget =
62
+ | { kind: 'room'; levelId: string; zoneId: string }
63
+ | { kind: 'node'; levelId: string | null; nodeId: string; zoneId: string | null }
64
+
65
+ const NO_MODIFIERS: SelectionModifierKeys = { alt: false, ctrl: false, meta: false, shift: false }
66
+
67
+ /** Surfaces a room is picked through, room first: its walls, its floor, its ceiling. */
68
+ const ROOM_SURFACES = new Set(['wall', 'slab', 'ceiling'])
69
+
70
+ /** Kinds a viewer click never lands on: they pass the pointer to what lies behind. */
71
+ export const VIEWER_PASS_THROUGH_KINDS: ReadonlySet<string> = new Set([
72
+ 'site',
73
+ 'building',
74
+ 'level',
75
+ 'zone',
76
+ 'ceiling',
77
+ 'scan',
78
+ 'guide',
79
+ 'spawn',
80
+ ])
81
+
82
+ const indexes = new WeakMap<ViewerNodes, Map<string, RoomSelectionIndex>>()
83
+
84
+ function roomIndex(levelId: string, nodes: ViewerNodes) {
85
+ let levels = indexes.get(nodes)
86
+ if (!levels) {
87
+ levels = new Map()
88
+ indexes.set(nodes, levels)
89
+ }
90
+ let index = levels.get(levelId)
91
+ if (!index) {
92
+ index = new RoomSelectionIndex(levelId)
93
+ levels.set(levelId, index)
94
+ }
95
+ index.update(nodes as Record<string, AnyNode>)
96
+ return index
97
+ }
98
+
99
+ /** The rooms the walls of a level close, as the editor detects them. */
100
+ export function viewerRooms(levelId: string, nodes: ViewerNodes): RoomSelectionRecord[] {
101
+ return roomIndex(levelId, nodes).update(nodes as Record<string, AnyNode>)
102
+ }
103
+
104
+ export function levelOfNode(nodeId: string, nodes: ViewerNodes): string | null {
105
+ const seen = new Set<string>()
106
+ let current = nodes[nodeId]
107
+ while (current && !seen.has(current.id)) {
108
+ if (current.type === 'level') return current.id
109
+ seen.add(current.id)
110
+ current = current.parentId ? nodes[current.parentId] : undefined
111
+ }
112
+ return null
113
+ }
114
+
115
+ function buildingOfLevel(levelId: string | null, nodes: ViewerNodes): BuildingNode['id'] | null {
116
+ const level = levelId ? nodes[levelId] : undefined
117
+ const parent = level?.parentId ? nodes[level.parentId] : undefined
118
+ if (parent?.type === 'building') return parent.id
119
+ const building = Object.values(nodes).find((node) => node.type === 'building')
120
+ return (building?.id as BuildingNode['id'] | undefined) ?? null
121
+ }
122
+
123
+ const hitPoint = new Vector3()
124
+
125
+ /**
126
+ * The parametric hit source: a node event as a pick, with the hit in its
127
+ * level's local XZ. A hit on a hidden floor, or on a kind a click passes
128
+ * through, is no pick: the pointer goes on to what lies behind it.
129
+ */
130
+ export function viewerPickFromNodeEvent(event: NodeEvent, nodes: ViewerNodes): ViewerPick | null {
131
+ if (VIEWER_PASS_THROUGH_KINDS.has(event.node.type)) return null
132
+ const levelId = levelOfNode(event.node.id, nodes)
133
+ const level = levelId ? sceneRegistry.nodes.get(levelId) : undefined
134
+ if (level?.visible === false) return null
135
+ if (!level) return { nodeId: event.node.id, point: null }
136
+ level.updateWorldMatrix(true, false)
137
+ hitPoint.set(...event.position)
138
+ level.worldToLocal(hitPoint)
139
+ return { nodeId: event.node.id, point: [hitPoint.x, hitPoint.z] }
140
+ }
141
+
142
+ /**
143
+ * What a pick acts on. A wall, floor or ceiling picks the room it bounds;
144
+ * once that room is selected, its own surfaces are picked as themselves.
145
+ * Anything else (an item, a door, a stair) is picked directly. Alt, Shift,
146
+ * Ctrl and Meta skip the room, as in the editor.
147
+ */
148
+ export function resolveViewerTarget(
149
+ selection: Pick<ViewerSelection, 'zoneId'>,
150
+ pick: ViewerPick,
151
+ nodes: ViewerNodes,
152
+ modifiers: SelectionModifierKeys = NO_MODIFIERS,
153
+ ): ViewerTarget | null {
154
+ const node = nodes[pick.nodeId]
155
+ if (!node || VIEWER_PASS_THROUGH_KINDS.has(node.type)) return null
156
+ const levelId = levelOfNode(node.id, nodes)
157
+ const bypass = modifiers.alt || modifiers.shift || modifiers.ctrl || modifiers.meta
158
+ if (!bypass && levelId && pick.point && ROOM_SURFACES.has(node.type)) {
159
+ const room = resolveRoomHit(
160
+ roomIndex(levelId, nodes),
161
+ levelId,
162
+ node,
163
+ [pick.point[0], pick.point[1]],
164
+ '3d',
165
+ )
166
+ if (room) {
167
+ const { zoneId } = room.key
168
+ return selection.zoneId === zoneId
169
+ ? { kind: 'node', levelId, nodeId: node.id, zoneId }
170
+ : { kind: 'room', levelId, zoneId }
171
+ }
172
+ }
173
+ const parent = node.parentId ? nodes[node.parentId] : undefined
174
+ const owner = node.type === 'roof-segment' && parent?.type === 'roof' ? parent : node
175
+ const nodeId = resolveSelectionProxyId(owner, nodes as Record<string, AnyNode | undefined>)
176
+ return { kind: 'node', levelId, nodeId, zoneId: null }
177
+ }
178
+
179
+ /**
180
+ * The selection a click makes. Empty space clears the room and the element
181
+ * and keeps the floor; nothing pops the user up a level.
182
+ */
183
+ export function resolveViewerClick(
184
+ selection: ViewerSelection,
185
+ pick: ViewerPick | null,
186
+ nodes: ViewerNodes,
187
+ modifiers: SelectionModifierKeys = NO_MODIFIERS,
188
+ ): ViewerSelection {
189
+ const target = pick ? resolveViewerTarget(selection, pick, nodes, modifiers) : null
190
+ if (!target) return { ...selection, zoneId: null, selectedIds: [] }
191
+ const levelId = (target.levelId as LevelNode['id'] | null) ?? selection.levelId
192
+ const buildingId = buildingOfLevel(levelId, nodes) ?? selection.buildingId
193
+ if (target.kind === 'room') {
194
+ return { buildingId, levelId, zoneId: target.zoneId as ZoneNode['id'], selectedIds: [] }
195
+ }
196
+ const toggles = modifiers.shift || modifiers.ctrl || modifiers.meta
197
+ const sameFloor = levelId === selection.levelId
198
+ const selectedIds =
199
+ toggles && sameFloor
200
+ ? selection.selectedIds.includes(target.nodeId)
201
+ ? selection.selectedIds.filter((id) => id !== target.nodeId)
202
+ : [...selection.selectedIds, target.nodeId]
203
+ : [target.nodeId]
204
+ return {
205
+ buildingId,
206
+ levelId,
207
+ zoneId: target.zoneId as ZoneNode['id'] | null,
208
+ selectedIds,
209
+ }
210
+ }
211
+
212
+ /** What a hover would select: a room's id (its highlight) or the element's (its outline). */
213
+ export function resolveViewerHover(
214
+ selection: Pick<ViewerSelection, 'zoneId'>,
215
+ pick: ViewerPick | null,
216
+ nodes: ViewerNodes,
217
+ ): string | null {
218
+ const target = pick ? resolveViewerTarget(selection, pick, nodes) : null
219
+ if (!target) return null
220
+ return target.kind === 'room' ? target.zoneId : target.nodeId
221
+ }
222
+
223
+ /**
224
+ * The current floor reads as the editor shows it: the levels above it step
225
+ * aside (in the stacked display) so its rooms are in view and in reach. Mount
226
+ * it in the surface's DOM tree, beside the editor's own level display, so one
227
+ * hands the flag to the other in order.
228
+ */
229
+ export function useViewerFloorDisplay() {
230
+ // A walkthrough tours the whole building.
231
+ const walking = useViewer((s) => s.walkthroughMode)
232
+ useEffect(() => {
233
+ if (walking) return
234
+ useViewer.setState({ hideLevelsAboveSelection: true })
235
+ return () => {
236
+ useViewer.setState({ hideLevelsAboveSelection: false })
237
+ }
238
+ }, [walking])
239
+ }
240
+
241
+ // ── Applying it to the shared store ──────────────────────────────────────────
242
+
243
+ function writeSelection(selection: ViewerSelection) {
244
+ const viewer = useViewer.getState()
245
+ viewer.setSelection(selection)
246
+ if (viewer.focusedUnitId) viewer.setFocusedUnit(null)
247
+ viewer.setHoveredId(null)
248
+ }
249
+
250
+ export function applyViewerClick(
251
+ pick: ViewerPick | null,
252
+ nodes: ViewerNodes,
253
+ modifiers: SelectionModifierKeys = NO_MODIFIERS,
254
+ ) {
255
+ writeSelection(resolveViewerClick(useViewer.getState().selection, pick, nodes, modifiers))
256
+ }
257
+
258
+ export function applyViewerHover(pick: ViewerPick | null, nodes: ViewerNodes) {
259
+ const viewer = useViewer.getState()
260
+ const hovered = resolveViewerHover(viewer.selection, pick, nodes)
261
+ viewer.setHoveredId(hovered as never)
262
+ }
263
+
264
+ /** A floor from the floor list: the floor alone, framed. */
265
+ export function selectViewerLevel(levelId: string, source: ViewerSource) {
266
+ writeSelection({
267
+ buildingId: buildingOfLevel(levelId, source.nodes),
268
+ levelId: levelId as LevelNode['id'],
269
+ zoneId: null,
270
+ selectedIds: [],
271
+ })
272
+ frameViewerNode(levelId, source)
273
+ }
274
+
275
+ /** The whole building again, framed. */
276
+ export function selectViewerBuilding(source: ViewerSource) {
277
+ const buildingId = buildingOfLevel(null, source.nodes)
278
+ writeSelection({ buildingId, levelId: null, zoneId: null, selectedIds: [] })
279
+ if (buildingId) frameViewerNode(buildingId, source)
280
+ }
281
+
282
+ /** A room or zone from its pill, the bar or the sidebar: selected on its floor, framed. */
283
+ export function selectViewerRoom(zoneId: string, source: ViewerSource) {
284
+ const zone = source.nodes[zoneId]
285
+ if (zone?.type !== 'zone' || !zone.parentId) return
286
+ const levelId = zone.parentId as LevelNode['id']
287
+ writeSelection({
288
+ buildingId: buildingOfLevel(levelId, source.nodes),
289
+ levelId,
290
+ zoneId: zone.id,
291
+ selectedIds: [],
292
+ })
293
+ frameViewerNode(zone.id, source)
294
+ }
295
+
296
+ /**
297
+ * A unit from the sidebar: its rooms highlighted and framed. The floor stays
298
+ * when it holds one of the unit's rooms, else the unit's lowest floor shows.
299
+ */
300
+ export function selectViewerUnit(unitId: string, source: ViewerSource) {
301
+ const unit = source.nodes[unitId]
302
+ if (unit?.type !== 'unit') return
303
+ const viewer = useViewer.getState()
304
+ const levels = unitLevels(unit, source.nodes)
305
+ const current = viewer.selection.levelId
306
+ const levelId = (current && levels.includes(current) ? current : (levels[0] ?? current)) as
307
+ | LevelNode['id']
308
+ | null
309
+ viewer.setSelection({
310
+ buildingId: buildingOfLevel(levelId, source.nodes),
311
+ levelId,
312
+ zoneId: null,
313
+ selectedIds: [],
314
+ })
315
+ viewer.setHoveredId(null)
316
+ viewer.setFocusedUnit(unit.id)
317
+ frameViewerNode(unit.id, source)
318
+ }
319
+
320
+ function unitLevels(unit: UnitNode, nodes: ViewerNodes): string[] {
321
+ const ordinals = new Map<string, number>()
322
+ for (const member of buildUnitReport(unit, nodes).members) {
323
+ if (member.levelId && member.levelOrdinal !== null)
324
+ ordinals.set(member.levelId, member.levelOrdinal)
325
+ }
326
+ return [...ordinals.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id)
327
+ }
328
+
329
+ // ── Framing ──────────────────────────────────────────────────────────────────
330
+
331
+ const corner = new Vector3()
332
+ const box = new Box3()
333
+
334
+ function expandByZone(target: Box3, zone: ZoneNode, source: ViewerSource) {
335
+ const level = zone.parentId ? source.objectFor(zone.parentId) : undefined
336
+ level?.updateWorldMatrix(true, false)
337
+ const floorY = roomFloorElevation(source.nodes as Record<string, AnyNode>, zone.id)
338
+ for (const [x, z] of zone.polygon) {
339
+ for (const y of [floorY, floorY + DEFAULT_LEVEL_HEIGHT]) {
340
+ corner.set(x, y, z)
341
+ if (level) corner.applyMatrix4(level.matrixWorld)
342
+ target.expandByPoint(corner)
343
+ }
344
+ }
345
+ }
346
+
347
+ /** The frame request for a node: its saved view, else the box it spans. */
348
+ function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameEvent | null {
349
+ const node = source.nodes[id]
350
+ if (!node) return null
351
+ const saved = node.camera
352
+ if (saved?.position && saved.target) {
353
+ return { bounds: null, pose: { position: saved.position, target: saved.target } }
354
+ }
355
+ box.makeEmpty()
356
+ if (node.type === 'zone') expandByZone(box, node, source)
357
+ else if (node.type === 'unit') {
358
+ for (const memberId of node.members) {
359
+ const member = source.nodes[memberId]
360
+ if (member?.type === 'zone') expandByZone(box, member, source)
361
+ }
362
+ } else {
363
+ const object = source.objectFor(id)
364
+ if (object) box.setFromObject(object)
365
+ }
366
+ if (box.isEmpty()) return null
367
+ return { bounds: { min: box.min.toArray(), max: box.max.toArray() } }
368
+ }
369
+
370
+ export function frameViewerNode(id: string, source: ViewerSource) {
371
+ const event = viewerFrameEvent(id, source)
372
+ if (event) emitter.emit('camera-controls:frame', event)
373
+ }
374
+
375
+ type Vec3 = [number, number, number]
376
+
377
+ /**
378
+ * Where a camera frames a box: about 45° above it, far enough for the box to
379
+ * fill the view, along the heading the camera already has so the scene does
380
+ * not swing round.
381
+ */
382
+ export function framedLookAt(
383
+ event: CameraControlFrameEvent,
384
+ from: { position: Vec3; target: Vec3 },
385
+ fovDegrees = 50,
386
+ ): { position: Vec3; target: Vec3 } | null {
387
+ if (event.pose) return event.pose
388
+ if (!event.bounds) return null
389
+ const { min, max } = event.bounds
390
+ const center: Vec3 = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]
391
+ const radius = Math.max(Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2, 1)
392
+ const halfFov = (Math.min(Math.max(fovDegrees, 10), 120) * Math.PI) / 360
393
+ const distance = Math.max((radius / Math.sin(halfFov)) * 1.6, 6)
394
+ const dx = from.position[0] - from.target[0]
395
+ const dz = from.position[2] - from.target[2]
396
+ const heading = Math.hypot(dx, dz) > 1e-6 ? Math.atan2(dx, dz) : Math.PI / 4
397
+ const elevation = Math.PI / 4
398
+ const flat = Math.cos(elevation) * distance
399
+ return {
400
+ position: [
401
+ center[0] + Math.sin(heading) * flat,
402
+ center[1] + Math.sin(elevation) * distance,
403
+ center[2] + Math.cos(heading) * flat,
404
+ ],
405
+ target: center,
406
+ }
407
+ }
408
+
409
+ const framePosition = new Vector3()
410
+ const frameTarget = new Vector3()
411
+ const frameSphere = new Sphere()
412
+
413
+ /** Apply the shared framing pose, fitting the orthographic viewport as well. */
414
+ export function frameViewerCamera(
415
+ control: CameraControlsImpl,
416
+ camera: Camera,
417
+ event: CameraControlFrameEvent,
418
+ ) {
419
+ control.getPosition(framePosition)
420
+ control.getTarget(frameTarget)
421
+ const pose = framedLookAt(
422
+ event,
423
+ { position: framePosition.toArray(), target: frameTarget.toArray() },
424
+ (camera as PerspectiveCamera).isPerspectiveCamera
425
+ ? (camera as PerspectiveCamera).fov
426
+ : undefined,
427
+ )
428
+ if (!pose) return
429
+ control.setLookAt(...pose.position, ...pose.target, true)
430
+ if ((camera as OrthographicCamera).isOrthographicCamera && event.bounds && !event.pose) {
431
+ box.min.set(...event.bounds.min)
432
+ box.max.set(...event.bounds.max)
433
+ box.getBoundingSphere(frameSphere)
434
+ frameSphere.radius = Math.max(frameSphere.radius * 1.3, 1)
435
+ control.fitToSphere(frameSphere, true)
436
+ }
437
+ }
438
+
439
+ // ── Breadcrumb ───────────────────────────────────────────────────────────────
440
+
441
+ export type ViewerCrumb = {
442
+ kind: 'building' | 'level' | 'unit' | 'room' | 'node'
443
+ id: string
444
+ label: string
445
+ }
446
+
447
+ export function zoneLabel(zone: ZoneNode): string {
448
+ return zone.name?.trim() || (zone.spaceRole === 'room' ? 'Room' : 'Zone')
449
+ }
450
+
451
+ /**
452
+ * The bar: building › floor › room › element. An element inside a room shows
453
+ * that room (`roomOf`) without selecting it; a focused unit stands where the
454
+ * room would.
455
+ */
456
+ export function viewerBreadcrumb(
457
+ selection: ViewerSelection,
458
+ focusedUnitId: string | null,
459
+ nodes: ViewerNodes,
460
+ roomOf: (nodeId: string, levelId: string) => string | null = () => null,
461
+ ): ViewerCrumb[] {
462
+ const crumbs: ViewerCrumb[] = []
463
+ const elementId = selection.selectedIds[0] ?? null
464
+ const element = elementId ? nodes[elementId] : undefined
465
+ const levelId = selection.levelId ?? (element ? levelOfNode(element.id, nodes) : null)
466
+ const buildingId = selection.buildingId ?? buildingOfLevel(levelId, nodes)
467
+ const building = buildingId ? nodes[buildingId] : undefined
468
+ if (building?.type === 'building') {
469
+ crumbs.push({ kind: 'building', id: building.id, label: building.name || 'Building' })
470
+ }
471
+ const level = levelId ? nodes[levelId] : undefined
472
+ if (level?.type !== 'level') return crumbs
473
+ crumbs.push({ kind: 'level', id: level.id, label: getLevelDisplayName(level) })
474
+ const unit = focusedUnitId ? nodes[focusedUnitId] : undefined
475
+ if (unit?.type === 'unit') crumbs.push({ kind: 'unit', id: unit.id, label: unit.name })
476
+ const roomId = selection.zoneId ?? (element ? roomOf(element.id, level.id) : null)
477
+ const room = roomId ? nodes[roomId] : undefined
478
+ if (room?.type === 'zone') crumbs.push({ kind: 'room', id: room.id, label: zoneLabel(room) })
479
+ if (element) crumbs.push({ kind: 'node', id: element.id, label: nodeDisplayLabel(element) })
480
+ return crumbs
481
+ }
482
+
483
+ /** The room or zone of a level around a level-local point: a detected room first. */
484
+ function roomAtLevelPoint(
485
+ levelId: string,
486
+ point: readonly [number, number],
487
+ nodes: ViewerNodes,
488
+ ): string | null {
489
+ const at: [number, number] = [point[0], point[1]]
490
+ const room = roomIndex(levelId, nodes).roomAtPoint(levelId, at)
491
+ if (room) return room.key.zoneId
492
+ for (const node of Object.values(nodes)) {
493
+ if (node.type !== 'zone' || node.parentId !== levelId || node.polygon.length < 3) continue
494
+ if (containsPoint([{ outer: node.polygon, holes: node.holes ?? [] }], at)) return node.id
495
+ }
496
+ return null
497
+ }
498
+
499
+ const center = new Vector3()
500
+
501
+ /** The room an element stands in, from where its object sits on its floor. */
502
+ export function roomOfObject(nodeId: string, levelId: string, source: ViewerSource) {
503
+ const object = source.objectFor(nodeId)
504
+ const level = source.objectFor(levelId)
505
+ if (!(object && level)) return null
506
+ box.setFromObject(object)
507
+ if (box.isEmpty()) object.getWorldPosition(center)
508
+ else box.getCenter(center)
509
+ level.updateWorldMatrix(true, false)
510
+ level.worldToLocal(center)
511
+ return roomAtLevelPoint(levelId, [center.x, center.z], source.nodes)
512
+ }
513
+
514
+ // ── Sidebar ──────────────────────────────────────────────────────────────────
515
+
516
+ export type ViewerSidebarRow = { id: string; label: string; color: string; areaM2: number }
517
+ export type ViewerSidebarSection = { kind: 'units' | 'rooms' | 'zones'; rows: ViewerSidebarRow[] }
518
+
519
+ /**
520
+ * What sits under the current floor: its units, its rooms, its other zones.
521
+ * An empty section is left out.
522
+ */
523
+ export function viewerSidebarSections(levelId: string, nodes: ViewerNodes): ViewerSidebarSection[] {
524
+ const zones = Object.values(nodes).filter(
525
+ (node): node is ZoneNode =>
526
+ node.type === 'zone' && node.parentId === levelId && node.visible !== false,
527
+ )
528
+ const zoneRow = (zone: ZoneNode): ViewerSidebarRow => ({
529
+ id: zone.id,
530
+ label: zoneLabel(zone),
531
+ color: zone.color,
532
+ areaM2: area([{ outer: zone.polygon, holes: zone.holes ?? [] }]),
533
+ })
534
+ const byName = (a: ViewerSidebarRow, b: ViewerSidebarRow) => a.label.localeCompare(b.label)
535
+ const units = Object.values(nodes)
536
+ .filter((node): node is UnitNode => node.type === 'unit')
537
+ .flatMap((unit) => {
538
+ const report = buildUnitReport(unit, nodes)
539
+ const here = report.members.filter((member) => member.levelId === levelId)
540
+ if (here.length === 0) return []
541
+ return [
542
+ {
543
+ id: unit.id,
544
+ label: unit.name,
545
+ color: unit.color,
546
+ areaM2: here.reduce((sum, member) => sum + member.areaM2, 0),
547
+ },
548
+ ]
549
+ })
550
+ .sort(byName)
551
+ const sections: ViewerSidebarSection[] = [
552
+ { kind: 'units', rows: units },
553
+ {
554
+ kind: 'rooms',
555
+ rows: zones
556
+ .filter((zone) => zone.spaceRole === 'room')
557
+ .map(zoneRow)
558
+ .sort(byName),
559
+ },
560
+ {
561
+ kind: 'zones',
562
+ rows: zones
563
+ .filter((zone) => zone.spaceRole !== 'room')
564
+ .map(zoneRow)
565
+ .sort(byName),
566
+ },
567
+ ]
568
+ return sections.filter((section) => section.rows.length > 0)
569
+ }
@@ -0,0 +1,71 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { emitter } from '@pascal-app/core'
3
+ import useEditor from '../store/use-editor'
4
+ import { toolHudTitle } from './hud-title'
5
+ import {
6
+ getWallDrawVariant,
7
+ selectWallDrawVariant,
8
+ WALL_DRAW_VARIANTS,
9
+ wallDrawVariantOf,
10
+ } from './wall-draw-variant'
11
+
12
+ afterEach(() => {
13
+ useEditor.getState().setContinuation('wall', 'room')
14
+ useEditor.getState().armToolMode({ mode: 'select' })
15
+ })
16
+
17
+ describe('Rooms variants', () => {
18
+ test('are Rectangle, Polygon and Walls, in the Build panel order', () => {
19
+ expect(WALL_DRAW_VARIANTS.map((variant) => [variant.id, variant.label, variant.mode])).toEqual([
20
+ ['rectangle', 'Rectangle', 'rectangle'],
21
+ ['polygon', 'Polygon', 'room'],
22
+ ['walls', 'Walls', 'single'],
23
+ ])
24
+ })
25
+
26
+ test('default to the chain that closes into a room', () => {
27
+ expect(getWallDrawVariant()).toBe('polygon')
28
+ expect(wallDrawVariantOf('unknown')).toBe('polygon')
29
+ })
30
+
31
+ test('picking one sets the wall tool drawing mode, and it sticks', () => {
32
+ for (const variant of WALL_DRAW_VARIANTS) {
33
+ selectWallDrawVariant(variant.id)
34
+ expect(useEditor.getState().getContinuation('wall')).toBe(variant.mode)
35
+ expect(getWallDrawVariant()).toBe(variant.id)
36
+ }
37
+ // Arming the wall tool (e.g. with B) keeps the picked variant.
38
+ selectWallDrawVariant('rectangle')
39
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
40
+ expect(getWallDrawVariant()).toBe('rectangle')
41
+ })
42
+
43
+ test('switching while the wall tool is armed drops the in-flight draft', () => {
44
+ let cancels = 0
45
+ const onCancel = () => {
46
+ cancels += 1
47
+ }
48
+ emitter.on('tool:cancel', onCancel)
49
+ try {
50
+ selectWallDrawVariant('walls')
51
+ expect(cancels).toBe(0)
52
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
53
+ selectWallDrawVariant('rectangle')
54
+ expect(cancels).toBe(1)
55
+ selectWallDrawVariant('rectangle')
56
+ expect(cancels).toBe(1)
57
+ } finally {
58
+ emitter.off('tool:cancel', onCancel)
59
+ }
60
+ })
61
+
62
+ test('name the HUD after the variant in hand', () => {
63
+ expect(toolHudTitle('wall', 'rectangle')).toEqual({
64
+ label: 'Rectangle room',
65
+ icon: { kind: 'url', src: '/icons/room.webp' },
66
+ shortcut: 'B',
67
+ })
68
+ expect(toolHudTitle('wall', 'room')?.label).toBe('Polygon room')
69
+ expect(toolHudTitle('wall', 'single')?.label).toBe('Walls')
70
+ })
71
+ })