@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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  type AnyNodeId,
5
+ collectionIdsOf,
5
6
  generateId,
6
7
  type RoofNode,
7
8
  RoofNode as RoofNodeSchema,
@@ -103,6 +104,7 @@ export function duplicateRoofSubtree(
103
104
  })
104
105
 
105
106
  const segmentClones: RoofSegmentNode[] = []
107
+ const segmentSourceIds: AnyNodeId[] = []
106
108
  for (const childId of sourceRoof.children ?? []) {
107
109
  const childNode = scene.nodes[childId as AnyNodeId]
108
110
  if (childNode?.type !== 'roof-segment') {
@@ -116,11 +118,17 @@ export function duplicateRoofSubtree(
116
118
  metadata: buildDuplicateMetadata(childNode.metadata),
117
119
  })
118
120
  segmentClones.push(childClone)
121
+ segmentSourceIds.push(childNode.id)
119
122
  }
120
123
 
124
+ // Each copy is in the collections its source is in.
121
125
  scene.createNodes([
122
- { node: roofClone, parentId },
123
- ...segmentClones.map((segment) => ({ node: segment, parentId: roofClone.id as AnyNodeId })),
126
+ { node: roofClone, parentId, collectionIds: collectionIdsOf(scene.collections, sourceRoofId) },
127
+ ...segmentClones.map((segment, index) => ({
128
+ node: segment,
129
+ parentId: roofClone.id as AnyNodeId,
130
+ collectionIds: collectionIdsOf(scene.collections, segmentSourceIds[index]!),
131
+ })),
124
132
  ])
125
133
 
126
134
  const nextScene = useScene.getState()
