@pascal-app/editor 0.9.1 → 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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  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 +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  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 +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  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 +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -5,6 +5,8 @@ import {
5
5
  type AnyNode,
6
6
  type AnyNodeId,
7
7
  type BuildingNode,
8
+ type CabinetModuleNode,
9
+ type CabinetNode,
8
10
  type CeilingNode,
9
11
  type ChimneyMaterialRole,
10
12
  type ChimneyNode,
@@ -16,6 +18,7 @@ import {
16
18
  type FenceNode,
17
19
  type ItemNode,
18
20
  type LevelNode,
21
+ nodeRegistry,
19
22
  type RoofNode,
20
23
  type RoofSegmentNode,
21
24
  type RoofSurfaceMaterialRole,
@@ -33,6 +36,13 @@ import {
33
36
  import { useViewer } from '@pascal-app/viewer'
34
37
  import { create } from 'zustand'
35
38
  import { persist } from 'zustand/middleware'
39
+ import {
40
+ CONTINUATION_PROFILES,
41
+ type ContinuationContext,
42
+ type ContinuationMode,
43
+ continuationContextOf,
44
+ nextContinuation,
45
+ } from '../lib/continuation'
36
46
  import {
37
47
  type ActivePaintMaterial,
38
48
  type PaintableMaterialTarget,
@@ -40,12 +50,57 @@ import {
40
50
  resolvePaintTargetFromSelection,
41
51
  type SingleSurfaceMaterialRole,
42
52
  } from '../lib/material-paint'
43
-
44
- const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
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'
73
+
74
+ const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build'
45
75
  const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
46
76
  const MIN_FLOORPLAN_PANE_RATIO = 0.15
47
77
  const MAX_FLOORPLAN_PANE_RATIO = 0.85
48
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
+
49
104
  export type ViewMode = '3d' | '2d' | 'split'
50
105
  export type SplitOrientation = 'horizontal' | 'vertical'
51
106
  export type WorkspaceMode = 'edit' | 'studio'
@@ -59,9 +114,16 @@ export type WorkspaceMode = 'edit' | 'studio'
59
114
  // — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and
60
115
  // applies those constraints. Keeping it a discriminated union lets us
61
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
+
62
124
  export type CaptureMode =
63
125
  | { mode: 'idle' }
64
- | { mode: 'standard' }
126
+ | { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect }
65
127
  | {
66
128
  mode: 'preset'
67
129
  isolated: AnyNodeId[]
@@ -106,9 +168,18 @@ export type StructureTool =
106
168
  | 'dormer'
107
169
  | 'gutter'
108
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'
109
180
 
110
181
  // Furnish mode tools (items and decoration)
111
- export type FurnishTool = 'item'
182
+ export type FurnishTool = 'item' | 'cabinet'
112
183
 
113
184
  // Site mode tools
114
185
  export type SiteTool = 'property-line'
@@ -140,8 +211,14 @@ export type NavigationSyncPose = {
140
211
 
141
212
  export type NavigationSyncPoseInput = Omit<NavigationSyncPose, 'revision'>
142
213
 
143
- // Combined tool type
144
- export type Tool = SiteTool | StructureTool | FurnishTool
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 & {})
145
222
 
146
223
  /**
147
224
  * Starting parameters seeded into a draw tool before it mints a node.
@@ -151,16 +228,6 @@ export type Tool = SiteTool | StructureTool | FurnishTool
151
228
  */
152
229
  export type ToolDefaults = Record<string, unknown>
153
230
 
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
231
  export type MaterialTargetRole =
165
232
  | WallSurfaceSide
166
233
  | StairSurfaceMaterialRole
@@ -168,6 +235,7 @@ export type MaterialTargetRole =
168
235
  | ChimneyMaterialRole
169
236
  | DormerSurfaceMaterialRole
170
237
  | SingleSurfaceMaterialRole
238
+ | string
171
239
 
172
240
  export type SelectedMaterialTarget = {
173
241
  nodeId: AnyNodeId
@@ -203,31 +271,14 @@ type EditorState = {
203
271
  */
204
272
  toolDefaults: Partial<Record<Tool, ToolDefaults>>
205
273
  setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
274
+ lastMeasurementKind: CreatableMeasurementKind
275
+ setLastMeasurementKind: (kind: CreatableMeasurementKind) => void
206
276
  structureLayer: StructureLayer
207
277
  setStructureLayer: (layer: StructureLayer) => void
208
278
  catalogCategory: CatalogCategory | null
209
279
  setCatalogCategory: (category: CatalogCategory | null) => void
210
280
  selectedItem: AssetInput | null
211
281
  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
282
  /**
232
283
  * True while a move was engaged by a press-drag gizmo (the on-canvas move
233
284
  * cross) rather than a click-to-place flow. The placement coordinator reads
@@ -235,6 +286,8 @@ type EditorState = {
235
286
  */
236
287
  placementDragMode: boolean
237
288
  setPlacementDragMode: (dragMode: boolean) => void
289
+ roofHostDragArmedId: AnyNodeId | null
290
+ setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void
238
291
  setMovingNode: (
239
292
  node:
240
293
  | ItemNode
@@ -254,6 +307,8 @@ type EditorState = {
254
307
  | StairNode
255
308
  | StairSegmentNode
256
309
  | BuildingNode
310
+ | CabinetNode
311
+ | CabinetModuleNode
257
312
  | null,
258
313
  ) => void
259
314
  /**
@@ -275,43 +330,57 @@ type EditorState = {
275
330
  */
276
331
  movingNodeOrigin: '2d' | '3d' | null
277
332
  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
333
  /**
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.
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.
291
338
  */
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
339
+ rotationAxis: 'x' | 'y' | 'z'
340
+ cycleRotationAxis: () => 'x' | 'y' | 'z'
298
341
  selectedMaterialTarget: SelectedMaterialTarget | null
299
342
  setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
300
343
  activePaintMaterial: ActivePaintMaterial | null
301
344
  setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
302
345
  activePaintTarget: PaintableMaterialTarget
303
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
304
361
  // When true, clicking a surface in paint mode clears it back to its
305
362
  // default material instead of applying `activePaintMaterial`.
306
363
  paintEraser: boolean
307
364
  setPaintEraser: (eraser: boolean) => void
308
365
  primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
309
- hoveredPaintTarget: PaintableMaterialTarget | null
310
- setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
311
- isPaintPanelOpen: boolean
312
- 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
313
377
  selectedReferenceId: string | null
314
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
315
384
  guideUi: Record<string, GuideUiState>
316
385
  setGuideLocked: (guideId: string, locked: boolean) => void
317
386
  setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void
@@ -319,9 +388,6 @@ type EditorState = {
319
388
  // Space detection for cutaway mode
320
389
  spaces: Record<string, Space>
321
390
  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
391
  hoveredHole: SurfaceHoleTarget | null
326
392
  setHoveredHole: (hole: SurfaceHoleTarget | null) => void
327
393
  // Preview mode (viewer-like experience inside the editor)
@@ -348,17 +414,38 @@ type EditorState = {
348
414
  toggleFloorplanOpen: () => void
349
415
  isFloorplanHovered: boolean
350
416
  setFloorplanHovered: (hovered: boolean) => void
417
+ // Toggleable DWV riser-diagram (plumbing isometric) overlay.
418
+ isRiserOpen: boolean
419
+ setRiserOpen: (open: boolean) => void
420
+ toggleRiserOpen: () => void
351
421
  navigationSyncPose: NavigationSyncPose | null
352
422
  publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
353
423
  floorplanSelectionTool: FloorplanSelectionTool
354
424
  setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
355
425
  gridSnapStep: GridSnapStep
356
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
357
430
  // Magnetic snapping while drafting — snaps wall endpoints onto existing
358
431
  // wall corners / wall bodies (the "magnetic" beacon). Independent of grid
359
432
  // snap. On by default; toggled from the Display menu.
360
433
  magneticSnap: boolean
361
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
362
449
  showReferenceFloor: boolean
363
450
  toggleReferenceFloor: () => void
364
451
  setShowReferenceFloor: (show: boolean) => void
@@ -369,6 +456,11 @@ type EditorState = {
369
456
  // Development-only camera debug flag for inspecting underside geometry
370
457
  allowUndergroundCamera: boolean
371
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
372
464
  // First-person walkthrough mode (street view)
373
465
  isFirstPersonMode: boolean
374
466
  _viewModeBeforeFirstPerson: ViewMode | null
@@ -402,6 +494,9 @@ type PersistedEditorLayoutState = Pick<
402
494
  | 'floorplanSelectionTool'
403
495
  | 'gridSnapStep'
404
496
  | 'magneticSnap'
497
+ | 'lastMeasurementKind'
498
+ | 'snappingModeByContext'
499
+ | 'continuationByContext'
405
500
  | 'showReferenceFloor'
406
501
  | 'referenceFloorOffset'
407
502
  | 'referenceFloorOpacity'
@@ -425,6 +520,18 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
425
520
  floorplanSelectionTool: 'click',
426
521
  gridSnapStep: 0.5,
427
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
+ },
428
535
  showReferenceFloor: false,
429
536
  referenceFloorOffset: 1,
430
537
  referenceFloorOpacity: 0.35,
@@ -527,8 +634,51 @@ export function normalizePersistedEditorUiState(
527
634
  }
528
635
  }
529
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
+
530
680
  function normalizePersistedEditorLayoutState(
531
- state: Partial<PersistedEditorLayoutState> | null | undefined,
681
+ state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
532
682
  ): PersistedEditorLayoutState {
533
683
  return {
534
684
  activeSidebarPanel:
@@ -543,6 +693,13 @@ function normalizePersistedEditorLayoutState(
543
693
  : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
544
694
  // Default on: only an explicit persisted `false` disables it.
545
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),
546
703
  showReferenceFloor: state?.showReferenceFloor === true,
547
704
  referenceFloorOffset:
548
705
  typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -662,6 +819,11 @@ export function selectSiteFloorplanContext() {
662
819
  })
663
820
  }
664
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
+
665
827
  const useEditor = create<EditorState>()(
666
828
  persist(
667
829
  (set, get) => ({
@@ -730,6 +892,10 @@ const useEditor = create<EditorState>()(
730
892
  else if (tool) {
731
893
  set({ tool: null })
732
894
  }
895
+
896
+ const scope = useInteractionScope.getState()
897
+ if (mode === 'material-paint') scope.begin({ kind: 'painting' })
898
+ else scope.endIf((s) => s.kind === 'painting')
733
899
  },
734
900
  tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
735
901
  setTool: (tool) => set({ tool }),
@@ -744,6 +910,8 @@ const useEditor = create<EditorState>()(
744
910
  }
745
911
  return { toolDefaults: next }
746
912
  }),
913
+ lastMeasurementKind: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.lastMeasurementKind,
914
+ setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
747
915
  structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
748
916
  setStructureLayer: (layer) => {
749
917
  const { mode } = get()
@@ -765,48 +933,57 @@ const useEditor = create<EditorState>()(
765
933
  setCatalogCategory: (category) => set({ catalogCategory: category }),
766
934
  selectedItem: null,
767
935
  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
936
  placementDragMode: false,
786
937
  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
- ),
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
+ },
798
978
  movingNodeOrigin: null as '2d' | '3d' | null,
799
979
  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 }),
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
+ },
810
987
  selectedMaterialTarget: null,
811
988
  setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
812
989
  activePaintMaterial: null,
@@ -819,6 +996,19 @@ const useEditor = create<EditorState>()(
819
996
  set((state) =>
820
997
  state.activePaintTarget === target ? state : { activePaintTarget: target },
821
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
+ },
822
1012
  paintEraser: false,
823
1013
  setPaintEraser: (eraser) => set({ paintEraser: eraser }),
824
1014
  primeMaterialPaintFromSelection: () => {
@@ -848,15 +1038,14 @@ const useEditor = create<EditorState>()(
848
1038
  activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
849
1039
  }
850
1040
  },
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 }),
1041
+ paintHover: null,
1042
+ setPaintHover: (info) => set({ paintHover: info }),
1043
+ canFindNode: false,
1044
+ setCanFindNode: (canFind) => set({ canFindNode: canFind }),
858
1045
  selectedReferenceId: null,
859
1046
  setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
1047
+ referenceScaleActiveGuideId: null,
1048
+ setReferenceScaleActiveGuideId: (id) => set({ referenceScaleActiveGuideId: id }),
860
1049
  guideUi: {},
861
1050
  setGuideLocked: (guideId, locked) =>
862
1051
  set((state) => ({
@@ -889,8 +1078,6 @@ const useEditor = create<EditorState>()(
889
1078
  }),
890
1079
  spaces: {},
891
1080
  setSpaces: (spaces) => set({ spaces }),
892
- editingHole: null,
893
- setEditingHole: (hole) => set({ editingHole: hole }),
894
1081
  hoveredHole: null,
895
1082
  setHoveredHole: (hole) =>
896
1083
  set((state) =>
@@ -914,7 +1101,35 @@ const useEditor = create<EditorState>()(
914
1101
  setCaptureMode: (next) => {
915
1102
  const resolved: CaptureMode =
916
1103
  typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
917
- set({ captureMode: resolved, isCaptureMode: resolved.mode !== 'idle' })
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
+ })
918
1133
  },
919
1134
  viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
920
1135
  setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
@@ -929,6 +1144,9 @@ const useEditor = create<EditorState>()(
929
1144
  }),
930
1145
  isFloorplanHovered: false,
931
1146
  setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
1147
+ isRiserOpen: false,
1148
+ setRiserOpen: (open) => set({ isRiserOpen: open }),
1149
+ toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
932
1150
  navigationSyncPose: null,
933
1151
  publishNavigationSyncPose: (pose) =>
934
1152
  set((state) => ({
@@ -941,8 +1159,48 @@ const useEditor = create<EditorState>()(
941
1159
  setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
942
1160
  gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
943
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
+ },
944
1169
  magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
945
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
+ },
946
1204
  showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
947
1205
  toggleReferenceFloor: () =>
948
1206
  set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -955,6 +1213,8 @@ const useEditor = create<EditorState>()(
955
1213
  set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
956
1214
  allowUndergroundCamera: false,
957
1215
  setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
1216
+ show2dVoronoi: false,
1217
+ setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
958
1218
  isFirstPersonMode: false,
959
1219
  _viewModeBeforeFirstPerson: null as ViewMode | null,
960
1220
  setFirstPersonMode: (enabled) => {
@@ -1014,11 +1274,28 @@ const useEditor = create<EditorState>()(
1014
1274
  }),
1015
1275
  {
1016
1276
  name: 'pascal-editor-ui-preferences',
1017
- merge: (persistedState, currentState) => ({
1018
- ...currentState,
1019
- ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
1020
- ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
1021
- }),
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
+ },
1022
1299
  partialize: (state) => ({
1023
1300
  phase: state.phase,
1024
1301
  mode: state.mode,
@@ -1033,12 +1310,105 @@ const useEditor = create<EditorState>()(
1033
1310
  floorplanSelectionTool: state.floorplanSelectionTool,
1034
1311
  gridSnapStep: state.gridSnapStep,
1035
1312
  magneticSnap: state.magneticSnap,
1313
+ lastMeasurementKind: state.lastMeasurementKind,
1314
+ snappingModeByContext: state.snappingModeByContext,
1315
+ continuationByContext: state.continuationByContext,
1036
1316
  showReferenceFloor: state.showReferenceFloor,
1037
1317
  referenceFloorOffset: state.referenceFloorOffset,
1038
1318
  referenceFloorOpacity: state.referenceFloorOpacity,
1039
1319
  }),
1320
+ skipHydration: true,
1040
1321
  },
1041
1322
  ),
1042
1323
  )
1043
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
+
1044
1414
  export default useEditor