@pascal-app/editor 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,192 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ ceilingRegionsOwner,
5
+ generateId,
6
+ generateSceneMaterialId,
7
+ type MaterialSchema,
8
+ type SceneMaterial,
9
+ type SceneMaterialId,
10
+ toSceneMaterialRef,
11
+ useScene,
12
+ WALL_FACE_REGION_LIMIT,
13
+ type WallFace,
14
+ type WallFaceRegion,
15
+ } from '@pascal-app/core'
16
+
17
+ // Data writes for the paint tool's region sub-modes: a wall face region
18
+ // (`wall.faceRegions`), a room floor region (`zone.floor.regions`) or a ceiling
19
+ // region (`zone.ceiling.regions` for a room's ceiling, `ceiling.regions` for a
20
+ // manual one). Every
21
+ // write is one scene update, so one undo step; a one-off colour mints one
22
+ // shared scene material, the same way the paint fan-out does.
23
+
24
+ export const WALL_REGION_CAP_MESSAGE = `Up to ${WALL_FACE_REGION_LIMIT} regions per wall face`
25
+
26
+ type Resolved = { ref: string; newSceneMaterial: SceneMaterial | null }
27
+
28
+ function sameMaterial(a: unknown, b: unknown) {
29
+ return JSON.stringify(a) === JSON.stringify(b)
30
+ }
31
+
32
+ /** The finish ref for the paint material, minting a scene material when it is a one-off colour. */
33
+ export function resolvePaintRegionFinish(
34
+ materials: Record<SceneMaterialId, SceneMaterial>,
35
+ material: MaterialSchema | undefined,
36
+ materialPreset: string | undefined,
37
+ ): Resolved | null {
38
+ if (materialPreset) return { ref: materialPreset, newSceneMaterial: null }
39
+ if (!material) return null
40
+ const existing = Object.values(materials).find((scene) => sameMaterial(scene.material, material))
41
+ if (existing) return { ref: toSceneMaterialRef(existing.id), newSceneMaterial: null }
42
+ const id = generateSceneMaterialId()
43
+ return {
44
+ ref: toSceneMaterialRef(id),
45
+ newSceneMaterial: { id, name: `Material ${Object.keys(materials).length + 1}`, material },
46
+ }
47
+ }
48
+
49
+ export type WallRegionBounds = Pick<WallFaceRegion, 'u0' | 'u1' | 'v0' | 'v1'>
50
+ export type RegionWriteResult = { ok: true; id: string } | { ok: false; message: string }
51
+
52
+ /** Whether a face can take another region. */
53
+ export function wallFaceHasRoom(
54
+ wall: { faceRegions?: WallFaceRegion[] } | undefined,
55
+ face: WallFace,
56
+ ): boolean {
57
+ return (
58
+ (wall?.faceRegions ?? []).filter((region) => region.face === face).length <
59
+ WALL_FACE_REGION_LIMIT
60
+ )
61
+ }
62
+
63
+ /** Adds a painted region to a wall face (later regions win). */
64
+ export function addWallRegion(
65
+ wallId: string,
66
+ face: WallFace,
67
+ bounds: WallRegionBounds,
68
+ paint: { material?: MaterialSchema; materialPreset?: string },
69
+ ): RegionWriteResult {
70
+ const state = useScene.getState()
71
+ const wall = state.nodes[wallId as AnyNodeId]
72
+ if (wall?.type !== 'wall') return { ok: false, message: 'Select a wall' }
73
+ if (!wallFaceHasRoom(wall, face)) return { ok: false, message: WALL_REGION_CAP_MESSAGE }
74
+ const resolved = resolvePaintRegionFinish(state.materials, paint.material, paint.materialPreset)
75
+ if (!resolved) return { ok: false, message: 'Pick a material first' }
76
+ const id = generateId('region')
77
+ const region: WallFaceRegion = {
78
+ id,
79
+ face,
80
+ ...Object.fromEntries(Object.entries(bounds).filter(([, value]) => value !== undefined)),
81
+ finish: resolved.ref,
82
+ }
83
+ writeNode(
84
+ wall.id,
85
+ { faceRegions: [...(wall.faceRegions ?? []), region] },
86
+ resolved.newSceneMaterial,
87
+ )
88
+ return { ok: true, id }
89
+ }
90
+
91
+ /** Replaces a region's bounds (absent = to the edge). */
92
+ export function updateWallRegion(wallId: string, regionId: string, bounds: WallRegionBounds) {
93
+ const wall = useScene.getState().nodes[wallId as AnyNodeId]
94
+ if (wall?.type !== 'wall') return false
95
+ const regions = wall.faceRegions ?? []
96
+ if (!regions.some((region) => region.id === regionId)) return false
97
+ writeNode(wall.id, {
98
+ faceRegions: regions.map((region) => {
99
+ if (region.id !== regionId) return region
100
+ const { u0: _u0, u1: _u1, v0: _v0, v1: _v1, ...rest } = region
101
+ return {
102
+ ...rest,
103
+ ...Object.fromEntries(Object.entries(bounds).filter(([, value]) => value !== undefined)),
104
+ } as WallFaceRegion
105
+ }),
106
+ })
107
+ return true
108
+ }
109
+
110
+ export function removeWallRegion(wallId: string, regionId: string) {
111
+ const wall = useScene.getState().nodes[wallId as AnyNodeId]
112
+ if (wall?.type !== 'wall') return false
113
+ const regions = wall.faceRegions ?? []
114
+ if (!regions.some((region) => region.id === regionId)) return false
115
+ const next = regions.filter((region) => region.id !== regionId)
116
+ writeNode(wall.id, { faceRegions: next.length ? next : undefined })
117
+ return true
118
+ }
119
+
120
+ /** Adds a painted region to a room floor (a polygon in level XZ, later wins). */
121
+ export function addFloorRegion(
122
+ zoneId: string,
123
+ polygon: [number, number][],
124
+ paint: { material?: MaterialSchema; materialPreset?: string },
125
+ ): RegionWriteResult {
126
+ const state = useScene.getState()
127
+ const zone = state.nodes[zoneId as AnyNodeId]
128
+ if (zone?.type !== 'zone') return { ok: false, message: 'Select a room' }
129
+ if (polygon.length < 3) return { ok: false, message: 'Draw at least three points' }
130
+ const resolved = resolvePaintRegionFinish(state.materials, paint.material, paint.materialPreset)
131
+ if (!resolved) return { ok: false, message: 'Pick a material first' }
132
+ const id = generateId('region')
133
+ const floor = { ...zone.floor }
134
+ floor.regions = [...(floor.regions ?? []), { id, polygon, finish: resolved.ref }]
135
+ writeNode(zone.id, { floor }, resolved.newSceneMaterial)
136
+ return { ok: true, id }
137
+ }
138
+
139
+ /**
140
+ * Adds a painted region to a ceiling's underside (a polygon in level XZ, later
141
+ * wins). An automatic ceiling is rebuilt from its room, so its room keeps it.
142
+ */
143
+ export function addCeilingRegion(
144
+ ceilingId: string,
145
+ polygon: [number, number][],
146
+ paint: { material?: MaterialSchema; materialPreset?: string },
147
+ ): RegionWriteResult {
148
+ const state = useScene.getState()
149
+ const ceiling = state.nodes[ceilingId as AnyNodeId]
150
+ if (ceiling?.type !== 'ceiling') return { ok: false, message: 'Select a ceiling' }
151
+ if (polygon.length < 3) return { ok: false, message: 'Draw at least three points' }
152
+ const resolved = resolvePaintRegionFinish(state.materials, paint.material, paint.materialPreset)
153
+ if (!resolved) return { ok: false, message: 'Pick a material first' }
154
+ const owner = ceilingRegionsOwner(ceiling)
155
+ const node = state.nodes[owner.id as AnyNodeId]
156
+ const id = generateId('region')
157
+ const region = { id, polygon, finish: resolved.ref }
158
+ if (node?.type === 'zone')
159
+ writeNode(
160
+ node.id,
161
+ { ceiling: { ...node.ceiling, regions: [...(node.ceiling?.regions ?? []), region] } },
162
+ resolved.newSceneMaterial,
163
+ )
164
+ else if (node?.type === 'ceiling')
165
+ writeNode(node.id, { regions: [...(node.regions ?? []), region] }, resolved.newSceneMaterial)
166
+ else return { ok: false, message: 'Select a room' }
167
+ useScene.getState().markDirty(ceiling.id as AnyNodeId)
168
+ return { ok: true, id }
169
+ }
170
+
171
+ function writeNode(id: string, data: Partial<AnyNode>, newSceneMaterial?: SceneMaterial | null) {
172
+ useScene.setState((current) => {
173
+ if (current.readOnly) return current
174
+ const node = current.nodes[id as AnyNodeId]
175
+ if (!node) return current
176
+ const next = { ...node, ...data } as Record<string, unknown>
177
+ for (const [key, value] of Object.entries(data)) if (value === undefined) delete next[key]
178
+ const nodes = { ...current.nodes, [id]: next as AnyNode }
179
+ return {
180
+ nodes,
181
+ ...(newSceneMaterial
182
+ ? {
183
+ materials: {
184
+ ...current.materials,
185
+ [newSceneMaterial.id as SceneMaterialId]: newSceneMaterial,
186
+ },
187
+ }
188
+ : {}),
189
+ }
190
+ })
191
+ useScene.getState().markDirty(id as AnyNodeId)
192
+ }
@@ -1,18 +1,30 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
4
6
  detectSpacesForLevel,
