@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -2,14 +2,23 @@
2
2
 
3
3
  import type { AssetInput } from '@pascal-app/core'
4
4
  import {
5
+ type AnyNode,
5
6
  type AnyNodeId,
6
7
  type BuildingNode,
8
+ type CabinetModuleNode,
9
+ type CabinetNode,
7
10
  type CeilingNode,
11
+ type ChimneyMaterialRole,
12
+ type ChimneyNode,
8
13
  type ColumnNode,
9
14
  type DoorNode,
15
+ type DormerNode,
16
+ type DormerSurfaceMaterialRole,
17
+ type ElevatorNode,
10
18
  type FenceNode,
11
19
  type ItemNode,
12
20
  type LevelNode,
21
+ nodeRegistry,
13
22
  type RoofNode,
14
23
  type RoofSegmentNode,
15
24
  type RoofSurfaceMaterialRole,
@@ -27,7 +36,13 @@ import {
27
36
  import { useViewer } from '@pascal-app/viewer'
28
37
  import { create } from 'zustand'
29
38
  import { persist } from 'zustand/middleware'
30
- import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
39
+ import {
40
+ CONTINUATION_PROFILES,
41
+ type ContinuationContext,
42
+ type ContinuationMode,
43
+ continuationContextOf,
44
+ nextContinuation,
45
+ } from '../lib/continuation'
31
46
  import {
32
47
  type ActivePaintMaterial,
33
48
  type PaintableMaterialTarget,
@@ -35,14 +50,90 @@ import {
35
50
  resolvePaintTargetFromSelection,
36
51
  type SingleSurfaceMaterialRole,
37
52
  } from '../lib/material-paint'
53
+ import {
54
+ type CreatableMeasurementKind,
55
+ DEFAULT_CREATABLE_MEASUREMENT_KIND,
56
+ normalizeCreatableMeasurementKind,
57
+ } from '../lib/measurement-kind'
58
+ import {
59
+ cyclePaintScope as cyclePaintScopeValue,
60
+ type PaintHoverInfo,
61
+ type PaintScope,
62
+ } from '../lib/paint-scope'
63
+ import {
64
+ cycleSnappingModeIn,
65
+ defaultSnappingModeFor,
66
+ resolveSnapFlags,
67
+ type SnapContext,
68
+ type SnappingMode,
69
+ snapContextOf,
70
+ snappingModesFor,
71
+ } from '../lib/snapping-mode'
72
+ import useInteractionScope from './use-interaction-scope'
38
73
 
39
- const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
74
+ const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build'
40
75
  const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
41
76
  const MIN_FLOORPLAN_PANE_RATIO = 0.15
42
77
  const MAX_FLOORPLAN_PANE_RATIO = 0.85
43
78
 
79
+ function resolveMovingNodeTarget(
80
+ node:
81
+ | ItemNode
82
+ | WindowNode
83
+ | DoorNode
84
+ | ElevatorNode
85
+ | CeilingNode
86
+ | ChimneyNode
87
+ | ColumnNode
88
+ | DormerNode
89
+ | SlabNode
90
+ | WallNode
91
+ | FenceNode
92
+ | RoofNode
93
+ | RoofSegmentNode
94
+ | SpawnNode
95
+ | StairNode
96
+ | StairSegmentNode
97
+ | BuildingNode
98
+ | CabinetNode
99
+ | CabinetModuleNode,
100
+ ) {
101
+ return node
102
+ }
103
+
44
104
  export type ViewMode = '3d' | '2d' | 'split'
45
105
  export type SplitOrientation = 'horizontal' | 'vertical'
106
+ export type WorkspaceMode = 'edit' | 'studio'
107
+
108
+ // Snapshot capture is invoked from two surfaces with different policies.
109
+ // `standard` mirrors the existing user-driven UX — pick region / viewport /
110
+ // area, save the blob as a project thumbnail. `preset` is the constrained
111
+ // variant for the unified preset capture flow (community save-as-preset
112
+ // modal): the overlay locks to a square crop, the renderer clears alpha
113
+ // (transparent background), and the rendered set is locked to `isolated`
114
+ // — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and
115
+ // applies those constraints. Keeping it a discriminated union lets us
116
+ // add future modes without surfacing the choice to end users.
117
+ // How the captured pixels are cropped: full-frame 16:9, raw canvas viewport,
118
+ // or user-dragged area. Hosts (e.g. the studio capture bar) can preselect it
119
+ // when entering capture mode.
120
+ export type SnapshotCropMode = 'standard' | 'viewport' | 'area'
121
+ /** Aspect presets available to `standard` crops. */
122
+ export type SnapshotStandardAspect = '16:9' | '9:16' | '4:3' | '3:4' | '1:1'
123
+
124
+ export type CaptureMode =
125
+ | { mode: 'idle' }
126
+ | { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect }
127
+ | {
128
+ mode: 'preset'
129
+ isolated: AnyNodeId[]
130
+ framingBounds?: {
131
+ min: [number, number]
132
+ max: [number, number]
133
+ center: [number, number]
134
+ size: [number, number]
135
+ }
136
+ }
46
137
 
47
138
  export type Phase = 'site' | 'structure' | 'furnish'
48
139
 
@@ -58,15 +149,37 @@ export type StructureTool =
58
149
  | 'ceiling'
59
150
  | 'roof'
60
151
  | 'column'
152
+ | 'elevator'
61
153
  | 'stair'
62
154
  | 'item'
63
155
  | 'zone'
64
156
  | 'spawn'
65
157
  | 'window'
66
158
  | 'door'
159
+ | 'shelf'
160
+ | 'box-vent'
161
+ | 'ridge-vent'
162
+ | 'turbine-vent'
163
+ | 'cupola'
164
+ | 'eyebrow-vent'
165
+ | 'chimney'
166
+ | 'solar-panel'
167
+ | 'skylight'
168
+ | 'dormer'
169
+ | 'gutter'
170
+ | 'downspout'
171
+ | 'duct-segment'
172
+ | 'duct-fitting'
173
+ | 'duct-terminal'
174
+ | 'hvac-equipment'
175
+ | 'lineset'
176
+ | 'liquid-line'
177
+ | 'pipe-segment'
178
+ | 'pipe-fitting'
179
+ | 'pipe-trap'
67
180
 
68
181
  // Furnish mode tools (items and decoration)
69
- export type FurnishTool = 'item'
182
+ export type FurnishTool = 'item' | 'cabinet'
70
183
 
71
184
  // Site mode tools
72
185
  export type SiteTool = 'property-line'
@@ -86,24 +199,43 @@ export type StructureLayer = 'zones' | 'elements'
86
199
  export type FloorplanSelectionTool = 'click' | 'marquee'
87
200
  export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
88
201
 
89
- // Combined tool type
90
- export type Tool = SiteTool | StructureTool | FurnishTool
202
+ export type NavigationSyncSource = '2d' | '3d'
91
203
 
92
- export type MovingWallEndpoint = {
93
- wall: WallNode
94
- endpoint: 'start' | 'end'
204
+ export type NavigationSyncPose = {
205
+ source: NavigationSyncSource
206
+ revision: number
207
+ target: [number, number, number]
208
+ azimuth: number
209
+ viewWidth: number
95
210
  }
96
211
 
97
- export type MovingFenceEndpoint = {
98
- fence: FenceNode
99
- endpoint: 'start' | 'end'
100
- }
212
+ export type NavigationSyncPoseInput = Omit<NavigationSyncPose, 'revision'>
213
+
214
+ // Combined tool type. Known literals keep autocomplete; the `(string & {})`
215
+ // arm lets plugin-contributed tool ids (e.g. `'trees:tree'`) typecheck without
216
+ // the host enumerating every plugin kind. The runtime dispatch is already
217
+ // registry-first — `tool-manager` resolves `nodeRegistry.get(tool)?.tool` — so
218
+ // an unknown-to-the-host tool string flows straight through to the plugin's
219
+ // placement component.
220
+ export type KnownTool = SiteTool | StructureTool | FurnishTool
221
+ export type Tool = KnownTool | (string & {})
222
+
223
+ /**
224
+ * Starting parameters seeded into a draw tool before it mints a node.
225
+ * A loose param bag — the tool's create path validates it through the
226
+ * kind's schema (`FenceNode.parse({ ...defaults, start, end })`), which
227
+ * is the real type gate, so unknown keys are simply ignored.
228
+ */
229
+ export type ToolDefaults = Record<string, unknown>
101
230
 
102
231
  export type MaterialTargetRole =
103
232
  | WallSurfaceSide
104
233
  | StairSurfaceMaterialRole
105
234
  | RoofSurfaceMaterialRole
235
+ | ChimneyMaterialRole
236
+ | DormerSurfaceMaterialRole
106
237
  | SingleSurfaceMaterialRole
238
+ | string
107
239
 
108
240
  export type SelectedMaterialTarget = {
109
241
  nodeId: AnyNodeId
@@ -116,6 +248,8 @@ type MaterialPaintSelectionSnapshot = {
116
248
  activePaintMaterial: ActivePaintMaterial | null
117
249
  }
118
250
 
251
+ export type SurfaceHoleTarget = { nodeId: string; holeIndex: number }
252
+
119
253
  export type GuideUiState = {
120
254
  locked?: boolean
121
255
  scaleReferenceVisible?: boolean
@@ -128,35 +262,42 @@ type EditorState = {
128
262
  setMode: (mode: Mode) => void
129
263
  tool: Tool | null
130
264
  setTool: (tool: Tool | null) => void
265
+ /**
266
+ * Per-tool starting parameters for the next node a draw tool mints.
267
+ * Transient (not persisted): host apps seed an entry just before
268
+ * activating the tool (placing a drawn preset, or a future dimension
269
+ * picker), the tool's create path merges it, and the tool clears its
270
+ * own entry on deactivation so a later manual draw isn't poisoned.
271
+ */
272
+ toolDefaults: Partial<Record<Tool, ToolDefaults>>
273
+ setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
274
+ lastMeasurementKind: CreatableMeasurementKind
275
+ setLastMeasurementKind: (kind: CreatableMeasurementKind) => void
131
276
  structureLayer: StructureLayer
132
277
  setStructureLayer: (layer: StructureLayer) => void
133
278
  catalogCategory: CatalogCategory | null
134
279
  setCatalogCategory: (category: CatalogCategory | null) => void
135
280
  selectedItem: AssetInput | null
136
281
  setSelectedItem: (item: AssetInput) => void
137
- movingNode:
138
- | ItemNode
139
- | WindowNode
140
- | DoorNode
141
- | CeilingNode
142
- | ColumnNode
143
- | SlabNode
144
- | WallNode
145
- | FenceNode
146
- | RoofNode
147
- | RoofSegmentNode
148
- | SpawnNode
149
- | StairNode
150
- | StairSegmentNode
151
- | BuildingNode
152
- | null
282
+ /**
283
+ * True while a move was engaged by a press-drag gizmo (the on-canvas move
284
+ * cross) rather than a click-to-place flow. The placement coordinator reads
285
+ * this to commit on pointer-release instead of waiting for a click.
286
+ */
287
+ placementDragMode: boolean
288
+ setPlacementDragMode: (dragMode: boolean) => void
289
+ roofHostDragArmedId: AnyNodeId | null
290
+ setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void
153
291
  setMovingNode: (
154
292
  node:
155
293
  | ItemNode
156
294
  | WindowNode
157
295
  | DoorNode
296
+ | ElevatorNode
158
297
  | CeilingNode
298
+ | ChimneyNode
159
299
  | ColumnNode
300
+ | DormerNode
160
301
  | SlabNode
161
302
  | WallNode
162
303
  | FenceNode
@@ -166,29 +307,80 @@ type EditorState = {
166
307
  | StairNode
167
308
  | StairSegmentNode
168
309
  | BuildingNode
310
+ | CabinetNode
311
+ | CabinetModuleNode
169
312
  | null,
170
313
  ) => void
171
- movingWallEndpoint: MovingWallEndpoint | null
172
- setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
173
- movingFenceEndpoint: MovingFenceEndpoint | null
174
- setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
175
- curvingWall: WallNode | null
176
- setCurvingWall: (wall: WallNode | null) => void
177
- curvingFence: FenceNode | null
178
- setCurvingFence: (fence: FenceNode | null) => void
314
+ /**
315
+ * Which view (2D floor plan or 3D viewer) most recently completed
316
+ * the active move — set by the committing or cancelling side just
317
+ * before clearing `movingNode`. Lets the *other* side's effect
318
+ * cleanup skip its own restore-from-snapshot when the drag was
319
+ * already finalised elsewhere (split view mounts both the 2D
320
+ * overlay and the 3D move tool for the same `movingNode`).
321
+ *
322
+ * Reset to null when the next non-null `setMovingNode` starts a
323
+ * fresh drag (so stale values from the previous drag don't poison
324
+ * cleanups). Preserved across `setMovingNode(null)` so the
325
+ * non-owning side's cleanup — which fires after the clear
326
+ * propagates — can still read who finalised. Null while a drag
327
+ * is in progress means "no side has claimed it yet" — both
328
+ * cleanups then restore to their pre-drag snapshot, which is the
329
+ * same baseline, so the result is idempotent.
330
+ */
331
+ movingNodeOrigin: '2d' | '3d' | null
332
+ setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void
333
+ /**
334
+ * World axis the R/T keyboard rotation turns around, for kinds with
335
+ * full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the
336
+ * kind's tool / keyboard actions read it, and the floating action
337
+ * menu surfaces it in a pill above the selected node.
338
+ */
339
+ rotationAxis: 'x' | 'y' | 'z'
340
+ cycleRotationAxis: () => 'x' | 'y' | 'z'
179
341
  selectedMaterialTarget: SelectedMaterialTarget | null
180
342
  setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
181
343
  activePaintMaterial: ActivePaintMaterial | null
182
344
  setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
183
345
  activePaintTarget: PaintableMaterialTarget
184
346
  setActivePaintTarget: (target: PaintableMaterialTarget) => void
347
+ // Live vertex count of an in-progress polygon draft (slab / ceiling), so the
348
+ // contextual HUD can gate hints on it (e.g. "Finish" only once ≥ 3 points).
349
+ // 0 when not drafting. Not persisted.
350
+ draftVertexCount: number
351
+ setDraftVertexCount: (count: number) => void
352
+ // Painter application scope — how far one paint click spreads (this surface /
353
+ // whole item / all matching / room). One global mode, target-aware in the HUD
354
+ // (see `lib/paint-scope.ts`), defaulting to the narrowest `'single'`. Not
355
+ // persisted: a "paint everything" scope should reset each session.
356
+ paintScope: PaintScope
357
+ setPaintScope: (scope: PaintScope) => void
358
+ // Cycle the scope within the hovered node's available set and return the new
359
+ // value. Bound to Shift while in paint mode.
360
+ cyclePaintScope: () => PaintScope
361
+ // When true, clicking a surface in paint mode clears it back to its
362
+ // default material instead of applying `activePaintMaterial`.
363
+ paintEraser: boolean
364
+ setPaintEraser: (eraser: boolean) => void
185
365
  primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
186
- hoveredPaintTarget: PaintableMaterialTarget | null
187
- setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
188
- isPaintPanelOpen: boolean
189
- setPaintPanelOpen: (open: boolean) => void
366
+ // What the cursor is over in paint mode: the scopes it offers + labels for the
367
+ // HUD chip. `null` when not over a paintable surface (drives the "hover a
368
+ // surface" hint). Set by the selection-manager paint hover; not persisted.
369
+ paintHover: PaintHoverInfo | null
370
+ setPaintHover: (info: PaintHoverInfo | null) => void
371
+ // Embedder capability: true when a host (e.g. community) can locate a selected
372
+ // node in its catalog browser. Gates the node action menu's "Find" button; the
373
+ // editor itself emits `selection:find-node` and lets the host fulfil it. Not
374
+ // persisted — it's a per-mount capability the host registers.
375
+ canFindNode: boolean
376
+ setCanFindNode: (canFind: boolean) => void
190
377
  selectedReferenceId: string | null
191
378
  setSelectedReferenceId: (id: string | null) => void
379
+ // Guide id with an in-flight reference-scale measurement (line drawing or
380
+ // length dialog). Owned by the floorplan panel; mirrored here so the
381
+ // reference panel can flip its Set Scale button into a Cancel.
382
+ referenceScaleActiveGuideId: string | null
383
+ setReferenceScaleActiveGuideId: (id: string | null) => void
192
384
  guideUi: Record<string, GuideUiState>
193
385
  setGuideLocked: (guideId: string, locked: boolean) => void
194
386
  setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void
@@ -196,15 +388,21 @@ type EditorState = {
196
388
  // Space detection for cutaway mode
197
389
  spaces: Record<string, Space>
198
390
  setSpaces: (spaces: Record<string, Space>) => void
199
- // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
200
- editingHole: { nodeId: string; holeIndex: number } | null
201
- setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
391
+ hoveredHole: SurfaceHoleTarget | null
392
+ setHoveredHole: (hole: SurfaceHoleTarget | null) => void
202
393
  // Preview mode (viewer-like experience inside the editor)
203
394
  isPreviewMode: boolean
204
395
  setPreviewMode: (preview: boolean) => void
205
- // Capture mode (snapshot toolbar — hides panels for clean framing)
396
+ // Capture mode (snapshot toolbar — hides panels for clean framing).
397
+ // `captureMode` is the canonical discriminated-union state; the boolean
398
+ // `isCaptureMode` is kept synced as a derived convenience for the many
399
+ // existing read sites that just gate chrome visibility on "is capture
400
+ // active". New write sites should pass a `CaptureMode` shape; passing a
401
+ // boolean is accepted as a back-compat shim (`true` → `'standard'`,
402
+ // `false` → `'idle'`).
403
+ captureMode: CaptureMode
206
404
  isCaptureMode: boolean
207
- setCaptureMode: (active: boolean) => void
405
+ setCaptureMode: (next: boolean | CaptureMode) => void
208
406
  // View mode (3D only, 2D only, or split 2D+3D)
209
407
  viewMode: ViewMode
210
408
  setViewMode: (mode: ViewMode) => void
@@ -216,10 +414,38 @@ type EditorState = {
216
414
  toggleFloorplanOpen: () => void
217
415
  isFloorplanHovered: boolean
218
416
  setFloorplanHovered: (hovered: boolean) => void
417
+ // Toggleable DWV riser-diagram (plumbing isometric) overlay.
418
+ isRiserOpen: boolean
419
+ setRiserOpen: (open: boolean) => void
420
+ toggleRiserOpen: () => void
421
+ navigationSyncPose: NavigationSyncPose | null
422
+ publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
219
423
  floorplanSelectionTool: FloorplanSelectionTool
220
424
  setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
221
425
  gridSnapStep: GridSnapStep
222
426
  setGridSnapStep: (step: GridSnapStep) => void
427
+ // Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 →
428
+ // 0.5) and returns the new value. Bound to the measurement-step shortcut.
429
+ cycleGridSnapStep: () => GridSnapStep
430
+ // Magnetic snapping while drafting — snaps wall endpoints onto existing
431
+ // wall corners / wall bodies (the "magnetic" beacon). Independent of grid
432
+ // snap. On by default; toggled from the Display menu.
433
+ magneticSnap: boolean
434
+ setMagneticSnap: (enabled: boolean) => void
435
+ // Per-context, user-cyclable snapping mode (see `lib/snapping-mode.ts`). Each
436
+ // activity (wall / item / polygon) keeps its own mode + default, because they
437
+ // want different snapping — drawing a wall wants grid + angle, nudging an item
438
+ // wants free movement that only catches alignment lines. Resolved to the live
439
+ // context via `getActiveSnappingMode()`; maps onto `gridSnapStep`/`magneticSnap`
440
+ // via `resolveSnapFlags`. Persisted per context.
441
+ snappingModeByContext: Record<SnapContext, SnappingMode>
442
+ setSnappingMode: (context: SnapContext, mode: SnappingMode) => void
443
+ // Cycle the *active* context's mode within its own set; returns the new value.
444
+ cycleSnappingMode: () => SnappingMode
445
+ continuationByContext: Record<ContinuationContext, ContinuationMode>
446
+ setContinuation: (context: ContinuationContext, mode: ContinuationMode) => void
447
+ cycleContinuation: (context: ContinuationContext) => ContinuationMode
448
+ getContinuation: (context: ContinuationContext) => ContinuationMode
223
449
  showReferenceFloor: boolean
224
450
  toggleReferenceFloor: () => void
225
451
  setShowReferenceFloor: (show: boolean) => void
@@ -230,13 +456,23 @@ type EditorState = {
230
456
  // Development-only camera debug flag for inspecting underside geometry
231
457
  allowUndergroundCamera: boolean
232
458
  setAllowUndergroundCamera: (enabled: boolean) => void
459
+ // Development-only debug overlay: draw each wall's opening-snap hit area
460
+ // (the capsule of points within the snap radius of its centerline). Lets us
461
+ // see why a door/window snaps where it does.
462
+ show2dVoronoi: boolean
463
+ setShow2dVoronoi: (enabled: boolean) => void
233
464
  // First-person walkthrough mode (street view)
234
465
  isFirstPersonMode: boolean
235
466
  _viewModeBeforeFirstPerson: ViewMode | null
236
467
  setFirstPersonMode: (enabled: boolean) => void
468
+ // Workspace mode: 'edit' is the full editing surface; 'studio' is the
469
+ // render/snapshot surface (clean canvas, no editing chrome or selection).
470
+ // Entering studio forces a 3D-only view and restores the prior view on exit.
471
+ workspaceMode: WorkspaceMode
472
+ _viewModeBeforeStudio: ViewMode | null
473
+ setWorkspaceMode: (mode: WorkspaceMode) => void
237
474
  activeSidebarPanel: string
238
475
  setActiveSidebarPanel: (id: string) => void
239
- setIsCaptureMode: (enabled: boolean) => void
240
476
  floorplanPaneRatio: number
241
477
  setFloorplanPaneRatio: (ratio: number) => void
242
478
  // Mobile-only: pixel height of the secondary panel sheet while open (0 when closed).
@@ -257,6 +493,10 @@ type PersistedEditorLayoutState = Pick<
257
493
  | 'splitOrientation'
258
494
  | 'floorplanSelectionTool'
259
495
  | 'gridSnapStep'
496
+ | 'magneticSnap'
497
+ | 'lastMeasurementKind'
498
+ | 'snappingModeByContext'
499
+ | 'continuationByContext'
260
500
  | 'showReferenceFloor'
261
501
  | 'referenceFloorOffset'
262
502
  | 'referenceFloorOpacity'
@@ -279,6 +519,19 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
279
519
  splitOrientation: 'horizontal',
280
520
  floorplanSelectionTool: 'click',
281
521
  gridSnapStep: 0.5,
522
+ magneticSnap: true,
523
+ lastMeasurementKind: DEFAULT_CREATABLE_MEASUREMENT_KIND,
524
+ snappingModeByContext: {
525
+ wall: defaultSnappingModeFor('wall'),
526
+ item: defaultSnappingModeFor('item'),
527
+ polygon: defaultSnappingModeFor('polygon'),
528
+ },
529
+ continuationByContext: {
530
+ wall: CONTINUATION_PROFILES.wall.default,
531
+ fence: CONTINUATION_PROFILES.fence.default,
532
+ point: CONTINUATION_PROFILES.point.default,
533
+ cabinet: CONTINUATION_PROFILES.cabinet.default,
534
+ },
282
535
  showReferenceFloor: false,
283
536
  referenceFloorOffset: 1,
284
537
  referenceFloorOpacity: 0.35,
@@ -286,6 +539,10 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
286
539
 
287
540
  const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
288
541
 
542
+ type SelectDefaultBuildingAndLevelOptions = {
543
+ forceGroundLevel?: boolean
544
+ }
545
+
289
546
  function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
290
547
  if (phase === 'site') {
291
548
  return 'select'
@@ -377,8 +634,51 @@ export function normalizePersistedEditorUiState(
377
634
  }
378
635
  }
379
636
 
637
+ // Validate a persisted per-context mode against that context's allowed set
638
+ // (so e.g. a stale `angles` for items resets), falling back to its default.
639
+ function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode {
640
+ return snappingModesFor(context).includes(value as SnappingMode)
641
+ ? (value as SnappingMode)
642
+ : defaultSnappingModeFor(context)
643
+ }
644
+
645
+ type LegacyContinuationState = {
646
+ continuationByContext?: Partial<Record<ContinuationContext, unknown>>
647
+ wallChainMode?: unknown
648
+ fenceChainMode?: unknown
649
+ }
650
+
651
+ function migrateContinuationMode(
652
+ value: unknown,
653
+ context: ContinuationContext,
654
+ ): ContinuationMode | null {
655
+ const profile = CONTINUATION_PROFILES[context]
656
+ return profile.options.includes(value as ContinuationMode) ? (value as ContinuationMode) : null
657
+ }
658
+
659
+ function normalizeContinuationByContext(
660
+ state: LegacyContinuationState | null | undefined,
661
+ ): Record<ContinuationContext, ContinuationMode> {
662
+ return {
663
+ wall:
664
+ migrateContinuationMode(state?.continuationByContext?.wall, 'wall') ??
665
+ migrateContinuationMode(state?.wallChainMode, 'wall') ??
666
+ CONTINUATION_PROFILES.wall.default,
667
+ fence:
668
+ migrateContinuationMode(state?.continuationByContext?.fence, 'fence') ??
669
+ migrateContinuationMode(state?.fenceChainMode, 'fence') ??
670
+ CONTINUATION_PROFILES.fence.default,
671
+ point:
672
+ migrateContinuationMode(state?.continuationByContext?.point, 'point') ??
673
+ CONTINUATION_PROFILES.point.default,
674
+ cabinet:
675
+ migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ??
676
+ CONTINUATION_PROFILES.cabinet.default,
677
+ }
678
+ }
679
+
380
680
  function normalizePersistedEditorLayoutState(
381
- state: Partial<PersistedEditorLayoutState> | null | undefined,
681
+ state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
382
682
  ): PersistedEditorLayoutState {
383
683
  return {
384
684
  activeSidebarPanel:
@@ -391,6 +691,15 @@ function normalizePersistedEditorLayoutState(
391
691
  gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
392
692
  ? (state?.gridSnapStep as GridSnapStep)
393
693
  : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
694
+ // Default on: only an explicit persisted `false` disables it.
695
+ magneticSnap: state?.magneticSnap !== false,
696
+ lastMeasurementKind: normalizeCreatableMeasurementKind(state?.lastMeasurementKind),
697
+ snappingModeByContext: {
698
+ wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
699
+ item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
700
+ polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
701
+ },
702
+ continuationByContext: normalizeContinuationByContext(state),
394
703
  showReferenceFloor: state?.showReferenceFloor === true,
395
704
  referenceFloorOffset:
396
705
  typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -420,46 +729,101 @@ export function hasCustomPersistedEditorUiState(
420
729
  )
421
730
  }
422
731
 
732
+ function getDefaultLevelId(
733
+ buildingNode: BuildingNode,
734
+ nodes: Record<string, AnyNode>,
735
+ ): LevelNode['id'] | null {
736
+ const levels = buildingNode.children
737
+ .map((childId) => nodes[childId as AnyNodeId])
738
+ .filter((node): node is LevelNode => node?.type === 'level')
739
+
740
+ if (levels.length === 0) {
741
+ return null
742
+ }
743
+
744
+ const groundLevel = levels.find((level) => level.level === 0)
745
+ if (groundLevel) {
746
+ return groundLevel.id
747
+ }
748
+
749
+ const firstLevel = levels[0]
750
+ if (!firstLevel) {
751
+ return null
752
+ }
753
+
754
+ let lowestLevel = firstLevel
755
+ for (const level of levels.slice(1)) {
756
+ if (level.level < lowestLevel.level) {
757
+ lowestLevel = level
758
+ }
759
+ }
760
+
761
+ return lowestLevel.id
762
+ }
763
+
423
764
  /**
424
765
  * Selects the first building and level 0 in the scene.
425
766
  * Safe to call any time — no-ops if already selected or scene is empty.
426
767
  */
427
- export function selectDefaultBuildingAndLevel() {
768
+ export function selectDefaultBuildingAndLevel(options: SelectDefaultBuildingAndLevelOptions = {}) {
428
769
  const viewer = useViewer.getState()
429
770
  const scene = useScene.getState()
430
771
 
431
- let buildingId = viewer.selection.buildingId
772
+ const selectedBuilding = viewer.selection.buildingId
773
+ ? scene.nodes[viewer.selection.buildingId]
774
+ : null
775
+ let buildingNode =
776
+ selectedBuilding?.type === 'building' ? (selectedBuilding as BuildingNode) : null
432
777
 
433
778
  // If no building selected, find the first one from site's children
434
- if (!buildingId) {
779
+ if (!buildingNode) {
435
780
  const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
436
781
  if (siteNode?.type === 'site') {
437
- const firstBuilding = siteNode.children
438
- .map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
439
- .find((node) => node?.type === 'building')
440
- if (firstBuilding) {
441
- buildingId = firstBuilding.id as BuildingNode['id']
442
- viewer.setSelection({ buildingId })
443
- }
782
+ buildingNode =
783
+ siteNode.children
784
+ .map((childId) => scene.nodes[childId as AnyNodeId])
785
+ .find((node): node is BuildingNode => node?.type === 'building') ?? null
444
786
  }
445
787
  }
446
788
 
447
- // If no level selected, find level 0 in the building
448
- if (buildingId && !viewer.selection.levelId) {
449
- const buildingNode = scene.nodes[buildingId] as BuildingNode
450
- const level0Id = buildingNode.children.find((childId) => {
451
- const levelNode = scene.nodes[childId] as LevelNode
452
- return levelNode?.type === 'level' && levelNode.level === 0
453
- })
454
- if (level0Id) {
455
- viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
456
- } else if (buildingNode.children[0]) {
457
- // Fallback to first level if level 0 doesn't exist
458
- viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
459
- }
789
+ if (!buildingNode) {
790
+ return
791
+ }
792
+
793
+ const selectedLevel = viewer.selection.levelId ? scene.nodes[viewer.selection.levelId] : null
794
+ const selectedLevelBelongsToBuilding =
795
+ selectedLevel?.type === 'level' && selectedLevel.parentId === buildingNode.id
796
+ const shouldSelectDefaultLevel = options.forceGroundLevel || !selectedLevelBelongsToBuilding
797
+ const defaultLevelId = shouldSelectDefaultLevel
798
+ ? getDefaultLevelId(buildingNode, scene.nodes as Record<string, AnyNode>)
799
+ : null
800
+
801
+ const selectionUpdate: Parameters<typeof viewer.setSelection>[0] = {}
802
+ if (viewer.selection.buildingId !== buildingNode.id) {
803
+ selectionUpdate.buildingId = buildingNode.id
460
804
  }
805
+ if (defaultLevelId) {
806
+ selectionUpdate.levelId = defaultLevelId
807
+ }
808
+
809
+ if (Object.keys(selectionUpdate).length > 0) {
810
+ viewer.setSelection(selectionUpdate)
811
+ }
812
+ }
813
+
814
+ export function selectSiteFloorplanContext() {
815
+ selectDefaultBuildingAndLevel({ forceGroundLevel: true })
816
+ useViewer.getState().setSelection({
817
+ selectedIds: [],
818
+ zoneId: null,
819
+ })
461
820
  }
462
821
 
822
+ // Stashes the view mode the user was in before entering capture, so we can
823
+ // restore it on exit. Snapshot capture always frames in 3D — the 2D/split
824
+ // floorplan panes render nothing meaningful for a thumbnail.
825
+ let viewModeBeforeCapture: ViewMode | null = null
826
+
463
827
  const useEditor = create<EditorState>()(
464
828
  persist(
465
829
  (set, get) => ({
@@ -488,12 +852,9 @@ const useEditor = create<EditorState>()(
488
852
  set({ mode: 'select', tool: null, catalogCategory: null })
489
853
  }
490
854
 
491
- const viewer = useViewer.getState()
492
-
493
855
  switch (phase) {
494
856
  case 'site':
495
- // In Site mode, we zoom out and deselect specific levels/buildings
496
- viewer.resetSelection()
857
+ selectSiteFloorplanContext()
497
858
  break
498
859
 
499
860
  case 'structure':
@@ -531,9 +892,26 @@ const useEditor = create<EditorState>()(
531
892
  else if (tool) {
532
893
  set({ tool: null })
533
894
  }
895
+
896
+ const scope = useInteractionScope.getState()
897
+ if (mode === 'material-paint') scope.begin({ kind: 'painting' })
898
+ else scope.endIf((s) => s.kind === 'painting')
534
899
  },
535
900
  tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
536
901
  setTool: (tool) => set({ tool }),
902
+ toolDefaults: {},
903
+ setToolDefaults: (tool, defaults) =>
904
+ set((state) => {
905
+ const next = { ...state.toolDefaults }
906
+ if (defaults === null) {
907
+ delete next[tool]
908
+ } else {
909
+ next[tool] = defaults
910
+ }
911
+ return { toolDefaults: next }
912
+ }),
913
+ lastMeasurementKind: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.lastMeasurementKind,
914
+ setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
537
915
  structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
538
916
  setStructureLayer: (layer) => {
539
917
  const { mode } = get()
@@ -555,38 +933,84 @@ const useEditor = create<EditorState>()(
555
933
  setCatalogCategory: (category) => set({ catalogCategory: category }),
556
934
  selectedItem: null,
557
935
  setSelectedItem: (item) => set({ selectedItem: item }),
558
- movingNode: null as
559
- | ItemNode
560
- | WindowNode
561
- | DoorNode
562
- | CeilingNode
563
- | SlabNode
564
- | WallNode
565
- | FenceNode
566
- | RoofNode
567
- | RoofSegmentNode
568
- | StairNode
569
- | StairSegmentNode
570
- | BuildingNode
571
- | null,
572
- setMovingNode: (node) => set({ movingNode: node }),
573
- movingWallEndpoint: null,
574
- setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
575
- movingFenceEndpoint: null,
576
- setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
577
- curvingWall: null,
578
- setCurvingWall: (wall) => set({ curvingWall: wall }),
579
- curvingFence: null,
580
- setCurvingFence: (fence) => set({ curvingFence: fence }),
936
+ placementDragMode: false,
937
+ setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
938
+ roofHostDragArmedId: null,
939
+ setRoofHostDragArmedId: (nodeId) => set({ roofHostDragArmedId: nodeId }),
940
+ // The node being placed/moved now lives inside the interaction scope
941
+ // (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter
942
+ // remains the single entry point: it drives the scope and still touches
943
+ // `movingNodeOrigin` / `placementDragMode` so cross-store subscribers that
944
+ // watch this store (community placement) keep firing on move start/end.
945
+ setMovingNode: (node) => {
946
+ const scope = useInteractionScope.getState()
947
+ if (node === null) {
948
+ scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
949
+ // Preserve `movingNodeOrigin` across the clear so the non-owning
950
+ // side's effect cleanup which fires after `setMovingNode(null)`
951
+ // propagates — can still read who finalised. The next non-null
952
+ // `setMovingNode` resets it. Always clear the press-drag flag.
953
+ set({ placementDragMode: false })
954
+ return
955
+ }
956
+ const targetNode = resolveMovingNodeTarget(node)
957
+ const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew)
958
+ if (isNew) {
959
+ scope.begin({
960
+ kind: 'placing',
961
+ node: targetNode,
962
+ nodeId: targetNode.id,
963
+ nodeType: targetNode.type,
964
+ view: '3d',
965
+ pressDrag: get().placementDragMode,
966
+ })
967
+ } else {
968
+ scope.begin({
969
+ kind: 'moving',
970
+ node: targetNode,
971
+ nodeId: targetNode.id,
972
+ nodeType: targetNode.type,
973
+ view: '3d',
974
+ })
975
+ }
976
+ set({ movingNodeOrigin: null })
977
+ },
978
+ movingNodeOrigin: null as '2d' | '3d' | null,
979
+ setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
980
+ rotationAxis: 'y',
981
+ cycleRotationAxis: () => {
982
+ const order = ['y', 'x', 'z'] as const
983
+ const next = order[(order.indexOf(get().rotationAxis as 'y' | 'x' | 'z') + 1) % 3]!
984
+ set({ rotationAxis: next })
985
+ return next
986
+ },
581
987
  selectedMaterialTarget: null,
582
988
  setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
583
989
  activePaintMaterial: null,
584
- setActivePaintMaterial: (material) => set({ activePaintMaterial: material }),
990
+ // Picking a material implies paint, not erase — clear the eraser so the
991
+ // next click applies the chosen material.
992
+ setActivePaintMaterial: (material) =>
993
+ set({ activePaintMaterial: material, paintEraser: false }),
585
994
  activePaintTarget: 'wall',
586
995
  setActivePaintTarget: (target) =>
587
996
  set((state) =>
588
997
  state.activePaintTarget === target ? state : { activePaintTarget: target },
589
998
  ),
999
+ draftVertexCount: 0,
1000
+ setDraftVertexCount: (count) =>
1001
+ set((state) => (state.draftVertexCount === count ? state : { draftVertexCount: count })),
1002
+ paintScope: 'single',
1003
+ setPaintScope: (scope) => set({ paintScope: scope }),
1004
+ cyclePaintScope: () => {
1005
+ // Cycle within the hovered node's available scopes (what the click will
1006
+ // actually hit). With nothing paintable hovered there's only `single`.
1007
+ const scopes = get().paintHover?.scopes ?? (['single'] as PaintScope[])
1008
+ const next = cyclePaintScopeValue(get().paintScope, scopes)
1009
+ set({ paintScope: next })
1010
+ return next
1011
+ },
1012
+ paintEraser: false,
1013
+ setPaintEraser: (eraser) => set({ paintEraser: eraser }),
590
1014
  primeMaterialPaintFromSelection: () => {
591
1015
  const selectedId =
592
1016
  useViewer.getState().selection.selectedIds.length === 1
@@ -614,15 +1038,14 @@ const useEditor = create<EditorState>()(
614
1038
  activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
615
1039
  }
616
1040
  },
617
- hoveredPaintTarget: null,
618
- setHoveredPaintTarget: (target) =>
619
- set((state) =>
620
- state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
621
- ),
622
- isPaintPanelOpen: false,
623
- setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
1041
+ paintHover: null,
1042
+ setPaintHover: (info) => set({ paintHover: info }),
1043
+ canFindNode: false,
1044
+ setCanFindNode: (canFind) => set({ canFindNode: canFind }),
624
1045
  selectedReferenceId: null,
625
1046
  setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
1047
+ referenceScaleActiveGuideId: null,
1048
+ setReferenceScaleActiveGuideId: (id) => set({ referenceScaleActiveGuideId: id }),
626
1049
  guideUi: {},
627
1050
  setGuideLocked: (guideId, locked) =>
628
1051
  set((state) => ({
@@ -655,8 +1078,14 @@ const useEditor = create<EditorState>()(
655
1078
  }),
656
1079
  spaces: {},
657
1080
  setSpaces: (spaces) => set({ spaces }),
658
- editingHole: null,
659
- setEditingHole: (hole) => set({ editingHole: hole }),
1081
+ hoveredHole: null,
1082
+ setHoveredHole: (hole) =>
1083
+ set((state) =>
1084
+ state.hoveredHole?.nodeId === hole?.nodeId &&
1085
+ state.hoveredHole?.holeIndex === hole?.holeIndex
1086
+ ? state
1087
+ : { hoveredHole: hole },
1088
+ ),
660
1089
  isPreviewMode: false,
661
1090
  setPreviewMode: (preview) => {
662
1091
  if (preview) {
@@ -667,8 +1096,41 @@ const useEditor = create<EditorState>()(
667
1096
  set({ isPreviewMode: false })
668
1097
  }
669
1098
  },
1099
+ captureMode: { mode: 'idle' } as CaptureMode,
670
1100
  isCaptureMode: false,
671
- setCaptureMode: (active) => set({ isCaptureMode: active }),
1101
+ setCaptureMode: (next) => {
1102
+ const resolved: CaptureMode =
1103
+ typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
1104
+ const entering = resolved.mode !== 'idle'
1105
+ set((state) => {
1106
+ if (entering) {
1107
+ // Force 3D for the shot. Remember the prior mode only on the first
1108
+ // entry (viewMode is already '3d' on re-entry), so we restore the
1109
+ // user's real choice — not the forced '3d' — when capture ends.
1110
+ if (state.viewMode !== '3d') {
1111
+ viewModeBeforeCapture = state.viewMode
1112
+ return {
1113
+ captureMode: resolved,
1114
+ isCaptureMode: true,
1115
+ viewMode: '3d',
1116
+ isFloorplanOpen: false,
1117
+ }
1118
+ }
1119
+ return { captureMode: resolved, isCaptureMode: true }
1120
+ }
1121
+ const restore = viewModeBeforeCapture
1122
+ viewModeBeforeCapture = null
1123
+ if (restore && restore !== '3d') {
1124
+ return {
1125
+ captureMode: resolved,
1126
+ isCaptureMode: false,
1127
+ viewMode: restore,
1128
+ isFloorplanOpen: true,
1129
+ }
1130
+ }
1131
+ return { captureMode: resolved, isCaptureMode: false }
1132
+ })
1133
+ },
672
1134
  viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
673
1135
  setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
674
1136
  splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
@@ -682,10 +1144,63 @@ const useEditor = create<EditorState>()(
682
1144
  }),
683
1145
  isFloorplanHovered: false,
684
1146
  setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
1147
+ isRiserOpen: false,
1148
+ setRiserOpen: (open) => set({ isRiserOpen: open }),
1149
+ toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
1150
+ navigationSyncPose: null,
1151
+ publishNavigationSyncPose: (pose) =>
1152
+ set((state) => ({
1153
+ navigationSyncPose: {
1154
+ ...pose,
1155
+ revision: (state.navigationSyncPose?.revision ?? 0) + 1,
1156
+ },
1157
+ })),
685
1158
  floorplanSelectionTool: 'click' as FloorplanSelectionTool,
686
1159
  setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
687
1160
  gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
688
1161
  setGridSnapStep: (step) => set({ gridSnapStep: step }),
1162
+ cycleGridSnapStep: () => {
1163
+ const current = get().gridSnapStep
1164
+ const index = GRID_SNAP_STEPS.indexOf(current)
1165
+ const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
1166
+ set({ gridSnapStep: next })
1167
+ return next
1168
+ },
1169
+ magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
1170
+ setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
1171
+ snappingModeByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingModeByContext,
1172
+ setSnappingMode: (context, mode) =>
1173
+ set((state) => ({
1174
+ snappingModeByContext: { ...state.snappingModeByContext, [context]: mode },
1175
+ })),
1176
+ cycleSnappingMode: () => {
1177
+ const context = getActiveSnapContext() ?? 'item'
1178
+ const current = get().snappingModeByContext[context]
1179
+ const next = cycleSnappingModeIn(context, current)
1180
+ set((state) => ({
1181
+ snappingModeByContext: { ...state.snappingModeByContext, [context]: next },
1182
+ }))
1183
+ return next
1184
+ },
1185
+ continuationByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.continuationByContext,
1186
+ setContinuation: (context, mode) => {
1187
+ const next =
1188
+ migrateContinuationMode(mode, context) ?? CONTINUATION_PROFILES[context].default
1189
+ set((state) => ({
1190
+ continuationByContext: { ...state.continuationByContext, [context]: next },
1191
+ }))
1192
+ },
1193
+ cycleContinuation: (context) => {
1194
+ const next = nextContinuation(context, get().getContinuation(context))
1195
+ set((state) => ({
1196
+ continuationByContext: { ...state.continuationByContext, [context]: next },
1197
+ }))
1198
+ return next
1199
+ },
1200
+ getContinuation: (context) => {
1201
+ const current = get().continuationByContext[context]
1202
+ return migrateContinuationMode(current, context) ?? CONTINUATION_PROFILES[context].default
1203
+ },
689
1204
  showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
690
1205
  toggleReferenceFloor: () =>
691
1206
  set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -698,6 +1213,8 @@ const useEditor = create<EditorState>()(
698
1213
  set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
699
1214
  allowUndergroundCamera: false,
700
1215
  setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
1216
+ show2dVoronoi: false,
1217
+ setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
701
1218
  isFirstPersonMode: false,
702
1219
  _viewModeBeforeFirstPerson: null as ViewMode | null,
703
1220
  setFirstPersonMode: (enabled) => {
@@ -721,9 +1238,34 @@ const useEditor = create<EditorState>()(
721
1238
  })
722
1239
  }
723
1240
  },
1241
+ workspaceMode: 'edit' as WorkspaceMode,
1242
+ _viewModeBeforeStudio: null as ViewMode | null,
1243
+ setWorkspaceMode: (mode) => {
1244
+ if (get().workspaceMode === mode) return
1245
+ if (mode === 'studio') {
1246
+ const currentViewMode = get().viewMode
1247
+ set({
1248
+ workspaceMode: 'studio',
1249
+ _viewModeBeforeStudio: currentViewMode,
1250
+ viewMode: '3d',
1251
+ isFloorplanOpen: false,
1252
+ mode: 'select',
1253
+ tool: null,
1254
+ catalogCategory: null,
1255
+ })
1256
+ // Clear selection so no edit affordances bleed into the clean canvas.
1257
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1258
+ } else {
1259
+ const prevMode = get()._viewModeBeforeStudio
1260
+ set({
1261
+ workspaceMode: 'edit',
1262
+ _viewModeBeforeStudio: null,
1263
+ ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
1264
+ })
1265
+ }
1266
+ },
724
1267
  activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
725
1268
  setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
726
- setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }),
727
1269
  floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
728
1270
  setFloorplanPaneRatio: (ratio) =>
729
1271
  set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
@@ -732,11 +1274,28 @@ const useEditor = create<EditorState>()(
732
1274
  }),
733
1275
  {
734
1276
  name: 'pascal-editor-ui-preferences',
735
- merge: (persistedState, currentState) => ({
736
- ...currentState,
737
- ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
738
- ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
739
- }),
1277
+ merge: (persistedState, currentState) => {
1278
+ const uiState = normalizePersistedEditorUiState(
1279
+ persistedState as Partial<PersistedEditorState>,
1280
+ )
1281
+ const layoutState = normalizePersistedEditorLayoutState(
1282
+ persistedState as Partial<PersistedEditorState>,
1283
+ )
1284
+
1285
+ return {
1286
+ ...currentState,
1287
+ ...uiState,
1288
+ ...layoutState,
1289
+ ...(uiState.mode === 'build' && uiState.tool === 'measurement'
1290
+ ? {
1291
+ toolDefaults: {
1292
+ ...currentState.toolDefaults,
1293
+ measurement: { kind: layoutState.lastMeasurementKind },
1294
+ },
1295
+ }
1296
+ : {}),
1297
+ }
1298
+ },
740
1299
  partialize: (state) => ({
741
1300
  phase: state.phase,
742
1301
  mode: state.mode,
@@ -750,12 +1309,106 @@ const useEditor = create<EditorState>()(
750
1309
  splitOrientation: state.splitOrientation,
751
1310
  floorplanSelectionTool: state.floorplanSelectionTool,
752
1311
  gridSnapStep: state.gridSnapStep,
1312
+ magneticSnap: state.magneticSnap,
1313
+ lastMeasurementKind: state.lastMeasurementKind,
1314
+ snappingModeByContext: state.snappingModeByContext,
1315
+ continuationByContext: state.continuationByContext,
753
1316
  showReferenceFloor: state.showReferenceFloor,
754
1317
  referenceFloorOffset: state.referenceFloorOffset,
755
1318
  referenceFloorOpacity: state.referenceFloorOpacity,
756
1319
  }),
1320
+ skipHydration: true,
757
1321
  },
758
1322
  ),
759
1323
  )
760
1324
 
1325
+ /**
1326
+ * Effective magnetic-snap state: the legacy `magneticSnap` flag AND the active
1327
+ * context's snapping mode. With exclusive modes, magnetic (alignment axes + wall
1328
+ * corner-join) is on only in `'lines'`. Read from the smallest magnetic choke
1329
+ * points so the mode is honoured without retuning any snap math.
1330
+ */
1331
+ export function isMagneticSnapActive(): boolean {
1332
+ const state = useEditor.getState()
1333
+ return state.magneticSnap && resolveSnapFlags(getActiveSnappingMode()).magnetic
1334
+ }
1335
+
1336
+ /**
1337
+ * Effective angle-lock state: the active context's snapping mode. With exclusive
1338
+ * modes the 15°/45° lock is on only in `'angles'`. Read from the smallest
1339
+ * angle-lock choke points (wall / fence draft call sites).
1340
+ */
1341
+ export function isAngleSnapActive(): boolean {
1342
+ return resolveSnapFlags(getActiveSnappingMode()).angles
1343
+ }
1344
+
1345
+ /**
1346
+ * Effective grid-lattice state: the active context's snapping mode. With
1347
+ * exclusive modes the grid quantize is on only in `'grid'`.
1348
+ */
1349
+ export function isGridSnapActive(): boolean {
1350
+ return resolveSnapFlags(getActiveSnappingMode()).grid
1351
+ }
1352
+
1353
+ /**
1354
+ * Whether alignment "lines" should be DISPLAYED for the active context.
1355
+ *
1356
+ * True whenever a snappable context is active — in EVERY snapping mode,
1357
+ * including `'off'`. The guides are passive reference feedback; this is
1358
+ * decoupled from the magnetic *pull*: a producer publishes guides whenever this
1359
+ * is true, but only applies the alignment delta when `isMagneticSnapActive()`
1360
+ * (i.e. `'lines'`). So the user always sees the same alignment lines while
1361
+ * snapping to grid / angles / off, and only snaps to them in `'lines'` mode.
1362
+ */
1363
+ export function isAlignmentGuideActive(): boolean {
1364
+ return getActiveSnapContext() !== null
1365
+ }
1366
+
1367
+ /**
1368
+ * The snapping context for what the user is currently doing (wall / item /
1369
+ * polygon), or null when nothing snappable is active. Derived from the
1370
+ * authoritative interaction scope, falling back to the armed build tool (the
1371
+ * `drafting` scope isn't wired). The single source every snap reader + the HUD
1372
+ * resolve their mode through.
1373
+ */
1374
+ export function getActiveSnapContext(): SnapContext | null {
1375
+ const editor = useEditor.getState()
1376
+ return snapContextOf({
1377
+ scope: useInteractionScope.getState().scope,
1378
+ mode: editor.mode,
1379
+ tool: editor.tool,
1380
+ profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
1381
+ draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
1382
+ })
1383
+ }
1384
+
1385
+ export function getActiveContinuationContext(): ContinuationContext | null {
1386
+ const scope = useInteractionScope.getState().scope
1387
+ if (scope.kind === 'drafting') return continuationContextOf(scope.tool)
1388
+ if (scope.kind === 'placing') return continuationContextOf(scope.nodeType)
1389
+ if (scope.kind !== 'idle') return null
1390
+
1391
+ const editor = useEditor.getState()
1392
+ if (editor.mode !== 'build' || !editor.tool) return null
1393
+ return continuationContextOf(editor.tool)
1394
+ }
1395
+
1396
+ export function getContinuation(context: ContinuationContext): ContinuationMode {
1397
+ return useEditor.getState().getContinuation(context)
1398
+ }
1399
+
1400
+ /**
1401
+ * The effective snapping mode for the active context. Falls back to `'off'` when
1402
+ * no snappable context is active (select / idle, no armed tool) so grid /
1403
+ * magnetic / angle readers — including the snap-grid overlay — stay inert
1404
+ * outside an interaction. Per-context defaults (item is `'grid'`) only take
1405
+ * effect once a tool is armed or an interaction begins; otherwise the item
1406
+ * default would light up the snap grid at idle.
1407
+ */
1408
+ export function getActiveSnappingMode(): SnappingMode {
1409
+ const context = getActiveSnapContext()
1410
+ if (!context) return 'off'
1411
+ return useEditor.getState().snappingModeByContext[context]
1412
+ }
1413
+
761
1414
  export default useEditor