@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
@@ -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,
@@ -590,25 +648,18 @@ type SelectDefaultBuildingAndLevelOptions = {
590
648
  forceGroundLevel?: boolean
591
649
  }
592
650
 
593
- function defaultBuildTool(phase: Phase, structureLayer: StructureLayer): StructureTool {
594
- if (phase === 'site') return 'property-line'
595
- if (phase === 'furnish') return 'item'
596
- return structureLayer === 'zones' ? 'zone' : 'wall'
651
+ function defaultBuildTool(phase: Phase): StructureTool {
652
+ return phase === 'site' ? 'property-line' : 'wall'
597
653
  }
598
654
 
599
- function materializeToolMode(
600
- mode: Mode,
601
- tool: unknown,
602
- phase: Phase,
603
- structureLayer: StructureLayer,
604
- ): ToolMode {
655
+ function materializeToolMode(mode: Mode, tool: unknown, phase: Phase): ToolMode {
605
656
  if (mode === 'build') {
606
657
  return {
607
658
  mode,
608
659
  tool:
609
660
  typeof tool === 'string' && tool.length > 0
610
661
  ? (tool as StructureTool)
611
- : defaultBuildTool(phase, structureLayer),
662
+ : defaultBuildTool(phase),
612
663
  }
613
664
  }
614
665
 
@@ -642,7 +693,7 @@ function withMaterializedToolMode(
642
693
  ): PersistedEditorUiState {
643
694
  return {
644
695
  ...state,
645
- toolMode: materializeToolMode(state.mode, state.tool, state.phase, state.structureLayer),
696
+ toolMode: materializeToolMode(state.mode, state.tool, state.phase),
646
697
  }
647
698
  }
648
699
 
@@ -656,91 +707,82 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
656
707
  return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
657
708
  }
658
709
 
659
- function normalizeFloorplanPaneRatio(value: unknown): number {
660
- if (!(typeof value === 'number' && Number.isFinite(value))) {
661
- 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
+ )
662
718
  }
719
+ if (state?.viewMode === undefined && state?.isFloorplanOpen === undefined) return {}
720
+ return { edit: viewLayoutFromLegacy(state) }
721
+ }
663
722
 
664
- 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
+ )
665
740
  }
666
741
 
667
742
  export function normalizePersistedEditorUiState(
668
- state: Partial<PersistedEditorUiState> | null | undefined,
743
+ state: (Partial<PersistedEditorUiState> & LegacyViewModeState) | null | undefined,
669
744
  ): PersistedEditorUiState {
670
- 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'
671
751
  const persistedToolMode = readPersistedToolMode(state)
672
752
  let mode = normalizeModeForPhase(phase, persistedToolMode.mode)
673
753
 
674
- // Migrate old isFloorplanOpen to viewMode
675
- let viewMode: ViewMode = '3d'
676
- if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {
677
- viewMode = state.viewMode
678
- } else if (state?.isFloorplanOpen) {
679
- viewMode = 'split'
680
- }
681
- const isFloorplanOpen = viewMode !== '3d'
754
+ const viewLayouts = normalizeViewLayouts(state)
682
755
 
683
- // Both are persisted independently, and rehydrate goes through neither setter,
684
- // so this is the third place the sculpt/2D pair has to be reconciled. The view
685
- // wins here for the same reason it does in `setViewMode`: a brush armed over a
686
- // hidden canvas is a mode the user cannot use, and reviving one on load is
687
- // worse than reviving it mid-session — nothing on screen explains it.
688
- 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'
689
763
 
690
764
  if (phase === 'site') {
691
765
  return withMaterializedToolMode({
692
766
  phase,
693
767
  mode,
694
768
  tool: mode === 'build' ? 'property-line' : null,
695
- structureLayer: 'elements',
696
769
  catalogCategory: null,
697
- viewMode,
698
- isFloorplanOpen,
699
- })
700
- }
701
-
702
- if (phase === 'furnish') {
703
- return withMaterializedToolMode({
704
- phase,
705
- mode,
706
- tool: mode === 'build' ? 'item' : null,
707
- structureLayer: 'elements',
708
- catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
709
- viewMode,
710
- isFloorplanOpen,
770
+ viewLayouts,
711
771
  })
712
772
  }
713
773
 
714
- const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
715
-
716
774
  if (mode !== 'build') {
717
775
  return withMaterializedToolMode({
718
776
  phase,
719
777
  mode,
720
778
  tool: null,
721
- structureLayer,
722
- catalogCategory: null,
723
- viewMode,
724
- isFloorplanOpen,
725
- })
726
- }
727
-
728
- if (structureLayer === 'zones') {
729
- return withMaterializedToolMode({
730
- phase,
731
- mode,
732
- tool: 'zone',
733
- structureLayer,
734
779
  catalogCategory: null,
735
- viewMode,
736
- isFloorplanOpen,
780
+ viewLayouts,
737
781
  })
738
782
  }
739
783
 
740
784
  const tool =
741
- persistedToolMode.tool &&
742
- persistedToolMode.tool !== 'property-line' &&
743
- persistedToolMode.tool !== 'zone'
785
+ persistedToolMode.tool && persistedToolMode.tool !== 'property-line'
744
786
  ? (persistedToolMode.tool as Tool)
745
787
  : 'wall'
746
788
 
@@ -748,13 +790,33 @@ export function normalizePersistedEditorUiState(
748
790
  phase,
749
791
  mode,
750
792
  tool,
751
- structureLayer,
752
793
  catalogCategory: tool === 'item' ? (state?.catalogCategory ?? null) : null,
753
- viewMode,
754
- isFloorplanOpen,
794
+ viewLayouts,
755
795
  })