5
7
  type ItemNode,
8
+ nodeRegistry,
9
+ registerNode,
6
10
  type SlabNode,
7
11
  type Space,
12
+ useScene,
8
13
  type WallNode,
14
+ type ZoneNode,
9
15
  } from '@pascal-app/core'
16
+ import { z } from 'zod'
10
17
  import {
11
18
  availablePaintScopes,
19
+ commitPaintScopeFanout,
12
20
  cyclePaintScope,
21
+ effectivePaintScope,
13
22
  type PaintHoverInfo,
14
23
  type PaintScope,
24
+ paintHoverInfo,
15
25
  paintScopeLabel,
26
+ paintScopeRole,
27
+ paintSurfaceLabel,
16
28
  resolvePaintScopeTargets,
17
29
  type WallPaintHit,
18
30
  } from './paint-scope'
@@ -188,51 +200,43 @@ describe('resolvePaintScopeTargets', () => {
188
200
 
189
201
  const leftRoom = resolve({
190
202
  node: shared,
191
- role: 'interior',
203
+ role: 'a',
192
204
  scope: 'room',
193
205
  nodes: walls,
194
206
  spaces,
195
207
  wallHit: { face: 'front', point: [3.9, 2] },
196
208
  })
197
- expect(keys(leftRoom).sort()).toEqual([
198
- 'bottom-left:interior',
199
- 'left:interior',
200
- 'shared:interior',
201
- 'top-left:interior',
202
- ])
209
+ // Without zones the detected space drives the spread; each wall paints the
210
+ // physical face it turns to the room.
211
+ expect(keys(leftRoom).sort()).toEqual(['bottom-left:a', 'left:a', 'shared:a', 'top-left:a'])
203
212
 
204
213
  const rightRoom = resolve({
205
214
  node: shared,
206
- role: 'exterior',
215
+ role: 'b',
207
216
  scope: 'room',
208
217
  nodes: walls,
209
218
  spaces,
210
219
  wallHit: { face: 'back', point: [4.1, 2] },
211
220
  })
212
- expect(keys(rightRoom).sort()).toEqual([
213
- 'bottom-right:interior',
214
- 'right:interior',
215
- 'shared:exterior',
216
- 'top-right:interior',
217
- ])
221
+ expect(keys(rightRoom).sort()).toEqual(['bottom-right:a', 'right:a', 'shared:b', 'top-right:a'])
218
222
  })
