@pascal-app/editor 1.0.3 → 1.0.5

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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,368 @@
1
+ import {
2
+ getWallArcData,
3
+ getWallChordFrame,
4
+ getWallFaceOffsets,
5
+ type WallFace,
6
+ type WallFaceRegion,
7
+ type WallNode,
8
+ } from '@pascal-app/core'
9
+ import { Matrix4, Quaternion, Vector3 } from 'three'
10
+ import type { WallRegionBounds } from './paint-regions'
11
+
12
+ // Geometry for editing a selected wall's paint regions in place. Everything is
13
+ // in the wall mesh's local frame: x along the chord from the start (= region
14
+ // `u`), y up from the wall base, z toward the reference line's left (face a).
15
+ // A region's `v` is measured above its face's own base, so callers pass the
16
+ // face base as `baseAt(u)`.
17
+
18
+ export const REGION_MIN_SIZE = 0.02
19
+ export const REGION_EDGE_SNAP_TOLERANCE = 0.08
20
+ const EDGE_EPSILON = 1e-4
21
+
22
+ export type RegionEdge = 'u0' | 'u1' | 'v0' | 'v1'
23
+ export const REGION_EDGES: readonly RegionEdge[] = ['u0', 'u1', 'v0', 'v1']
24
+
25
+ export type WallFaceSurface =
26
+ | { kind: 'plane'; face: WallFace; length: number; z: number }
27
+ | {
28
+ kind: 'arc'
29
+ face: WallFace
30
+ length: number
31
+ cx: number
32
+ cz: number
33
+ radius: number
34
+ direction: number
35
+ }
36
+
37
+ type WallSurfaceInput = Pick<
38
+ WallNode,
39
+ 'start' | 'end' | 'curveOffset' | 'thickness' | 'justification'
40
+ >
41
+
42
+ /** The face's surface in wall-local coordinates: a vertical plane or a vertical cylinder. */
43
+ export function getWallFaceSurface(wall: WallSurfaceInput, face: WallFace): WallFaceSurface {
44
+ const chord = getWallChordFrame(wall)
45
+ const offset = getWallFaceOffsets(wall)[face]
46
+ const arc = getWallArcData(wall)
47
+ if (!arc) return { kind: 'plane', face, length: chord.length, z: offset }
48
+ const dx = arc.center.x - chord.start.x
49
+ const dy = arc.center.y - chord.start.y
50
+ // Left of a counter-clockwise arc points at its centre, so an offset toward
51
+ // the left shrinks the radius there.
52
+ return {
53
+ kind: 'arc',
54
+ face,
55
+ length: chord.length,
56
+ cx: dx * chord.tangent.x + dy * chord.tangent.y,
57
+ cz: dx * chord.normal.x + dy * chord.normal.y,
58
+ radius: Math.max(1e-6, arc.radius - arc.direction * offset),
59
+ direction: arc.direction,
60
+ }
61
+ }
62
+
63
+ const Y_AXIS = new Vector3(0, 1, 0)
64
+ const UNIT_SCALE = new Vector3(1, 1, 1)
65
+
66
+ /** Wall-local → parent (level) matrix, the transform the wall mesh renders with. */
67
+ export function wallLocalMatrix(
68
+ wall: Pick<WallNode, 'start' | 'end'>,
69
+ baseElevation: number,
70
+ target = new Matrix4(),
71
+ ): Matrix4 {
72
+ const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
73
+ return target.compose(
74
+ new Vector3(wall.start[0], baseElevation, wall.start[1]),
75
+ new Quaternion().setFromAxisAngle(Y_AXIS, -angle),
76
+ UNIT_SCALE,
77
+ )
78
+ }
79
+
80
+ export type FaceSurfacePoint = {
81
+ x: number
82
+ z: number
83
+ /** Outward normal of this face, wall-local XZ. */
84
+ nx: number
85
+ nz: number
86
+ /** Unit tangent toward increasing u, wall-local XZ. */
87
+ tx: number
88
+ tz: number
89
+ }
90
+
91
+ /** The point on the face above chord station `u`, with its outward normal and tangent. */
92
+ export function faceSurfacePoint(surface: WallFaceSurface, u: number): FaceSurfacePoint {
93
+ const outward = surface.face === 'a' ? 1 : -1
94
+ if (surface.kind === 'plane') {
95
+ return { x: u, z: surface.z, nx: 0, nz: outward, tx: 1, tz: 0 }
96
+ }
97
+ const du = u - surface.cx
98
+ const root = Math.sqrt(Math.max(0, surface.radius * surface.radius - du * du))
99
+ const z = surface.cz - surface.direction * root
100
+ // Left normal of the curve: toward the centre on a counter-clockwise arc.
101
+ const lx = (surface.direction * (surface.cx - u)) / surface.radius
102
+ const lz = (surface.direction * (surface.cz - z)) / surface.radius
103
+ const length = Math.hypot(lx, lz) || 1
104
+ const nx = (lx / length) * outward
105
+ const nz = (lz / length) * outward
106
+ return { x: u, z, nx, nz, tx: lz / length, tz: -lx / length }
107
+ }
108
+
109
+ type Vec3 = { x: number; y: number; z: number }
110
+
111
+ /**
112
+ * Where a wall-local ray meets the face: `u` along the chord and local `y`.
113
+ * Null when the ray runs parallel to the face or misses it.
114
+ */
115
+ export function intersectFaceSurface(
116
+ surface: WallFaceSurface,
117
+ origin: Vec3,
118
+ direction: Vec3,
119
+ ): { u: number; y: number } | null {
120
+ if (surface.kind === 'plane') {
121
+ if (Math.abs(direction.z) < 1e-9) return null
122
+ const t = (surface.z - origin.z) / direction.z
123
+ if (!(t >= 0)) return null
124
+ return { u: origin.x + direction.x * t, y: origin.y + direction.y * t }
125
+ }
126
+ const ox = origin.x - surface.cx
127
+ const oz = origin.z - surface.cz
128
+ const a = direction.x * direction.x + direction.z * direction.z
129
+ if (a < 1e-12) return null
130
+ const b = 2 * (ox * direction.x + oz * direction.z)
131
+ const c = ox * ox + oz * oz - surface.radius * surface.radius
132
+ const discriminant = b * b - 4 * a * c
133
+ if (discriminant < 0) return null
134
+ const root = Math.sqrt(discriminant)
135
+ for (const t of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) {
136
+ if (t < 0) continue
137
+ const z = origin.z + direction.z * t
138
+ // Only the branch the wall bulges along is the face.
139
+ if ((z - surface.cz) * -surface.direction < -1e-9) continue
140
+ return { u: origin.x + direction.x * t, y: origin.y + direction.y * t }
141
+ }
142
+ return null
143
+ }
144
+
145
+ export type ResolvedRegionBounds = { u0: number; u1: number; v0: number; v1: number }
146
+
147
+ /** Region bounds with absent edges resolved to the face's edges. */
148
+ export function resolveRegionBounds(
149
+ region: Pick<WallFaceRegion, 'u0' | 'u1' | 'v0' | 'v1'>,
150
+ length: number,
151
+ faceHeight: number,
152
+ ): ResolvedRegionBounds {
153
+ return {
154
+ u0: Math.max(0, region.u0 ?? 0),
155
+ u1: Math.min(length, region.u1 ?? length),
156
+ v0: Math.max(0, region.v0 ?? 0),
157
+ v1: Math.min(faceHeight, region.v1 ?? faceHeight),
158
+ }
159
+ }
160
+
161
+ export function regionBoundsOf(region: WallFaceRegion): WallRegionBounds {
162
+ const bounds: WallRegionBounds = {}
163
+ for (const edge of REGION_EDGES) if (region[edge] !== undefined) bounds[edge] = region[edge]
164
+ return bounds
165
+ }
166
+
167
+ export type RegionBoundDragInput = {
168
+ region: WallFaceRegion
169
+ edge: RegionEdge
170
+ /** Unsnapped pointer value for the edge: metres along (u) or above the face base (v). */
171
+ raw: number
172
+ length: number
173
+ faceHeight: number
174
+ gridStep?: number | null
175
+ /** Openings' and other regions' bounds on this face (`wallRegionSnapTargets`); face edges are always targets. */
176
+ snapTargets?: readonly number[]
177
+ edgeSnap?: boolean
178
+ snapTolerance?: number
179
+ }
180
+
181
+ /**
182
+ * The region's bounds after dragging one edge. Snaps to the grid, then (when
183
+ * edge snapping is on) to the face edges and other regions within tolerance;
184
+ * clamps so the region keeps REGION_MIN_SIZE and stays on the face. An edge
185
+ * dragged onto the face edge becomes absent, so it follows the wall.
186
+ */
187
+ export function resolveRegionBoundDrag(input: RegionBoundDragInput): WallRegionBounds {
188
+ const { region, edge, raw, length, faceHeight } = input
189
+ const isU = edge[0] === 'u'
190
+ const extent = isU ? length : faceHeight
191
+ let value = raw
192
+ if (input.gridStep && input.gridStep > 0)
193
+ value = Math.round(raw / input.gridStep) * input.gridStep
194
+ if (input.edgeSnap) {
195
+ const tolerance = input.snapTolerance ?? REGION_EDGE_SNAP_TOLERANCE
196
+ let best: number | null = null
197
+ for (const target of [0, extent, ...(input.snapTargets ?? [])]) {
198
+ const distance = Math.abs(target - raw)
199
+ if (distance <= tolerance && (best === null || distance < Math.abs(best - raw))) {
200
+ best = target
201
+ }
202
+ }
203
+ if (best !== null) value = best
204
+ }
205
+
206
+ const current = resolveRegionBounds(region, length, faceHeight)
207
+ const low = edge === 'u0' || edge === 'v0'
208
+ const other = current[isU ? (low ? 'u1' : 'u0') : low ? 'v1' : 'v0']
209
+ const min = low ? 0 : Math.min(extent, other + REGION_MIN_SIZE)
210
+ const max = low ? Math.max(0, other - REGION_MIN_SIZE) : extent
211
+ value = Math.min(max, Math.max(min, value))
212
+
213
+ const bounds = regionBoundsOf(region)
214
+ const atFaceEdge = low ? value <= EDGE_EPSILON : value >= extent - EDGE_EPSILON
215
+ if (atFaceEdge) delete bounds[edge]
216
+ else bounds[edge] = value
217
+ return bounds
218
+ }
219
+
220
+ /** The region list with one region's bounds replaced (absent = to the edge). */
221
+ export function withRegionBounds(
222
+ regions: readonly WallFaceRegion[],
223
+ regionId: string,
224
+ bounds: WallRegionBounds,
225
+ ): WallFaceRegion[] {
226
+ return regions.map((region) => {
227
+ if (region.id !== regionId) return region
228
+ const { u0: _u0, u1: _u1, v0: _v0, v1: _v1, ...rest } = region
229
+ const next: WallFaceRegion = { ...rest }
230
+ for (const edge of REGION_EDGES) if (bounds[edge] !== undefined) next[edge] = bounds[edge]
231
+ return next
232
+ })
233
+ }
234
+
235
+ export type RegionHandlePlacement = {
236
+ edge: RegionEdge
237
+ position: [number, number, number]
238
+ /** Yaw turning local +x onto the face tangent. */
239
+ yaw: number
240
+ /** Direction the handle points in the face plane: 0 = +u, π/2 = up. */
241
+ tipAngle: number
242
+ }
243
+
244
+ const TIP_ANGLE: Record<RegionEdge, number> = {
245
+ u0: Math.PI,
246
+ u1: 0,
247
+ v0: -Math.PI / 2,
248
+ v1: Math.PI / 2,
249
+ }
250
+
251
+ function yawOf(point: FaceSurfacePoint) {
252
+ return Math.atan2(-point.tz, point.tx)
253
+ }
254
+
255
+ /**
256
+ * One handle per bounded edge: u edges at the region's mid-height, v edges at
257
+ * its mid-width, lifted `lift` off the face. `keep` holds an edge that is
258
+ * being dragged, so its handle survives being dragged onto the face edge.
259
+ */
260
+ export function regionHandlePlacements(
261
+ surface: WallFaceSurface,
262
+ region: WallFaceRegion,
263
+ faceHeight: number,
264
+ baseAt: (u: number) => number,
265
+ keep: RegionEdge | null = null,
266
+ lift = 0.01,
267
+ ): RegionHandlePlacement[] {
268
+ const bounds = resolveRegionBounds(region, surface.length, faceHeight)
269
+ const midU = (bounds.u0 + bounds.u1) / 2
270
+ const midV = (bounds.v0 + bounds.v1) / 2
271
+ const placements: RegionHandlePlacement[] = []
272
+ for (const edge of REGION_EDGES) {
273
+ if (region[edge] === undefined && edge !== keep) continue
274
+ const u = edge === 'u0' ? bounds.u0 : edge === 'u1' ? bounds.u1 : midU
275
+ const v = edge === 'v0' ? bounds.v0 : edge === 'v1' ? bounds.v1 : midV
276
+ const point = faceSurfacePoint(surface, u)
277
+ placements.push({
278
+ edge,
279
+ position: [point.x + point.nx * lift, baseAt(u) + v, point.z + point.nz * lift],
280
+ yaw: yawOf(point),
281
+ tipAngle: TIP_ANGLE[edge],
282
+ })
283
+ }
284
+ return placements
285
+ }
286
+
287
+ export type OutlinePoint = { position: [number, number, number]; normal: [number, number] }
288
+
289
+ /** Closed outline of a region on its face (counter-clockwise seen from outside), lifted off it. */
290
+ export function regionOutline(
291
+ surface: WallFaceSurface,
292
+ region: WallFaceRegion,
293
+ faceHeight: number,
294
+ baseAt: (u: number) => number,
295
+ lift = 0.004,
296
+ ): OutlinePoint[] {
297
+ const bounds = resolveRegionBounds(region, surface.length, faceHeight)
298
+ const width = bounds.u1 - bounds.u0
299
+ const segments = surface.kind === 'arc' ? Math.min(48, Math.max(2, Math.ceil(width / 0.2))) : 1
300
+ const at = (u: number, v: number): OutlinePoint => {
301
+ const point = faceSurfacePoint(surface, u)
302
+ return {
303
+ position: [point.x + point.nx * lift, baseAt(u) + v, point.z + point.nz * lift],
304
+ normal: [point.nx, point.nz],
305
+ }
306
+ }
307
+ const stations = Array.from(
308
+ { length: segments + 1 },
309
+ (_, index) => bounds.u0 + (width * index) / segments,
310
+ )
311
+ return [
312
+ ...stations.map((u) => at(u, bounds.v0)),
313
+ ...stations.reverse().map((u) => at(u, bounds.v1)),
314
+ ]
315
+ }
316
+
317
+ /**
318
+ * Triangles for a ribbon of half-width `halfWidth` running along a closed
319
+ * outline, lying in the face. Each segment is extended by the half-width so
320
+ * corners close.
321
+ */
322
+ export function buildOutlineRibbon(
323
+ points: readonly OutlinePoint[],
324
+ halfWidth: number,
325
+ ): Float32Array {
326
+ const out: number[] = []
327
+ const count = points.length
328
+ for (let index = 0; index < count; index += 1) {
329
+ const a = points[index]!
330
+ const b = points[(index + 1) % count]!
331
+ const dx = b.position[0] - a.position[0]
332
+ const dy = b.position[1] - a.position[1]
333
+ const dz = b.position[2] - a.position[2]
334
+ const length = Math.hypot(dx, dy, dz)
335
+ if (length < 1e-9) continue
336
+ const ux = dx / length
337
+ const uy = dy / length
338
+ const uz = dz / length
339
+ const nx = (a.normal[0] + b.normal[0]) / 2
340
+ const nz = (a.normal[1] + b.normal[1]) / 2
341
+ // In-face perpendicular: normal × direction.
342
+ let wx = -nz * uy
343
+ let wy = nz * ux - nx * uz
344
+ let wz = nx * uy
345
+ const wLength = Math.hypot(wx, wy, wz) || 1
346
+ wx = (wx / wLength) * halfWidth
347
+ wy = (wy / wLength) * halfWidth
348
+ wz = (wz / wLength) * halfWidth
349
+ const p0 = [
350
+ a.position[0] - ux * halfWidth,
351
+ a.position[1] - uy * halfWidth,
352
+ a.position[2] - uz * halfWidth,
353
+ ]
354
+ const p1 = [
355
+ b.position[0] + ux * halfWidth,
356
+ b.position[1] + uy * halfWidth,
357
+ b.position[2] + uz * halfWidth,
358
+ ]
359
+ const q = [
360
+ [p0[0]! - wx, p0[1]! - wy, p0[2]! - wz],
361
+ [p0[0]! + wx, p0[1]! + wy, p0[2]! + wz],
362
+ [p1[0]! + wx, p1[1]! + wy, p1[2]! + wz],
363
+ [p1[0]! - wx, p1[1]! - wy, p1[2]! - wz],
364
+ ]
365
+ for (const corner of [0, 1, 2, 0, 2, 3]) out.push(...q[corner]!)
366
+ }
367
+ return new Float32Array(out)
368
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type SceneMaterial,
5
+ type SceneMaterialId,
6
+ WALL_FACE_REGION_LIMIT,
7
+ type WallFaceRegion,
8
+ WallNode,
9
+ } from '@pascal-app/core'
10
+ import {
11
+ describeRegionFinish,
12
+ formatRegionBounds,
13
+ wallRegionCapNotice,
14
+ wallRegionFaceLabels,
15
+ wallRegionRows,
16
+ } from './wall-region-list'
17
+
18
+ const LEVEL = 'level_region-list'
19
+ const SCENE_ID = 'scene-material_region-list' as SceneMaterialId
20
+ const materials = {
21
+ [SCENE_ID]: {
22
+ id: SCENE_ID,
23
+ name: 'Sage green',
24
+ material: { properties: { color: '#8fa98a' } },
25
+ } as SceneMaterial,
26
+ }
27
+
28
+ function wall(id: string, start: [number, number], end: [number, number]) {
29
+ return WallNode.parse({ id, parentId: LEVEL, start, end })
30
+ }
31
+
32
+ function nodesOf(walls: WallNode[]) {
33
+ return Object.fromEntries(walls.map((node) => [node.id, node])) as Record<string, AnyNode>
34
+ }
35
+
36
+ describe('bounds text', () => {
37
+ test('reads each axis in the viewer unit; absent bounds run to the edge', () => {
38
+ expect(formatRegionBounds({ v1: 0.9 }, 'metric')).toBe('0–0.90 m high')
39
+ expect(formatRegionBounds({ u0: 1.2, u1: 2.4 }, 'metric')).toBe('1.20–2.40 m along')
40
+ expect(formatRegionBounds({ u0: 1.2, u1: 2.4, v0: 0.9, v1: 2.1 }, 'metric')).toBe(
41
+ '1.20–2.40 m along, 0.90–2.10 m high',
42
+ )
43
+ expect(formatRegionBounds({ v0: 0.9 }, 'metric')).toBe('Above 0.90 m')
44
+ expect(formatRegionBounds({ u0: 1.5 }, 'metric')).toBe('From 1.50 m along')
45
+ expect(formatRegionBounds({}, 'metric')).toBe('Whole face')
46
+ })
47
+
48
+ test('imperial converts to feet', () => {
49
+ expect(formatRegionBounds({ v1: 0.9144 }, 'imperial')).toBe('0–3.00 ft high')
50
+ })
51
+ })
52
+
53
+ describe('face labels', () => {
54
+ test('a lone wall reads Face A / Face B', () => {
55
+ const lone = wall('wall_lone', [0, 0], [4, 0])
56
+ expect(wallRegionFaceLabels(nodesOf([lone]), lone.id)).toEqual({ a: 'Face A', b: 'Face B' })
57
+ expect(wallRegionFaceLabels({}, 'wall_missing')).toEqual({ a: 'Face A', b: 'Face B' })
58
+ })
59
+
60
+ test('a room wall names its inside and outside faces', () => {
61
+ const walls = [
62
+ wall('wall_s', [0, 0], [4, 0]),
63
+ wall('wall_e', [4, 0], [4, 3]),
64
+ wall('wall_n', [4, 3], [0, 3]),
65
+ wall('wall_w', [0, 3], [0, 0]),
66
+ ]
67
+ const labels = wallRegionFaceLabels(nodesOf(walls), 'wall_s')
68
+ expect(new Set(Object.values(labels))).toEqual(new Set(['Inside', 'Outside']))
69
+ // Drawn counter-clockwise, the room sits on the left: face a.
70
+ expect(labels.a).toBe('Inside')
71
+ })
72
+ })
73
+
74
+ describe('finish', () => {
75
+ test('a scene material shows its name and colour', () => {
76
+ expect(describeRegionFinish(`scene:${SCENE_ID}`, materials)).toEqual({
77
+ name: 'Sage green',
78
+ color: '#8fa98a',
79
+ })
80
+ })
81
+
82
+ test('unknown refs fall back to a neutral swatch', () => {
83
+ expect(describeRegionFinish('scene:missing', {}).name).toBe('Custom finish')
84
+ expect(describeRegionFinish('not-a-ref', {}).name).toBe('Custom finish')
85
+ expect(describeRegionFinish('library:no-such-material', {}).name).toBe('Library finish')
86
+ })
87
+ })
88
+
89
+ describe('rows', () => {
90
+ test('one row per region, face a first, with label, finish and bounds', () => {
91
+ const faceRegions: WallFaceRegion[] = [
92
+ { id: 'r1', face: 'b', v1: 0.9, finish: `scene:${SCENE_ID}` },
93
+ { id: 'r2', face: 'a', u0: 1.2, u1: 2.4, finish: `scene:${SCENE_ID}` },
94
+ ]
95
+ const rows = wallRegionRows({ faceRegions }, { a: 'Inside', b: 'Outside' }, materials, 'metric')
96
+ expect(rows.map(({ id, faceLabel, boundsText }) => ({ id, faceLabel, boundsText }))).toEqual([
97
+ { id: 'r2', faceLabel: 'Inside', boundsText: '1.20–2.40 m along' },
98
+ { id: 'r1', faceLabel: 'Outside', boundsText: '0–0.90 m high' },
99
+ ])
100
+ expect(rows[0]!.finish.name).toBe('Sage green')
101
+ })
102
+
103
+ test('the cap notice appears only when a face is full', () => {
104
+ const full = (face: 'a' | 'b') =>
105
+ Array.from({ length: WALL_FACE_REGION_LIMIT }, (_, index) => ({
106
+ id: `${face}${index}`,
107
+ face,
108
+ finish: 'x',
109
+ }))
110
+ const labels = { a: 'Face A', b: 'Face B' }
111
+ expect(wallRegionCapNotice({ faceRegions: full('a').slice(1) }, labels)).toBeNull()
112
+ expect(wallRegionCapNotice({ faceRegions: full('b') }, labels)).toBe(
113
+ `Face B is full: up to ${WALL_FACE_REGION_LIMIT} regions per face`,
114
+ )
115
+ expect(wallRegionCapNotice({ faceRegions: [...full('a'), ...full('b')] }, labels)).toBe(
116
+ `Up to ${WALL_FACE_REGION_LIMIT} regions per face`,
117
+ )
118
+ })
119
+ })
@@ -0,0 +1,136 @@
1
+ import {
2
+ type AnyNode,
3
+ getCatalogMaterialById,
4
+ parseMaterialRef,
5
+ roomSideFaces,
6
+ type SceneMaterial,
7
+ type SceneMaterialId,
8
+ WALL_FACE_REGION_LIMIT,
9
+ type WallFace,
10
+ type WallFaceRegion,
11
+ type WallNode,
12
+ } from '@pascal-app/core'
13
+ import { getLinearUnitLabel, type LinearUnit, metersToLinearUnit } from './measurements'
14
+
15
+ // Rows for the wall panel's "Paint regions" list: finish swatch + name, the
16
+ // face in room terms when the wall has a room on exactly one side, and the
17
+ // bounds in the viewer unit.
18
+
19
+ const FALLBACK_SWATCH_COLOR = '#d4d4d8'
20
+
21
+ export type WallRegionFinish = { name: string; color: string; imageUrl?: string }
22
+
23
+ export type WallRegionRow = {
24
+ id: string
25
+ face: WallFace
26
+ faceLabel: string
27
+ finish: WallRegionFinish
28
+ boundsText: string
29
+ }
30
+
31
+ /** "Inside" / "Outside" when a room sits on exactly one side of the wall, else "Face A" / "Face B". */
32
+ export function wallRegionFaceLabels(
33
+ nodes: Readonly<Record<string, AnyNode>>,
34
+ wallId: string,
35
+ ): Record<WallFace, string> {
36
+ try {
37
+ const sides = roomSideFaces(nodes as Parameters<typeof roomSideFaces>[0], wallId)
38
+ if (sides.inside && sides.outside) {
39
+ return {
40
+ [sides.inside]: 'Inside',
41
+ [sides.outside]: 'Outside',
42
+ } as Record<WallFace, string>
43
+ }
44
+ } catch {
45
+ // Not a wall (any more): fall through to the neutral labels.
46
+ }
47
+ return { a: 'Face A', b: 'Face B' }
48
+ }
49
+
50
+ /** Name and swatch for a region's finish ref (library or scene material). */
51
+ export function describeRegionFinish(
52
+ ref: string,
53
+ materials: Readonly<Record<SceneMaterialId, SceneMaterial>>,
54
+ ): WallRegionFinish {
55
+ const parsed = parseMaterialRef(ref)
56
+ if (parsed?.kind === 'scene') {
57
+ const scene = materials[parsed.id as SceneMaterialId]
58
+ return {
59
+ name: scene?.name ?? 'Custom finish',
60
+ color: scene?.material.properties?.color ?? FALLBACK_SWATCH_COLOR,
61
+ ...(scene?.material.texture?.url ? { imageUrl: scene.material.texture.url } : {}),
62
+ }
63
+ }
64
+ if (parsed?.kind === 'library') {
65
+ const item = getCatalogMaterialById(parsed.id)
66
+ const imageUrl = item?.previewThumbnailUrl ?? item?.preset.maps.albedoMap
67
+ return {
68
+ name: item?.label ?? 'Library finish',
69
+ color: item?.previewColor ?? item?.preset.mapProperties.color ?? FALLBACK_SWATCH_COLOR,
70
+ ...(imageUrl ? { imageUrl } : {}),
71
+ }
72
+ }
73
+ return { name: 'Custom finish', color: FALLBACK_SWATCH_COLOR }
74
+ }
75
+
76
+ function range(low: number | undefined, high: number | undefined, unit: LinearUnit, axis: string) {
77
+ const value = (meters: number) => metersToLinearUnit(meters, unit).toFixed(2)
78
+ const label = getLinearUnitLabel(unit)
79
+ if (low === undefined && high === undefined) return null
80
+ if (low === undefined) return `0–${value(high!)} ${label} ${axis}`
81
+ if (high === undefined)
82
+ return axis === 'high' ? `Above ${value(low)} ${label}` : `From ${value(low)} ${label} along`
83
+ return `${value(low)}–${value(high)} ${label} ${axis}`
84
+ }
85
+
86
+ /** "1.20–2.40 m along, 0–0.90 m high"; absent bounds run to the face edge. */
87
+ export function formatRegionBounds(
88
+ region: Pick<WallFaceRegion, 'u0' | 'u1' | 'v0' | 'v1'>,
89
+ unit: LinearUnit,
90
+ ): string {
91
+ const parts = [
92
+ range(region.u0, region.u1, unit, 'along'),
93
+ range(region.v0, region.v1, unit, 'high'),
94
+ ].filter((part): part is string => part !== null)
95
+ return parts.length > 0 ? parts.join(', ') : 'Whole face'
96
+ }
97
+
98
+ export function wallRegionRows(
99
+ wall: Pick<WallNode, 'faceRegions'>,
100
+ faceLabels: Record<WallFace, string>,
101
+ materials: Readonly<Record<SceneMaterialId, SceneMaterial>>,
102
+ unit: LinearUnit,
103
+ ): WallRegionRow[] {
104
+ const regions = wall.faceRegions ?? []
105
+ return (['a', 'b'] as const).flatMap((face) =>
106
+ regions
107
+ .filter((region) => region.face === face)
108
+ .map((region) => ({
109
+ id: region.id,
110
+ face,
111
+ faceLabel: faceLabels[face],
112
+ finish: describeRegionFinish(region.finish, materials),
113
+ boundsText: formatRegionBounds(region, unit),
114
+ })),
115
+ )
116
+ }
117
+
118
+ /** Faces that already hold the maximum number of regions. */
119
+ export function fullWallFaces(wall: Pick<WallNode, 'faceRegions'>): WallFace[] {
120
+ const regions = wall.faceRegions ?? []
121
+ return (['a', 'b'] as const).filter(
122
+ (face) => regions.filter((region) => region.face === face).length >= WALL_FACE_REGION_LIMIT,
123
+ )
124
+ }
125
+
126
+ /** The cap notice under the list, naming the full face when only one is. */
127
+ export function wallRegionCapNotice(
128
+ wall: Pick<WallNode, 'faceRegions'>,
129
+ faceLabels: Record<WallFace, string>,
130
+ ): string | null {
131
+ const full = fullWallFaces(wall)
132
+ const cap = `up to ${WALL_FACE_REGION_LIMIT} regions per face`
133
+ if (full.length === 0) return null
134
+ if (full.length === 2) return `Up to ${WALL_FACE_REGION_LIMIT} regions per face`
135
+ return `${faceLabels[full[0]!]} is full: ${cap}`
136
+ }