756
796
  }
757
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
+
758
820
  // Validate a persisted per-context mode against that context's allowed set
759
821
  // (so e.g. a stale `angles` for items resets), falling back to its default.
760
822
  function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode {
@@ -811,12 +873,8 @@ export function normalizePersistedEditorLayoutState(
811
873
  | undefined,
812
874
  ): PersistedEditorLayoutState {
813
875
  return {
814
- activeSidebarPanel:
815
- typeof state?.activeSidebarPanel === 'string' && state.activeSidebarPanel.trim()
816
- ? state.activeSidebarPanel
817
- : DEFAULT_ACTIVE_SIDEBAR_PANEL,
818
- floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
819
- splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
876
+ sidebarPanels: normalizeSidebarPanels(state?.sidebarPanels),
877
+ pinnedViews: normalizePinnedViews(state?.pinnedViews),
820
878
  floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
821
879
  gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
822
880
  ? (state?.gridSnapStep as GridSnapStep)
@@ -853,10 +911,8 @@ export function hasCustomPersistedEditorUiState(
853
911
  normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase ||
854
912
  normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||
855
913
  normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
856
- normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
857
914
  normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
858
- normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen ||
859
- normalizedState.viewMode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode
915
+ Object.keys(normalizedState.viewLayouts).length > 0
860
916
  )
861
917
  }
862
918
 
@@ -950,10 +1006,29 @@ export function selectSiteFloorplanContext() {
950
1006
  })
951
1007
  }
952
1008
 
953
- // Stashes the view mode the user was in before entering capture, so we can
954
- // restore it on exit. Snapshot capture always frames in 3D — the 2D/split
955
- // floorplan panes render nothing meaningful for a thumbnail.
956
- 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
+ }
957
1032
 