219
223
 
220
- it('wall room preserves the vertical band while mapping each boundary face side', () => {
224
+ it('wall room keeps the trim while mapping each boundary face side', () => {
221
225
  const { walls, spaces } = adjacentRooms()
222
226
  const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
223
227
  const result = resolve({
224
228
  node: shared,
225
- role: 'lowerExterior',
229
+ role: 'bSkirting',
226
230
  scope: 'room',
227
231
  nodes: walls,
228
232
  spaces,
229
233
  wallHit: { face: 'back', point: [4.1, 2] },
230
234
  })
231
235
  expect(keys(result).sort()).toEqual([
232
- 'bottom-right:lowerInterior',
233
- 'right:lowerInterior',
234
- 'shared:lowerExterior',
235
- 'top-right:lowerInterior',
236
+ 'bottom-right:aSkirting',
237
+ 'right:aSkirting',
238
+ 'shared:bSkirting',
239
+ 'top-right:aSkirting',
236
240
  ])
237
241
  })
238
242
 
@@ -245,15 +249,16 @@ describe('resolvePaintScopeTargets', () => {
245
249
  const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
246
250
  const result = resolve({
247
251
  node: shared,
248
- role: 'exterior',
252
+ role: 'b',
249
253
  scope: 'room',
250
254
  nodes: walls,
251
255
  spaces,
252
256
  wallHit: { face: 'back', point: [4.1, 2] },
253
257
  })
254
258
 
255
- expect(keys(result)).toContain('top-right:exterior')
256
- expect(keys(result)).not.toContain('top-right:interior')
259
+ // Reversed, top-right turns its b face to the right room.
260
+ expect(keys(result)).toContain('top-right:b')
261
+ expect(keys(result)).not.toContain('top-right:a')
257
262
  })
258
263
 
259
264
  it('wall room excludes duplicate geometry and spaces from another level', () => {
@@ -267,7 +272,7 @@ describe('resolvePaintScopeTargets', () => {
267
272
  const shared = levelA.walls.find((candidate) => String(candidate.id) === 'shared')!
268
273
  const result = resolve({
269
274
  node: shared,
270
- role: 'interior',
275
+ role: 'a',
271
276
  scope: 'room',
272
277
  nodes: [...levelA.walls, ...levelBWalls],
273
278
  spaces: [...levelA.spaces, ...otherSpaces],
@@ -291,7 +296,7 @@ describe('resolvePaintScopeTargets', () => {
291
296
 
292
297
  const leftBay = resolve({
293
298
  node: long,
294
- role: 'exterior',
299
+ role: 'b',
295
300
  scope: 'room',
296
301
  nodes: walls,
297
302
  spaces,
@@ -299,7 +304,7 @@ describe('resolvePaintScopeTargets', () => {
299
304
  })
300
305
  const rightBay = resolve({
301
306
  node: long,
302
- role: 'exterior',
307
+ role: 'b',
303
308
  scope: 'room',
304
309
  nodes: walls,
305
310
  spaces,
@@ -314,9 +319,9 @@ describe('resolvePaintScopeTargets', () => {
314
319
 
315
320
  it('wall room with no enclosing space falls back to single', () => {
316
321
  const w1 = wall('w1', [0, 0], [4, 0])
317
- expect(
318
- keys(resolve({ node: w1, role: 'interior', scope: 'room', nodes: [w1], spaces: [] })),
319
- ).toEqual(['w1:interior'])
322
+ expect(keys(resolve({ node: w1, role: 'a', scope: 'room', nodes: [w1], spaces: [] }))).toEqual([
323
+ 'w1:a',
324
+ ])
320
325
  })
321
326
 
322
327
  it('wall room paints the connected exterior envelope from an exterior face', () => {
@@ -331,14 +336,14 @@ describe('resolvePaintScopeTargets', () => {
331
336
  keys(
332
337
  resolve({
333
338
  node: walls[0]!,
334
- role: 'exterior',
339
+ role: 'b',
335
340
  scope: 'room',
336
341
  nodes: walls,
337
342
  spaces,
338
343
  wallHit: { face: 'back', point: [2, -0.1] },
339
344
  }),
340
345
  ).sort(),
341
- ).toEqual(['bottom:exterior', 'left:exterior', 'right:exterior', 'top:exterior'])
346
+ ).toEqual(['bottom:b', 'left:b', 'right:b', 'top:b'])
342
347
  })
343
348
 
344
349
  it('wall room does not cross to a disconnected exterior envelope', () => {
@@ -358,7 +363,7 @@ describe('resolvePaintScopeTargets', () => {
358
363
  const spaces = detectSpacesForLevel('l1', walls).spaces
359
364
  const result = resolve({
360
365
  node: first[0]!,
361
- role: 'exterior',
366
+ role: 'b',
362
367
  scope: 'room',
363
368
  nodes: walls,
364
369
  spaces,
@@ -374,7 +379,7 @@ describe('resolvePaintScopeTargets', () => {
374
379
  const bottomLeft = walls.find((candidate) => String(candidate.id) === 'bottom-left')!
375
380
  const result = resolve({
376
381
  node: bottomLeft,
377
- role: 'exterior',
382
+ role: 'b',
378
383
  scope: 'room',
379
384
  nodes: walls,
380
385
  spaces,
@@ -398,14 +403,14 @@ describe('resolvePaintScopeTargets', () => {
398
403
  const spaces = detectSpacesForLevel('l1', walls).spaces
399
404
  const result = resolve({
400
405
  node: long,
401
- role: 'exterior',
406
+ role: 'b',
402
407
  scope: 'room',
403
408
  nodes: walls,
404
409
  spaces,
405
410
  wallHit: { face: 'back', point: [2, -0.1] },
406
411
  })
407
412
 
408
- expect(keys(result).filter((key) => key === 'long:exterior')).toHaveLength(1)
413
+ expect(keys(result).filter((key) => key === 'long:b')).toHaveLength(1)
409
414
  expect(keys(result).some((key) => key.startsWith('divider:'))).toBe(false)
410
415
  expect(result).toHaveLength(5)
411
416
  })
@@ -508,3 +513,280 @@ describe('resolvePaintScopeTargets', () => {
508
513
  expect(keys(result)).toEqual(['ground:surface'])
509
514
  })
510
515
  })
516
+
517
+ describe('wall room scope inside a zone', () => {
518
+ function zonedRooms() {
519
+ const { walls, spaces } = adjacentRooms()
520
+ const level = { id: 'l1', type: 'level', children: [] as string[] } as unknown as AnyNode
521
+ // The fixture walls carry plain ids, so the zones skip schema parsing.
522
+ const zone = (id: string, polygon: Array<[number, number]>, boundary: string[]) =>
523
+ ({
524
+ id,
525
+ type: 'zone',
526
+ name: id,
527
+ parentId: 'l1',
528
+ polygon,
529
+ holes: [],
530
+ boundaryWallIds: boundary,
531
+ }) as unknown as ZoneNode
532
+ const zones = [
533
+ zone(
534
+ 'zone_left',
535
+ [
536
+ [0, 0],
537
+ [4, 0],
538
+ [4, 4],
539
+ [0, 4],
540
+ ],
541
+ ['bottom-left', 'shared', 'top-left', 'left'],
542
+ ),
543
+ zone(
544
+ 'zone_right',
545
+ [
546
+ [4, 0],
547
+ [8, 0],
548
+ [8, 4],
549
+ [4, 4],
550
+ ],
551
+ ['bottom-right', 'right', 'top-right', 'shared'],
552
+ ),
553
+ ]
554
+ ;(level as unknown as { children: string[] }).children = [
555
+ ...walls.map((w) => String(w.id)),
556
+ ...zones.map((z) => z.id),
557
+ ]
558
+ const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
559
+ return { nodes: [level, ...walls, ...zones] as AnyNode[], spaces, shared }
560
+ }
561
+
562
+ it('paints the room the hit face borders, as one room commit listed on its walls', () => {
563
+ const { nodes, spaces, shared } = zonedRooms()
564
+ const left = resolve({
565
+ node: shared,
566
+ role: 'a',
567
+ scope: 'room',
568
+ nodes,
569
+ spaces,
570
+ wallHit: { face: 'front', point: [3.9, 2] },
571
+ })
572
+ expect(keys(left).sort()).toEqual([
573
+ 'bottom-left:room:zone_left',
574
+ 'left:room:zone_left',
575
+ 'shared:room:zone_left',
576
+ 'top-left:room:zone_left',
577
+ ])
578
+ // A hit on a face already showing its room finish names the room directly.
579
+ const right = resolve({
580
+ node: shared,
581
+ role: 'room:zone_right/b',
582
+ scope: 'room',
583
+ nodes,
584
+ spaces,
585
+ wallHit: { face: 'front', point: [4.1, 2] },
586
+ })
587
+ expect(new Set(right.map((target) => target.role))).toEqual(new Set(['room:zone_right']))
588
+ expect(right).toHaveLength(4)
589
+ })
590
+
591
+ it('fans a trim to the face each wall turns to the room, and keeps regions single', () => {
592
+ const { nodes, spaces, shared } = zonedRooms()
593
+ const trims = resolve({
594
+ node: shared,
595
+ role: 'bSkirting',
596
+ scope: 'room',
597
+ nodes,
598
+ spaces,
599
+ wallHit: { face: 'back', point: [4.1, 2] },
600
+ })
601
+ expect(keys(trims).sort()).toEqual([
602
+ 'bottom-right:aSkirting',
603
+ 'right:aSkirting',
604
+ 'shared:bSkirting',
605
+ 'top-right:aSkirting',
606
+ ])
607
+ expect(
608
+ keys(
609
+ resolve({
610
+ node: shared,
611
+ role: 'region:wainscot',
612
+ scope: 'room',
613
+ nodes,
614
+ spaces,
615
+ wallHit: { face: 'front', point: [3.9, 2] },
616
+ }),
617
+ ),
618
+ ).toEqual([
619
+ 'shared:room:zone_left',
620
+ 'bottom-left:room:zone_left',
621
+ 'top-left:room:zone_left',
622
+ 'left:room:zone_left',
623
+ ])
624
+ })
625
+ })
626
+
627
+ describe('wall and floor scopes', () => {
628
+ it('a wall offers its face or its room, never the whole wall', () => {
629
+ const wall = { id: 'wall_x', type: 'wall', parentId: 'level_x' } as unknown as AnyNode
630
+ expect(availablePaintScopes({ node: wall, slotRoles: ['a', 'b', 'aSkirting'] })).toEqual([
631
+ 'single',
632
+ 'room',
633
+ ])
634
+ expect(paintScopeLabel('single', { scopes: [], slotLabel: 'Face', nodeNoun: 'wall' })).toBe(
635
+ 'Face',
636
+ )
637
+ expect(paintSurfaceLabel(wall, 'room:zone_left/a')).toBe('Face')
638
+ expect(paintSurfaceLabel(wall, 'region:wainscot')).toBe('Face')
639
+ expect(paintSurfaceLabel(wall, 'a')).toBe('Face')
640
+ })
641
+
642
+ it('the room scope on a room floor is one room-wide role on every plate of the level', () => {
643
+ const plate = (id: string) =>
644
+ ({
645
+ id,
646
+ type: 'slab',
647
+ parentId: 'level_f',
648
+ boundary: 'auto',
649
+ zoneIds: [],
650
+ }) as unknown as AnyNode
651
+ const nodes = { plate_a: plate('plate_a'), plate_b: plate('plate_b') } as Record<
652
+ string,
653
+ AnyNode
654
+ >
655
+ const targets = resolvePaintScopeTargets({
656
+ node: nodes.plate_a!,
657
+ role: 'room:zone_k/rug',
658
+ scope: 'room',
659
+ nodes,
660
+ spaces: {},
661
+ slotRolesOf: () => [],
662
+ })
663
+ expect(targets.map((target) => `${target.nodeId}:${target.role}`)).toEqual([
664
+ 'plate_a:room:zone_k/*',
665
+ 'plate_b:room:zone_k/*',
666
+ ])
667
+ })
668
+ })
669
+
670
+ describe('commitPaintScopeFanout routing', () => {
671
+ it('commits a routed role once through its kind and writes slot roles as slots', () => {
672
+ const kind = 'paint-route-test'
673
+ const commits: string[] = []
674
+ if (!nodeRegistry.has(kind))
675
+ registerNode({
676
+ kind,
677
+ schemaVersion: 1,
678
+ schema: z.object({ type: z.literal(kind) }) as never,
679
+ category: 'structure',
680
+ defaults: () => ({}),
681
+ capabilities: {
682
+ slots: () => [{ slotId: 'a', label: 'A', default: 'library:x' }],
683
+ paint: {
684
+ resolveRole: () => null,
685
+ buildPatch: () => ({}),
686
+ applyPreview: () => null,
687
+ commit: ({ role }: { role: string }) => {
688
+ commits.push(role)
689
+ },
690
+ },
691
+ },
692
+ floorplanScope: 'level',
693
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
694
+ } as unknown as AnyNodeDefinition)
695
+ const node = (id: string) => ({ id, type: kind, slots: {} }) as unknown as AnyNode
696
+ useScene.setState({
697
+ nodes: {
698
+ n1: node('n1'),
699
+ n2: node('n2'),
700
+ zone_x: { id: 'zone_x', type: 'zone' } as unknown as AnyNode,
701
+ },
702
+ materials: {},
703
+ dirtyNodes: new Set(),
704
+ } as never)
705
+ const at = (id: string, role: string) => ({ nodeId: id as AnyNodeId, role })
706
+ commitPaintScopeFanout(
707
+ [at('n1', 'room:zone_x'), at('n2', 'room:zone_x'), at('zone_x', 'room:zone_x')],
708
+ undefined,
709
+ 'library:red',
710
+ )
711
+ expect(commits).toEqual(['room:zone_x'])
712
+ expect(
713
+ (useScene.getState().nodes.zone_x as unknown as { slots?: unknown }).slots,
714
+ ).toBeUndefined()
715
+ commitPaintScopeFanout([at('n1', 'a'), at('n2', 'a')], undefined, 'library:red')
716
+ const slots = (id: string) =>
717
+ (useScene.getState().nodes[id as AnyNodeId] as never as { slots: object }).slots
718
+ expect(slots('n1')).toEqual({ a: 'library:red' })
719
+ expect(slots('n2')).toEqual({ a: 'library:red' })
720
+ expect(commits).toEqual(['room:zone_x'])
721
+ })
722
+ })
723
+
724
+ describe('generated floor plates: scopes per surface', () => {
725
+ const plate = (plateRole: 'base' | 'platform' = 'base') =>
726
+ ({
727
+ id: `plate_${plateRole}`,
728
+ type: 'slab',
729
+ parentId: 'level_p',
730
+ boundary: 'auto',
731
+ plateRole,
732
+ zoneIds: [],
733
+ }) as unknown as AnyNode
734
+ const manual = { id: 'slab_hand', type: 'slab', parentId: 'level_p' } as unknown as AnyNode
735
+ const chip = (node: AnyNode, role: string) => {
736
+ const info = paintHoverInfo(node, role, ['foundation', 'surface', 'side', 'edge'])
737
+ return info.scopes.map((scope) => paintScopeLabel(scope, info))
738
+ }
739
+
740
+ it('a floor is this surface or the whole room', () => {
741
+ expect(chip(plate(), 'room:zone_k')).toEqual(['This surface', 'Whole room'])
742
+ expect(chip(plate(), 'room:zone_k/rug')).toEqual(['This surface', 'Whole room'])
743
+ expect(chip(plate('platform'), 'room:zone_k')).toEqual(['This surface', 'Whole room'])
744
+ })
745
+
746
+ it('a step is this step or every step of its room', () => {
747
+ expect(chip(plate(), 'step:zone_k/door_1')).toEqual(['This step', 'All steps in this room'])
748
+ })
749
+
750
+ it("a footprint's faces have one fixed label and nothing to cycle", () => {
751
+ expect(chip(plate(), 'edge')).toEqual(['Floor edge · all around this floor'])
752
+ expect(chip(plate(), 'foundation')).toEqual(['Foundation · all around this floor'])
753
+ expect(chip(plate(), 'riser')).toEqual(['Riser · all around this floor'])
754
+ expect(chip(plate(), 'underside')).toEqual(['Underside · all of this floor'])
755
+ })
756
+
757
+ it('no plate offers the whole slab; a hand-drawn slab still does', () => {
758
+ for (const role of ['room:zone_k', 'step:zone_k/door_1', 'edge', 'foundation', 'riser'])
759
+ expect(paintHoverInfo(plate(), role, ['foundation', 'surface', 'side']).scopes).not.toContain(
760
+ 'object',
761
+ )
762
+ // (The room scope comes from the registry, not wired in this unit context.)
763
+ expect(chip(manual, 'surface')).toContain('Whole slab')
764
+ })
765
+
766
+ it('a scope the surface does not offer paints just that surface', () => {
767
+ const node = plate()
768
+ const nodes = { [node.id]: node } as Record<string, AnyNode>
769
+ const targets = (role: string, scope: PaintScope) =>
770
+ resolvePaintScopeTargets({
771
+ node,
772
+ role,
773
+ scope,
774
+ nodes,
775
+ spaces: {},
776
+ slotRolesOf: () => ['foundation', 'surface', 'side', 'edge', 'riser', 'underside'],
777
+ }).map((target) => `${target.nodeId}:${target.role}`)
778
+ expect(targets('room:zone_k', 'object')).toEqual(['plate_base:room:zone_k'])
779
+ expect(targets('edge', 'object')).toEqual(['plate_base:edge'])
780
+ expect(targets('edge', 'room')).toEqual(['plate_base:edge'])
781
+ expect(targets('foundation', 'room')).toEqual(['plate_base:foundation'])
782
+ expect(effectivePaintScope('object', ['single', 'room'])).toBe('single')
783
+ expect(effectivePaintScope('room', ['single', 'room'])).toBe('room')
784
+ })
785
+
786
+ it('the whole room is one room-wide role; its steps are one room step role', () => {
787
+ expect(paintScopeRole(plate(), 'room:zone_k/rug', 'room')).toBe('room:zone_k/*')
788
+ expect(paintScopeRole(plate(), 'room:zone_k', 'single')).toBe('room:zone_k')
789
+ expect(paintScopeRole(plate(), 'step:zone_k/door_1', 'room')).toBe('step:zone_k')
790
+ expect(paintScopeRole(manual, 'surface', 'room')).toBe('surface')
791
+ })
792
+ })