@pascal-app/editor 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -1,10 +1,16 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ floorStepRole,
4
5
  generateSceneMaterialId,
6
+ getWallLevelZones,
7
+ getWallLocalFaceZ,
8
+ getWallZoneSpans,
5
9
  type ItemNode,
6
10
  type MaterialSchema,
7
11
  nodeRegistry,
12
+ parseFloorStepRole,
13
+ parseRoomFinishRole,
8
14
  pointInPolygon2D,
9
15
  resolveLevelId,
10
16
  type SceneMaterial,
@@ -15,6 +21,7 @@ import {
15
21
  toSceneMaterialRef,
16
22
  useScene,
17
23
  type WallNode,
24
+ wallRoomFinishRole,
18
25
  } from '@pascal-app/core'
19
26
 
20
27
  /**
@@ -36,6 +43,8 @@ export type PaintHoverInfo = {
36
43
  slotLabel: string
37
44
  /** Kind noun for the `'object'` label (e.g. "Whole shelf"). */
38
45
  nodeNoun: string
46
+ /** Labels a surface names itself, per scope, over the generic ones. */
47
+ labels?: Partial<Record<PaintScope, string>>
39
48
  }
40
49
 
41
50
  function nodeHasAsset(node: AnyNode): boolean {
@@ -46,13 +55,55 @@ function nodeOffersRoomScope(node: AnyNode): boolean {
46
55
  return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true
47
56
  }
48
57
 
58
+ const isFloorPlate = (node: AnyNode): boolean => node.type === 'slab' && node.boundary === 'auto'
59
+
60
+ const FOOTPRINT_FACE_LABELS: Record<string, string> = {
61
+ edge: 'Floor edge · all around this floor',
62
+ riser: 'Riser · all around this floor',
63
+ underside: 'Underside · all of this floor',
64
+ foundation: 'Foundation · all around this floor',
65
+ }
66
+
67
+ /**
68
+ * The scopes a generated floor plate offers for the surface under the cursor.
69
+ * A plate is not an object anyone paints whole: a room's floor is "this
70
+ * surface" (the painted part under the cursor, else the room floor) or the
71
+ * whole room; a step is its doorway or every step of the room; the footprint's
72
+ * faces are one finish all around the floor, so they have nothing to cycle.
73
+ */
74
+ export function platePaintScopes(
75
+ node: AnyNode,
76
+ role: string,
77
+ ): { scopes: PaintScope[]; labels: Partial<Record<PaintScope, string>> } | null {
78
+ if (node.type !== 'slab' || node.boundary !== 'auto') return null
79
+ if (parseFloorStepRole(role))
80
+ return {
81
+ scopes: ['single', 'room'],
82
+ labels: { single: 'This step', room: 'All steps in this room' },
83
+ }
84
+ if (parseRoomFinishRole(role))
85
+ return { scopes: ['single', 'room'], labels: { single: 'This surface', room: 'Whole room' } }
86
+ const fixed = node.plateRole === 'base' ? FOOTPRINT_FACE_LABELS[role] : undefined
87
+ return { scopes: ['single'], labels: fixed ? { single: fixed } : {} }
88
+ }
89
+
49
90
  /**
50
91
  * The scopes a hovered node offers, derived from the node itself: every node
51
92
  * paints `single`; > 1 slot adds `object`; an `asset` adds `matching`; a
52
93
  * `roomScope`-declaring kind adds `room`. `slotRoles` is the node's full slot set
53
- * (declared or mesh-derived), passed in by the caller.
94
+ * (declared or mesh-derived), passed in by the caller. A floor plate's set
95
+ * depends on the surface under the cursor (`role`).
54
96
  */
55
- export function availablePaintScopes(args: { node: AnyNode; slotRoles: string[] }): PaintScope[] {
97
+ export function availablePaintScopes(args: {
98
+ node: AnyNode
99
+ slotRoles: string[]
100
+ role?: string
101
+ }): PaintScope[] {
102
+ // A wall paints the face under the cursor, or its room's walls: "whole wall"
103
+ // (both faces of one wall, across two rooms) is nobody's surface.
104
+ if (args.node.type === 'wall') return ['single', 'room']
105
+ const plate = args.role === undefined ? null : platePaintScopes(args.node, args.role)
106
+ if (plate) return plate.scopes
56
107
  const scopes: PaintScope[] = ['single']
57
108
  if (args.slotRoles.length > 1) scopes.push('object')
58
109
  if (nodeHasAsset(args.node)) scopes.push('matching')
@@ -66,7 +117,24 @@ export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintS
66
117
  return list[(index + 1) % list.length] ?? 'single'
67
118
  }
68
119
 
120
+ /** The chip's hover info for `role` on `node`: its scopes and what each is called. */
121
+ export function paintHoverInfo(node: AnyNode, role: string, slotRoles: string[]): PaintHoverInfo {
122
+ return {
123
+ scopes: availablePaintScopes({ node, slotRoles, role }),
124
+ slotLabel: paintSurfaceLabel(node, role),
125
+ nodeNoun: node.type,
126
+ labels: platePaintScopes(node, role)?.labels,
127
+ }
128
+ }
129
+
130
+ /** The scope a click applies: the chosen one when the hovered surface offers it. */
131
+ export function effectivePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintScope {
132
+ return scopes.includes(scope) ? scope : 'single'
133
+ }
134
+
69
135
  export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string {
136
+ const own = info.labels?.[scope]
137
+ if (own) return own
70
138
  switch (scope) {
71
139
  case 'object':
72
140
  return `Whole ${info.nodeNoun}`
@@ -91,13 +159,21 @@ export function nodeSlotRoles(node: AnyNode, meshSlotRoles: (node: AnyNode) => s
91
159
  return meshSlotRoles(node)
92
160
  }
93
161
 
162
+ /**
163
+ * What the paint HUD calls the surface under the cursor: a wall face is the
164
+ * "Face" (the narrow scope), a trim its own name, anything else its slot.
165
+ */
166
+ export function paintSurfaceLabel(node: AnyNode, role: string): string {
167
+ if (node.type === 'wall' && !WALL_TRIM_ROLE.test(role)) return 'Face'
168
+ return slotDisplayLabel(node, role)
169
+ }
170
+
94
171
  /** Display label for the hovered slot — declared label wins, else derived from the id. */
95
172
  export function slotDisplayLabel(node: AnyNode, role: string): string {
96
- const declared = nodeRegistry
97
- .get(node.type)
98
- ?.capabilities?.slots?.(node)
99
- ?.find((slot) => slot.slotId === role)
100
- return declared?.label ?? slotLabelFromId(role)
173
+ const capabilities = nodeRegistry.get(node.type)?.capabilities
174
+ const declared = capabilities?.slots?.(node)?.find((slot) => slot.slotId === role)
175
+ if (declared) return declared.label
176
+ return capabilities?.paint?.roleLabel?.(node, role) ?? slotLabelFromId(role)
101
177
  }
102
178
 
103
179
  // ── Fan-out resolution ──────────────────────────────────────────────────────
@@ -141,17 +217,87 @@ function distanceToPolyline(
141
217
  return distance
142
218
  }
143
219
 
144
- function wallRoleForRoomFace(role: string, wall: WallNode, face: 'front' | 'back'): string | null {
145
- const semantic = face === 'front' ? wall.frontSide : wall.backSide
146
- const fallback = face === 'front' ? 'interior' : 'exterior'
147
- const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback
220
+ const WALL_TRIM_ROLE = /^([ab])(Skirting|Crown|ChairRail)$/
221
+ const WALL_ROOM_FACE_ROLE = /^room:(.+)\/([ab])$/
222
+
223
+ /** The same surface on a boundary face: face slots and trims follow the physical face. */
224
+ function wallRoleForRoomFace(role: string, face: 'front' | 'back'): string | null {
225
+ const physical = face === 'front' ? 'a' : 'b'
226
+ if (role === 'a' || role === 'b') return physical
227
+ const trim = WALL_TRIM_ROLE.exec(role)
228
+ return trim ? `${physical}${trim[2]}` : null
229
+ }
230
+
231
+ /** The face a wall paint role sits on, when the role names one. */
232
+ function wallRoleFace(role: string): 'a' | 'b' | null {
233
+ if (role === 'a' || role === 'b') return role
234
+ return (WALL_TRIM_ROLE.exec(role)?.[1] ?? WALL_ROOM_FACE_ROLE.exec(role)?.[2] ?? null) as
235
+ | 'a'
236
+ | 'b'
237
+ | null
238
+ }
239
+
240
+ /** The room (zone) a wall face borders at the hit point, from the zones' boundary spans. */
241
+ function zoneAtWallHit(
242
+ wall: WallNode,
243
+ face: 'a' | 'b',
244
+ point: readonly [number, number],
245
+ nodes: Record<string, AnyNode>,
246
+ ): string | null {
247
+ const dx = wall.end[0] - wall.start[0]
248
+ const dz = wall.end[1] - wall.start[1]
249
+ const lengthSquared = dx * dx + dz * dz
250
+ if (lengthSquared < 1e-12) return null
251
+ const t = ((point[0] - wall.start[0]) * dx + (point[1] - wall.start[1]) * dz) / lengthSquared
252
+ const spans = getWallZoneSpans(wall, getWallLevelZones(wall, nodes))
253
+ const span = spans.find(
254
+ (candidate) =>
255
+ candidate.face === face &&
256
+ t >= candidate.t0 - 1e-6 &&
257
+ (t < candidate.t1 || (candidate.t1 >= 1 && t <= 1 + 1e-6)),
258
+ )
259
+ return span?.zoneId ?? null
260
+ }
148
261
 
149
- if (role === 'interior' || role === 'exterior') return side
150
- if (role.endsWith('Interior'))
151
- return `${role.slice(0, -'Interior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
152
- if (role.endsWith('Exterior'))
153
- return `${role.slice(0, -'Exterior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
154
- return null
262
+ /**
263
+ * Room scope on a wall face inside a room paints the room's wall finish: one
264
+ * `room:<zoneId>` commit on the zone. Every wall bordering the room is listed so
265
+ * each previews the change; trims fan out to the face each wall turns to the room.
266
+ */
267
+ function wallRoomTargets(
268
+ wall: WallNode,
269
+ role: string,
270
+ zoneId: string,
271
+ nodes: Record<string, AnyNode>,
272
+ ): Array<{ nodeId: AnyNodeId; role: string }> {
273
+ const zone = nodes[zoneId]
274
+ if (zone?.type !== 'zone') return []
275
+ const walls = [
276
+ wall,
277
+ ...zone.boundaryWallIds
278
+ .map((id) => nodes[id])
279
+ .filter((node): node is WallNode => node?.type === 'wall' && node.id !== wall.id),
280
+ ]
281
+ const trim = WALL_TRIM_ROLE.exec(role)
282
+ if (trim) {
283
+ const targets = new Map<string, { nodeId: AnyNodeId; role: string }>()
284
+ for (const target of walls) {
285
+ for (const span of getWallZoneSpans(target, [zone])) {
286
+ const targetRole = `${span.face}${trim[2]}`
287
+ targets.set(`${target.id}:${targetRole}`, {
288
+ nodeId: target.id as AnyNodeId,
289
+ role: targetRole,
290
+ })
291
+ }
292
+ }
293
+ return [...targets.values()]
294
+ }
295
+ const roomRole = wallRoomFinishRole(zoneId)
296
+ const targets = walls.map((target) => ({ nodeId: target.id as AnyNodeId, role: roomRole }))
297
+ // A room bounded by one wall still commits through the fan-out path, which
298
+ // routes the room role to the zone; the zone target itself previews nothing.
299
+ if (targets.length === 1) targets.push({ nodeId: zone.id as AnyNodeId, role: roomRole })
300
+ return targets
155
301
  }
156
302
 
157
303
  function resolveWallPaintSpace(args: {
@@ -162,7 +308,13 @@ function resolveWallPaintSpace(args: {
162
308
  }): Space | null {
163
309
  const { wall, wallHit, nodes, spaces } = args
164
310
  const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
165
- const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
311
+ const tolerance =
312
+ getWallLocalFaceZ(
313
+ { ...wall, thickness: wall.thickness ?? 0.2 },
314
+ wallHit.face === 'front' ? 'a' : 'b',
315
+ ) *
316
+ (wallHit.face === 'front' ? 1 : -1) +
317
+ 0.08
166
318
  let best: { space: Space; distance: number } | null = null
167
319
 
168
320
  for (const space of Object.values(spaces)) {
@@ -216,7 +368,13 @@ function connectedExteriorBoundaries(args: {
216
368
  if (!boundary) return []
217
369
  return [{ ...boundary, face: oppositeWallFace(boundary.face) }]
218
370
  })
219
- const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
371
+ const tolerance =
372
+ getWallLocalFaceZ(
373
+ { ...wall, thickness: wall.thickness ?? 0.2 },
374
+ wallHit.face === 'front' ? 'a' : 'b',
375
+ ) *
376
+ (wallHit.face === 'front' ? 1 : -1) +
377
+ 0.08
220
378
  const seed = exterior
221
379
  .filter((boundary) => boundary.wallId === wall.id && boundary.face === wallHit.face)
222
380
  .map((boundary) => ({ boundary, distance: distanceToPolyline(wallHit.point, boundary.points) }))
@@ -277,7 +435,7 @@ function wallTargetsForBoundaries(args: {
277
435
  ) {
278
436
  continue
279
437
  }
280
- const targetRole = wallRoleForRoomFace(role, targetWall, boundary.face)
438
+ const targetRole = wallRoleForRoomFace(role, boundary.face)
281
439
  if (!targetRole) continue
282
440
  const key = `${targetWall.id}:${targetRole}`
283
441
  targets.set(key, { nodeId: targetWall.id as AnyNodeId, role: targetRole })
@@ -298,6 +456,20 @@ function polygonCentroid(
298
456
  return [x / points.length, z / points.length]
299
457
  }
300
458
 
459
+ /**
460
+ * The role one paint click writes under `scope`: a floor step paints its own
461
+ * doorway, and in the room scope every step of its room (`step:<zoneId>`); a
462
+ * room's floor in the room scope is the whole room (`room:<zoneId>/*`).
463
+ * Hover outline, preview and commit all start from this role.
464
+ */
465
+ export function paintScopeRole(node: AnyNode, role: string, scope: PaintScope): string {
466
+ if (node.type !== 'slab' || scope !== 'room') return role
467
+ const step = parseFloorStepRole(role)
468
+ if (step) return floorStepRole(step.zoneId)
469
+ const floor = isFloorPlate(node) ? parseRoomFinishRole(role) : null
470
+ return floor ? `room:${floor.zoneId}/*` : role
471
+ }
472
+
301
473
  /**
302
474
  * Expand one paint hit (`node` + resolved `role`) into the full list of
303
475
  * (node, role) targets the current `scope` should paint. Returns just the
@@ -318,8 +490,21 @@ export function resolvePaintScopeTargets(args: {
318
490
  slotRolesOf: (node: AnyNode) => string[]
319
491
  wallHit?: WallPaintHit
320
492
  }): Array<{ nodeId: AnyNodeId; role: string }> {
321
- const { node, role, scope, nodes, spaces, slotRolesOf, wallHit } = args
493
+ const { node, role, nodes, spaces, slotRolesOf, wallHit } = args
494
+ const plateScopes = platePaintScopes(node, role)?.scopes
495
+ const scope = plateScopes ? effectivePaintScope(args.scope, plateScopes) : args.scope
322
496
  const single = [{ nodeId: node.id as AnyNodeId, role }]
497
+ if (node.type === 'slab' && parseFloorStepRole(role)) {
498
+ // A room's steps are drawn by whichever plates carry them.
499
+ const stepRole = paintScopeRole(node, role, scope)
500
+ const targets = Object.values(nodes)
501
+ .filter(
502
+ (other) =>
503
+ other.type === 'slab' && other.parentId === node.parentId && other.boundary === 'auto',
504
+ )
505
+ .map((other) => ({ nodeId: other.id, role: stepRole }))
506
+ return targets.length ? targets : [{ nodeId: node.id as AnyNodeId, role: stepRole }]
507
+ }
323
508
  if (scope === 'single') return single
324
509
 
325
510
  // Whole object: paint every slot of the clicked node. Generic across any
@@ -344,6 +529,13 @@ export function resolvePaintScopeTargets(args: {
344
529
  if (!wallHit) return single
345
530
  const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
346
531
  if (!levelId) return single
532
+ const face = wallRoleFace(role) ?? (wallHit.face === 'front' ? 'a' : 'b')
533
+ const zoneId =
534
+ WALL_ROOM_FACE_ROLE.exec(role)?.[1] ?? zoneAtWallHit(wall, face, wallHit.point, nodes)
535
+ if (zoneId) {
536
+ const targets = wallRoomTargets(wall, role, zoneId, nodes)
537
+ if (targets.length > 0) return targets
538
+ }
347
539
  const space = resolveWallPaintSpace({ wall, wallHit, nodes, spaces })
348
540
  const boundaries = space
349
541
  ? space.boundaryFaces
@@ -353,6 +545,24 @@ export function resolvePaintScopeTargets(args: {
353
545
  }
354
546
 
355
547
  if (node.type === 'slab' && scope === 'room') {
548
+ // A room's floor in the room scope: every finish source of that room (its
549
+ // floor, painted parts, steps and edge) on every plate of the level, as one
550
+ // `room:<zoneId>/*` role the zone commits.
551
+ const floor = parseRoomFinishRole(role)
552
+ if (floor) {
553
+ const roomWide = `room:${floor.zoneId}/*`
554
+ const plates = Object.values(nodes).filter(
555
+ (other) =>
556
+ other.type === 'slab' && other.parentId === node.parentId && other.boundary === 'auto',
557
+ )
558
+ return (plates.length ? plates : [node]).map((plate) => ({
559
+ nodeId: plate.id as AnyNodeId,
560
+ role: roomWide,
561
+ }))
562
+ }
563
+ // A floor plate's own faces have no room scope; below is the hand-drawn
564
+ // slab's spread over the slabs of its room.
565
+ if (isFloorPlate(node)) return single
356
566
  const centroid = polygonCentroid((node as SlabNode).polygon)
357
567
  if (!centroid) return single
358
568
  // Space polygons are per-level footprints, and stacked storeys share a
@@ -386,7 +596,7 @@ function materialsEqual(a: unknown, b: unknown): boolean {
386
596
  if (Object.is(a, b)) return true
387
597
  if (typeof a !== typeof b || a === null || b === null) return false
388
598
  if (Array.isArray(a) || Array.isArray(b)) {
389
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
599
+ if (!(Array.isArray(a) && Array.isArray(b)) || a.length !== b.length) return false
390
600
  return a.every((value, index) => materialsEqual(value, b[index]))
391
601
  }
392
602
  if (typeof a === 'object') {
@@ -401,18 +611,49 @@ function materialsEqual(a: unknown, b: unknown): boolean {
401
611
  return false
402
612
  }
403
613
 
614
+ /**
615
+ * Split a fan-out into slot writes and roles a kind routes elsewhere — a wall's
616
+ * `room:<zoneId>` paints the zone, through the wall's own commit, once per role.
617
+ * Targets without a paint capability (the zone listed for a one-wall room) only
618
+ * ride along for the preview.
619
+ */
620
+ function partitionFanout(targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>) {
621
+ const nodes = useScene.getState().nodes
622
+ const routed = new Map<string, { nodeId: AnyNodeId; role: string }>()
623
+ const slots: Array<{ nodeId: AnyNodeId; role: string }> = []
624
+ for (const target of targets) {
625
+ const node = nodes[target.nodeId]
626
+ if (!node) continue
627
+ const capabilities = nodeRegistry.get(node.type)?.capabilities
628
+ const declared = capabilities?.slots?.(node)
629
+ const isSlot = declared?.length
630
+ ? declared.some((slot) => slot.slotId === target.role)
631
+ : !target.role.includes(':')
632
+ const key = target.role.includes(':') ? target.role : `${target.nodeId}:${target.role}`
633
+ if (isSlot) slots.push(target)
634
+ else if (capabilities?.paint?.commit && !routed.has(key)) routed.set(key, target)
635
+ }
636
+ return { routed: [...routed.values()], slots }
637
+ }
638
+
404
639
  /**
405
640
  * Apply one paint to many slot-model targets in a single undo step. Resolves
406
641
  * the slot ref ONCE — a one-off colour creates a single shared scene material
407
642
  * for the whole fan-out, not one per node — then writes every `node.slots[role]`
408
- * (or deletes it, for the eraser) in one `useScene.setState`. Only ever called
409
- * for item / wall / slab fan-outs, all of which use the unified slot model.
643
+ * (or deletes it, for the eraser) in one `useScene.setState`. Routed roles
644
+ * (see `partitionFanout`) commit through their kind instead.
410
645
  */
411
646
  export function commitPaintScopeFanout(
412
- targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>,
647
+ fanout: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>,
413
648
  material: MaterialSchema | undefined,
414
649
  materialPreset: string | undefined,
415
650
  ): void {
651
+ const { routed, slots: targets } = partitionFanout(fanout)
652
+ for (const { nodeId, role } of routed) {
653
+ const node = useScene.getState().nodes[nodeId]
654
+ const paint = node ? nodeRegistry.get(node.type)?.capabilities?.paint : undefined
655
+ if (node && paint?.commit) paint.commit({ node, role, material, materialPreset })
656
+ }
416
657
  if (targets.length === 0) return
417
658
  const state = useScene.getState()
418
659
 
@@ -62,12 +62,33 @@ export type PanelRow = { section?: string } & (
62
62
 
63
63
  export const PANEL_MODEL_EXTENSION = 'pascal:editor/panel-model'
64
64
 
65
+ /**
66
+ * A control the multi-selection inspector renders for every selected node of
67
+ * a kind at once (derived settings the raw parametrics can't express). `value`
68
+ * null means the selection is mixed: no option shows as active.
69
+ */
70
+ export type MultiPanelControl = {
71
+ id: string
72
+ kind: 'segmented'
73
+ /** The parametrics group title the control sits in. */
74
+ section: string
75
+ label: string
76
+ options: ReadonlyArray<{ value: string; label: string }>
77
+ value: string | null
78
+ /** Applies to every selected node in one undo step. */
79
+ onChange: (value: string) => void
80
+ }
81
+
65
82
  export type NodePanelModel<N extends AnyNode = AnyNode> = {
66
83
  rows: (context: {
67
84
  node: N
68
85
  nodes: Record<AnyNodeId, AnyNode>
69
86
  update: (patch: Partial<N>) => void
70
87
  }) => PanelRow[]
88
+ multiControls?: (context: {
89
+ selection: N[]
90
+ nodes: Record<AnyNodeId, AnyNode>
91
+ }) => MultiPanelControl[]
71
92
  }
72
93
 
73
94
  export function getNodePanelModel(definition: AnyNodeDefinition | undefined) {
@@ -31,7 +31,7 @@ export function commitParametricNodeFields(
31
31
  ]
32
32
  if (parametrics?.reconcile) {
33
33
  const next = { ...node, ...patch } as AnyNode
34
- updates.push(...parametrics.reconcile(node, next))
34
+ updates.push(...parametrics.reconcile(node, next, { ...scene.nodes, [nodeId]: next }))
35
35
  }
36
36
  scene.updateNodes(updates)
37
37
  }