958
1033
  /**
959
1034
  * Hold the interaction scope that belongs to a sustained brush mode.
@@ -981,6 +1056,19 @@ function syncBrushModeScope(mode: Mode): void {
981
1056
  scope.endIf((s) => s.kind === 'painting' || s.kind === 'sculpting')
982
1057
  if (useEditor.getState().terrainSampling) useEditor.getState().setTerrainSampling(false)
983
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'
984
1072
  }
985
1073
 
986
1074
  /**
@@ -1001,110 +1089,86 @@ export function isBrushMode(mode: Mode): boolean {
1001
1089
  const useEditor = create<EditorState>()(
1002
1090
  persist(
1003
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
+ },
1004
1100
  phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
1005
1101
  setPhase: (phase) => {
1006
1102
  const currentPhase = get().phase
1007
1103
  if (currentPhase === phase) return
1008
1104
  const wasBuilding = get().toolMode.mode === 'build'
1009
- const structureLayer = phase === 'furnish' ? 'elements' : get().structureLayer
1010
1105
  set({
1011
1106
  phase,
1012
- structureLayer,
1013
- catalogCategory: wasBuilding && phase === 'furnish' ? 'furniture' : null,
1107
+ // Site has no rooms.
1108
+ ...(phase === 'site' ? { room: null } : {}),
1109
+ hoveredRoom: null,
1110
+ catalogCategory: null,
1014
1111
  })
1015
1112
  get().armToolMode(
1016
- wasBuilding
1017
- ? { mode: 'build', tool: defaultBuildTool(phase, structureLayer) }
1018
- : { mode: 'select' },
1113
+ wasBuilding ? { mode: 'build', tool: defaultBuildTool(phase) } : { mode: 'select' },
1019
1114
  )
1020
1115
 
1021
- switch (phase) {
1022
- case 'site':
1023
- selectSiteFloorplanContext()
1024
- break
1025
-
1026
- case 'structure':
1027
- selectDefaultBuildingAndLevel()
1028
- break
1029
-
1030
- case 'furnish':
1031
- selectDefaultBuildingAndLevel()
1032
- break
1033
- }
1116
+ if (phase === 'site') selectSiteFloorplanContext()
1117
+ else selectDefaultBuildingAndLevel()
1034
1118
  },
1035
1119
  toolMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.toolMode,
1036
1120
  armToolMode: (requested) => {
1037
1121
  const current = get()
1038
1122
  let phase = current.phase
1039
- let structureLayer = current.structureLayer
1040
- let viewMode = current.viewMode
1041
- let isFloorplanOpen = current.isFloorplanOpen
1042
1123
  const next = materializeToolMode(
1043
1124
  requested.mode,
1044
1125
  requested.mode === 'build' ? requested.tool : null,
1045
1126
  phase,
1046
- structureLayer,
1047
1127
  )
1048
1128
 
1049
1129
  if (next.mode === 'terrain-sculpt') {
1050
1130
  phase = 'site'
1051
- structureLayer = 'elements'
1052
- if (viewMode === '2d') {
1053
- viewMode = 'split'
1054
- isFloorplanOpen = true
1055
- }
1056
1131
  } else if (next.mode === 'build' && next.tool === 'property-line') {
1057
1132
  phase = 'site'
1058
- structureLayer = 'elements'
1059
- } else if (next.mode === 'build' && next.tool === 'zone') {
1060
- phase = 'structure'
1061
- structureLayer = 'zones'
1062
- } else if (next.mode === 'build' && phase === 'site') {
1063
- phase = 'structure'
1064
- structureLayer = 'elements'
1065
- } else if (next.mode === 'material-paint' && phase === 'site') {
1066
- phase = 'structure'
1067
- structureLayer = 'elements'
1068
- } else if (phase !== 'structure') {
1069
- structureLayer = 'elements'
1070
1133
  }
1071
1134
 
1072
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()
1073
1140
  set({
1074
1141
  toolMode: next,
1075
1142
  mode: next.mode,
1076
- tool: next.mode === 'build' ? next.tool : null,
1143
+ tool: nextTool,
1144
+ ...(endsSelection ? { room: null, hoveredRoom: null, selectedReferenceId: null } : {}),
1077
1145
  ...(phaseChanged ? { phase } : {}),
1078
- ...(structureLayer !== current.structureLayer ? { structureLayer } : {}),
1079
- ...(viewMode !== current.viewMode ? { viewMode } : {}),
1080
- ...(isFloorplanOpen !== current.isFloorplanOpen ? { isFloorplanOpen } : {}),
1081
- ...(next.mode === 'build' && phase === 'furnish' && !current.catalogCategory
1082
- ? { catalogCategory: 'furniture' }
1083
- : {}),
1084
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
+ }
1085
1151
 
1086
1152
  if (phaseChanged) {
1087
1153
  if (phase === 'site') selectSiteFloorplanContext()
1088
1154
  else selectDefaultBuildingAndLevel()
1089
1155
  }
1090
- if (next.mode === 'material-paint') get().primeMaterialPaintFromSelection()
1091
- if (next.mode === 'terrain-sculpt') {
1092
- useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1093
- }
1156
+ if (endsSelection) useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1094
1157
  syncBrushModeScope(next.mode)
1095
1158
  },
1096
- armMaterialPaint: (material) => {
1159
+ armMaterialPaint: (material, subMode) => {
1097
1160
  get().armToolMode({ mode: 'material-paint' })
1161
+ const paintMode = usePaintRegionMode.getState()
1162
+ paintMode.setMode(subMode ?? paintMode.drawMode)
1098
1163
  if (material) get().setActivePaintMaterial(material)
1099
1164
  },
1100
1165
  mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
1101
1166
  setMode: (mode) => {
1102
1167
  if (mode === 'build') {
1103
- const { phase, structureLayer, toolMode } = get()
1168
+ const { phase, toolMode } = get()
1104
1169
  get().armToolMode({
1105
1170
  mode,
1106
- tool:
1107
- toolMode.mode === 'build' ? toolMode.tool : defaultBuildTool(phase, structureLayer),
1171
+ tool: toolMode.mode === 'build' ? toolMode.tool : defaultBuildTool(phase),
1108
1172
  })
1109
1173
  return
1110
1174
  }
@@ -1120,7 +1184,7 @@ const useEditor = create<EditorState>()(
1120
1184
  get().armToolMode({ mode: 'select' })
1121
1185
  return
1122
1186
  }
1123
- get().armToolMode(materializeToolMode(get().mode, null, get().phase, get().structureLayer))
1187
+ get().armToolMode(materializeToolMode(get().mode, null, get().phase))
1124
1188
  },
1125
1189
  toolDefaults: {},
1126
1190
  setToolDefaults: (tool, defaults) =>
@@ -1135,22 +1199,6 @@ const useEditor = create<EditorState>()(
1135
1199
  }),
1136
1200
  lastMeasurementKind: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.lastMeasurementKind,
1137
1201
  setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
1138
- structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
1139
- setStructureLayer: (layer) => {
1140
- const wasBuilding = get().toolMode.mode === 'build'
1141
- set({ structureLayer: layer })
1142
- get().armToolMode(
1143
- wasBuilding
1144
- ? { mode: 'build', tool: layer === 'zones' ? 'zone' : 'wall' }
1145
- : { mode: 'select' },
1146
- )
1147
-
1148
- const viewer = useViewer.getState()
1149
- viewer.setSelection({
1150
- selectedIds: [],
1151
- zoneId: null,
1152
- })
1153
- },
1154
1202
  catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
1155
1203
  setCatalogCategory: (category) => set({ catalogCategory: category }),
1156
1204
  selectedItem: null,
@@ -1209,11 +1257,16 @@ const useEditor = create<EditorState>()(
1209
1257
  },
1210
1258
  selectedMaterialTarget: null,
1211
1259
  setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
1260
+ paintEraser: usePaintRegionMode.getState().mode === 'erase',
1212
1261
  activePaintMaterial: null,
1213
- // 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
1214
1263
  // next click applies the chosen material.
1215
- setActivePaintMaterial: (material) =>
1216
- 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
+ },
1217
1270
  activePaintTarget: 'wall',
1218
1271
  setActivePaintTarget: (target) =>
1219
1272
  set((state) =>
@@ -1232,8 +1285,6 @@ const useEditor = create<EditorState>()(
1232
1285
  set({ paintScope: next })
1233
1286
  return next
1234
1287
  },
1235
- paintEraser: false,
1236
- setPaintEraser: (eraser) => set({ paintEraser: eraser }),
1237
1288
  primeMaterialPaintFromSelection: () => {
1238
1289
  const selectedId =
1239
1290
  useViewer.getState().selection.selectedIds.length === 1
@@ -1245,6 +1296,7 @@ const useEditor = create<EditorState>()(
1245
1296
  selectedId,
1246
1297
  }) ?? get().activePaintTarget
1247
1298
  const activePaintMaterial = resolveActivePaintMaterialFromSelection({
1299
+ materials: useScene.getState().materials,
1248
1300
  nodes: useScene.getState().nodes,
1249
1301
  selectedId,
1250
1302
  selectedMaterialTarget: get().selectedMaterialTarget,
@@ -1281,7 +1333,8 @@ const useEditor = create<EditorState>()(
1281
1333
  canFindNode: false,
1282
1334
  setCanFindNode: (canFind) => set({ canFindNode: canFind }),
1283
1335
  selectedReferenceId: null,
1284
- setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
1336
+ setSelectedReferenceId: (id) =>
1337
+ set({ selectedReferenceId: id, ...(id ? { room: null, hoveredRoom: null } : {}) }),
1285
1338
  referenceScaleActiveGuideId: null,
1286
1339
  setReferenceScaleActiveGuideId: (id) => set({ referenceScaleActiveGuideId: id }),
1287
1340
  guideUi: {},
@@ -1326,13 +1379,63 @@ const useEditor = create<EditorState>()(
1326
1379
  ),
1327
1380
  isPreviewMode: false,
1328
1381
  setPreviewMode: (preview) => {
1382
+ if (preview === get().isPreviewMode) return
1329
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
1330
1398
  set({ isPreviewMode: true, catalogCategory: null })
1331
1399
  get().armToolMode({ mode: 'select' })
1332
- // Clear zone/item selection for clean viewer drill-down hierarchy
1333
- 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)
1334
1405
  } else {
1406
+ if (get().isFirstPersonMode) get().setFirstPersonMode(false)
1407
+ useViewer.getState().setWalkthroughMode(false)
1335
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
1336
1439
  }
1337
1440
  },
1338
1441
  captureMode: { mode: 'idle' } as CaptureMode,
@@ -1341,59 +1444,60 @@ const useEditor = create<EditorState>()(
1341
1444
  const resolved: CaptureMode =
1342
1445
  typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
1343
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
+ }
1344
1452
  // Walk / drone framing is a capture-only camera, so leaving capture always
1345
- // lands back on orbit. Run it first: it restores its own view mode, and
1346
- // 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.
1347
1455
  if (!entering && get().isFirstPersonMode) {
1348
1456
  get().setFirstPersonMode(false)
1349
1457
  }
1350
- set((state) => {
1351
- if (entering) {
1352
- // Force 3D for the shot. Remember the prior mode only on the first
1353
- // entry (viewMode is already '3d' on re-entry), so we restore the
1354
- // user's real choice — not the forced '3d' — when capture ends.
1355
- if (state.viewMode !== '3d') {
1356
- viewModeBeforeCapture = state.viewMode
1357
- return {
1358
- captureMode: resolved,
1359
- isCaptureMode: true,
1360
- viewMode: '3d',
1361
- isFloorplanOpen: false,
1362
- }
1363
- }
1364
- return { captureMode: resolved, isCaptureMode: true }
1365
- }
1366
- const restore = viewModeBeforeCapture
1367
- viewModeBeforeCapture = null
1368
- if (restore && restore !== '3d') {
1369
- return {
1370
- captureMode: resolved,
1371
- isCaptureMode: false,
1372
- viewMode: restore,
1373
- isFloorplanOpen: true,
1374
- }
1375
- }
1376
- return { captureMode: resolved, isCaptureMode: false }
1377
- })
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
+ }
1378
1471
  },
1379
- viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
1380
- setViewMode: (mode) => {
1381
- set({ viewMode: mode, isFloorplanOpen: mode !== '3d' })
1382
- // Going the other way, the view wins and the mode yields. Hiding the 3D
1383
- // pane leaves the brush unreachable, and a held `sculpting` scope would
1384
- // then keep selection suppressed in a floorplan the user is trying to
1385
- // work in — a mode you cannot use and cannot see how to leave.
1386
- 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 })
1387
1478
  },
1388
- splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
1389
- setSplitOrientation: (orientation) => set({ splitOrientation: orientation }),
1390
- isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
1391
- setFloorplanOpen: (open) => set({ isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }),
1392
- toggleFloorplanOpen: () =>
1393
- set((state) => {
1394
- const open = !state.isFloorplanOpen
1395
- return { isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }
1396
- }),
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 } })),
1397
1501
  isFloorplanHovered: false,
1398
1502
  setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
1399
1503
  isRiserOpen: false,
@@ -1469,26 +1573,14 @@ const useEditor = create<EditorState>()(
1469
1573
  show2dVoronoi: false,
1470
1574
  setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
1471
1575
  isFirstPersonMode: false,
1472
- _viewModeBeforeFirstPerson: null as ViewMode | null,
1576
+ // First person shows 3D alone (`activeViewLayout`); the stored layout
1577
+ // comes back on exit untouched.
1473
1578
  setFirstPersonMode: (enabled) => {
1474
1579
  if (enabled) {
1475
- const currentViewMode = get().viewMode
1476
- set({
1477
- isFirstPersonMode: true,
1478
- _viewModeBeforeFirstPerson: currentViewMode,
1479
- viewMode: '3d',
1480
- isFloorplanOpen: false,
1481
- catalogCategory: null,
1482
- })
1580
+ set({ isFirstPersonMode: true, catalogCategory: null })
1483
1581
  get().armToolMode({ mode: 'select' })
1484
1582
  } else {
1485
- const prevMode = get()._viewModeBeforeFirstPerson
1486
- set({
1487
- isFirstPersonMode: false,
1488
- firstPersonMovementMode: 'walk',
1489
- _viewModeBeforeFirstPerson: null,
1490
- ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
1491
- })
1583
+ set({ isFirstPersonMode: false, firstPersonMovementMode: 'walk' })
1492
1584
  }
1493
1585
  },
1494
1586
  firstPersonMovementMode: 'walk' as FirstPersonMovementMode,
@@ -1506,35 +1598,32 @@ const useEditor = create<EditorState>()(
1506
1598
  captureShutterHold: false,
1507
1599
  setCaptureShutterHold: (hold) => set({ captureShutterHold: hold }),
1508
1600
  workspaceMode: 'edit' as WorkspaceMode,
1509
- _viewModeBeforeStudio: null as ViewMode | null,
1510
1601
  setWorkspaceMode: (mode) => {
1511
1602
  if (get().workspaceMode === mode) return
1512
- if (mode === 'studio') {
1513
- const currentViewMode = get().viewMode
1514
- set({
1515
- workspaceMode: 'studio',
1516
- _viewModeBeforeStudio: currentViewMode,
1517
- viewMode: '3d',
1518
- isFloorplanOpen: false,
1519
- catalogCategory: null,
1520
- })
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 })
1521
1612
  get().armToolMode({ mode: 'select' })
1522
1613
  // Clear selection so no edit affordances bleed into the clean canvas.
1523
1614
  useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1524
1615
  } else {
1525
- const prevMode = get()._viewModeBeforeStudio
1526
- set({
1527
- workspaceMode: 'edit',
1528
- _viewModeBeforeStudio: null,
1529
- ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
1530
- })
1616
+ set({ workspaceMode: 'edit', activeSidebarPanel })
1531
1617
  }
1618
+ yieldBrushToHiddenCanvas()
1532
1619
  },
1533
1620
  activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
1534
- setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
1535
- floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
1536
- setFloorplanPaneRatio: (ratio) =>
1537
- 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,
1538
1627
  mobilePanelSheetHeight: 0,
1539
1628
  setMobilePanelSheetHeight: (px) => set({ mobilePanelSheetHeight: Math.max(0, px) }),
1540
1629
  modelExport: null,
@@ -1542,10 +1631,9 @@ const useEditor = create<EditorState>()(
1542
1631
  }),
1543
1632
  {
1544
1633
  name: 'pascal-editor-ui-preferences',
1634
+ storage: hydrationGatedStorage((): boolean => useEditor.persist.hasHydrated()),
1545
1635
  merge: (persistedState, currentState) => {
1546
- const uiState = normalizePersistedEditorUiState(
1547
- persistedState as Partial<PersistedEditorState>,
1548
- )
1636
+ const uiState = editorUiStateOnOpen(persistedState as Partial<PersistedEditorState>)
1549
1637
  const layoutState = normalizePersistedEditorLayoutState(
1550
1638
  persistedState as Partial<PersistedEditorState>,
1551
1639
  )
@@ -1554,35 +1642,27 @@ const useEditor = create<EditorState>()(
1554
1642
  ...currentState,
1555
1643
  ...uiState,
1556
1644
  ...layoutState,
1557
- ...(uiState.mode === 'build' && uiState.tool === 'measurement'
1558
- ? {
1559
- toolDefaults: {
1560
- ...currentState.toolDefaults,
1561
- measurement: { kind: layoutState.lastMeasurementKind },
1562
- },
1563
- }
1564
- : {}),
1645
+ activeSidebarPanel:
1646
+ layoutState.sidebarPanels[currentState.workspaceMode] ?? DEFAULT_ACTIVE_SIDEBAR_PANEL,
1565
1647
  }
1566
1648
  },
1567
- // `toolMode` is persisted, but the interaction scope a brush mode holds is not
1568
- // — it lives in a separate, non-persisted store. Rehydrating into
1569
- // `terrain-sculpt` (or paint) without re-claiming the scope would restore
1570
- // 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.
1571
1654
  onRehydrateStorage: () => (state) => {
1572
1655
  if (state) syncBrushModeScope(state.mode)
1573
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.
1574
1661
  partialize: (state) => ({
1575
1662
  phase: state.phase,
1576
- toolMode: state.toolMode,
1577
- mode: state.mode,
1578
- tool: state.tool,
1579
- structureLayer: state.structureLayer,
1580
- catalogCategory: state.catalogCategory,
1581
- isFloorplanOpen: state.isFloorplanOpen,
1582
- viewMode: state.viewMode,
1583
- activeSidebarPanel: state.activeSidebarPanel,
1584
- floorplanPaneRatio: state.floorplanPaneRatio,
1585
- splitOrientation: state.splitOrientation,
1663
+ viewLayouts: state.viewLayouts,
1664
+ sidebarPanels: state.sidebarPanels,
1665
+ pinnedViews: state.pinnedViews,
1586
1666
  floorplanSelectionTool: state.floorplanSelectionTool,
1587
1667
  gridSnapStep: state.gridSnapStep,
1588
1668
  magneticSnap: state.magneticSnap,
@@ -1598,12 +1678,37 @@ const useEditor = create<EditorState>()(
1598
1678
  ),
1599
1679
  )
1600
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
+
1601
1706
  export function armToolMode(next: ToolMode): void {
1602
1707
  useEditor.getState().armToolMode(next)
1603
1708
  }
1604
1709
 
1605
- export function armMaterialPaint(material?: ActivePaintMaterial): void {
1606
- useEditor.getState().armMaterialPaint(material)
1710
+ export function armMaterialPaint(material?: ActivePaintMaterial, subMode?: PaintMode): void {
1711
+ useEditor.getState().armMaterialPaint(material, subMode)
1607
1712
  }
1608
1713
 
1609
1714
  /**
@@ -1667,6 +1772,7 @@ export function getActiveSnapContext(): SnapContext | null {
1667
1772
  return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
1668
1773
  },
1669
1774
  draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
1775
+ paintRegion: paintRegionModeActive(editor.mode),
1670
1776
  })
1671
1777
  }
1672
1778
 
@@ -1699,4 +1805,9 @@ export function getActiveSnappingMode(): SnappingMode {
1699
1805
  return useEditor.getState().snappingModeByContext[context]
1700
1806
  }
1701
1807
 
1808
+ usePaintRegionMode.subscribe((state) => {
1809
+ const paintEraser = state.mode === 'erase'
1810
+ if (useEditor.getState().paintEraser !== paintEraser) useEditor.setState({ paintEraser })
1811
+ })
1812
+
1702
1813
  export default useEditor