@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -4,11 +4,15 @@ import type { AssetInput } from '@pascal-app/core'
4
4
  import {
5
5
  type AnyNode,
6
6
  type AnyNodeId,
7
+ type BrushSettings,
7
8
  type BuildingNode,
9
+ type CabinetModuleNode,
10
+ type CabinetNode,
8
11
  type CeilingNode,
9
12
  type ChimneyMaterialRole,
10
13
  type ChimneyNode,
11
14
  type ColumnNode,
15
+ DEFAULT_BRUSH_SETTINGS,
12
16
  type DoorNode,
13
17
  type DormerNode,
14
18
  type DormerSurfaceMaterialRole,
@@ -16,6 +20,7 @@ import {
16
20
  type FenceNode,
17
21
  type ItemNode,
18
22
  type LevelNode,
23
+ nodeRegistry,
19
24
  type RoofNode,
20
25
  type RoofSegmentNode,
21
26
  type RoofSurfaceMaterialRole,
@@ -25,6 +30,7 @@ import {
25
30
  type StairNode,
26
31
  type StairSegmentNode,
27
32
  type StairSurfaceMaterialRole,
33
+ type TerrainVerb,
28
34
  useScene,
29
35
  type WallNode,
30
36
  type WallSurfaceSide,
@@ -33,6 +39,13 @@ import {
33
39
  import { useViewer } from '@pascal-app/viewer'
34
40
  import { create } from 'zustand'
35
41
  import { persist } from 'zustand/middleware'
42
+ import {
43
+ CONTINUATION_PROFILES,
44
+ type ContinuationContext,
45
+ type ContinuationMode,
46
+ continuationContextOf,
47
+ nextContinuation,
48
+ } from '../lib/continuation'
36
49
  import {
37
50
  type ActivePaintMaterial,
38
51
  type PaintableMaterialTarget,
@@ -40,12 +53,58 @@ import {
40
53
  resolvePaintTargetFromSelection,
41
54
  type SingleSurfaceMaterialRole,
42
55
  } from '../lib/material-paint'
43
-
44
- const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
56
+ import {
57
+ type CreatableMeasurementKind,
58
+ DEFAULT_CREATABLE_MEASUREMENT_KIND,
59
+ normalizeCreatableMeasurementKind,
60
+ } from '../lib/measurement-kind'
61
+ import {
62
+ cyclePaintScope as cyclePaintScopeValue,
63
+ type PaintHoverInfo,
64
+ type PaintScope,
65
+ } from '../lib/paint-scope'
66
+ import {
67
+ cycleSnappingModeIn,
68
+ defaultSnappingModeFor,
69
+ resolveSnapFlags,
70
+ type SnapContext,
71
+ type SnappingMode,
72
+ snapContextOf,
73
+ snappingModesFor,
74
+ } from '../lib/snapping-mode'
75
+ import { publishNavigationSyncPoseToStore } from './navigation-sync-pose-store'
76
+ import useInteractionScope from './use-interaction-scope'
77
+
78
+ const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build'
45
79
  const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
46
80
  const MIN_FLOORPLAN_PANE_RATIO = 0.15
47
81
  const MAX_FLOORPLAN_PANE_RATIO = 0.85
48
82
 
83
+ function resolveMovingNodeTarget(
84
+ node:
85
+ | ItemNode
86
+ | WindowNode
87
+ | DoorNode
88
+ | ElevatorNode
89
+ | CeilingNode
90
+ | ChimneyNode
91
+ | ColumnNode
92
+ | DormerNode
93
+ | SlabNode
94
+ | WallNode
95
+ | FenceNode
96
+ | RoofNode
97
+ | RoofSegmentNode
98
+ | SpawnNode
99
+ | StairNode
100
+ | StairSegmentNode
101
+ | BuildingNode
102
+ | CabinetNode
103
+ | CabinetModuleNode,
104
+ ) {
105
+ return node
106
+ }
107
+
49
108
  export type ViewMode = '3d' | '2d' | 'split'
50
109
  export type SplitOrientation = 'horizontal' | 'vertical'
51
110
  export type WorkspaceMode = 'edit' | 'studio'
@@ -59,9 +118,16 @@ export type WorkspaceMode = 'edit' | 'studio'
59
118
  // — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and
60
119
  // applies those constraints. Keeping it a discriminated union lets us
61
120
  // add future modes without surfacing the choice to end users.
121
+ // How the captured pixels are cropped: full-frame 16:9, raw canvas viewport,
122
+ // or user-dragged area. Hosts (e.g. the studio capture bar) can preselect it
123
+ // when entering capture mode.
124
+ export type SnapshotCropMode = 'standard' | 'viewport' | 'area'
125
+ /** Aspect presets available to `standard` crops. */
126
+ export type SnapshotStandardAspect = '16:9' | '9:16' | '4:3' | '3:4' | '1:1'
127
+
62
128
  export type CaptureMode =
63
129
  | { mode: 'idle' }
64
- | { mode: 'standard' }
130
+ | { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect }
65
131
  | {
66
132
  mode: 'preset'
67
133
  isolated: AnyNodeId[]
@@ -75,10 +141,24 @@ export type CaptureMode =
75
141
 
76
142
  export type Phase = 'site' | 'structure' | 'furnish'
77
143
 
78
- export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
144
+ /**
145
+ * `terrain-sculpt` is a mode, not a build tool, and that is the whole answer to
146
+ * "how does terrain editing avoid conflicting with everything else".
147
+ *
148
+ * A build tool places a node and hands the pointer back. Sculpting is a
149
+ * sustained brush over the *ground* — the one surface every other tool uses as
150
+ * its reference plane — so while it is armed, clicks must not select a wall,
151
+ * drag a window, or arm a ghost. Modeling it as a mode gets that for free: it is
152
+ * mutually exclusive with `build`/`select`/`delete` by construction, every
153
+ * selection manager already early-returns unless `mode === 'select'`, and it
154
+ * holds a `sculpting` interaction scope for its whole lifetime so conflicting
155
+ * controls stay stepped back. `material-paint` is the existing precedent for
156
+ * exactly this shape.
157
+ */
158
+ export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' | 'terrain-sculpt'
79
159
 
80
160
  // Structure mode tools (building elements)
81
- export type StructureTool =
161
+ type BuiltInStructureTool =
82
162
  | 'wall'
83
163
  | 'fence'
84
164
  | 'room'
@@ -87,6 +167,7 @@ export type StructureTool =
87
167
  | 'ceiling'
88
168
  | 'roof'
89
169
  | 'column'
170
+ | 'structural-grid'
90
171
  | 'elevator'
91
172
  | 'stair'
92
173
  | 'item'
@@ -106,9 +187,21 @@ export type StructureTool =
106
187
  | 'dormer'
107
188
  | 'gutter'
108
189
  | 'downspout'
190
+ | 'duct-segment'
191
+ | 'duct-fitting'
192
+ | 'duct-terminal'
193
+ | 'hvac-equipment'
194
+ | 'lineset'
195
+ | 'liquid-line'
196
+ | 'pipe-segment'
197
+ | 'pipe-fitting'
198
+ | 'pipe-trap'
199
+
200
+ /** Registry node kinds are valid build tools without central union edits. */
201
+ export type StructureTool = BuiltInStructureTool | (string & {})
109
202
 
110
203
  // Furnish mode tools (items and decoration)
111
- export type FurnishTool = 'item'
204
+ export type FurnishTool = 'item' | 'cabinet'
112
205
 
113
206
  // Site mode tools
114
207
  export type SiteTool = 'property-line'
@@ -140,8 +233,14 @@ export type NavigationSyncPose = {
140
233
 
141
234
  export type NavigationSyncPoseInput = Omit<NavigationSyncPose, 'revision'>
142
235
 
143
- // Combined tool type
144
- export type Tool = SiteTool | StructureTool | FurnishTool
236
+ // Combined tool type. Known literals keep autocomplete; the `(string & {})`
237
+ // arm lets plugin-contributed tool ids (e.g. `'trees:tree'`) typecheck without
238
+ // the host enumerating every plugin kind. The runtime dispatch is already
239
+ // registry-first — `tool-manager` resolves `nodeRegistry.get(tool)?.tool` — so
240
+ // an unknown-to-the-host tool string flows straight through to the plugin's
241
+ // placement component.
242
+ export type KnownTool = SiteTool | StructureTool | FurnishTool
243
+ export type Tool = KnownTool | (string & {})
145
244
 
146
245
  /**
147
246
  * Starting parameters seeded into a draw tool before it mints a node.
@@ -151,16 +250,6 @@ export type Tool = SiteTool | StructureTool | FurnishTool
151
250
  */
152
251
  export type ToolDefaults = Record<string, unknown>
153
252
 
154
- export type MovingWallEndpoint = {
155
- wall: WallNode
156
- endpoint: 'start' | 'end'
157
- }
158
-
159
- export type MovingFenceEndpoint = {
160
- fence: FenceNode
161
- endpoint: 'start' | 'end'
162
- }
163
-
164
253
  export type MaterialTargetRole =
165
254
  | WallSurfaceSide
166
255
  | StairSurfaceMaterialRole
@@ -168,6 +257,7 @@ export type MaterialTargetRole =
168
257
  | ChimneyMaterialRole
169
258
  | DormerSurfaceMaterialRole
170
259
  | SingleSurfaceMaterialRole
260
+ | string
171
261
 
172
262
  export type SelectedMaterialTarget = {
173
263
  nodeId: AnyNodeId
@@ -203,31 +293,14 @@ type EditorState = {
203
293
  */
204
294
  toolDefaults: Partial<Record<Tool, ToolDefaults>>
205
295
  setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
296
+ lastMeasurementKind: CreatableMeasurementKind
297
+ setLastMeasurementKind: (kind: CreatableMeasurementKind) => void
206
298
  structureLayer: StructureLayer
207
299
  setStructureLayer: (layer: StructureLayer) => void
208
300
  catalogCategory: CatalogCategory | null
209
301
  setCatalogCategory: (category: CatalogCategory | null) => void
210
302
  selectedItem: AssetInput | null
211
303
  setSelectedItem: (item: AssetInput) => void
212
- movingNode:
213
- | ItemNode
214
- | WindowNode
215
- | DoorNode
216
- | ElevatorNode
217
- | CeilingNode
218
- | ChimneyNode
219
- | ColumnNode
220
- | DormerNode
221
- | SlabNode
222
- | WallNode
223
- | FenceNode
224
- | RoofNode
225
- | RoofSegmentNode
226
- | SpawnNode
227
- | StairNode
228
- | StairSegmentNode
229
- | BuildingNode
230
- | null
231
304
  /**
232
305
  * True while a move was engaged by a press-drag gizmo (the on-canvas move
233
306
  * cross) rather than a click-to-place flow. The placement coordinator reads
@@ -235,6 +308,8 @@ type EditorState = {
235
308
  */
236
309
  placementDragMode: boolean
237
310
  setPlacementDragMode: (dragMode: boolean) => void
311
+ roofHostDragArmedId: AnyNodeId | null
312
+ setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void
238
313
  setMovingNode: (
239
314
  node:
240
315
  | ItemNode
@@ -254,6 +329,8 @@ type EditorState = {
254
329
  | StairNode
255
330
  | StairSegmentNode
256
331
  | BuildingNode
332
+ | CabinetNode
333
+ | CabinetModuleNode
257
334
  | null,
258
335
  ) => void
259
336
  /**
@@ -275,43 +352,80 @@ type EditorState = {
275
352
  */
276
353
  movingNodeOrigin: '2d' | '3d' | null
277
354
  setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void
278
- movingWallEndpoint: MovingWallEndpoint | null
279
- setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
280
- movingFenceEndpoint: MovingFenceEndpoint | null
281
- setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
282
355
  /**
283
- * Generic per-kind handle drag state. Set by a node's resize handle
284
- * (height arrow, width arrow, rise / sweep / inner-radius for curved
285
- * stairs, …) at drag-start and cleared on drag-end. `label`
286
- * identifies which dimension the handle controls measurement
287
- * overlays read it to render the right caption; the camera controls
288
- * use the truthy value to suppress one-finger pan-rotate. Replaces
289
- * the previous per-kind `resizing*` fields so adding a new resize
290
- * handle doesn't require a new store field.
356
+ * World axis the R/T keyboard rotation turns around, for kinds with
357
+ * full 3D orientation (duct fittings). Alt cycles it Y X Z; the
358
+ * kind's tool / keyboard actions read it, and the floating action
359
+ * menu surfaces it in a pill above the selected node.
291
360
  */
292
- activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
293
- setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void
294
- curvingWall: WallNode | null
295
- setCurvingWall: (wall: WallNode | null) => void
296
- curvingFence: FenceNode | null
297
- setCurvingFence: (fence: FenceNode | null) => void
361
+ rotationAxis: 'x' | 'y' | 'z'
362
+ cycleRotationAxis: () => 'x' | 'y' | 'z'
298
363
  selectedMaterialTarget: SelectedMaterialTarget | null
299
364
  setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
300
365
  activePaintMaterial: ActivePaintMaterial | null
301
366
  setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
302
367
  activePaintTarget: PaintableMaterialTarget
303
368
  setActivePaintTarget: (target: PaintableMaterialTarget) => void
369
+ // Live vertex count of an in-progress polygon draft (slab / ceiling), so the
370
+ // contextual HUD can gate hints on it (e.g. "Finish" only once ≥ 3 points).
371
+ // 0 when not drafting. Not persisted.
372
+ draftVertexCount: number
373
+ setDraftVertexCount: (count: number) => void
374
+ // Painter application scope — how far one paint click spreads (this surface /
375
+ // whole item / all matching / room). One global mode, target-aware in the HUD
376
+ // (see `lib/paint-scope.ts`), defaulting to the narrowest `'single'`. Not
377
+ // persisted: a "paint everything" scope should reset each session.
378
+ paintScope: PaintScope
379
+ setPaintScope: (scope: PaintScope) => void
380
+ // Cycle the scope within the hovered node's available set and return the new
381
+ // value. Bound to Shift while in paint mode.
382
+ cyclePaintScope: () => PaintScope
304
383
  // When true, clicking a surface in paint mode clears it back to its
305
384
  // default material instead of applying `activePaintMaterial`.
306
385
  paintEraser: boolean
307
386
  setPaintEraser: (eraser: boolean) => void
308
387
  primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
309
- hoveredPaintTarget: PaintableMaterialTarget | null
310
- setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
311
- isPaintPanelOpen: boolean
312
- setPaintPanelOpen: (open: boolean) => void
388
+ /**
389
+ * Terrain sculpt state. Lives here rather than in the tool component so the
390
+ * bottom-bar HUD, the keyboard shortcuts, and the brush all read one source —
391
+ * the same reason the paint mode's material/scope/eraser live here.
392
+ */
393
+ terrainVerb: TerrainVerb
394
+ setTerrainVerb: (verb: TerrainVerb) => void
395
+ terrainBrush: BrushSettings
396
+ setTerrainBrush: (settings: Partial<BrushSettings>) => void
397
+ /**
398
+ * Absolute height in metres the `flatten` verb aims at. Sampled by clicking
399
+ * the ground with the eyedropper, or typed. `null` means "sample on first
400
+ * click", which is what makes flatten usable without ever opening a number
401
+ * field.
402
+ */
403
+ terrainFlattenTarget: number | null
404
+ setTerrainFlattenTarget: (metres: number | null) => void
405
+ /**
406
+ * True while the next click should sample a flatten target instead of
407
+ * sculpting. One-shot: sampling clears it.
408
+ */
409
+ terrainSampling: boolean
410
+ setTerrainSampling: (sampling: boolean) => void
411
+ // What the cursor is over in paint mode: the scopes it offers + labels for the
412
+ // HUD chip. `null` when not over a paintable surface (drives the "hover a
413
+ // surface" hint). Set by the selection-manager paint hover; not persisted.
414
+ paintHover: PaintHoverInfo | null
415
+ setPaintHover: (info: PaintHoverInfo | null) => void
416
+ // Embedder capability: true when a host (e.g. community) can locate a selected
417
+ // node in its catalog browser. Gates the node action menu's "Find" button; the
418
+ // editor itself emits `selection:find-node` and lets the host fulfil it. Not
419
+ // persisted — it's a per-mount capability the host registers.
420
+ canFindNode: boolean
421
+ setCanFindNode: (canFind: boolean) => void
313
422
  selectedReferenceId: string | null
314
423
  setSelectedReferenceId: (id: string | null) => void
424
+ // Guide id with an in-flight reference-scale measurement (line drawing or
425
+ // length dialog). Owned by the floorplan panel; mirrored here so the
426
+ // reference panel can flip its Set Scale button into a Cancel.
427
+ referenceScaleActiveGuideId: string | null
428
+ setReferenceScaleActiveGuideId: (id: string | null) => void
315
429
  guideUi: Record<string, GuideUiState>
316
430
  setGuideLocked: (guideId: string, locked: boolean) => void
317
431
  setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void
@@ -319,9 +433,6 @@ type EditorState = {
319
433
  // Space detection for cutaway mode
320
434
  spaces: Record<string, Space>
321
435
  setSpaces: (spaces: Record<string, Space>) => void
322
- // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
323
- editingHole: SurfaceHoleTarget | null
324
- setEditingHole: (hole: SurfaceHoleTarget | null) => void
325
436
  hoveredHole: SurfaceHoleTarget | null
326
437
  setHoveredHole: (hole: SurfaceHoleTarget | null) => void
327
438
  // Preview mode (viewer-like experience inside the editor)
@@ -348,17 +459,38 @@ type EditorState = {
348
459
  toggleFloorplanOpen: () => void
349
460
  isFloorplanHovered: boolean
350
461
  setFloorplanHovered: (hovered: boolean) => void
462
+ // Toggleable DWV riser-diagram (plumbing isometric) overlay.
463
+ isRiserOpen: boolean
464
+ setRiserOpen: (open: boolean) => void
465
+ toggleRiserOpen: () => void
351
466
  navigationSyncPose: NavigationSyncPose | null
352
467
  publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
353
468
  floorplanSelectionTool: FloorplanSelectionTool
354
469
  setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
355
470
  gridSnapStep: GridSnapStep
356
471
  setGridSnapStep: (step: GridSnapStep) => void
472
+ // Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 →
473
+ // 0.5) and returns the new value. Bound to the measurement-step shortcut.
474
+ cycleGridSnapStep: () => GridSnapStep
357
475
  // Magnetic snapping while drafting — snaps wall endpoints onto existing
358
476
  // wall corners / wall bodies (the "magnetic" beacon). Independent of grid
359
477
  // snap. On by default; toggled from the Display menu.
360
478
  magneticSnap: boolean
361
479
  setMagneticSnap: (enabled: boolean) => void
480
+ // Per-context, user-cyclable snapping mode (see `lib/snapping-mode.ts`). Each
481
+ // activity (wall / item / polygon) keeps its own mode + default, because they
482
+ // want different snapping — drawing a wall wants grid + angle, nudging an item
483
+ // wants free movement that only catches alignment lines. Resolved to the live
484
+ // context via `getActiveSnappingMode()`; maps onto `gridSnapStep`/`magneticSnap`
485
+ // via `resolveSnapFlags`. Persisted per context.
486
+ snappingModeByContext: Record<SnapContext, SnappingMode>
487
+ setSnappingMode: (context: SnapContext, mode: SnappingMode) => void
488
+ // Cycle the *active* context's mode within its own set; returns the new value.
489
+ cycleSnappingMode: () => SnappingMode
490
+ continuationByContext: Record<ContinuationContext, ContinuationMode>
491
+ setContinuation: (context: ContinuationContext, mode: ContinuationMode) => void
492
+ cycleContinuation: (context: ContinuationContext) => ContinuationMode
493
+ getContinuation: (context: ContinuationContext) => ContinuationMode
362
494
  showReferenceFloor: boolean
363
495
  toggleReferenceFloor: () => void
364
496
  setShowReferenceFloor: (show: boolean) => void
@@ -369,6 +501,11 @@ type EditorState = {
369
501
  // Development-only camera debug flag for inspecting underside geometry
370
502
  allowUndergroundCamera: boolean
371
503
  setAllowUndergroundCamera: (enabled: boolean) => void
504
+ // Development-only debug overlay: draw each wall's opening-snap hit area
505
+ // (the capsule of points within the snap radius of its centerline). Lets us
506
+ // see why a door/window snaps where it does.
507
+ show2dVoronoi: boolean
508
+ setShow2dVoronoi: (enabled: boolean) => void
372
509
  // First-person walkthrough mode (street view)
373
510
  isFirstPersonMode: boolean
374
511
  _viewModeBeforeFirstPerson: ViewMode | null
@@ -402,6 +539,9 @@ type PersistedEditorLayoutState = Pick<
402
539
  | 'floorplanSelectionTool'
403
540
  | 'gridSnapStep'
404
541
  | 'magneticSnap'
542
+ | 'lastMeasurementKind'
543
+ | 'snappingModeByContext'
544
+ | 'continuationByContext'
405
545
  | 'showReferenceFloor'
406
546
  | 'referenceFloorOffset'
407
547
  | 'referenceFloorOpacity'
@@ -425,6 +565,18 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
425
565
  floorplanSelectionTool: 'click',
426
566
  gridSnapStep: 0.5,
427
567
  magneticSnap: true,
568
+ lastMeasurementKind: DEFAULT_CREATABLE_MEASUREMENT_KIND,
569
+ snappingModeByContext: {
570
+ wall: defaultSnappingModeFor('wall'),
571
+ item: defaultSnappingModeFor('item'),
572
+ polygon: defaultSnappingModeFor('polygon'),
573
+ },
574
+ continuationByContext: {
575
+ wall: CONTINUATION_PROFILES.wall.default,
576
+ fence: CONTINUATION_PROFILES.fence.default,
577
+ point: CONTINUATION_PROFILES.point.default,
578
+ cabinet: CONTINUATION_PROFILES.cabinet.default,
579
+ },
428
580
  showReferenceFloor: false,
429
581
  referenceFloorOffset: 1,
430
582
  referenceFloorOpacity: 0.35,
@@ -437,8 +589,12 @@ type SelectDefaultBuildingAndLevelOptions = {
437
589
  }
438
590
 
439
591
  function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
592
+ // The site phase used to hard-return `select`, which made a site-phase brush
593
+ // mode unrepresentable. It is an allowlist now rather than a free-for-all:
594
+ // `delete` and `material-paint` still have nothing to act on at site scope, so
595
+ // letting them survive here would restore a mode with no targets.
440
596
  if (phase === 'site') {
441
- return 'select'
597
+ return mode === 'terrain-sculpt' ? mode : 'select'
442
598
  }
443
599
 
444
600
  return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
@@ -456,7 +612,7 @@ export function normalizePersistedEditorUiState(
456
612
  state: Partial<PersistedEditorUiState> | null | undefined,
457
613
  ): PersistedEditorUiState {
458
614
  const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
459
- const mode = normalizeModeForPhase(phase, state?.mode)
615
+ let mode = normalizeModeForPhase(phase, state?.mode)
460
616
 
461
617
  // Migrate old isFloorplanOpen to viewMode
462
618
  let viewMode: ViewMode = '3d'
@@ -467,6 +623,13 @@ export function normalizePersistedEditorUiState(
467
623
  }
468
624
  const isFloorplanOpen = viewMode !== '3d'
469
625
 
626
+ // Both are persisted independently, and rehydrate goes through neither setter,
627
+ // so this is the third place the sculpt/2D pair has to be reconciled. The view
628
+ // wins here for the same reason it does in `setViewMode`: a brush armed over a
629
+ // hidden canvas is a mode the user cannot use, and reviving one on load is
630
+ // worse than reviving it mid-session — nothing on screen explains it.
631
+ if (mode === 'terrain-sculpt' && viewMode === '2d') mode = 'select'
632
+
470
633
  if (phase === 'site') {
471
634
  return {
472
635
  ...DEFAULT_PERSISTED_EDITOR_UI_STATE,
@@ -527,8 +690,51 @@ export function normalizePersistedEditorUiState(
527
690
  }
528
691
  }
529
692
 
693
+ // Validate a persisted per-context mode against that context's allowed set
694
+ // (so e.g. a stale `angles` for items resets), falling back to its default.
695
+ function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode {
696
+ return snappingModesFor(context).includes(value as SnappingMode)
697
+ ? (value as SnappingMode)
698
+ : defaultSnappingModeFor(context)
699
+ }
700
+
701
+ type LegacyContinuationState = {
702
+ continuationByContext?: Partial<Record<ContinuationContext, unknown>>
703
+ wallChainMode?: unknown
704
+ fenceChainMode?: unknown
705
+ }
706
+
707
+ function migrateContinuationMode(
708
+ value: unknown,
709
+ context: ContinuationContext,
710
+ ): ContinuationMode | null {
711
+ const profile = CONTINUATION_PROFILES[context]
712
+ return profile.options.includes(value as ContinuationMode) ? (value as ContinuationMode) : null
713
+ }
714
+
715
+ function normalizeContinuationByContext(
716
+ state: LegacyContinuationState | null | undefined,
717
+ ): Record<ContinuationContext, ContinuationMode> {
718
+ return {
719
+ wall:
720
+ migrateContinuationMode(state?.continuationByContext?.wall, 'wall') ??
721
+ migrateContinuationMode(state?.wallChainMode, 'wall') ??
722
+ CONTINUATION_PROFILES.wall.default,
723
+ fence:
724
+ migrateContinuationMode(state?.continuationByContext?.fence, 'fence') ??
725
+ migrateContinuationMode(state?.fenceChainMode, 'fence') ??
726
+ CONTINUATION_PROFILES.fence.default,
727
+ point:
728
+ migrateContinuationMode(state?.continuationByContext?.point, 'point') ??
729
+ CONTINUATION_PROFILES.point.default,
730
+ cabinet:
731
+ migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ??
732
+ CONTINUATION_PROFILES.cabinet.default,
733
+ }
734
+ }
735
+
530
736
  function normalizePersistedEditorLayoutState(
531
- state: Partial<PersistedEditorLayoutState> | null | undefined,
737
+ state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
532
738
  ): PersistedEditorLayoutState {
533
739
  return {
534
740
  activeSidebarPanel:
@@ -543,6 +749,13 @@ function normalizePersistedEditorLayoutState(
543
749
  : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
544
750
  // Default on: only an explicit persisted `false` disables it.
545
751
  magneticSnap: state?.magneticSnap !== false,
752
+ lastMeasurementKind: normalizeCreatableMeasurementKind(state?.lastMeasurementKind),
753
+ snappingModeByContext: {
754
+ wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
755
+ item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
756
+ polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
757
+ },
758
+ continuationByContext: normalizeContinuationByContext(state),
546
759
  showReferenceFloor: state?.showReferenceFloor === true,
547
760
  referenceFloorOffset:
548
761
  typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -662,6 +875,59 @@ export function selectSiteFloorplanContext() {
662
875
  })
663
876
  }
664
877
 
878
+ // Stashes the view mode the user was in before entering capture, so we can
879
+ // restore it on exit. Snapshot capture always frames in 3D — the 2D/split
880
+ // floorplan panes render nothing meaningful for a thumbnail.
881
+ let viewModeBeforeCapture: ViewMode | null = null
882
+
883
+ /**
884
+ * Hold the interaction scope that belongs to a sustained brush mode.
885
+ *
886
+ * Paint and sculpt are the two modes whose scope lifetime is the *mode*, not a
887
+ * pointer gesture. Both must be released whenever the mode changes for any
888
+ * reason — including the phase switch that resets the mode without going through
889
+ * `setMode`. A stuck `sculpting` scope would leave selection disabled across the
890
+ * whole editor, so this is one function called from every mode transition rather
891
+ * than a rule each transition remembers.
892
+ *
893
+ * The eyedropper arm rides along for the same reason: it is one-shot state whose
894
+ * UI is unmounted the moment sculpt mode ends, so it has to be cleared on every
895
+ * exit path and not just the one through `setMode`.
896
+ *
897
+ * Every `set({ mode: ... })` in this store must be followed by a call to this —
898
+ * see the callers in `setPhase`, `setStructureLayer`, `setPreviewMode`,
899
+ * `setFirstPersonMode` and `setWorkspaceMode`. The four view-swapping ones are
900
+ * the dangerous class: they unmount `ToolManager` (via `noEditing`), so the
901
+ * sculpt tool that would otherwise release the scope on unmount is gone, and a
902
+ * scope leaked there is unrecoverable without a reload.
903
+ */
904
+ function syncBrushModeScope(mode: Mode): void {
905
+ const scope = useInteractionScope.getState()
906
+ if (mode === 'material-paint') scope.begin({ kind: 'painting' })
907
+ else if (mode === 'terrain-sculpt') scope.begin({ kind: 'sculpting' })
908
+ // `isBrushMode` is the same set as the two branches above — kept as one
909
+ // predicate so an added brush mode cannot be handled here and missed there.
910
+ else {
911
+ scope.endIf((s) => s.kind === 'painting' || s.kind === 'sculpting')
912
+ if (useEditor.getState().terrainSampling) useEditor.getState().setTerrainSampling(false)
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Whether `mode` is one of the two sustained brush modes.
918
+ *
919
+ * Named because callers *outside* this store need the same test: a scope is
920
+ * single-owner, so anything that calls `begin()` while a brush mode holds its
921
+ * scope silently evicts it — the brush stays armed and painting while the rest of
922
+ * the editor believes a drag is running. Almost every producer is unreachable
923
+ * under a brush mode because it needs a selection and entering the mode clears
924
+ * one, but the clipboard paths read the clipboard instead (see
925
+ * `pasteSelectionAndPickUp`), so they have to ask.
926
+ */
927
+ export function isBrushMode(mode: Mode): boolean {
928
+ return mode === 'material-paint' || mode === 'terrain-sculpt'
929
+ }
930
+
665
931
  const useEditor = create<EditorState>()(
666
932
  persist(
667
933
  (set, get) => ({
@@ -690,6 +956,11 @@ const useEditor = create<EditorState>()(
690
956
  set({ mode: 'select', tool: null, catalogCategory: null })
691
957
  }
692
958
 
959
+ // Leaving the site phase must drop a held sculpt scope: this branch
960
+ // rewrites `mode` without going through `setMode`, so without this a
961
+ // stuck `sculpting` scope would disable selection in structure phase.
962
+ syncBrushModeScope(get().mode)
963
+
693
964
  switch (phase) {
694
965
  case 'site':
695
966
  selectSiteFloorplanContext()
@@ -708,6 +979,23 @@ const useEditor = create<EditorState>()(
708
979
  },
709
980
  mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
710
981
  setMode: (mode) => {
982
+ // Sculpting is a site-phase mode. Arming it from structure/furnish moves
983
+ // the phase rather than failing silently: the user asked for the ground,
984
+ // and `normalizeModeForPhase` would otherwise reject the mode on the next
985
+ // rehydrate, leaving the UI showing a mode the store does not hold.
986
+ if (mode === 'terrain-sculpt' && get().phase !== 'site') {
987
+ get().setPhase('site')
988
+ }
989
+
990
+ // Same promotion, one axis over. The brush needs the 3D canvas: in `2d`
991
+ // the pane is only `display: none`, so the mode would arm, hold its
992
+ // scope and show its HUD while no pointer event could ever reach it.
993
+ // `split` is the smallest move that satisfies it — a plain `3d` would
994
+ // throw away a floorplan the user deliberately opened.
995
+ if (mode === 'terrain-sculpt' && get().viewMode === '2d') {
996
+ set({ viewMode: 'split', isFloorplanOpen: true })
997
+ }
998
+
711
999
  set({ mode })
712
1000
 
713
1001
  const { phase, structureLayer, tool } = get()
@@ -730,6 +1018,15 @@ const useEditor = create<EditorState>()(
730
1018
  else if (tool) {
731
1019
  set({ tool: null })
732
1020
  }
1021
+
1022
+ if (mode === 'terrain-sculpt') {
1023
+ // Sculpting acts on the ground, never on a node. Clearing the
1024
+ // selection on entry is what stops the selected wall's gizmo from
1025
+ // sitting under the brush, competing for the same clicks.
1026
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1027
+ }
1028
+
1029
+ syncBrushModeScope(mode)
733
1030
  },
734
1031
  tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
735
1032
  setTool: (tool) => set({ tool }),
@@ -744,6 +1041,8 @@ const useEditor = create<EditorState>()(
744
1041
  }
745
1042
  return { toolDefaults: next }
746
1043
  }),
1044
+ lastMeasurementKind: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.lastMeasurementKind,
1045
+ setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
747
1046
  structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
748
1047
  setStructureLayer: (layer) => {
749
1048
  const { mode } = get()
@@ -753,6 +1052,7 @@ const useEditor = create<EditorState>()(
753
1052
  set({ structureLayer: layer, tool })
754
1053
  } else {
755
1054
  set({ structureLayer: layer, mode: 'select', tool: null })
1055
+ syncBrushModeScope('select')
756
1056
  }
757
1057
 
758
1058
  const viewer = useViewer.getState()
@@ -765,48 +1065,57 @@ const useEditor = create<EditorState>()(
765
1065
  setCatalogCategory: (category) => set({ catalogCategory: category }),
766
1066
  selectedItem: null,
767
1067
  setSelectedItem: (item) => set({ selectedItem: item }),
768
- movingNode: null as
769
- | ItemNode
770
- | WindowNode
771
- | DoorNode
772
- | ElevatorNode
773
- | CeilingNode
774
- | ColumnNode
775
- | SlabNode
776
- | WallNode
777
- | FenceNode
778
- | RoofNode
779
- | RoofSegmentNode
780
- | SpawnNode
781
- | StairNode
782
- | StairSegmentNode
783
- | BuildingNode
784
- | null,
785
1068
  placementDragMode: false,
786
1069
  setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
787
- setMovingNode: (node) =>
788
- set(
789
- node === null
790
- ? // Preserve `movingNodeOrigin` across the clear so the
791
- // non-owning side's effect cleanup which fires after
792
- // `setMovingNode(null)` propagates can still read who
793
- // finalised. The next non-null `setMovingNode` resets it.
794
- // Always clear the press-drag flag when a move ends.
795
- { movingNode: null, placementDragMode: false }
796
- : { movingNode: node, movingNodeOrigin: null },
797
- ),
1070
+ roofHostDragArmedId: null,
1071
+ setRoofHostDragArmedId: (nodeId) => set({ roofHostDragArmedId: nodeId }),
1072
+ // The node being placed/moved now lives inside the interaction scope
1073
+ // (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter
1074
+ // remains the single entry point: it drives the scope and still touches
1075
+ // `movingNodeOrigin` / `placementDragMode` so cross-store subscribers that
1076
+ // watch this store (community placement) keep firing on move start/end.
1077
+ setMovingNode: (node) => {
1078
+ const scope = useInteractionScope.getState()
1079
+ if (node === null) {
1080
+ scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
1081
+ // Preserve `movingNodeOrigin` across the clear so the non-owning
1082
+ // side's effect cleanup — which fires after `setMovingNode(null)`
1083
+ // propagates — can still read who finalised. The next non-null
1084
+ // `setMovingNode` resets it. Always clear the press-drag flag.
1085
+ set({ placementDragMode: false })
1086
+ return
1087
+ }
1088
+ const targetNode = resolveMovingNodeTarget(node)
1089
+ const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew)
1090
+ if (isNew) {
1091
+ scope.begin({
1092
+ kind: 'placing',
1093
+ node: targetNode,
1094
+ nodeId: targetNode.id,
1095
+ nodeType: targetNode.type,
1096
+ view: '3d',
1097
+ pressDrag: get().placementDragMode,
1098
+ })
1099
+ } else {
1100
+ scope.begin({
1101
+ kind: 'moving',
1102
+ node: targetNode,
1103
+ nodeId: targetNode.id,
1104
+ nodeType: targetNode.type,
1105
+ view: '3d',
1106
+ })
1107
+ }
1108
+ set({ movingNodeOrigin: null })
1109
+ },
798
1110
  movingNodeOrigin: null as '2d' | '3d' | null,
799
1111
  setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
800
- movingWallEndpoint: null,
801
- setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
802
- movingFenceEndpoint: null,
803
- setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
804
- activeHandleDrag: null,
805
- setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
806
- curvingWall: null,
807
- setCurvingWall: (wall) => set({ curvingWall: wall }),
808
- curvingFence: null,
809
- setCurvingFence: (fence) => set({ curvingFence: fence }),
1112
+ rotationAxis: 'y',
1113
+ cycleRotationAxis: () => {
1114
+ const order = ['y', 'x', 'z'] as const
1115
+ const next = order[(order.indexOf(get().rotationAxis as 'y' | 'x' | 'z') + 1) % 3]!
1116
+ set({ rotationAxis: next })
1117
+ return next
1118
+ },
810
1119
  selectedMaterialTarget: null,
811
1120
  setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
812
1121
  activePaintMaterial: null,
@@ -819,6 +1128,19 @@ const useEditor = create<EditorState>()(
819
1128
  set((state) =>
820
1129
  state.activePaintTarget === target ? state : { activePaintTarget: target },
821
1130
  ),
1131
+ draftVertexCount: 0,
1132
+ setDraftVertexCount: (count) =>
1133
+ set((state) => (state.draftVertexCount === count ? state : { draftVertexCount: count })),
1134
+ paintScope: 'single',
1135
+ setPaintScope: (scope) => set({ paintScope: scope }),
1136
+ cyclePaintScope: () => {
1137
+ // Cycle within the hovered node's available scopes (what the click will
1138
+ // actually hit). With nothing paintable hovered there's only `single`.
1139
+ const scopes = get().paintHover?.scopes ?? (['single'] as PaintScope[])
1140
+ const next = cyclePaintScopeValue(get().paintScope, scopes)
1141
+ set({ paintScope: next })
1142
+ return next
1143
+ },
822
1144
  paintEraser: false,
823
1145
  setPaintEraser: (eraser) => set({ paintEraser: eraser }),
824
1146
  primeMaterialPaintFromSelection: () => {
@@ -848,15 +1170,29 @@ const useEditor = create<EditorState>()(
848
1170
  activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
849
1171
  }
850
1172
  },
851
- hoveredPaintTarget: null,
852
- setHoveredPaintTarget: (target) =>
853
- set((state) =>
854
- state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
855
- ),
856
- isPaintPanelOpen: false,
857
- setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
1173
+ paintHover: null,
1174
+ setPaintHover: (info) => set({ paintHover: info }),
1175
+ terrainVerb: 'flatten',
1176
+ setTerrainVerb: (verb) =>
1177
+ set({
1178
+ terrainVerb: verb,
1179
+ // Switching away from flatten drops the sampling arm — an eyedropper
1180
+ // that survives into the raise brush would swallow its first click.
1181
+ terrainSampling: verb === 'flatten' ? get().terrainSampling : false,
1182
+ }),
1183
+ terrainBrush: DEFAULT_BRUSH_SETTINGS,
1184
+ setTerrainBrush: (settings) => set({ terrainBrush: { ...get().terrainBrush, ...settings } }),
1185
+ terrainFlattenTarget: null,
1186
+ setTerrainFlattenTarget: (metres) =>
1187
+ set({ terrainFlattenTarget: metres, terrainSampling: false }),
1188
+ terrainSampling: false,
1189
+ setTerrainSampling: (sampling) => set({ terrainSampling: sampling }),
1190
+ canFindNode: false,
1191
+ setCanFindNode: (canFind) => set({ canFindNode: canFind }),
858
1192
  selectedReferenceId: null,
859
1193
  setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
1194
+ referenceScaleActiveGuideId: null,
1195
+ setReferenceScaleActiveGuideId: (id) => set({ referenceScaleActiveGuideId: id }),
860
1196
  guideUi: {},
861
1197
  setGuideLocked: (guideId, locked) =>
862
1198
  set((state) => ({
@@ -889,8 +1225,6 @@ const useEditor = create<EditorState>()(
889
1225
  }),
890
1226
  spaces: {},
891
1227
  setSpaces: (spaces) => set({ spaces }),
892
- editingHole: null,
893
- setEditingHole: (hole) => set({ editingHole: hole }),
894
1228
  hoveredHole: null,
895
1229
  setHoveredHole: (hole) =>
896
1230
  set((state) =>
@@ -903,6 +1237,7 @@ const useEditor = create<EditorState>()(
903
1237
  setPreviewMode: (preview) => {
904
1238
  if (preview) {
905
1239
  set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
1240
+ syncBrushModeScope('select')
906
1241
  // Clear zone/item selection for clean viewer drill-down hierarchy
907
1242
  useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
908
1243
  } else {
@@ -914,10 +1249,45 @@ const useEditor = create<EditorState>()(
914
1249
  setCaptureMode: (next) => {
915
1250
  const resolved: CaptureMode =
916
1251
  typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
917
- set({ captureMode: resolved, isCaptureMode: resolved.mode !== 'idle' })
1252
+ const entering = resolved.mode !== 'idle'
1253
+ set((state) => {
1254
+ if (entering) {
1255
+ // Force 3D for the shot. Remember the prior mode only on the first
1256
+ // entry (viewMode is already '3d' on re-entry), so we restore the
1257
+ // user's real choice — not the forced '3d' — when capture ends.
1258
+ if (state.viewMode !== '3d') {
1259
+ viewModeBeforeCapture = state.viewMode
1260
+ return {
1261
+ captureMode: resolved,
1262
+ isCaptureMode: true,
1263
+ viewMode: '3d',
1264
+ isFloorplanOpen: false,
1265
+ }
1266
+ }
1267
+ return { captureMode: resolved, isCaptureMode: true }
1268
+ }
1269
+ const restore = viewModeBeforeCapture
1270
+ viewModeBeforeCapture = null
1271
+ if (restore && restore !== '3d') {
1272
+ return {
1273
+ captureMode: resolved,
1274
+ isCaptureMode: false,
1275
+ viewMode: restore,
1276
+ isFloorplanOpen: true,
1277
+ }
1278
+ }
1279
+ return { captureMode: resolved, isCaptureMode: false }
1280
+ })
918
1281
  },
919
1282
  viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
920
- setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
1283
+ setViewMode: (mode) => {
1284
+ set({ viewMode: mode, isFloorplanOpen: mode !== '3d' })
1285
+ // Going the other way, the view wins and the mode yields. Hiding the 3D
1286
+ // pane leaves the brush unreachable, and a held `sculpting` scope would
1287
+ // then keep selection suppressed in a floorplan the user is trying to
1288
+ // work in — a mode you cannot use and cannot see how to leave.
1289
+ if (mode === '2d' && get().mode === 'terrain-sculpt') get().setMode('select')
1290
+ },
921
1291
  splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
922
1292
  setSplitOrientation: (orientation) => set({ splitOrientation: orientation }),
923
1293
  isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
@@ -929,20 +1299,64 @@ const useEditor = create<EditorState>()(
929
1299
  }),
930
1300
  isFloorplanHovered: false,
931
1301
  setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
1302
+ isRiserOpen: false,
1303
+ setRiserOpen: (open) => set({ isRiserOpen: open }),
1304
+ toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
932
1305
  navigationSyncPose: null,
933
- publishNavigationSyncPose: (pose) =>
934
- set((state) => ({
935
- navigationSyncPose: {
936
- ...pose,
937
- revision: (state.navigationSyncPose?.revision ?? 0) + 1,
938
- },
939
- })),
1306
+ publishNavigationSyncPose: (pose) => {
1307
+ const navigationSyncPose = {
1308
+ ...pose,
1309
+ revision: (get().navigationSyncPose?.revision ?? 0) + 1,
1310
+ }
1311
+ publishNavigationSyncPoseToStore(navigationSyncPose)
1312
+ set({ navigationSyncPose })
1313
+ },
940
1314
  floorplanSelectionTool: 'click' as FloorplanSelectionTool,
941
1315
  setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
942
1316
  gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
943
1317
  setGridSnapStep: (step) => set({ gridSnapStep: step }),
1318
+ cycleGridSnapStep: () => {
1319
+ const current = get().gridSnapStep
1320
+ const index = GRID_SNAP_STEPS.indexOf(current)
1321
+ const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
1322
+ set({ gridSnapStep: next })
1323
+ return next
1324
+ },
944
1325
  magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
945
1326
  setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
1327
+ snappingModeByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingModeByContext,
1328
+ setSnappingMode: (context, mode) =>
1329
+ set((state) => ({
1330
+ snappingModeByContext: { ...state.snappingModeByContext, [context]: mode },
1331
+ })),
1332
+ cycleSnappingMode: () => {
1333
+ const context = getActiveSnapContext() ?? 'item'
1334
+ const current = get().snappingModeByContext[context]
1335
+ const next = cycleSnappingModeIn(context, current)
1336
+ set((state) => ({
1337
+ snappingModeByContext: { ...state.snappingModeByContext, [context]: next },
1338
+ }))
1339
+ return next
1340
+ },
1341
+ continuationByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.continuationByContext,
1342
+ setContinuation: (context, mode) => {
1343
+ const next =
1344
+ migrateContinuationMode(mode, context) ?? CONTINUATION_PROFILES[context].default
1345
+ set((state) => ({
1346
+ continuationByContext: { ...state.continuationByContext, [context]: next },
1347
+ }))
1348
+ },
1349
+ cycleContinuation: (context) => {
1350
+ const next = nextContinuation(context, get().getContinuation(context))
1351
+ set((state) => ({
1352
+ continuationByContext: { ...state.continuationByContext, [context]: next },
1353
+ }))
1354
+ return next
1355
+ },
1356
+ getContinuation: (context) => {
1357
+ const current = get().continuationByContext[context]
1358
+ return migrateContinuationMode(current, context) ?? CONTINUATION_PROFILES[context].default
1359
+ },
946
1360
  showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
947
1361
  toggleReferenceFloor: () =>
948
1362
  set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -955,6 +1369,8 @@ const useEditor = create<EditorState>()(
955
1369
  set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
956
1370
  allowUndergroundCamera: false,
957
1371
  setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
1372
+ show2dVoronoi: false,
1373
+ setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
958
1374
  isFirstPersonMode: false,
959
1375
  _viewModeBeforeFirstPerson: null as ViewMode | null,
960
1376
  setFirstPersonMode: (enabled) => {
@@ -969,6 +1385,7 @@ const useEditor = create<EditorState>()(
969
1385
  tool: null,
970
1386
  catalogCategory: null,
971
1387
  })
1388
+ syncBrushModeScope('select')
972
1389
  } else {
973
1390
  const prevMode = get()._viewModeBeforeFirstPerson
974
1391
  set({
@@ -993,6 +1410,7 @@ const useEditor = create<EditorState>()(
993
1410
  tool: null,
994
1411
  catalogCategory: null,
995
1412
  })
1413
+ syncBrushModeScope('select')
996
1414
  // Clear selection so no edit affordances bleed into the clean canvas.
997
1415
  useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
998
1416
  } else {
@@ -1014,11 +1432,35 @@ const useEditor = create<EditorState>()(
1014
1432
  }),
1015
1433
  {
1016
1434
  name: 'pascal-editor-ui-preferences',
1017
- merge: (persistedState, currentState) => ({
1018
- ...currentState,
1019
- ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
1020
- ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
1021
- }),
1435
+ merge: (persistedState, currentState) => {
1436
+ const uiState = normalizePersistedEditorUiState(
1437
+ persistedState as Partial<PersistedEditorState>,
1438
+ )
1439
+ const layoutState = normalizePersistedEditorLayoutState(
1440
+ persistedState as Partial<PersistedEditorState>,
1441
+ )
1442
+
1443
+ return {
1444
+ ...currentState,
1445
+ ...uiState,
1446
+ ...layoutState,
1447
+ ...(uiState.mode === 'build' && uiState.tool === 'measurement'
1448
+ ? {
1449
+ toolDefaults: {
1450
+ ...currentState.toolDefaults,
1451
+ measurement: { kind: layoutState.lastMeasurementKind },
1452
+ },
1453
+ }
1454
+ : {}),
1455
+ }
1456
+ },
1457
+ // `mode` is persisted, but the interaction scope a brush mode holds is not
1458
+ // — it lives in a separate, non-persisted store. Rehydrating into
1459
+ // `terrain-sculpt` (or paint) without re-claiming the scope would restore
1460
+ // the brush with selection still enabled, so every dab could grab a wall.
1461
+ onRehydrateStorage: () => (state) => {
1462
+ if (state) syncBrushModeScope(state.mode)
1463
+ },
1022
1464
  partialize: (state) => ({
1023
1465
  phase: state.phase,
1024
1466
  mode: state.mode,
@@ -1033,12 +1475,109 @@ const useEditor = create<EditorState>()(
1033
1475
  floorplanSelectionTool: state.floorplanSelectionTool,
1034
1476
  gridSnapStep: state.gridSnapStep,
1035
1477
  magneticSnap: state.magneticSnap,
1478
+ lastMeasurementKind: state.lastMeasurementKind,
1479
+ snappingModeByContext: state.snappingModeByContext,
1480
+ continuationByContext: state.continuationByContext,
1036
1481
  showReferenceFloor: state.showReferenceFloor,
1037
1482
  referenceFloorOffset: state.referenceFloorOffset,
1038
1483
  referenceFloorOpacity: state.referenceFloorOpacity,
1039
1484
  }),
1485
+ skipHydration: true,
1040
1486
  },
1041
1487
  ),
1042
1488
  )
1043
1489
 
1490
+ /**
1491
+ * Effective magnetic-snap state: the legacy `magneticSnap` flag AND the active
1492
+ * context's snapping mode. With exclusive modes, magnetic (alignment axes + wall
1493
+ * corner-join) is on only in `'lines'`. Read from the smallest magnetic choke
1494
+ * points so the mode is honoured without retuning any snap math.
1495
+ */
1496
+ export function isMagneticSnapActive(): boolean {
1497
+ const state = useEditor.getState()
1498
+ return state.magneticSnap && resolveSnapFlags(getActiveSnappingMode()).magnetic
1499
+ }
1500
+
1501
+ /**
1502
+ * Effective angle-lock state: the active context's snapping mode. With exclusive
1503
+ * modes the 15°/45° lock is on only in `'angles'`. Read from the smallest
1504
+ * angle-lock choke points (wall / fence draft call sites).
1505
+ */
1506
+ export function isAngleSnapActive(): boolean {
1507
+ return resolveSnapFlags(getActiveSnappingMode()).angles
1508
+ }
1509
+
1510
+ /**
1511
+ * Effective grid-lattice state: the active context's snapping mode. With
1512
+ * exclusive modes the grid quantize is on only in `'grid'`.
1513
+ */
1514
+ export function isGridSnapActive(): boolean {
1515
+ return resolveSnapFlags(getActiveSnappingMode()).grid
1516
+ }
1517
+
1518
+ /**
1519
+ * Whether alignment "lines" should be DISPLAYED for the active context.
1520
+ *
1521
+ * True whenever a snappable context is active — in EVERY snapping mode,
1522
+ * including `'off'`. The guides are passive reference feedback; this is
1523
+ * decoupled from the magnetic *pull*: a producer publishes guides whenever this
1524
+ * is true, but only applies the alignment delta when `isMagneticSnapActive()`
1525
+ * (i.e. `'lines'`). So the user always sees the same alignment lines while
1526
+ * snapping to grid / angles / off, and only snaps to them in `'lines'` mode.
1527
+ */
1528
+ export function isAlignmentGuideActive(): boolean {
1529
+ return getActiveSnapContext() !== null
1530
+ }
1531
+
1532
+ /**
1533
+ * The snapping context for what the user is currently doing (wall / item /
1534
+ * polygon), or null when nothing snappable is active. Derived from the
1535
+ * authoritative interaction scope, falling back to the armed build tool (the
1536
+ * `drafting` scope isn't wired). The single source every snap reader + the HUD
1537
+ * resolve their mode through.
1538
+ */
1539
+ export function getActiveSnapContext(): SnapContext | null {
1540
+ const editor = useEditor.getState()
1541
+ return snapContextOf({
1542
+ scope: useInteractionScope.getState().scope,
1543
+ mode: editor.mode,
1544
+ tool: editor.tool,
1545
+ profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
1546
+ profileOfNode: (nodeId) => {
1547
+ const node = useScene.getState().nodes[nodeId as AnyNodeId]
1548
+ return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
1549
+ },
1550
+ draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
1551
+ })
1552
+ }
1553
+
1554
+ export function getActiveContinuationContext(): ContinuationContext | null {
1555
+ const scope = useInteractionScope.getState().scope
1556
+ if (scope.kind === 'drafting') return continuationContextOf(scope.tool)
1557
+ if (scope.kind === 'placing') return continuationContextOf(scope.nodeType)
1558
+ if (scope.kind !== 'idle') return null
1559
+
1560
+ const editor = useEditor.getState()
1561
+ if (editor.mode !== 'build' || !editor.tool) return null
1562
+ return continuationContextOf(editor.tool)
1563
+ }
1564
+
1565
+ export function getContinuation(context: ContinuationContext): ContinuationMode {
1566
+ return useEditor.getState().getContinuation(context)
1567
+ }
1568
+
1569
+ /**
1570
+ * The effective snapping mode for the active context. Falls back to `'off'` when
1571
+ * no snappable context is active (select / idle, no armed tool) so grid /
1572
+ * magnetic / angle readers — including the snap-grid overlay — stay inert
1573
+ * outside an interaction. Per-context defaults (item is `'grid'`) only take
1574
+ * effect once a tool is armed or an interaction begins; otherwise the item
1575
+ * default would light up the snap grid at idle.
1576
+ */
1577
+ export function getActiveSnappingMode(): SnappingMode {
1578
+ const context = getActiveSnapContext()
1579
+ if (!context) return 'off'
1580
+ return useEditor.getState().snappingModeByContext[context]
1581
+ }
1582
+
1044
1583
  export default useEditor