@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
@@ -6,6 +6,7 @@ import {
6
6
  type AnyNodeId,
7
7
  type BrushSettings,
8
8
  type BuildingNode,
9
+ type CameraPose,
9
10
  type ChimneyMaterialRole,
10
11
  DEFAULT_BRUSH_SETTINGS,
11
12
  type DormerSurfaceMaterialRole,
@@ -28,6 +29,7 @@ import {
28
29
  continuationContextOf,
29
30
  nextContinuation,
30
31
  } from '../lib/continuation'
32
+ import { hydrationGatedStorage } from '../lib/hydration-gated-storage'
31
33
  import {
32
34
  type ActivePaintMaterial,
33
35
  type PaintableMaterialTarget,
@@ -41,11 +43,19 @@ import {
41
43
  normalizeCreatableMeasurementKind,
42
44
  } from '../lib/measurement-kind'
43
45
  import type { ModelExport } from '../lib/model-export'
46
+ import {
47
+ endPaintSession,
48
+ isPaintErasing,
49
+ type PaintMode,
50
+ paintRegionModeActive,
51
+ usePaintRegionMode,
52
+ } from '../lib/paint-region-mode'
44
53
  import {
45
54
  cyclePaintScope as cyclePaintScopeValue,
46
55
  type PaintHoverInfo,
47
56
  type PaintScope,
48
57
  } from '../lib/paint-scope'
58
+ import { type RoomKey, sameRoom } from '../lib/room-selection'
49
59
  import {
50
60
  cycleSnappingModeIn,
51
61
  defaultSnappingModeFor,
@@ -55,17 +65,35 @@ import {
55
65
  snapContextOf,
56
66
  snappingModesFor,
57
67
  } from '../lib/snapping-mode'
68
+ import { cameraPoseStore } from './camera-pose-store'
58
69
  import { publishNavigationSyncPoseToStore } from './navigation-sync-pose-store'
59
70
  import useInteractionScope from './use-interaction-scope'
71
+ import {
72
+ clampViewPaneRatio,
73
+ DEFAULT_VIEW_LAYOUT,
74
+ isViewVisible,
75
+ normalizeViewLayout,
76
+ paneOfView,
77
+ sceneLayout,
78
+ storedViewLayout,
79
+ swapped,
80
+ VIEW_3D,
81
+ type ViewLayout,
82
+ type ViewPaneIndex,
83
+ type VisibleScene,
84
+ viewLayoutFromLegacy,
85
+ visibleScene,
86
+ withoutView,
87
+ withPaneView,
88
+ withSplitToggled,
89
+ withView,
90
+ } from './view-layout'
60
91
 
61
92
  const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build'
62
- const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
63
- const MIN_FLOORPLAN_PANE_RATIO = 0.15
64
- const MAX_FLOORPLAN_PANE_RATIO = 0.85
65
93
 
66
- export type ViewMode = '3d' | '2d' | 'split'
67
- export type SplitOrientation = 'horizontal' | 'vertical'
68
- export type WorkspaceMode = 'edit' | 'studio'
94
+ export type WorkspaceMode = 'edit' | 'studio' | 'sheets'
95
+ /** @deprecated The stage is views now: read `visibleScene(state)` (`VisibleScene`). */
96
+ export type ViewMode = VisibleScene
69
97
 
70
98
  // Snapshot capture is invoked from two surfaces with different policies.
71
99
  // `standard` mirrors the existing user-driven UX — pick region / viewport /
@@ -120,7 +148,11 @@ function clampCaptureFov(fov: number): number {
120
148
  return Math.min(Math.max(Math.round(fov), CAPTURE_FOV_MIN), CAPTURE_FOV_MAX)
121
149
  }
122
150
 
123
- export type Phase = 'site' | 'structure' | 'furnish'
151
+ /**
152
+ * `site` is the outside (property line, terrain, the site panel); `building` is
153
+ * everything on the active level — walls, openings, stairs, items, rooms.
154
+ */
155
+ export type Phase = 'site' | 'building'
124
156
 
125
157
  /**
126
158
  * `terrain-sculpt` is a mode, not a build tool, and that is the whole answer to
@@ -138,7 +170,7 @@ export type Phase = 'site' | 'structure' | 'furnish'
138
170
  */
139
171
  export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' | 'terrain-sculpt'
140
172
 
141
- // Structure mode tools (building elements)
173
+ // Building tools
142
174
  type BuiltInStructureTool =
143
175
  | 'wall'
144
176
  | 'fence'
@@ -181,13 +213,13 @@ type BuiltInStructureTool =
181
213
  /** Registry node kinds are valid build tools without central union edits. */
182
214
  export type StructureTool = BuiltInStructureTool | (string & {})
183
215
 
184
- // Furnish mode tools (items and decoration)
216
+ // Item tools (items and decoration)
185
217
  export type FurnishTool = 'item' | 'cabinet'
186
218
 
187
219
  // Site mode tools
188
220
  export type SiteTool = 'property-line'
189
221
 
190
- // Catalog categories for furnish mode items
222
+ // Catalog categories for the item tool
191
223
  export type CatalogCategory =
192
224
  | 'furniture'
193
225
  | 'appliance'
@@ -197,8 +229,6 @@ export type CatalogCategory =
197
229
  | 'window'
198
230
  | 'door'
199
231
 
200
- export type StructureLayer = 'zones' | 'elements'
201
-
202
232
  export type FloorplanSelectionTool = 'click' | 'marquee'
203
233
  export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
204
234
 
@@ -267,11 +297,20 @@ export type GuideUiState = {
267
297
  }
268
298
 
269
299
  type EditorState = {
300
+ room: RoomKey | null
301
+ hoveredRoom: RoomKey | null
302
+ selectRoom: (room: RoomKey) => void
303
+ clearRoom: () => void
304
+ setHoveredRoom: (room: RoomKey | null) => void
270
305
  phase: Phase
271
306
  setPhase: (phase: Phase) => void
272
307
  toolMode: ToolMode
273
308
  armToolMode: (next: ToolMode) => void
274
- armMaterialPaint: (material?: ActivePaintMaterial) => void
309
+ /**
310
+ * The paint tool: enter paint mode in `subMode`, else the last painting
311
+ * sub-mode (never erase), picking `material` when given.
312
+ */
313
+ armMaterialPaint: (material?: ActivePaintMaterial, subMode?: PaintMode) => void
275
314
  mode: Mode
276
315
  setMode: (mode: Mode) => void
277
316
  tool: Tool | null
@@ -287,8 +326,6 @@ type EditorState = {
287
326
  setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
288
327
  lastMeasurementKind: CreatableMeasurementKind
289
328
  setLastMeasurementKind: (kind: CreatableMeasurementKind) => void
290
- structureLayer: StructureLayer
291
- setStructureLayer: (layer: StructureLayer) => void
292
329
  catalogCategory: CatalogCategory | null
293
330
  setCatalogCategory: (category: CatalogCategory | null) => void
294
331
  selectedItem: AssetInput | null
@@ -336,6 +373,8 @@ type EditorState = {
336
373
  setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
337
374
  activePaintTarget: PaintableMaterialTarget
338
375
  setActivePaintTarget: (target: PaintableMaterialTarget) => void
376
+ /** Mirror of the paint sub-mode being `erase`, for plugins that read the editor store. */
377
+ paintEraser: boolean
339
378
  // Live vertex count of an in-progress polygon draft (slab / ceiling), so the
340
379
  // contextual HUD can gate hints on it (e.g. "Finish" only once ≥ 3 points).
341
380
  // 0 when not drafting. Not persisted.
@@ -350,10 +389,6 @@ type EditorState = {
350
389
  // Cycle the scope within the hovered node's available set and return the new
351
390
  // value. Bound to Shift while in paint mode.
352
391
  cyclePaintScope: () => PaintScope
353
- // When true, clicking a surface in paint mode clears it back to its
354
- // default material instead of applying `activePaintMaterial`.
355
- paintEraser: boolean
356
- setPaintEraser: (eraser: boolean) => void
357
392
  primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
358
393
  /**
359
394
  * Terrain sculpt state. Lives here rather than in the tool component so the
@@ -418,15 +453,40 @@ type EditorState = {
418
453
  captureMode: CaptureMode
419
454
  isCaptureMode: boolean
420
455
  setCaptureMode: (next: boolean | CaptureMode) => void
421
- // View mode (3D only, 2D only, or split 2D+3D)
456
+ // What a scene capture shows: null is the whole building (every level, the
457
+ // default on entering capture); a level id shows that level and the ones
458
+ // below it, like editing. Picking a level moves the viewer's active level;
459
+ // leaving capture puts back the level the editor was on.
460
+ captureLevelId: LevelNode['id'] | null
461
+ setCaptureLevel: (levelId: LevelNode['id'] | null) => void
462
+ // The stage's panes, per workspace (see `view-layout.ts`). Read what is on
463
+ // screen through `activeViewLayout` / `isViewVisible`, never the raw record.
464
+ viewLayouts: Record<string, ViewLayout>
465
+ /** Show a view: focus it if visible, else in the focused pane, or `beside` it. */
466
+ showView: (viewId: string, options?: { beside?: boolean }) => void
467
+ setPaneView: (pane: ViewPaneIndex, viewId: string) => void
468
+ /** Take a view off the stage: a split keeps the other pane, a single pane returns to 3D. */
469
+ closeView: (viewId: string) => void
470
+ /** Replace the current workspace's layout (following a collaborator, tests). */
471
+ setViewLayout: (layout: ViewLayout) => void
472
+ /**
473
+ * @deprecated The scene projection on screen, kept in step with the layout for
474
+ * plugins written before views: `'3d'` also while only other views show. Read
475
+ * `visibleScene(state)` instead.
476
+ */
422
477
  viewMode: ViewMode
478
+ /**
479
+ * @deprecated Shows `mode` as the whole stage (`'split'`: the plan beside 3D).
480
+ * Use `setViewLayout(sceneLayout(mode))`, or `showView` for one view.
481
+ */
423
482
  setViewMode: (mode: ViewMode) => void
424
- splitOrientation: SplitOrientation
425
- setSplitOrientation: (orientation: SplitOrientation) => void
426
- // Toggleable 2D floorplan overlay (backward compat — derived from viewMode)
427
- isFloorplanOpen: boolean
428
- setFloorplanOpen: (open: boolean) => void
429
- toggleFloorplanOpen: () => void
483
+ toggleSplit: () => void
484
+ swapPanes: () => void
485
+ setViewPaneRatio: (ratio: number) => void
486
+ focusViewPane: (pane: ViewPaneIndex) => void
487
+ /** Per-browser pins of the view bar, over each view's `defaultPinned`. */
488
+ pinnedViews: Record<string, boolean>
489
+ setViewPinned: (viewId: string, pinned: boolean) => void
430
490
  isFloorplanHovered: boolean
431
491
  setFloorplanHovered: (hovered: boolean) => void
432
492
  // Toggleable DWV riser-diagram (plumbing isometric) overlay.
@@ -478,7 +538,6 @@ type EditorState = {
478
538
  setShow2dVoronoi: (enabled: boolean) => void
479
539
  // First-person walkthrough mode (street view)
480
540
  isFirstPersonMode: boolean
481
- _viewModeBeforeFirstPerson: ViewMode | null
482
541
  setFirstPersonMode: (enabled: boolean) => void
483
542
  // Which first-person controller runs while `isFirstPersonMode` is on. Reset to
484
543
  // `walk` whenever first person is left, so the grounded controller stays the
@@ -502,14 +561,17 @@ type EditorState = {
502
561
  setCaptureShutterHold: (hold: boolean) => void
503
562
  // Workspace mode: 'edit' is the full editing surface; 'studio' is the
504
563
  // render/snapshot surface (clean canvas, no editing chrome or selection).
505
- // Entering studio forces a 3D-only view and restores the prior view on exit.
564
+ // Each workspace keeps its own view layout.
506
565
  workspaceMode: WorkspaceMode
507
- _viewModeBeforeStudio: ViewMode | null
508
566
  setWorkspaceMode: (mode: WorkspaceMode) => void
567
+ // The rail panel on screen. Setting it picks it for the current workspace.
568
+ // While the rail lacks the pick (a plugin panel registers once the scene's
569
+ // plugins load), the layout shows its first panel and leaves the pick in
570
+ // `sidebarPanels` (`useShownSidebarPanel`).
509
571
  activeSidebarPanel: string
510
572
  setActiveSidebarPanel: (id: string) => void
511
- floorplanPaneRatio: number
512
- setFloorplanPaneRatio: (ratio: number) => void
573
+ // The panel last picked in each workspace: entering one reopens its own.
574
+ sidebarPanels: Partial<Record<WorkspaceMode, string>>
513
575
  // Mobile-only: pixel height of the secondary panel sheet while open (0 when closed).
514
576
  // Read by the mobile layout so the viewer container can shrink to preview edits.
515
577
  mobilePanelSheetHeight: number
@@ -520,21 +582,20 @@ type EditorState = {
520
582
 
521
583
  export type PersistedEditorUiState = Pick<
522
584
  EditorState,
523
- | 'phase'
524
- | 'toolMode'
525
- | 'mode'
526
- | 'tool'
527
- | 'structureLayer'
528
- | 'catalogCategory'
529
- | 'isFloorplanOpen'
530
- | 'viewMode'
585
+ 'phase' | 'toolMode' | 'mode' | 'tool' | 'catalogCategory' | 'viewLayouts'
531
586
  >
532
587
 
588
+ /** What blobs written before view layouts stored instead. */
589
+ type LegacyViewModeState = {
590
+ viewMode?: unknown
591
+ isFloorplanOpen?: unknown
592
+ floorplanPaneRatio?: unknown
593
+ }
594
+
533
595
  type PersistedEditorLayoutState = Pick<
534
596
  EditorState,
535
- | 'activeSidebarPanel'
536
- | 'floorplanPaneRatio'
537
- | 'splitOrientation'
597
+ | 'sidebarPanels'
598
+ | 'pinnedViews'
538
599
  | 'floorplanSelectionTool'
539
600
  | 'gridSnapStep'
540
601
  | 'magneticSnap'
@@ -552,16 +613,13 @@ export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
552
613
  toolMode: { mode: 'select' },
553
614
  mode: 'select',
554
615
  tool: null,
555
- structureLayer: 'elements',
556
616
  catalogCategory: null,
557
- isFloorplanOpen: false,
558
- viewMode: '3d',
617
+ viewLayouts: {},
559
618
  }
560
619
 
561
620
  export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = {
562
- activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
563
- floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
564
- splitOrientation: 'horizontal',
621
+ sidebarPanels: {},
622
+ pinnedViews: {},
565
623
  floorplanSelectionTool: 'click',
566
624
  gridSnapStep: 0.5,
567
625
  magneticSnap: true,
@@ -570,6 +628,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
570
628
  wall: defaultSnappingModeFor('wall'),
571
629
  item: defaultSnappingModeFor('item'),
572
630
  polygon: defaultSnappingModeFor('polygon'),
631
+ rotation: defaultSnappingModeFor('rotation'),
573
632
  },
574
633
  continuationByContext: {
575
634
  wall: CONTINUATION_PROFILES.wall.default,
@@ -589,25 +648,18 @@ type SelectDefaultBuildingAndLevelOptions = {
589
648
  forceGroundLevel?: boolean
590
649
  }
591
650
 
592
- function defaultBuildTool(phase: Phase, structureLayer: StructureLayer): StructureTool {
593
- if (phase === 'site') return 'property-line'
594
- if (phase === 'furnish') return 'item'
595
- return structureLayer === 'zones' ? 'zone' : 'wall'
651
+ function defaultBuildTool(phase: Phase): StructureTool {
652
+ return phase === 'site' ? 'property-line' : 'wall'
596
653
  }
597
654
 
598
- function materializeToolMode(
599
- mode: Mode,
600
- tool: unknown,
601
- phase: Phase,
602
- structureLayer: StructureLayer,
603
- ): ToolMode {
655
+ function materializeToolMode(mode: Mode, tool: unknown, phase: Phase): ToolMode {
604
656
  if (mode === 'build') {
605
657
  return {
606
658
  mode,
607
659
  tool:
608
660
  typeof tool === 'string' && tool.length > 0
609
661
  ? (tool as StructureTool)
610
- : defaultBuildTool(phase, structureLayer),
662
+ : defaultBuildTool(phase),
611
663
  }
612
664
  }
613
665
 
@@ -641,7 +693,7 @@ function withMaterializedToolMode(
641
693
  ): PersistedEditorUiState {
642
694
  return {
643
695
  ...state,
644
- toolMode: materializeToolMode(state.mode, state.tool, state.phase, state.structureLayer),
696
+ toolMode: materializeToolMode(state.mode, state.tool, state.phase),
645
697
  }
646
698
  }
647
699
 
@@ -655,91 +707,82 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
655
707
  return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
656
708
  }
657
709
 
658
- function normalizeFloorplanPaneRatio(value: unknown): number {
659
- if (!(typeof value === 'number' && Number.isFinite(value))) {
660
- return DEFAULT_FLOORPLAN_PANE_RATIO
710
+ function normalizeViewLayouts(
711
+ state: (Partial<PersistedEditorUiState> & LegacyViewModeState) | null | undefined,
712
+ ): Record<string, ViewLayout> {
713
+ const stored = state?.viewLayouts
714
+ if (stored && typeof stored === 'object') {
715
+ return Object.fromEntries(
716
+ Object.entries(stored).map(([workspace, layout]) => [workspace, normalizeViewLayout(layout)]),
717
+ )
661
718
  }
719
+ if (state?.viewMode === undefined && state?.isFloorplanOpen === undefined) return {}
720
+ return { edit: viewLayoutFromLegacy(state) }
721
+ }
662
722
 
663
- return Math.min(MAX_FLOORPLAN_PANE_RATIO, Math.max(MIN_FLOORPLAN_PANE_RATIO, value))
723
+ function normalizeSidebarPanels(value: unknown): Partial<Record<WorkspaceMode, string>> {
724
+ if (!value || typeof value !== 'object') return {}
725
+ return Object.fromEntries(
726
+ Object.entries(value).filter(
727
+ (entry): entry is [string, string] =>
728
+ typeof entry[1] === 'string' && entry[1].trim().length > 0,
729
+ ),
730
+ )
731
+ }
732
+
733
+ function normalizePinnedViews(value: unknown): Record<string, boolean> {
734
+ if (!value || typeof value !== 'object') return {}
735
+ return Object.fromEntries(
736
+ Object.entries(value).filter(
737
+ (entry): entry is [string, boolean] => typeof entry[1] === 'boolean',
738
+ ),
739
+ )
664
740
  }
665
741
 
666
742
  export function normalizePersistedEditorUiState(
667
- state: Partial<PersistedEditorUiState> | null | undefined,
743
+ state: (Partial<PersistedEditorUiState> & LegacyViewModeState) | null | undefined,
668
744
  ): PersistedEditorUiState {
669
- const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
745
+ // Before Structure and Furnish merged, `phase` stored either of them.
746
+ const storedPhase: unknown = state?.phase
747
+ const phase: Phase =
748
+ storedPhase === 'building' || storedPhase === 'structure' || storedPhase === 'furnish'
749
+ ? 'building'
750
+ : 'site'
670
751
  const persistedToolMode = readPersistedToolMode(state)
671
752
  let mode = normalizeModeForPhase(phase, persistedToolMode.mode)
672
753
 
673
- // Migrate old isFloorplanOpen to viewMode
674
- let viewMode: ViewMode = '3d'
675
- if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {
676
- viewMode = state.viewMode
677
- } else if (state?.isFloorplanOpen) {
678
- viewMode = 'split'
679
- }
680
- const isFloorplanOpen = viewMode !== '3d'
754
+ const viewLayouts = normalizeViewLayouts(state)
681
755
 
682
- // Both are persisted independently, and rehydrate goes through neither setter,
683
- // so this is the third place the sculpt/2D pair has to be reconciled. The view
684
- // wins here for the same reason it does in `setViewMode`: a brush armed over a
685
- // hidden canvas is a mode the user cannot use, and reviving one on load is
686
- // worse than reviving it mid-session — nothing on screen explains it.
687
- if (mode === 'terrain-sculpt' && viewMode === '2d') mode = 'select'
756
+ // Both are persisted independently, and rehydrate goes through no setter, so
757
+ // this is the third place the sculpt/3D pair has to be reconciled. The view
758
+ // wins here for the same reason it does in `commitViewLayout`: a brush armed
759
+ // over a hidden canvas is a mode the user cannot use, and reviving one on load
760
+ // is worse than reviving it mid-session — nothing on screen explains it.
761
+ const editLayout = viewLayouts.edit ?? DEFAULT_VIEW_LAYOUT
762
+ if (mode === 'terrain-sculpt' && paneOfView(editLayout, VIEW_3D) === null) mode = 'select'
688
763
 
689
764
  if (phase === 'site') {
690
765
  return withMaterializedToolMode({
691
766
  phase,
692
767
  mode,
693
768
  tool: mode === 'build' ? 'property-line' : null,
694
- structureLayer: 'elements',
695
769
  catalogCategory: null,
696
- viewMode,
697
- isFloorplanOpen,
698
- })
699
- }
700
-
701
- if (phase === 'furnish') {
702
- return withMaterializedToolMode({
703
- phase,
704
- mode,
705
- tool: mode === 'build' ? 'item' : null,
706
- structureLayer: 'elements',
707
- catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
708
- viewMode,
709
- isFloorplanOpen,
770
+ viewLayouts,
710
771
  })
711
772
  }
712
773
 
713
- const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
714
-
715
774
  if (mode !== 'build') {
716
775
  return withMaterializedToolMode({
717
776
  phase,
718
777
  mode,
719
778
  tool: null,
720
- structureLayer,
721
- catalogCategory: null,
722
- viewMode,
723
- isFloorplanOpen,
724
- })
725
- }
726
-
727
- if (structureLayer === 'zones') {
728
- return withMaterializedToolMode({
729
- phase,
730
- mode,
731
- tool: 'zone',
732
- structureLayer,
733
779
  catalogCategory: null,
734
- viewMode,
735
- isFloorplanOpen,
780
+ viewLayouts,
736
781
  })
737
782
  }
738
783
 
739
784
  const tool =
740
- persistedToolMode.tool &&
741
- persistedToolMode.tool !== 'property-line' &&
742
- persistedToolMode.tool !== 'zone'
785
+ persistedToolMode.tool && persistedToolMode.tool !== 'property-line'
743
786
  ? (persistedToolMode.tool as Tool)
744
787
  : 'wall'
745
788
 
@@ -747,13 +790,33 @@ export function normalizePersistedEditorUiState(
747
790
  phase,
748
791
  mode,
749
792
  tool,
750
- structureLayer,
751
793
  catalogCategory: tool === 'item' ? (state?.catalogCategory ?? null) : null,
752
- viewMode,
753
- isFloorplanOpen,
794
+ viewLayouts,
754
795
  })
755
796
  }
756
797
 
798
+ /**
799
+ * The UI state a project opens with.
800
+ *
801
+ * Every persisted preference comes back except the armed tool: a project always
802
+ * opens in select mode. The tool is session state, not a preference — re-arming
803
+ * the one the last session happened to leave behind turns the first canvas click
804
+ * of the new one into a wall (or a dropped item) instead of a selection, with
805
+ * nothing on screen to explain why. `partialize` no longer writes it; a blob
806
+ * written before that still carries it, so the rule is enforced on read.
807
+ */
808
+ export function editorUiStateOnOpen(
809
+ state: (Partial<PersistedEditorUiState> & LegacyViewModeState) | null | undefined,
810
+ ): PersistedEditorUiState {
811
+ return {
812
+ ...normalizePersistedEditorUiState(state),
813
+ toolMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.toolMode,
814
+ mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
815
+ tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
816
+ catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
817
+ }
818
+ }
819
+
757
820
  // Validate a persisted per-context mode against that context's allowed set
758
821
  // (so e.g. a stale `angles` for items resets), falling back to its default.
759
822
  function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode {
@@ -800,16 +863,18 @@ function normalizeContinuationByContext(
800
863
  }
801
864
  }
802
865
 
803
- function normalizePersistedEditorLayoutState(
804
- state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
866
+ export function normalizePersistedEditorLayoutState(
867
+ state:
868
+ | (Omit<Partial<PersistedEditorLayoutState>, 'snappingModeByContext'> &
869
+ LegacyContinuationState & {
870
+ snappingModeByContext?: Partial<Record<SnapContext, unknown>>
871
+ })
872
+ | null
873
+ | undefined,
805
874
  ): PersistedEditorLayoutState {
806
875
  return {
807
- activeSidebarPanel:
808
- typeof state?.activeSidebarPanel === 'string' && state.activeSidebarPanel.trim()
809
- ? state.activeSidebarPanel
810
- : DEFAULT_ACTIVE_SIDEBAR_PANEL,
811
- floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
812
- splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
876
+ sidebarPanels: normalizeSidebarPanels(state?.sidebarPanels),
877
+ pinnedViews: normalizePinnedViews(state?.pinnedViews),
813
878
  floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
814
879
  gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
815
880
  ? (state?.gridSnapStep as GridSnapStep)
@@ -821,6 +886,7 @@ function normalizePersistedEditorLayoutState(
821
886
  wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
822
887
  item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
823
888
  polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
889
+ rotation: migrateSnappingMode(state?.snappingModeByContext?.rotation, 'rotation'),
824
890
  },
825
891
  continuationByContext: normalizeContinuationByContext(state),
826
892
  showReferenceFloor: state?.showReferenceFloor === true,
@@ -845,10 +911,8 @@ export function hasCustomPersistedEditorUiState(
845
911
  normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase ||
846
912
  normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||
847
913
  normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
848
- normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
849
914
  normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
850
- normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen ||
851
- normalizedState.viewMode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode
915
+ Object.keys(normalizedState.viewLayouts).length > 0
852
916
  )
853
917
  }
854
918
 
@@ -942,10 +1006,29 @@ export function selectSiteFloorplanContext() {
942
1006
  })
943
1007
  }
944
1008
 
945
- // Stashes the view mode the user was in before entering capture, so we can
946
- // restore it on exit. Snapshot capture always frames in 3D — the 2D/split
947
- // floorplan panes render nothing meaningful for a thumbnail.
948
- let viewModeBeforeCapture: ViewMode | null = null
1009
+ // The editor's active level when capture began (undefined: not in capture).
1010
+ let levelBeforeCapture: LevelNode['id'] | null | undefined
1011
+ // The editor's viewer selection while Preview borrows the shared viewer store.
1012
+ let viewerBeforePreview: {
1013
+ selection: ReturnType<typeof useViewer.getState>['selection']
1014
+ focusedUnitId: ReturnType<typeof useViewer.getState>['focusedUnitId']
1015
+ room: RoomKey | null
1016
+ toolMode: ToolMode
1017
+ cameraPose: CameraPose | null
1018
+ cameraMode: ReturnType<typeof useViewer.getState>['cameraMode']
1019
+ levelMode: ReturnType<typeof useViewer.getState>['levelMode']
1020
+ wallMode: ReturnType<typeof useViewer.getState>['wallMode']
1021
+ hideLevelsAboveSelection: boolean
1022
+ showZones: boolean
1023
+ } | null = null
1024
+ let previewCameraRestore: CameraPose | null = null
1025
+
1026
+ // Preview replaces the canvas; the returning controls consume this after mounting.
1027
+ export function takePreviewCameraRestore(): CameraPose | null {
1028
+ const pose = previewCameraRestore
1029
+ previewCameraRestore = null
1030
+ return pose
1031
+ }
949
1032
 
950
1033
  /**
951
1034
  * Hold the interaction scope that belongs to a sustained brush mode.
@@ -973,6 +1056,19 @@ function syncBrushModeScope(mode: Mode): void {
973
1056
  scope.endIf((s) => s.kind === 'painting' || s.kind === 'sculpting')
974
1057
  if (useEditor.getState().terrainSampling) useEditor.getState().setTerrainSampling(false)
975
1058
  }
1059
+ // Erasing and a refused-gesture notice end with the paint session, whatever left it.
1060
+ if (mode !== 'material-paint') endPaintSession()
1061
+ }
1062
+
1063
+ /**
1064
+ * The modes whose click acts on what is selected. In every other mode a click
1065
+ * draws, places, paints, sculpts or deletes, so the ToolMode transition into
1066
+ * one ends the selection — room, nodes and reference — and its panel and
1067
+ * overlay go with it. A flow that needs a target inside such a mode hands it to
1068
+ * the tool itself (paint primes its target from the selection before it goes).
1069
+ */
1070
+ function keepsSelection(mode: Mode): boolean {
1071
+ return mode === 'select' || mode === 'edit'
976
1072
  }
977
1073
 
978
1074
  /**
@@ -993,110 +1089,86 @@ export function isBrushMode(mode: Mode): boolean {
993
1089
  const useEditor = create<EditorState>()(
994
1090
  persist(
995
1091
  (set, get) => ({
1092
+ room: null,
1093
+ hoveredRoom: null,
1094
+ selectRoom: (room) =>
1095
+ set({ room, hoveredRoom: null, selectedReferenceId: null, selectedMaterialTarget: null }),
1096
+ clearRoom: () => set({ room: null, hoveredRoom: null }),
1097
+ setHoveredRoom: (room) => {
1098
+ if (!sameRoom(get().hoveredRoom, room)) set({ hoveredRoom: room })
1099
+ },
996
1100
  phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
997
1101
  setPhase: (phase) => {
998
1102
  const currentPhase = get().phase
999
1103
  if (currentPhase === phase) return
1000
1104
  const wasBuilding = get().toolMode.mode === 'build'
1001
- const structureLayer = phase === 'furnish' ? 'elements' : get().structureLayer
1002
1105
  set({
1003
1106
  phase,
1004
- structureLayer,
1005
- catalogCategory: wasBuilding && phase === 'furnish' ? 'furniture' : null,
1107
+ // Site has no rooms.
1108
+ ...(phase === 'site' ? { room: null } : {}),
1109
+ hoveredRoom: null,
1110
+ catalogCategory: null,
1006
1111
  })
1007
1112
  get().armToolMode(
1008
- wasBuilding
1009
- ? { mode: 'build', tool: defaultBuildTool(phase, structureLayer) }
1010
- : { mode: 'select' },
1113
+ wasBuilding ? { mode: 'build', tool: defaultBuildTool(phase) } : { mode: 'select' },
1011
1114
  )
1012
1115
 
1013
- switch (phase) {
1014
- case 'site':
1015
- selectSiteFloorplanContext()
1016
- break
1017
-
1018
- case 'structure':
1019
- selectDefaultBuildingAndLevel()
1020
- break
1021
-
1022
- case 'furnish':
1023
- selectDefaultBuildingAndLevel()
1024
- break
1025
- }
1116
+ if (phase === 'site') selectSiteFloorplanContext()
1117
+ else selectDefaultBuildingAndLevel()
1026
1118
  },
1027
1119
  toolMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.toolMode,
1028
1120
  armToolMode: (requested) => {
1029
1121
  const current = get()
1030
1122
  let phase = current.phase
1031
- let structureLayer = current.structureLayer
1032
- let viewMode = current.viewMode
1033
- let isFloorplanOpen = current.isFloorplanOpen
1034
1123
  const next = materializeToolMode(
1035
1124
  requested.mode,
1036
1125
  requested.mode === 'build' ? requested.tool : null,
1037
1126
  phase,
1038
- structureLayer,
1039
1127
  )
1040
1128
 
1041
1129
  if (next.mode === 'terrain-sculpt') {
1042
1130
  phase = 'site'
1043
- structureLayer = 'elements'
1044
- if (viewMode === '2d') {
1045
- viewMode = 'split'
1046
- isFloorplanOpen = true
1047
- }
1048
1131
  } else if (next.mode === 'build' && next.tool === 'property-line') {
1049
1132
  phase = 'site'
1050
- structureLayer = 'elements'
1051
- } else if (next.mode === 'build' && next.tool === 'zone') {
1052
- phase = 'structure'
1053
- structureLayer = 'zones'
1054
- } else if (next.mode === 'build' && phase === 'site') {
1055
- phase = 'structure'
1056
- structureLayer = 'elements'
1057
- } else if (next.mode === 'material-paint' && phase === 'site') {
1058
- phase = 'structure'
1059
- structureLayer = 'elements'
1060
- } else if (phase !== 'structure') {
1061
- structureLayer = 'elements'
1062
1133
  }
1063
1134
 
1064
1135
  const phaseChanged = phase !== current.phase
1136
+ const nextTool = next.mode === 'build' ? next.tool : null
1137
+ const endsSelection =
1138
+ !keepsSelection(next.mode) && (next.mode !== current.mode || nextTool !== current.tool)
1139
+ if (next.mode === 'material-paint') get().primeMaterialPaintFromSelection()
1065
1140
  set({
1066
1141
  toolMode: next,
1067
1142
  mode: next.mode,
1068
- tool: next.mode === 'build' ? next.tool : null,
1143
+ tool: nextTool,
1144
+ ...(endsSelection ? { room: null, hoveredRoom: null, selectedReferenceId: null } : {}),
1069
1145
  ...(phaseChanged ? { phase } : {}),
1070
- ...(structureLayer !== current.structureLayer ? { structureLayer } : {}),
1071
- ...(viewMode !== current.viewMode ? { viewMode } : {}),
1072
- ...(isFloorplanOpen !== current.isFloorplanOpen ? { isFloorplanOpen } : {}),
1073
- ...(next.mode === 'build' && phase === 'furnish' && !current.catalogCategory
1074
- ? { catalogCategory: 'furniture' }
1075
- : {}),
1076
1146
  })
1147
+ // The brush works on the 3D canvas, so arming it opens 3D beside the plan.
1148
+ if (next.mode === 'terrain-sculpt' && !isViewVisible(get(), VIEW_3D)) {
1149
+ get().showView(VIEW_3D, { beside: true })
1150
+ }
1077
1151
 
1078
1152
  if (phaseChanged) {
1079
1153
  if (phase === 'site') selectSiteFloorplanContext()
1080
1154
  else selectDefaultBuildingAndLevel()
1081
1155
  }
1082
- if (next.mode === 'material-paint') get().primeMaterialPaintFromSelection()
1083
- if (next.mode === 'terrain-sculpt') {
1084
- useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1085
- }
1156
+ if (endsSelection) useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1086
1157
  syncBrushModeScope(next.mode)
1087
1158
  },
1088
- armMaterialPaint: (material) => {
1159
+ armMaterialPaint: (material, subMode) => {
1089
1160
  get().armToolMode({ mode: 'material-paint' })
1161
+ const paintMode = usePaintRegionMode.getState()
1162
+ paintMode.setMode(subMode ?? paintMode.drawMode)
1090
1163
  if (material) get().setActivePaintMaterial(material)
1091
1164
  },
1092
1165
  mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
1093
1166
  setMode: (mode) => {
1094
1167
  if (mode === 'build') {
1095
- const { phase, structureLayer, toolMode } = get()
1168
+ const { phase, toolMode } = get()
1096
1169
  get().armToolMode({
1097
1170
  mode,
1098
- tool:
1099
- toolMode.mode === 'build' ? toolMode.tool : defaultBuildTool(phase, structureLayer),
1171
+ tool: toolMode.mode === 'build' ? toolMode.tool : defaultBuildTool(phase),
1100
1172
  })
1101
1173
  return
1102
1174
  }
@@ -1112,7 +1184,7 @@ const useEditor = create<EditorState>()(
1112
1184
  get().armToolMode({ mode: 'select' })
1113
1185
  return
1114
1186
  }
1115
- get().armToolMode(materializeToolMode(get().mode, null, get().phase, get().structureLayer))
1187
+ get().armToolMode(materializeToolMode(get().mode, null, get().phase))
1116
1188
  },
1117
1189
  toolDefaults: {},
1118
1190
  setToolDefaults: (tool, defaults) =>
@@ -1127,22 +1199,6 @@ const useEditor = create<EditorState>()(
1127
1199
  }),
1128
1200
  lastMeasurementKind: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.lastMeasurementKind,
1129
1201
  setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
1130
- structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
1131
- setStructureLayer: (layer) => {
1132
- const wasBuilding = get().toolMode.mode === 'build'
1133
- set({ structureLayer: layer })
1134
- get().armToolMode(
1135
- wasBuilding
1136
- ? { mode: 'build', tool: layer === 'zones' ? 'zone' : 'wall' }
1137
- : { mode: 'select' },
1138
- )
1139
-
1140
- const viewer = useViewer.getState()
1141
- viewer.setSelection({
1142
- selectedIds: [],
1143
- zoneId: null,
1144
- })
1145
- },
1146
1202
  catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
1147
1203
  setCatalogCategory: (category) => set({ catalogCategory: category }),
1148
1204
  selectedItem: null,
@@ -1201,11 +1257,16 @@ const useEditor = create<EditorState>()(
1201
1257
  },
1202
1258
  selectedMaterialTarget: null,
1203
1259
  setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
1260
+ paintEraser: usePaintRegionMode.getState().mode === 'erase',
1204
1261
  activePaintMaterial: null,
1205
- // Picking a material implies paint, not erase — clear the eraser so the
1262
+ // Picking a material implies paint, not erase — leave the eraser so the
1206
1263
  // next click applies the chosen material.
1207
- setActivePaintMaterial: (material) =>
1208
- set({ activePaintMaterial: material, paintEraser: false }),
1264
+ setActivePaintMaterial: (material) => {
1265
+ set({ activePaintMaterial: material })
1266
+ if (material && isPaintErasing()) {
1267
+ usePaintRegionMode.getState().setMode(usePaintRegionMode.getState().drawMode)
1268
+ }
1269
+ },
1209
1270
  activePaintTarget: 'wall',
1210
1271
  setActivePaintTarget: (target) =>
1211
1272
  set((state) =>
@@ -1224,8 +1285,6 @@ const useEditor = create<EditorState>()(
1224
1285
  set({ paintScope: next })
1225
1286
  return next
1226
1287
  },
1227
- paintEraser: false,
1228
- setPaintEraser: (eraser) => set({ paintEraser: eraser }),
1229
1288
  primeMaterialPaintFromSelection: () => {
1230
1289
  const selectedId =
1231
1290
  useViewer.getState().selection.selectedIds.length === 1
@@ -1237,6 +1296,7 @@ const useEditor = create<EditorState>()(
1237
1296
  selectedId,
1238
1297
  }) ?? get().activePaintTarget
1239
1298
  const activePaintMaterial = resolveActivePaintMaterialFromSelection({
1299
+ materials: useScene.getState().materials,
1240
1300
  nodes: useScene.getState().nodes,
1241
1301
  selectedId,
1242
1302
  selectedMaterialTarget: get().selectedMaterialTarget,
@@ -1273,7 +1333,8 @@ const useEditor = create<EditorState>()(
1273
1333
  canFindNode: false,
1274
1334
  setCanFindNode: (canFind) => set({ canFindNode: canFind }),
1275
1335
  selectedReferenceId: null,
1276
- setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
1336
+ setSelectedReferenceId: (id) =>
1337
+ set({ selectedReferenceId: id, ...(id ? { room: null, hoveredRoom: null } : {}) }),
1277
1338
  referenceScaleActiveGuideId: null,
1278
1339
  setReferenceScaleActiveGuideId: (id) => set({ referenceScaleActiveGuideId: id }),
1279
1340
  guideUi: {},
@@ -1318,13 +1379,63 @@ const useEditor = create<EditorState>()(
1318
1379
  ),
1319
1380
  isPreviewMode: false,
1320
1381
  setPreviewMode: (preview) => {
1382
+ if (preview === get().isPreviewMode) return
1321
1383
  if (preview) {
1384
+ const viewer = useViewer.getState()
1385
+ viewerBeforePreview = {
1386
+ selection: viewer.selection,
1387
+ focusedUnitId: viewer.focusedUnitId,
1388
+ room: get().room,
1389
+ toolMode: get().toolMode,
1390
+ cameraPose: cameraPoseStore.getState().pose,
1391
+ cameraMode: viewer.cameraMode,
1392
+ levelMode: viewer.levelMode,
1393
+ wallMode: viewer.wallMode,
1394
+ hideLevelsAboveSelection: viewer.hideLevelsAboveSelection,
1395
+ showZones: viewer.showZones,
1396
+ }
1397
+ previewCameraRestore = null
1322
1398
  set({ isPreviewMode: true, catalogCategory: null })
1323
1399
  get().armToolMode({ mode: 'select' })
1324
- // Clear zone/item selection for clean viewer drill-down hierarchy
1325
- useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1400
+ // Preview starts on the editor's floor with nothing else picked; what
1401
+ // the visitor selects there is its own and is dropped on the way out.
1402
+ viewer.setSelection({ selectedIds: [], zoneId: null })
1403
+ viewer.setFocusedUnit(null)
1404
+ viewer.setHoveredId(null)
1326
1405
  } else {
1406
+ if (get().isFirstPersonMode) get().setFirstPersonMode(false)
1407
+ useViewer.getState().setWalkthroughMode(false)
1327
1408
  set({ isPreviewMode: false })
1409
+ const before = viewerBeforePreview
1410
+ viewerBeforePreview = null
1411
+ if (!before) return
1412
+ const viewer = useViewer.getState()
1413
+ const nodes = useScene.getState().nodes
1414
+ const exists = (id: string | null) => id !== null && !!nodes[id as AnyNodeId]
1415
+ get().armToolMode(before.toolMode)
1416
+ set({
1417
+ room:
1418
+ before.room && exists(before.room.zoneId) && exists(before.room.levelId)
1419
+ ? before.room
1420
+ : null,
1421
+ hoveredRoom: null,
1422
+ })
1423
+ viewer.setSelection({
1424
+ buildingId: exists(before.selection.buildingId) ? before.selection.buildingId : null,
1425
+ levelId: exists(before.selection.levelId) ? before.selection.levelId : null,
1426
+ zoneId: exists(before.selection.zoneId) ? before.selection.zoneId : null,
1427
+ selectedIds: before.selection.selectedIds.filter(exists),
1428
+ })
1429
+ viewer.setFocusedUnit(exists(before.focusedUnitId) ? before.focusedUnitId : null)
1430
+ viewer.setHoveredId(null)
1431
+ viewer.setCameraMode(before.cameraMode)
1432
+ viewer.setLevelMode(before.levelMode)
1433
+ viewer.setWallMode(before.wallMode)
1434
+ useViewer.setState({
1435
+ hideLevelsAboveSelection: before.hideLevelsAboveSelection,
1436
+ showZones: before.showZones,
1437
+ })
1438
+ previewCameraRestore = before.cameraPose
1328
1439
  }
1329
1440
  },
1330
1441
  captureMode: { mode: 'idle' } as CaptureMode,
@@ -1333,59 +1444,60 @@ const useEditor = create<EditorState>()(
1333
1444
  const resolved: CaptureMode =
1334
1445
  typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
1335
1446
  const entering = resolved.mode !== 'idle'
1447
+ const wasCapturing = get().isCaptureMode
1448
+ if (entering && !wasCapturing) {
1449
+ levelBeforeCapture = useViewer.getState().selection.levelId
1450
+ set({ captureLevelId: null })
1451
+ }
1336
1452
  // Walk / drone framing is a capture-only camera, so leaving capture always
1337
- // lands back on orbit. Run it first: it restores its own view mode, and
1338
- // the capture restore below has the final say.
1453
+ // lands back on orbit. Capture frames in 3D alone (`activeViewLayout`)
1454
+ // without touching the stored layout, so leaving it needs no restore.
1339
1455
  if (!entering && get().isFirstPersonMode) {
1340
1456
  get().setFirstPersonMode(false)
1341
1457
  }
1342
- set((state) => {
1343
- if (entering) {
1344
- // Force 3D for the shot. Remember the prior mode only on the first
1345
- // entry (viewMode is already '3d' on re-entry), so we restore the
1346
- // user's real choice — not the forced '3d' — when capture ends.
1347
- if (state.viewMode !== '3d') {
1348
- viewModeBeforeCapture = state.viewMode
1349
- return {
1350
- captureMode: resolved,
1351
- isCaptureMode: true,
1352
- viewMode: '3d',
1353
- isFloorplanOpen: false,
1354
- }
1355
- }
1356
- return { captureMode: resolved, isCaptureMode: true }
1357
- }
1358
- const restore = viewModeBeforeCapture
1359
- viewModeBeforeCapture = null
1360
- if (restore && restore !== '3d') {
1361
- return {
1362
- captureMode: resolved,
1363
- isCaptureMode: false,
1364
- viewMode: restore,
1365
- isFloorplanOpen: true,
1366
- }
1367
- }
1368
- return { captureMode: resolved, isCaptureMode: false }
1369
- })
1458
+ set(
1459
+ entering
1460
+ ? { captureMode: resolved, isCaptureMode: true }
1461
+ : { captureMode: resolved, isCaptureMode: false, captureLevelId: null },
1462
+ )
1463
+ if (!entering && wasCapturing) {
1464
+ const level = levelBeforeCapture
1465
+ levelBeforeCapture = undefined
1466
+ const viewer = useViewer.getState()
1467
+ const exists = level === null || useScene.getState().nodes[level as AnyNodeId]
1468
+ if (level !== undefined && exists && viewer.selection.levelId !== level)
1469
+ viewer.setSelection({ levelId: level })
1470
+ }
1370
1471
  },
1371
- viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
1372
- setViewMode: (mode) => {
1373
- set({ viewMode: mode, isFloorplanOpen: mode !== '3d' })
1374
- // Going the other way, the view wins and the mode yields. Hiding the 3D
1375
- // pane leaves the brush unreachable, and a held `sculpting` scope would
1376
- // then keep selection suppressed in a floorplan the user is trying to
1377
- // work in — a mode you cannot use and cannot see how to leave.
1378
- if (mode === '2d' && get().mode === 'terrain-sculpt') get().setMode('select')
1472
+ captureLevelId: null,
1473
+ setCaptureLevel: (levelId) => {
1474
+ if (!get().isCaptureMode) return
1475
+ set({ captureLevelId: levelId })
1476
+ const viewer = useViewer.getState()
1477
+ if (levelId && viewer.selection.levelId !== levelId) viewer.setSelection({ levelId })
1379
1478
  },
1380
- splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
1381
- setSplitOrientation: (orientation) => set({ splitOrientation: orientation }),
1382
- isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
1383
- setFloorplanOpen: (open) => set({ isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }),
1384
- toggleFloorplanOpen: () =>
1385
- set((state) => {
1386
- const open = !state.isFloorplanOpen
1387
- return { isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }
1388
- }),
1479
+ viewLayouts: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewLayouts,
1480
+ showView: (viewId, options) =>
1481
+ commitViewLayout(withView(storedViewLayout(get()), viewId, options)),
1482
+ setPaneView: (pane, viewId) =>
1483
+ commitViewLayout(withPaneView(storedViewLayout(get()), pane, viewId)),
1484
+ closeView: (viewId) => commitViewLayout(withoutView(storedViewLayout(get()), viewId)),
1485
+ setViewLayout: (layout) => commitViewLayout(normalizeViewLayout(layout)),
1486
+ viewMode: '3d',
1487
+ setViewMode: (mode) => commitViewLayout(sceneLayout(mode)),
1488
+ toggleSplit: () => commitViewLayout(withSplitToggled(storedViewLayout(get()))),
1489
+ swapPanes: () => commitViewLayout(swapped(storedViewLayout(get()))),
1490
+ setViewPaneRatio: (ratio) =>
1491
+ commitViewLayout({ ...storedViewLayout(get()), ratio: clampViewPaneRatio(ratio) }),
1492
+ focusViewPane: (pane) => {
1493
+ const layout = storedViewLayout(get())
1494
+ if (layout.focus !== pane && (pane === 0 || layout.split)) {
1495
+ commitViewLayout({ ...layout, focus: pane })
1496
+ }
1497
+ },
1498
+ pinnedViews: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.pinnedViews,
1499
+ setViewPinned: (viewId, pinned) =>
1500
+ set((state) => ({ pinnedViews: { ...state.pinnedViews, [viewId]: pinned } })),
1389
1501
  isFloorplanHovered: false,
1390
1502
  setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
1391
1503
  isRiserOpen: false,
@@ -1461,26 +1573,14 @@ const useEditor = create<EditorState>()(
1461
1573
  show2dVoronoi: false,
1462
1574
  setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
1463
1575
  isFirstPersonMode: false,
1464
- _viewModeBeforeFirstPerson: null as ViewMode | null,
1576
+ // First person shows 3D alone (`activeViewLayout`); the stored layout
1577
+ // comes back on exit untouched.
1465
1578
  setFirstPersonMode: (enabled) => {
1466
1579
  if (enabled) {
1467
- const currentViewMode = get().viewMode
1468
- set({
1469
- isFirstPersonMode: true,
1470
- _viewModeBeforeFirstPerson: currentViewMode,
1471
- viewMode: '3d',
1472
- isFloorplanOpen: false,
1473
- catalogCategory: null,
1474
- })
1580
+ set({ isFirstPersonMode: true, catalogCategory: null })
1475
1581
  get().armToolMode({ mode: 'select' })
1476
1582
  } else {
1477
- const prevMode = get()._viewModeBeforeFirstPerson
1478
- set({
1479
- isFirstPersonMode: false,
1480
- firstPersonMovementMode: 'walk',
1481
- _viewModeBeforeFirstPerson: null,
1482
- ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
1483
- })
1583
+ set({ isFirstPersonMode: false, firstPersonMovementMode: 'walk' })
1484
1584
  }
1485
1585
  },
1486
1586
  firstPersonMovementMode: 'walk' as FirstPersonMovementMode,
@@ -1498,35 +1598,32 @@ const useEditor = create<EditorState>()(
1498
1598
  captureShutterHold: false,
1499
1599
  setCaptureShutterHold: (hold) => set({ captureShutterHold: hold }),
1500
1600
  workspaceMode: 'edit' as WorkspaceMode,
1501
- _viewModeBeforeStudio: null as ViewMode | null,
1502
1601
  setWorkspaceMode: (mode) => {
1503
1602
  if (get().workspaceMode === mode) return
1504
- if (mode === 'studio') {
1505
- const currentViewMode = get().viewMode
1506
- set({
1507
- workspaceMode: 'studio',
1508
- _viewModeBeforeStudio: currentViewMode,
1509
- viewMode: '3d',
1510
- isFloorplanOpen: false,
1511
- catalogCategory: null,
1512
- })
1603
+ // Each workspace reopens on its last pick. One with no pick keeps the
1604
+ // panel open now when its rail has it, else shows its first
1605
+ // (`useShownSidebarPanel`).
1606
+ const activeSidebarPanel = get().sidebarPanels[mode] ?? get().activeSidebarPanel
1607
+ // Every non-'edit' workspace (studio's clean canvas, sheets' paper
1608
+ // space) enters the same way: drop the editing chrome. The view layout
1609
+ // needs no stash — each workspace keeps its own.
1610
+ if (mode !== 'edit') {
1611
+ set({ workspaceMode: mode, activeSidebarPanel, catalogCategory: null })
1513
1612
  get().armToolMode({ mode: 'select' })
1514
1613
  // Clear selection so no edit affordances bleed into the clean canvas.
1515
1614
  useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1516
1615
  } else {
1517
- const prevMode = get()._viewModeBeforeStudio
1518
- set({
1519
- workspaceMode: 'edit',
1520
- _viewModeBeforeStudio: null,
1521
- ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
1522
- })
1616
+ set({ workspaceMode: 'edit', activeSidebarPanel })
1523
1617
  }
1618
+ yieldBrushToHiddenCanvas()
1524
1619
  },
1525
1620
  activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
1526
- setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
1527
- floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
1528
- setFloorplanPaneRatio: (ratio) =>
1529
- set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
1621
+ setActiveSidebarPanel: (id) =>
1622
+ set((state) => ({
1623
+ activeSidebarPanel: id,
1624
+ sidebarPanels: { ...state.sidebarPanels, [state.workspaceMode]: id },
1625
+ })),
1626
+ sidebarPanels: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.sidebarPanels,
1530
1627
  mobilePanelSheetHeight: 0,
1531
1628
  setMobilePanelSheetHeight: (px) => set({ mobilePanelSheetHeight: Math.max(0, px) }),
1532
1629
  modelExport: null,
@@ -1534,10 +1631,9 @@ const useEditor = create<EditorState>()(
1534
1631
  }),
1535
1632
  {
1536
1633
  name: 'pascal-editor-ui-preferences',
1634
+ storage: hydrationGatedStorage((): boolean => useEditor.persist.hasHydrated()),
1537
1635
  merge: (persistedState, currentState) => {
1538
- const uiState = normalizePersistedEditorUiState(
1539
- persistedState as Partial<PersistedEditorState>,
1540
- )
1636
+ const uiState = editorUiStateOnOpen(persistedState as Partial<PersistedEditorState>)
1541
1637
  const layoutState = normalizePersistedEditorLayoutState(
1542
1638
  persistedState as Partial<PersistedEditorState>,
1543
1639
  )
@@ -1546,35 +1642,27 @@ const useEditor = create<EditorState>()(
1546
1642
  ...currentState,
1547
1643
  ...uiState,
1548
1644
  ...layoutState,
1549
- ...(uiState.mode === 'build' && uiState.tool === 'measurement'
1550
- ? {
1551
- toolDefaults: {
1552
- ...currentState.toolDefaults,
1553
- measurement: { kind: layoutState.lastMeasurementKind },
1554
- },
1555
- }
1556
- : {}),
1645
+ activeSidebarPanel:
1646
+ layoutState.sidebarPanels[currentState.workspaceMode] ?? DEFAULT_ACTIVE_SIDEBAR_PANEL,
1557
1647
  }
1558
1648
  },
1559
- // `toolMode` is persisted, but the interaction scope a brush mode holds is not
1560
- // — it lives in a separate, non-persisted store. Rehydrating into
1561
- // `terrain-sculpt` (or paint) without re-claiming the scope would restore
1562
- // the brush with selection still enabled, so every dab could grab a wall.
1649
+ // `merge` rewrites `mode` wholesale, while the interaction scope a brush
1650
+ // mode holds is not persisted — it lives in a separate store. A paint or
1651
+ // sculpt brush armed before rehydrate would otherwise keep its scope after
1652
+ // the merge resets the mode, leaving selection suppressed for the rest of
1653
+ // the session with no mode on screen to explain it.
1563
1654
  onRehydrateStorage: () => (state) => {
1564
1655
  if (state) syncBrushModeScope(state.mode)
1565
1656
  },
1657
+ // The armed tool (`toolMode` / `mode` / `tool`, and the catalog category
1658
+ // that only exists while a build tool is armed) is deliberately absent:
1659
+ // a project always opens in select mode, so persisting it could only ever
1660
+ // arm a tool nobody asked for on the next load.
1566
1661
  partialize: (state) => ({
1567
1662
  phase: state.phase,
1568
- toolMode: state.toolMode,
1569
- mode: state.mode,
1570
- tool: state.tool,
1571
- structureLayer: state.structureLayer,
1572
- catalogCategory: state.catalogCategory,
1573
- isFloorplanOpen: state.isFloorplanOpen,
1574
- viewMode: state.viewMode,
1575
- activeSidebarPanel: state.activeSidebarPanel,
1576
- floorplanPaneRatio: state.floorplanPaneRatio,
1577
- splitOrientation: state.splitOrientation,
1663
+ viewLayouts: state.viewLayouts,
1664
+ sidebarPanels: state.sidebarPanels,
1665
+ pinnedViews: state.pinnedViews,
1578
1666
  floorplanSelectionTool: state.floorplanSelectionTool,
1579
1667
  gridSnapStep: state.gridSnapStep,
1580
1668
  magneticSnap: state.magneticSnap,
@@ -1590,12 +1678,37 @@ const useEditor = create<EditorState>()(
1590
1678
  ),
1591
1679
  )
1592
1680
 
1681
+ /**
1682
+ * The one writer of the stored layout. The view wins over the mode: hiding the
1683
+ * 3D pane leaves the terrain brush unreachable, and a held `sculpting` scope
1684
+ * would keep selection suppressed in a plan the user is trying to work in — a
1685
+ * mode you cannot use and cannot see how to leave.
1686
+ */
1687
+ function commitViewLayout(layout: ViewLayout): void {
1688
+ useEditor.setState((state) => ({
1689
+ viewLayouts: { ...state.viewLayouts, [state.workspaceMode]: layout },
1690
+ }))
1691
+ yieldBrushToHiddenCanvas()
1692
+ }
1693
+
1694
+ function yieldBrushToHiddenCanvas(): void {
1695
+ const state = useEditor.getState()
1696
+ if (state.mode === 'terrain-sculpt' && !isViewVisible(state, VIEW_3D)) state.setMode('select')
1697
+ }
1698
+
1699
+ // The deprecated `viewMode` mirror follows every input of `visibleScene`
1700
+ // (layouts, workspace, capture, first person) without each writer knowing it.
1701
+ useEditor.subscribe((state) => {
1702
+ const viewMode = visibleScene(state) ?? '3d'
1703
+ if (state.viewMode !== viewMode) useEditor.setState({ viewMode })
1704
+ })
1705
+
1593
1706
  export function armToolMode(next: ToolMode): void {
1594
1707
  useEditor.getState().armToolMode(next)
1595
1708
  }
1596
1709
 
1597
- export function armMaterialPaint(material?: ActivePaintMaterial): void {
1598
- useEditor.getState().armMaterialPaint(material)
1710
+ export function armMaterialPaint(material?: ActivePaintMaterial, subMode?: PaintMode): void {
1711
+ useEditor.getState().armMaterialPaint(material, subMode)
1599
1712
  }
1600
1713
 
1601
1714
  /**
@@ -1659,6 +1772,7 @@ export function getActiveSnapContext(): SnapContext | null {
1659
1772
  return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
1660
1773
  },
1661
1774
  draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
1775
+ paintRegion: paintRegionModeActive(editor.mode),
1662
1776
  })
1663
1777
  }
1664
1778
 
@@ -1691,4 +1805,9 @@ export function getActiveSnappingMode(): SnappingMode {
1691
1805
  return useEditor.getState().snappingModeByContext[context]
1692
1806
  }
1693
1807
 
1808
+ usePaintRegionMode.subscribe((state) => {
1809
+ const paintEraser = state.mode === 'erase'
1810
+ if (useEditor.getState().paintEraser !== paintEraser) useEditor.setState({ paintEraser })
1811
+ })
1812
+
1694
1813
  export default useEditor