@@ -0,0 +1,275 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import {
4
+ type AnyNode,
5
+ BuildingNode,
6
+ CeilingNode,
7
+ getWallArcData,
8
+ getWallFaceOffsets,
9
+ LevelNode,
10
+ SlabNode,
11
+ sceneRegistry,
12
+ WallNode,
13
+ } from '@pascal-app/core'
14
+ import { migrateCeilingRoomLinks, migrateRoomZones } from '@pascal-app/core/scene-migrations'
15
+ import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three'
16
+ import {
17
+ buildRoomAssembly,
18
+ getRoomAssembly,
19
+ type RoomAssemblyHeights,
20
+ resolveRoomAssemblyHeights,
21
+ roomWallFaces,
22
+ } from './room-assembly-overlay'
23
+ import { RoomSelectionIndex, type RoomSelectionRecord } from './room-selection'
24
+
25
+ const levelId = 'level_room_assembly'
26
+ const buildingId = 'building_room_assembly'
27
+
28
+ function wall(id: string, start: [number, number], end: [number, number], extra = {}) {
29
+ return WallNode.parse({
30
+ id: `wall_${id}`,
31
+ parentId: levelId,
32
+ start,
33
+ end,
34
+ thickness: 0.2,
35
+ ...extra,
36
+ })
37
+ }
38
+
39
+ function scene(walls: WallNode[], extra: AnyNode[] = []) {
40
+ const raw = Object.fromEntries(
41
+ [
42
+ BuildingNode.parse({ id: buildingId, children: [levelId] }),
43
+ LevelNode.parse({
44
+ id: levelId,
45
+ parentId: buildingId,
46
+ children: [...walls, ...extra].map((node) => node.id),
47
+ }),
48
+ ...walls,
49
+ ...extra,
50
+ ].map((node) => [node.id, node]),
51
+ )
52
+ return migrateCeilingRoomLinks(migrateRoomZones(raw).nodes).nodes as Record<string, AnyNode>
53
+ }
54
+
55
+ // Two 4 x 4 rooms split by `shared`, with a T-stem into the east room.
56
+ function twoRooms(extraWalls: WallNode[] = []) {
57
+ return scene([
58
+ wall('south', [0, 0], [8, 0]),
59
+ wall('east', [8, 0], [8, 4]),
60
+ wall('north', [8, 4], [0, 4]),
61
+ wall('west', [0, 4], [0, 0]),
62
+ wall('shared', [4, 0], [4, 4]),
63
+ ...extraWalls,
64
+ ])
65
+ }
66
+
67
+ function roomAt(records: RoomSelectionRecord[], point: [number, number]) {
68
+ const [x, z] = point
69
+ return records.find(({ polygon }) => {
70
+ const xs = polygon.map((p) => p[0])
71
+ const zs = polygon.map((p) => p[1])
72
+ return x > Math.min(...xs) && x < Math.max(...xs) && z > Math.min(...zs) && z < Math.max(...zs)
73
+ })!
74
+ }
75
+
76
+ function withNodes(nodes: Record<string, AnyNode>, ...added: AnyNode[]) {
77
+ const level = nodes[levelId] as LevelNode
78
+ return {
79
+ ...nodes,
80
+ ...Object.fromEntries(added.map((node) => [node.id, node])),
81
+ [levelId]: { ...level, children: [...level.children, ...added.map((node) => node.id)] },
82
+ } as Record<string, AnyNode>
83
+ }
84
+
85
+ const heights = (tops: Record<string, number>, floorY = 0): RoomAssemblyHeights => ({
86
+ floorY,
87
+ wallTops: new Map(Object.entries(tops)),
88
+ ceiling: null,
89
+ })
90
+
91
+ const length = ({ from, to }: { from: readonly number[]; to: readonly number[] }) =>
92
+ Math.hypot(to[0]! - from[0]!, to[1]! - from[1]!)
93
+
94
+ function yValues(array: Float32Array) {
95
+ const ys = new Set<number>()
96
+ for (let i = 1; i < array.length; i += 3) ys.add(Math.round(array[i]! * 1000) / 1000)
97
+ return [...ys].sort((a, b) => a - b)
98
+ }
99
+
100
+ afterEach(() => sceneRegistry.clear())
101
+
102
+ describe('room assembly faces', () => {
103
+ test('only the room-facing face, clipped at the partition and the corners', () => {
104
+ const room = roomAt(new RoomSelectionIndex(levelId).update(twoRooms()), [2, 2])
105
+ const faces = roomWallFaces(room.geometry)
106
+ const byWall = new Map<string, typeof faces>()
107
+ for (const face of faces) byWall.set(face.wallId, [...(byWall.get(face.wallId) ?? []), face])
108
+ expect([...byWall.keys()].sort()).toEqual(
109
+ ['wall_north', 'wall_shared', 'wall_south', 'wall_west'].sort(),
110
+ )
111
+ const south = byWall.get('wall_south')!
112
+ expect(south.every(({ face }) => face === 'a')).toBe(true)
113
+ for (const { from, to } of south) {
114
+ expect(from[1]).toBeCloseTo(0.1)
115
+ expect(to[1]).toBeCloseTo(0.1)
116
+ for (const x of [from[0], to[0]]) {
117
+ expect(x).toBeGreaterThanOrEqual(0.1 - 1e-6)
118
+ expect(x).toBeLessThanOrEqual(3.9 + 1e-6)
119
+ }
120
+ }
121
+ expect(south.reduce((sum, face) => sum + length(face), 0)).toBeCloseTo(3.8)
122
+ // The partition contributes its west face only.
123
+ for (const { from, to } of byWall.get('wall_shared')!) {
124
+ expect(from[0]).toBeCloseTo(3.9)
125
+ expect(to[0]).toBeCloseTo(3.9)
126
+ }
127
+ })
128
+
129
+ test('a T-stem splits the host face into per-room t-ranges', () => {
130
+ const nodes = twoRooms([wall('stem', [4, 2], [8, 2])])
131
+ const records = new RoomSelectionIndex(levelId).update(nodes)
132
+ const lower = roomAt(records, [6, 1])
133
+ const upper = roomAt(records, [6, 3])
134
+ for (const [room, zMin, zMax] of [
135
+ [lower, 0.1, 1.9],
136
+ [upper, 2.1, 3.9],
137
+ ] as const) {
138
+ const shared = roomWallFaces(room.geometry).filter(({ wallId }) => wallId === 'wall_shared')
139
+ const span = room.spans.find(({ boundaryId }) => boundaryId === 'wall_shared')!
140
+ expect(shared.reduce((sum, face) => sum + length(face), 0)).toBeCloseTo(zMax - zMin)
141
+ for (const { from, to, face } of shared) {
142
+ expect(face).toBe(span.face)
143
+ for (const [x, z] of [from, to]) {
144
+ expect(x).toBeCloseTo(4.1)
145
+ expect(z).toBeGreaterThanOrEqual(zMin - 1e-6)
146
+ expect(z).toBeLessThanOrEqual(zMax + 1e-6)
147
+ const t = z / 4
148
+ expect(t).toBeGreaterThanOrEqual(span.t0 - 1e-6)
149
+ expect(t).toBeLessThanOrEqual(span.t1 + 1e-6)
150
+ }
151
+ }
152
+ }
153
+ })
154
+
155
+ test('curved faces follow the offset arc and only draw verticals at their ends', () => {
156
+ const nodes = twoRooms()
157
+ const curved = { ...(nodes.wall_shared as WallNode), curveOffset: 0.5 }
158
+ const room = roomAt(
159
+ new RoomSelectionIndex(levelId).update({ ...nodes, [curved.id]: curved }),
160
+ [1, 2],
161
+ )
162
+ const faces = roomWallFaces(room.geometry).filter(({ wallId }) => wallId === curved.id)
163
+ expect(faces.length).toBeGreaterThan(4)
164
+ const arc = getWallArcData(curved)!
165
+ const face = faces[0]!.face
166
+ const radius = arc.radius - arc.direction * getWallFaceOffsets(curved)[face]
167
+ for (const { from, to } of faces) {
168
+ for (const [x, z] of [from, to])
169
+ expect(Math.hypot(x - arc.center.x, z - arc.center.y)).toBeCloseTo(radius, 2)
170
+ }
171
+ const tops = Object.fromEntries(room.boundaryWallIds.map((id) => [id, 2.5]))
172
+ const { outline } = buildRoomAssembly(room.geometry, heights(tops))
173
+ let verticals = 0
174
+ for (let i = 0; i < outline.length; i += 6) {
175
+ if (
176
+ outline[i + 1]! !== outline[i + 4]! &&
177
+ Math.hypot(outline[i]! - outline[i + 3]!, outline[i + 2]! - outline[i + 5]!) < 1e-6
178
+ )
179
+ verticals++
180
+ }
181
+ // Four walls, two run ends each; the arc's chords share their joints.
182
+ expect(verticals).toBe(8)
183
+ })
184
+ })
185
+
186
+ describe('room assembly geometry', () => {
187
+ test('walls rise from the plate top to their own tops; the ceiling sits at its height', () => {
188
+ const base = twoRooms()
189
+ const initial = roomAt(new RoomSelectionIndex(levelId).update(base), [2, 2])
190
+ const polygon: [number, number][] = [
191
+ [0.1, 0.1],
192
+ [3.9, 0.1],
193
+ [3.9, 3.9],
194
+ [0.1, 3.9],
195
+ ]
196
+ const nodes = withNodes(
197
+ { ...base, wall_south: { ...(base.wall_south as WallNode), height: 2.1 } },
198
+ SlabNode.parse({ parentId: levelId, polygon, zoneIds: [initial.zoneId], elevation: 0.3 }),
199
+ CeilingNode.parse({ parentId: levelId, polygon, zoneId: initial.zoneId, height: 2.4 }),
200
+ )
201
+ const room = roomAt(new RoomSelectionIndex(levelId).update(nodes), [2, 2])
202
+ const resolved = resolveRoomAssemblyHeights(room, nodes)
203
+ expect(resolved.floorY).toBe(0.3)
204
+ expect(resolved.ceiling?.y).toBe(2.4)
205
+ expect(resolved.wallTops.get('wall_south')).toBe(2.1)
206
+ const planeTop = resolved.wallTops.get('wall_west')!
207
+ expect(planeTop).toBeGreaterThan(2.1)
208
+ const assembly = buildRoomAssembly(room.geometry, resolved)
209
+ expect(yValues(assembly.floor)).toEqual([0.305])
210
+ expect(yValues(assembly.surfaces)).toEqual(
211
+ [0.305, 2.1, 2.38, Math.round(planeTop * 1000) / 1000].sort((a, b) => a - b),
212
+ )
213
+ expect(yValues(assembly.outline)).toContain(2.38)
214
+ expect(assembly.floor.length).toBeGreaterThan(0)
215
+ })
216
+
217
+ test('cache hits on the same topology revision and misses on topology or height change', () => {
218
+ const index = new RoomSelectionIndex(levelId)
219
+ const nodes = twoRooms()
220
+ const room = roomAt(index.update(nodes), [2, 2])
221
+ const tops = Object.fromEntries(room.boundaryWallIds.map((id) => [id, 2.5]))
222
+ const first = getRoomAssembly(room.geometry, heights(tops))
223
+ // A fresh heights object with equal values and an unrelated scene edit both hit.
224
+ expect(getRoomAssembly(room.geometry, heights({ ...tops }))).toBe(first)
225
+ const zone = nodes[room.zoneId]!
226
+ const renamed = roomAt(
227
+ index.update({ ...nodes, [zone.id]: { ...zone, name: 'Kitchen' } as AnyNode }),
228
+ [2, 2],
229
+ )
230
+ expect(renamed.geometry).toBe(room.geometry)
231
+ expect(getRoomAssembly(renamed.geometry, heights(tops))).toBe(first)
232
+ expect(getRoomAssembly(room.geometry, heights({ ...tops, wall_south: 2 }))).not.toBe(first)
233
+ const moved = roomAt(
234
+ index.update({
235
+ ...nodes,
236
+ wall_shared: { ...(nodes.wall_shared as WallNode), start: [5, 0], end: [5, 4] },
237
+ }),
238
+ [2, 2],
239
+ )
240
+ expect(moved.geometry).not.toBe(room.geometry)
241
+ const rebuilt = getRoomAssembly(moved.geometry, heights(tops))
242
+ expect(rebuilt).not.toBe(first)
243
+ expect(rebuilt.floor.length).toBeGreaterThan(0)
244
+ })
245
+
246
+ test('building and resolving never touch the rendered wall meshes', () => {
247
+ const nodes = twoRooms()
248
+ const room = roomAt(new RoomSelectionIndex(levelId).update(nodes), [2, 2])
249
+ const meshes = room.boundaryWallIds.map((id) => {
250
+ const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial())
251
+ mesh.userData = { batched: true, pascalId: id }
252
+ sceneRegistry.nodes.set(id, mesh)
253
+ return {
254
+ mesh,
255
+ material: mesh.material,
256
+ geometry: mesh.geometry,
257
+ layers: mesh.layers.mask,
258
+ userData: structuredClone(mesh.userData),
259
+ visible: mesh.visible,
260
+ }
261
+ })
262
+ getRoomAssembly(room.geometry, resolveRoomAssemblyHeights(room, nodes))
263
+ for (const { mesh, material, geometry, layers, userData, visible } of meshes) {
264
+ expect(mesh.material).toBe(material)
265
+ expect(mesh.geometry).toBe(geometry)
266
+ expect(mesh.layers.mask).toBe(layers)
267
+ expect(mesh.userData).toEqual(userData)
268
+ expect(mesh.visible).toBe(visible)
269
+ }
270
+ for (const file of ['./room-assembly-overlay.ts', '../components/editor/room-highlight.tsx']) {
271
+ const source = readFileSync(new URL(file, import.meta.url), 'utf8')
272
+ expect(source).not.toMatch(/\.material\s*=[^=]|userData|sceneRegistry\.nodes\.set/)
273
+ }
274
+ })
275
+ })
@@ -0,0 +1,266 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ area,
5
+ getWallArcData,
6
+ getWallBaseElevationForNodes,
7
+ getWallCurveFrameAt,
8
+ getWallFaceOffsets,
9
+ getWallPlaneTop,
10
+ resolveCeilingHeight,
11
+ resolveWallTop,
12
+ roomDrawnFloor,
13
+ type WallNode,
14
+ } from '@pascal-app/core'
15
+ import { ShapeUtils, Vector2 } from 'three'
16
+ import type { RoomSelectionGeometry, RoomSelectionRecord } from './room-selection'
17
+
18
+ type Point = readonly [number, number]
19
+ type Ring = readonly Point[]
20
+
21
+ /** Everything the assembly depends on besides the room's topology geometry. */
22
+ export type RoomAssemblyHeights = {
23
+ /** Level-local Y of the room's plate top (the level plane without a plate). */
24
+ floorY: number
25
+ /** Level-local Y of each boundary wall's top. */
26
+ wallTops: ReadonlyMap<string, number>
27
+ ceiling: { polygon: Ring; holes: readonly Ring[]; y: number } | null
28
+ }
29
+
30
+ export type RoomWallFacePortion = { wallId: string; face: 'a' | 'b'; from: Point; to: Point }
31
+
32
+ /** Flat level-local position buffers; the renderer only wraps them. */
33
+ export type RoomAssembly = {
34
+ floor: Float32Array
35
+ surfaces: Float32Array
36
+ outline: Float32Array
37
+ faces: RoomWallFacePortion[]
38
+ }
39
+
40
+ const ON_FACE = 1e-3
41
+ const FLOOR_LIFT = 0.005
42
+ const CEILING_DROP = 0.02
43
+ const CURVE_SAMPLES = 24
44
+ const MIN_PIECE_AREA = 0.01
45
+
46
+ /**
47
+ * Curved boundaries leave hairline slivers between the sampled room face and
48
+ * the sampled wall footprint; they would add stray face runs.
49
+ */
50
+ function clearPieces(geometry: RoomSelectionGeometry) {
51
+ return geometry.clearPolygon.filter((piece) => area([piece]) >= MIN_PIECE_AREA)
52
+ }
53
+
54
+ export function resolveRoomAssemblyHeights(
55
+ room: RoomSelectionRecord,
56
+ nodes: Readonly<Record<string, AnyNode>>,
57
+ ): RoomAssemblyHeights {
58
+ const sceneNodes = nodes as Record<AnyNodeId, AnyNode>
59
+ const { levelId } = room.key
60
+ const zone = nodes[room.zoneId]
61
+ let floorY = 0
62
+ // A drawn slab floors the room even with its floor switched off.
63
+ if (zone?.type === 'zone' && (zone.hasFloor !== false || roomDrawnFloor(nodes, zone.id))) {
64
+ const level = nodes[levelId]
65
+ const plate =
66
+ level?.type === 'level'
67
+ ? level.children
68
+ .map((id) => nodes[id])
69
+ .find((node) => node?.type === 'slab' && node.zoneIds?.includes(room.zoneId))
70
+ : undefined
71
+ const drawn = plate ? null : roomDrawnFloor(nodes, room.zoneId)
72
+ const linked =
73
+ plate ?? (drawn ? nodes[drawn.slabId] : room.slabId ? nodes[room.slabId] : undefined)
74
+ floorY = (linked?.type === 'slab' ? linked.elevation : zone.floor?.elevation) ?? 0
75
+ }
76
+ const wallTops = new Map<string, number>()
77
+ for (const id of room.boundaryWallIds) {
78
+ const wall = nodes[id]
79
+ if (wall?.type !== 'wall') continue
80
+ const base = getWallBaseElevationForNodes(wall, sceneNodes)
81
+ wallTops.set(id, resolveWallTop(wall, getWallPlaneTop(wall, levelId, sceneNodes), base))
82
+ }
83
+ const ceilingNode = room.ceilingId ? nodes[room.ceilingId] : undefined
84
+ const ceiling =
85
+ ceilingNode?.type === 'ceiling'
86
+ ? {
87
+ polygon: ceilingNode.polygon,
88
+ holes: ceilingNode.holes,
89
+ y: resolveCeilingHeight(ceilingNode, sceneNodes),
90
+ }
91
+ : null
92
+ return { floorY, wallTops, ceiling }
93
+ }
94
+
95
+ function heightsKey({ floorY, wallTops, ceiling }: RoomAssemblyHeights) {
96
+ const rings = ceiling ? [ceiling.polygon, ...ceiling.holes].map((ring) => ring.join(' ')) : []
97
+ return [floorY, ...wallTops, ceiling?.y, ...rings].join('|')
98
+ }
99
+
100
+ const cache = new WeakMap<RoomSelectionGeometry, { key: string; assembly: RoomAssembly }>()
101
+
102
+ /**
103
+ * The room geometry object is reused for as long as the level topology
104
+ * revision stands, so it keys the cache; heights and the ceiling outline are
105
+ * compared by value. Hover changes and pointer moves only ever hit.
106
+ */
107
+ export function getRoomAssembly(
108
+ geometry: RoomSelectionGeometry,
109
+ heights: RoomAssemblyHeights,
110
+ ): RoomAssembly {
111
+ const key = heightsKey(heights)
112
+ const cached = cache.get(geometry)
113
+ if (cached?.key === key) return cached.assembly
114
+ const assembly = buildRoomAssembly(geometry, heights)
115
+ cache.set(geometry, { key, assembly })
116
+ return assembly
117
+ }
118
+
119
+ function faceTest(wall: WallNode, face: 'a' | 'b') {
120
+ const offset = getWallFaceOffsets(wall)[face]
121
+ const arc = getWallArcData(wall)
122
+ if (arc) {
123
+ const radius = arc.radius - arc.direction * offset
124
+ return ([x, z]: Point) =>
125
+ Math.abs(Math.hypot(x - arc.center.x, z - arc.center.y) - radius) < ON_FACE
126
+ }
127
+ const dx = wall.end[0] - wall.start[0]
128
+ const dz = wall.end[1] - wall.start[1]
129
+ const length = Math.hypot(dx, dz)
130
+ if (length < 1e-9) return () => false
131
+ return ([x, z]: Point) =>
132
+ Math.abs(((x - wall.start[0]) * -dz + (z - wall.start[1]) * dx) / length - offset) < ON_FACE
133
+ }
134
+
135
+ function* ringEdges(ring: Ring) {
136
+ for (let i = 0; i < ring.length; i++) yield [ring[i]!, ring[(i + 1) % ring.length]!] as const
137
+ }
138
+
139
+ function overlap(p: Point, q: Point, a: Point, b: Point): [Point, Point] | null {
140
+ const ex = b[0] - a[0]
141
+ const ez = b[1] - a[1]
142
+ const lengthSq = ex * ex + ez * ez
143
+ if (lengthSq < 1e-12) return null
144
+ const length = Math.sqrt(lengthSq)
145
+ const offLine = ([x, z]: Point) => Math.abs((x - a[0]) * ez - (z - a[1]) * ex) / length
146
+ if (offLine(p) > ON_FACE || offLine(q) > ON_FACE) return null
147
+ const up = ((p[0] - a[0]) * ex + (p[1] - a[1]) * ez) / lengthSq
148
+ const uq = ((q[0] - a[0]) * ex + (q[1] - a[1]) * ez) / lengthSq
149
+ const lo = Math.max(0, Math.min(up, uq))
150
+ const hi = Math.min(1, Math.max(up, uq))
151
+ if ((hi - lo) * length < 1e-4) return null
152
+ return [
153
+ [a[0] + ex * lo, a[1] + ez * lo],
154
+ [a[0] + ex * hi, a[1] + ez * hi],
155
+ ]
156
+ }
157
+
158
+ /**
159
+ * The room-facing part of each boundary wall face. The clear polygon's
160
+ * boundary is exactly the mitered faces the room sees, clipped by T-stems
161
+ * and separators, so its edges are matched against each wall's footprint
162
+ * face edges; that also keeps collinear walls apart.
163
+ */
164
+ export function roomWallFaces(geometry: RoomSelectionGeometry): RoomWallFacePortion[] {
165
+ const rings = clearPieces(geometry).flatMap(({ outer, holes }) => [outer, ...holes])
166
+ const faces: RoomWallFacePortion[] = []
167
+ const seen = new Set<string>()
168
+ for (const span of geometry.spans) {
169
+ if (span.kind !== 'wall') continue
170
+ const wallId = span.boundaryId
171
+ const wall = geometry.context.walls.get(wallId)
172
+ if (!wall) continue
173
+ if (!rings.length) {
174
+ faces.push(...sampledFace(wall, span.face, span.t0, span.t1))
175
+ continue
176
+ }
177
+ const key = `${wallId}:${span.face}`
178
+ const footprint = geometry.context.wallFootprints.get(wallId)
179
+ if (seen.has(key) || !footprint) continue
180
+ seen.add(key)
181
+ const onFace = faceTest(wall, span.face)
182
+ const faceEdges = [...ringEdges(footprint)].filter(([a, b]) => onFace(a) && onFace(b))
183
+ for (const ring of rings) {
184
+ for (const [p, q] of ringEdges(ring)) {
185
+ if (!(onFace(p) && onFace(q))) continue
186
+ for (const [a, b] of faceEdges) {
187
+ const portion = overlap(p, q, a, b)
188
+ if (portion) faces.push({ wallId, face: span.face, from: portion[0], to: portion[1] })
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return faces
194
+ }
195
+
196
+ // Only used when the clear polygon could not be computed for the room.
197
+ function sampledFace(wall: WallNode, face: 'a' | 'b', t0: number, t1: number) {
198
+ const offset = getWallFaceOffsets(wall)[face]
199
+ const steps = getWallArcData(wall) ? CURVE_SAMPLES : 1
200
+ const points = Array.from({ length: steps + 1 }, (_, i): Point => {
201
+ const { point, normal } = getWallCurveFrameAt(wall, t0 + ((t1 - t0) * i) / steps)
202
+ return [point.x + normal.x * offset, point.y + normal.y * offset]
203
+ })
204
+ return points
205
+ .slice(1)
206
+ .map((to, i): RoomWallFacePortion => ({ wallId: wall.id, face, from: points[i]!, to }))
207
+ }
208
+
209
+ function pushTriangles(target: number[], outer: Ring, holes: readonly Ring[], y: number) {
210
+ const contour = outer.map(([x, z]) => new Vector2(x, z))
211
+ const holePoints = holes.map((ring) => ring.map(([x, z]) => new Vector2(x, z)))
212
+ const points = [...contour, ...holePoints.flat()]
213
+ for (const triangle of ShapeUtils.triangulateShape(contour, holePoints)) {
214
+ for (const index of triangle) target.push(points[index]!.x, y, points[index]!.y)
215
+ }
216
+ }
217
+
218
+ function pushRing(target: number[], ring: Ring, y: number) {
219
+ for (const [[ax, az], [bx, bz]] of ringEdges(ring)) target.push(ax, y, az, bx, y, bz)
220
+ }
221
+
222
+ export function buildRoomAssembly(
223
+ geometry: RoomSelectionGeometry,
224
+ heights: RoomAssemblyHeights,
225
+ ): RoomAssembly {
226
+ const floor: number[] = []
227
+ const surfaces: number[] = []
228
+ const outline: number[] = []
229
+ const floorY = heights.floorY + FLOOR_LIFT
230
+ for (const { outer, holes } of clearPieces(geometry)) {
231
+ pushTriangles(floor, outer, holes, floorY)
232
+ for (const ring of [outer, ...holes]) pushRing(outline, ring, floorY)
233
+ }
234
+ const faces = roomWallFaces(geometry)
235
+ const ends = new Map<string, { point: Point; top: number; count: number }>()
236
+ for (const { wallId, from, to } of faces) {
237
+ const top = heights.wallTops.get(wallId)
238
+ if (top === undefined || top <= floorY) continue
239
+ const [ax, az] = from
240
+ const [bx, bz] = to
241
+ surfaces.push(ax, floorY, az, bx, floorY, bz, bx, top, bz)
242
+ surfaces.push(ax, floorY, az, bx, top, bz, ax, top, az)
243
+ outline.push(ax, top, az, bx, top, bz)
244
+ for (const point of [from, to]) {
245
+ const key = `${wallId}:${point[0].toFixed(4)},${point[1].toFixed(4)}`
246
+ const end = ends.get(key)
247
+ if (end) end.count++
248
+ else ends.set(key, { point, top, count: 1 })
249
+ }
250
+ }
251
+ // Verticals only where a face run starts or stops, not between curve chords.
252
+ for (const { point, top, count } of ends.values()) {
253
+ if (count === 1) outline.push(point[0], floorY, point[1], point[0], top, point[1])
254
+ }
255
+ if (heights.ceiling) {
256
+ const { polygon, holes, y } = heights.ceiling
257
+ pushTriangles(surfaces, polygon, holes, y - CEILING_DROP)
258
+ for (const ring of [polygon, ...holes]) pushRing(outline, ring, y - CEILING_DROP)
259
+ }
260
+ return {
261
+ floor: new Float32Array(floor),
262
+ surfaces: new Float32Array(surfaces),
263
+ outline: new Float32Array(outline),
264
+ faces,
265
+ }
266
+ }