@pascal-app/editor 0.7.0 → 0.9.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,12 +1,18 @@
1
1
  'use client'
2
2
 
3
+ import type { AssetInput } from '@pascal-app/core'
3
4
  import {
5
+ type AnyNode,
4
6
  type AnyNodeId,
5
- type AssetInput,
6
7
  type BuildingNode,
7
8
  type CeilingNode,
9
+ type ChimneyMaterialRole,
10
+ type ChimneyNode,
8
11
  type ColumnNode,
9
12
  type DoorNode,
13
+ type DormerNode,
14
+ type DormerSurfaceMaterialRole,
15
+ type ElevatorNode,
10
16
  type FenceNode,
11
17
  type ItemNode,
12
18
  type LevelNode,
@@ -27,7 +33,6 @@ import {
27
33
  import { useViewer } from '@pascal-app/viewer'
28
34
  import { create } from 'zustand'
29
35
  import { persist } from 'zustand/middleware'
30
- import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
31
36
  import {
32
37
  type ActivePaintMaterial,
33
38
  type PaintableMaterialTarget,
@@ -36,13 +41,37 @@ import {
36
41
  type SingleSurfaceMaterialRole,
37
42
  } from '../lib/material-paint'
38
43
 
39
- const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
44
+ const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
40
45
  const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
41
46
  const MIN_FLOORPLAN_PANE_RATIO = 0.15
42
47
  const MAX_FLOORPLAN_PANE_RATIO = 0.85
43
48
 
44
49
  export type ViewMode = '3d' | '2d' | 'split'
45
50
  export type SplitOrientation = 'horizontal' | 'vertical'
51
+ export type WorkspaceMode = 'edit' | 'studio'
52
+
53
+ // Snapshot capture is invoked from two surfaces with different policies.
54
+ // `standard` mirrors the existing user-driven UX — pick region / viewport /
55
+ // area, save the blob as a project thumbnail. `preset` is the constrained
56
+ // variant for the unified preset capture flow (community save-as-preset
57
+ // modal): the overlay locks to a square crop, the renderer clears alpha
58
+ // (transparent background), and the rendered set is locked to `isolated`
59
+ // — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and
60
+ // applies those constraints. Keeping it a discriminated union lets us
61
+ // add future modes without surfacing the choice to end users.
62
+ export type CaptureMode =
63
+ | { mode: 'idle' }
64
+ | { mode: 'standard' }
65
+ | {
66
+ mode: 'preset'
67
+ isolated: AnyNodeId[]
68
+ framingBounds?: {
69
+ min: [number, number]
70
+ max: [number, number]
71
+ center: [number, number]
72
+ size: [number, number]
73
+ }
74
+ }
46
75
 
47
76
  export type Phase = 'site' | 'structure' | 'furnish'
48
77
 
@@ -58,12 +87,25 @@ export type StructureTool =
58
87
  | 'ceiling'
59
88
  | 'roof'
60
89
  | 'column'
90
+ | 'elevator'
61
91
  | 'stair'
62
92
  | 'item'
63
93
  | 'zone'
64
94
  | 'spawn'
65
95
  | 'window'
66
96
  | 'door'
97
+ | 'shelf'
98
+ | 'box-vent'
99
+ | 'ridge-vent'
100
+ | 'turbine-vent'
101
+ | 'cupola'
102
+ | 'eyebrow-vent'
103
+ | 'chimney'
104
+ | 'solar-panel'
105
+ | 'skylight'
106
+ | 'dormer'
107
+ | 'gutter'
108
+ | 'downspout'
67
109
 
68
110
  // Furnish mode tools (items and decoration)
69
111
  export type FurnishTool = 'item'
@@ -86,9 +128,29 @@ export type StructureLayer = 'zones' | 'elements'
86
128
  export type FloorplanSelectionTool = 'click' | 'marquee'
87
129
  export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
88
130
 
131
+ export type NavigationSyncSource = '2d' | '3d'
132
+
133
+ export type NavigationSyncPose = {
134
+ source: NavigationSyncSource
135
+ revision: number
136
+ target: [number, number, number]
137
+ azimuth: number
138
+ viewWidth: number
139
+ }
140
+
141
+ export type NavigationSyncPoseInput = Omit<NavigationSyncPose, 'revision'>
142
+
89
143
  // Combined tool type
90
144
  export type Tool = SiteTool | StructureTool | FurnishTool
91
145
 
146
+ /**
147
+ * Starting parameters seeded into a draw tool before it mints a node.
148
+ * A loose param bag — the tool's create path validates it through the
149
+ * kind's schema (`FenceNode.parse({ ...defaults, start, end })`), which
150
+ * is the real type gate, so unknown keys are simply ignored.
151
+ */
152
+ export type ToolDefaults = Record<string, unknown>
153
+
92
154
  export type MovingWallEndpoint = {
93
155
  wall: WallNode
94
156
  endpoint: 'start' | 'end'
@@ -103,6 +165,8 @@ export type MaterialTargetRole =
103
165
  | WallSurfaceSide
104
166
  | StairSurfaceMaterialRole
105
167
  | RoofSurfaceMaterialRole
168
+ | ChimneyMaterialRole
169
+ | DormerSurfaceMaterialRole
106
170
  | SingleSurfaceMaterialRole
107
171
 
108
172
  export type SelectedMaterialTarget = {
@@ -116,6 +180,8 @@ type MaterialPaintSelectionSnapshot = {
116
180
  activePaintMaterial: ActivePaintMaterial | null
117
181
  }
118
182
 
183
+ export type SurfaceHoleTarget = { nodeId: string; holeIndex: number }
184
+
119
185
  export type GuideUiState = {
120
186
  locked?: boolean
121
187
  scaleReferenceVisible?: boolean
@@ -128,6 +194,15 @@ type EditorState = {
128
194
  setMode: (mode: Mode) => void
129
195
  tool: Tool | null
130
196
  setTool: (tool: Tool | null) => void
197
+ /**
198
+ * Per-tool starting parameters for the next node a draw tool mints.
199
+ * Transient (not persisted): host apps seed an entry just before
200
+ * activating the tool (placing a drawn preset, or a future dimension
201
+ * picker), the tool's create path merges it, and the tool clears its
202
+ * own entry on deactivation so a later manual draw isn't poisoned.
203
+ */
204
+ toolDefaults: Partial<Record<Tool, ToolDefaults>>
205
+ setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
131
206
  structureLayer: StructureLayer
132
207
  setStructureLayer: (layer: StructureLayer) => void
133
208
  catalogCategory: CatalogCategory | null
@@ -138,11 +213,14 @@ type EditorState = {
138
213
  | ItemNode
139
214
  | WindowNode
140
215
  | DoorNode
141
- | FenceNode
216
+ | ElevatorNode
142
217
  | CeilingNode
218
+ | ChimneyNode
143
219
  | ColumnNode
220
+ | DormerNode
144
221
  | SlabNode
145
222
  | WallNode
223
+ | FenceNode
146
224
  | RoofNode
147
225
  | RoofSegmentNode
148
226
  | SpawnNode
@@ -150,16 +228,26 @@ type EditorState = {
150
228
  | StairSegmentNode
151
229
  | BuildingNode
152
230
  | null
231
+ /**
232
+ * True while a move was engaged by a press-drag gizmo (the on-canvas move
233
+ * cross) rather than a click-to-place flow. The placement coordinator reads
234
+ * this to commit on pointer-release instead of waiting for a click.
235
+ */
236
+ placementDragMode: boolean
237
+ setPlacementDragMode: (dragMode: boolean) => void
153
238
  setMovingNode: (
154
239
  node:
155
240
  | ItemNode
156
241
  | WindowNode
157
242
  | DoorNode
158
- | FenceNode
243
+ | ElevatorNode
159
244
  | CeilingNode
245
+ | ChimneyNode
160
246
  | ColumnNode
247
+ | DormerNode
161
248
  | SlabNode
162
249
  | WallNode
250
+ | FenceNode
163
251
  | RoofNode
164
252
  | RoofSegmentNode
165
253
  | SpawnNode
@@ -168,10 +256,41 @@ type EditorState = {
168
256
  | BuildingNode
169
257
  | null,
170
258
  ) => void
259
+ /**
260
+ * Which view (2D floor plan or 3D viewer) most recently completed
261
+ * the active move — set by the committing or cancelling side just
262
+ * before clearing `movingNode`. Lets the *other* side's effect
263
+ * cleanup skip its own restore-from-snapshot when the drag was
264
+ * already finalised elsewhere (split view mounts both the 2D
265
+ * overlay and the 3D move tool for the same `movingNode`).
266
+ *
267
+ * Reset to null when the next non-null `setMovingNode` starts a
268
+ * fresh drag (so stale values from the previous drag don't poison
269
+ * cleanups). Preserved across `setMovingNode(null)` so the
270
+ * non-owning side's cleanup — which fires after the clear
271
+ * propagates — can still read who finalised. Null while a drag
272
+ * is in progress means "no side has claimed it yet" — both
273
+ * cleanups then restore to their pre-drag snapshot, which is the
274
+ * same baseline, so the result is idempotent.
275
+ */
276
+ movingNodeOrigin: '2d' | '3d' | null
277
+ setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void
171
278
  movingWallEndpoint: MovingWallEndpoint | null
172
279
  setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
173
280
  movingFenceEndpoint: MovingFenceEndpoint | null
174
281
  setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
282
+ /**
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.
291
+ */
292
+ activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
293
+ setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void
175
294
  curvingWall: WallNode | null
176
295
  setCurvingWall: (wall: WallNode | null) => void
177
296
  curvingFence: FenceNode | null
@@ -182,6 +301,10 @@ type EditorState = {
182
301
  setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
183
302
  activePaintTarget: PaintableMaterialTarget
184
303
  setActivePaintTarget: (target: PaintableMaterialTarget) => void
304
+ // When true, clicking a surface in paint mode clears it back to its
305
+ // default material instead of applying `activePaintMaterial`.
306
+ paintEraser: boolean
307
+ setPaintEraser: (eraser: boolean) => void
185
308
  primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
186
309
  hoveredPaintTarget: PaintableMaterialTarget | null
187
310
  setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
@@ -197,11 +320,23 @@ type EditorState = {
197
320
  spaces: Record<string, Space>
198
321
  setSpaces: (spaces: Record<string, Space>) => void
199
322
  // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
200
- editingHole: { nodeId: string; holeIndex: number } | null
201
- setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
323
+ editingHole: SurfaceHoleTarget | null
324
+ setEditingHole: (hole: SurfaceHoleTarget | null) => void
325
+ hoveredHole: SurfaceHoleTarget | null
326
+ setHoveredHole: (hole: SurfaceHoleTarget | null) => void
202
327
  // Preview mode (viewer-like experience inside the editor)
203
328
  isPreviewMode: boolean
204
329
  setPreviewMode: (preview: boolean) => void
330
+ // Capture mode (snapshot toolbar — hides panels for clean framing).
331
+ // `captureMode` is the canonical discriminated-union state; the boolean
332
+ // `isCaptureMode` is kept synced as a derived convenience for the many
333
+ // existing read sites that just gate chrome visibility on "is capture
334
+ // active". New write sites should pass a `CaptureMode` shape; passing a
335
+ // boolean is accepted as a back-compat shim (`true` → `'standard'`,
336
+ // `false` → `'idle'`).
337
+ captureMode: CaptureMode
338
+ isCaptureMode: boolean
339
+ setCaptureMode: (next: boolean | CaptureMode) => void
205
340
  // View mode (3D only, 2D only, or split 2D+3D)
206
341
  viewMode: ViewMode
207
342
  setViewMode: (mode: ViewMode) => void
@@ -213,10 +348,17 @@ type EditorState = {
213
348
  toggleFloorplanOpen: () => void
214
349
  isFloorplanHovered: boolean
215
350
  setFloorplanHovered: (hovered: boolean) => void
351
+ navigationSyncPose: NavigationSyncPose | null
352
+ publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
216
353
  floorplanSelectionTool: FloorplanSelectionTool
217
354
  setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
218
355
  gridSnapStep: GridSnapStep
219
356
  setGridSnapStep: (step: GridSnapStep) => void
357
+ // Magnetic snapping while drafting — snaps wall endpoints onto existing
358
+ // wall corners / wall bodies (the "magnetic" beacon). Independent of grid
359
+ // snap. On by default; toggled from the Display menu.
360
+ magneticSnap: boolean
361
+ setMagneticSnap: (enabled: boolean) => void
220
362
  showReferenceFloor: boolean
221
363
  toggleReferenceFloor: () => void
222
364
  setShowReferenceFloor: (show: boolean) => void
@@ -224,21 +366,27 @@ type EditorState = {
224
366
  setReferenceFloorOffset: (offset: number) => void
225
367
  referenceFloorOpacity: number
226
368
  setReferenceFloorOpacity: (opacity: number) => void
369
+ // Development-only camera debug flag for inspecting underside geometry
370
+ allowUndergroundCamera: boolean
371
+ setAllowUndergroundCamera: (enabled: boolean) => void
227
372
  // First-person walkthrough mode (street view)
228
373
  isFirstPersonMode: boolean
229
374
  _viewModeBeforeFirstPerson: ViewMode | null
230
375
  setFirstPersonMode: (enabled: boolean) => void
231
- // Development-only camera debug flag for inspecting underside geometry
232
- allowUndergroundCamera: boolean
233
- setAllowUndergroundCamera: (enabled: boolean) => void
376
+ // Workspace mode: 'edit' is the full editing surface; 'studio' is the
377
+ // render/snapshot surface (clean canvas, no editing chrome or selection).
378
+ // Entering studio forces a 3D-only view and restores the prior view on exit.
379
+ workspaceMode: WorkspaceMode
380
+ _viewModeBeforeStudio: ViewMode | null
381
+ setWorkspaceMode: (mode: WorkspaceMode) => void
234
382
  activeSidebarPanel: string
235
383
  setActiveSidebarPanel: (id: string) => void
236
- mobilePanelSheetHeight: number
237
- setMobilePanelSheetHeight: (height: number) => void
238
- isCaptureMode: boolean
239
- setIsCaptureMode: (enabled: boolean) => void
240
384
  floorplanPaneRatio: number
241
385
  setFloorplanPaneRatio: (ratio: number) => void
386
+ // Mobile-only: pixel height of the secondary panel sheet while open (0 when closed).
387
+ // Read by the mobile layout so the viewer container can shrink to preview edits.
388
+ mobilePanelSheetHeight: number
389
+ setMobilePanelSheetHeight: (px: number) => void
242
390
  }
243
391
 
244
392
  export type PersistedEditorUiState = Pick<
@@ -253,6 +401,7 @@ type PersistedEditorLayoutState = Pick<
253
401
  | 'splitOrientation'
254
402
  | 'floorplanSelectionTool'
255
403
  | 'gridSnapStep'
404
+ | 'magneticSnap'
256
405
  | 'showReferenceFloor'
257
406
  | 'referenceFloorOffset'
258
407
  | 'referenceFloorOpacity'
@@ -275,6 +424,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
275
424
  splitOrientation: 'horizontal',
276
425
  floorplanSelectionTool: 'click',
277
426
  gridSnapStep: 0.5,
427
+ magneticSnap: true,
278
428
  showReferenceFloor: false,
279
429
  referenceFloorOffset: 1,
280
430
  referenceFloorOpacity: 0.35,
@@ -282,6 +432,10 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
282
432
 
283
433
  const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
284
434
 
435
+ type SelectDefaultBuildingAndLevelOptions = {
436
+ forceGroundLevel?: boolean
437
+ }
438
+
285
439
  function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
286
440
  if (phase === 'site') {
287
441
  return 'select'
@@ -387,6 +541,8 @@ function normalizePersistedEditorLayoutState(
387
541
  gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
388
542
  ? (state?.gridSnapStep as GridSnapStep)
389
543
  : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
544
+ // Default on: only an explicit persisted `false` disables it.
545
+ magneticSnap: state?.magneticSnap !== false,
390
546
  showReferenceFloor: state?.showReferenceFloor === true,
391
547
  referenceFloorOffset:
392
548
  typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -416,48 +572,94 @@ export function hasCustomPersistedEditorUiState(
416
572
  )
417
573
  }
418
574
 
575
+ function getDefaultLevelId(
576
+ buildingNode: BuildingNode,
577
+ nodes: Record<string, AnyNode>,
578
+ ): LevelNode['id'] | null {
579
+ const levels = buildingNode.children
580
+ .map((childId) => nodes[childId as AnyNodeId])
581
+ .filter((node): node is LevelNode => node?.type === 'level')
582
+
583
+ if (levels.length === 0) {
584
+ return null
585
+ }
586
+
587
+ const groundLevel = levels.find((level) => level.level === 0)
588
+ if (groundLevel) {
589
+ return groundLevel.id
590
+ }
591
+
592
+ const firstLevel = levels[0]
593
+ if (!firstLevel) {
594
+ return null
595
+ }
596
+
597
+ let lowestLevel = firstLevel
598
+ for (const level of levels.slice(1)) {
599
+ if (level.level < lowestLevel.level) {
600
+ lowestLevel = level
601
+ }
602
+ }
603
+
604
+ return lowestLevel.id
605
+ }
606
+
419
607
  /**
420
608
  * Selects the first building and level 0 in the scene.
421
609
  * Safe to call any time — no-ops if already selected or scene is empty.
422
610
  */
423
- export function selectDefaultBuildingAndLevel() {
611
+ export function selectDefaultBuildingAndLevel(options: SelectDefaultBuildingAndLevelOptions = {}) {
424
612
  const viewer = useViewer.getState()
425
613
  const scene = useScene.getState()
426
614
 
427
- let buildingId = viewer.selection.buildingId
615
+ const selectedBuilding = viewer.selection.buildingId
616
+ ? scene.nodes[viewer.selection.buildingId]
617
+ : null
618
+ let buildingNode =
619
+ selectedBuilding?.type === 'building' ? (selectedBuilding as BuildingNode) : null
428
620
 
429
621
  // If no building selected, find the first one from site's children
430
- if (!buildingId) {
622
+ if (!buildingNode) {
431
623
  const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
432
624
  if (siteNode?.type === 'site') {
433
- const firstBuilding = siteNode.children
434
- .map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
435
- .find((node) => node?.type === 'building')
436
- if (firstBuilding) {
437
- buildingId = firstBuilding.id as BuildingNode['id']
438
- viewer.setSelection({ buildingId })
439
- }
625
+ buildingNode =
626
+ siteNode.children
627
+ .map((childId) => scene.nodes[childId as AnyNodeId])
628
+ .find((node): node is BuildingNode => node?.type === 'building') ?? null
440
629
  }
441
630
  }
442
631
 
443
- // If no level selected, find level 0 in the building
444
- if (buildingId && !viewer.selection.levelId) {
445
- const buildingNode = scene.nodes[buildingId] as BuildingNode
446
- const level0Id = buildingNode.children.find((childId) => {
447
- const levelNode = scene.nodes[childId] as LevelNode
448
- return levelNode?.type === 'level' && levelNode.level === 0
449
- })
450
- if (level0Id) {
451
- viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
452
- } else if (buildingNode.children[0]) {
453
- // Fallback to first level if level 0 doesn't exist
454
- viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
455
- }
632
+ if (!buildingNode) {
633
+ return
634
+ }
635
+
636
+ const selectedLevel = viewer.selection.levelId ? scene.nodes[viewer.selection.levelId] : null
637
+ const selectedLevelBelongsToBuilding =
638
+ selectedLevel?.type === 'level' && selectedLevel.parentId === buildingNode.id
639
+ const shouldSelectDefaultLevel = options.forceGroundLevel || !selectedLevelBelongsToBuilding
640
+ const defaultLevelId = shouldSelectDefaultLevel
641
+ ? getDefaultLevelId(buildingNode, scene.nodes as Record<string, AnyNode>)
642
+ : null
643
+
644
+ const selectionUpdate: Parameters<typeof viewer.setSelection>[0] = {}
645
+ if (viewer.selection.buildingId !== buildingNode.id) {
646
+ selectionUpdate.buildingId = buildingNode.id
647
+ }
648
+ if (defaultLevelId) {
649
+ selectionUpdate.levelId = defaultLevelId
650
+ }
651
+
652
+ if (Object.keys(selectionUpdate).length > 0) {
653
+ viewer.setSelection(selectionUpdate)
456
654
  }
457
655
  }
458
656
 
459
- function getDefaultSelectedItemForCategory(category: CatalogCategory | null): AssetInput | null {
460
- return getDefaultCatalogItem(category)
657
+ export function selectSiteFloorplanContext() {
658
+ selectDefaultBuildingAndLevel({ forceGroundLevel: true })
659
+ useViewer.getState().setSelection({
660
+ selectedIds: [],
661
+ zoneId: null,
662
+ })
461
663
  }
462
664
 
463
665
  const useEditor = create<EditorState>()(
@@ -481,23 +683,16 @@ const useEditor = create<EditorState>()(
481
683
  } else if (phase === 'structure') {
482
684
  set({ tool: 'wall', catalogCategory: null })
483
685
  } else if (phase === 'furnish') {
484
- set({
485
- tool: 'item',
486
- catalogCategory: 'furniture',
487
- selectedItem: getDefaultSelectedItemForCategory('furniture'),
488
- })
686
+ set({ tool: 'item', catalogCategory: 'furniture' })
489
687
  }
490
688
  } else {
491
689
  // Reset to select mode and clear tool/catalog when switching phases
492
690
  set({ mode: 'select', tool: null, catalogCategory: null })
493
691
  }
494
692
 
495
- const viewer = useViewer.getState()
496
-
497
693
  switch (phase) {
498
694
  case 'site':
499
- // In Site mode, we zoom out and deselect specific levels/buildings
500
- viewer.resetSelection()
695
+ selectSiteFloorplanContext()
501
696
  break
502
697
 
503
698
  case 'structure':
@@ -525,15 +720,8 @@ const useEditor = create<EditorState>()(
525
720
  } else if (phase === 'structure' && structureLayer === 'elements') {
526
721
  set({ tool: 'wall' })
527
722
  } else if (phase === 'furnish') {
528
- set({
529
- tool: 'item',
530
- catalogCategory: 'furniture',
531
- selectedItem: getDefaultSelectedItemForCategory('furniture'),
532
- })
723
+ set({ tool: 'item', catalogCategory: 'furniture' })
533
724
  }
534
- } else if (phase === 'furnish' && tool === 'item' && !get().selectedItem) {
535
- const category = get().catalogCategory ?? 'furniture'
536
- set({ selectedItem: getDefaultSelectedItemForCategory(category) })
537
725
  }
538
726
  } else if (mode === 'material-paint') {
539
727
  get().primeMaterialPaintFromSelection()
@@ -545,6 +733,17 @@ const useEditor = create<EditorState>()(
545
733
  },
546
734
  tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
547
735
  setTool: (tool) => set({ tool }),
736
+ toolDefaults: {},
737
+ setToolDefaults: (tool, defaults) =>
738
+ set((state) => {
739
+ const next = { ...state.toolDefaults }
740
+ if (defaults === null) {
741
+ delete next[tool]
742
+ } else {
743
+ next[tool] = defaults
744
+ }
745
+ return { toolDefaults: next }
746
+ }),
548
747
  structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
549
748
  setStructureLayer: (layer) => {
550
749
  const { mode } = get()
@@ -563,38 +762,47 @@ const useEditor = create<EditorState>()(
563
762
  })
564
763
  },
565
764
  catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
566
- setCatalogCategory: (category) =>
567
- set((state) => ({
568
- catalogCategory: category,
569
- selectedItem:
570
- category !== null &&
571
- state.phase === 'furnish' &&
572
- state.mode === 'build' &&
573
- state.tool === 'item'
574
- ? getDefaultSelectedItemForCategory(category)
575
- : state.selectedItem,
576
- })),
765
+ setCatalogCategory: (category) => set({ catalogCategory: category }),
577
766
  selectedItem: null,
578
767
  setSelectedItem: (item) => set({ selectedItem: item }),
579
768
  movingNode: null as
580
769
  | ItemNode
581
770
  | WindowNode
582
771
  | DoorNode
583
- | FenceNode
772
+ | ElevatorNode
584
773
  | CeilingNode
774
+ | ColumnNode
585
775
  | SlabNode
586
776
  | WallNode
777
+ | FenceNode
587
778
  | RoofNode
588
779
  | RoofSegmentNode
780
+ | SpawnNode
589
781
  | StairNode
590
782
  | StairSegmentNode
591
783
  | BuildingNode
592
784
  | null,
593
- setMovingNode: (node) => set({ movingNode: node }),
785
+ placementDragMode: false,
786
+ 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
+ ),
798
+ movingNodeOrigin: null as '2d' | '3d' | null,
799
+ setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
594
800
  movingWallEndpoint: null,
595
801
  setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
596
802
  movingFenceEndpoint: null,
597
803
  setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
804
+ activeHandleDrag: null,
805
+ setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
598
806
  curvingWall: null,
599
807
  setCurvingWall: (wall) => set({ curvingWall: wall }),
600
808
  curvingFence: null,
@@ -602,12 +810,17 @@ const useEditor = create<EditorState>()(
602
810
  selectedMaterialTarget: null,
603
811
  setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
604
812
  activePaintMaterial: null,
605
- setActivePaintMaterial: (material) => set({ activePaintMaterial: material }),
813
+ // Picking a material implies paint, not erase — clear the eraser so the
814
+ // next click applies the chosen material.
815
+ setActivePaintMaterial: (material) =>
816
+ set({ activePaintMaterial: material, paintEraser: false }),
606
817
  activePaintTarget: 'wall',
607
818
  setActivePaintTarget: (target) =>
608
819
  set((state) =>
609
820
  state.activePaintTarget === target ? state : { activePaintTarget: target },
610
821
  ),
822
+ paintEraser: false,
823
+ setPaintEraser: (eraser) => set({ paintEraser: eraser }),
611
824
  primeMaterialPaintFromSelection: () => {
612
825
  const selectedId =
613
826
  useViewer.getState().selection.selectedIds.length === 1
@@ -678,6 +891,14 @@ const useEditor = create<EditorState>()(
678
891
  setSpaces: (spaces) => set({ spaces }),
679
892
  editingHole: null,
680
893
  setEditingHole: (hole) => set({ editingHole: hole }),
894
+ hoveredHole: null,
895
+ setHoveredHole: (hole) =>
896
+ set((state) =>
897
+ state.hoveredHole?.nodeId === hole?.nodeId &&
898
+ state.hoveredHole?.holeIndex === hole?.holeIndex
899
+ ? state
900
+ : { hoveredHole: hole },
901
+ ),
681
902
  isPreviewMode: false,
682
903
  setPreviewMode: (preview) => {
683
904
  if (preview) {
@@ -688,6 +909,13 @@ const useEditor = create<EditorState>()(
688
909
  set({ isPreviewMode: false })
689
910
  }
690
911
  },
912
+ captureMode: { mode: 'idle' } as CaptureMode,
913
+ isCaptureMode: false,
914
+ setCaptureMode: (next) => {
915
+ const resolved: CaptureMode =
916
+ typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
917
+ set({ captureMode: resolved, isCaptureMode: resolved.mode !== 'idle' })
918
+ },
691
919
  viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
692
920
  setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
693
921
  splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
@@ -701,10 +929,20 @@ const useEditor = create<EditorState>()(
701
929
  }),
702
930
  isFloorplanHovered: false,
703
931
  setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
932
+ navigationSyncPose: null,
933
+ publishNavigationSyncPose: (pose) =>
934
+ set((state) => ({
935
+ navigationSyncPose: {
936
+ ...pose,
937
+ revision: (state.navigationSyncPose?.revision ?? 0) + 1,
938
+ },
939
+ })),
704
940
  floorplanSelectionTool: 'click' as FloorplanSelectionTool,
705
941
  setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
706
942
  gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
707
943
  setGridSnapStep: (step) => set({ gridSnapStep: step }),
944
+ magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
945
+ setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
708
946
  showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
709
947
  toggleReferenceFloor: () =>
710
948
  set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -740,35 +978,47 @@ const useEditor = create<EditorState>()(
740
978
  })
741
979
  }
742
980
  },
981
+ workspaceMode: 'edit' as WorkspaceMode,
982
+ _viewModeBeforeStudio: null as ViewMode | null,
983
+ setWorkspaceMode: (mode) => {
984
+ if (get().workspaceMode === mode) return
985
+ if (mode === 'studio') {
986
+ const currentViewMode = get().viewMode
987
+ set({
988
+ workspaceMode: 'studio',
989
+ _viewModeBeforeStudio: currentViewMode,
990
+ viewMode: '3d',
991
+ isFloorplanOpen: false,
992
+ mode: 'select',
993
+ tool: null,
994
+ catalogCategory: null,
995
+ })
996
+ // Clear selection so no edit affordances bleed into the clean canvas.
997
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
998
+ } else {
999
+ const prevMode = get()._viewModeBeforeStudio
1000
+ set({
1001
+ workspaceMode: 'edit',
1002
+ _viewModeBeforeStudio: null,
1003
+ ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
1004
+ })
1005
+ }
1006
+ },
743
1007
  activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
744
1008
  setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
745
- mobilePanelSheetHeight: 0,
746
- setMobilePanelSheetHeight: (height) => set({ mobilePanelSheetHeight: height }),
747
- isCaptureMode: false,
748
- setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }),
749
1009
  floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
750
1010
  setFloorplanPaneRatio: (ratio) =>
751
1011
  set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
1012
+ mobilePanelSheetHeight: 0,
1013
+ setMobilePanelSheetHeight: (px) => set({ mobilePanelSheetHeight: Math.max(0, px) }),
752
1014
  }),
753
1015
  {
754
1016
  name: 'pascal-editor-ui-preferences',
755
- merge: (persistedState, currentState) => {
756
- const mergedState = {
757
- ...currentState,
758
- ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
759
- ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
760
- }
761
-
762
- return {
763
- ...mergedState,
764
- selectedItem:
765
- mergedState.phase === 'furnish' &&
766
- mergedState.mode === 'build' &&
767
- mergedState.tool === 'item'
768
- ? getDefaultSelectedItemForCategory(mergedState.catalogCategory ?? 'furniture')
769
- : currentState.selectedItem,
770
- }
771
- },
1017
+ merge: (persistedState, currentState) => ({
1018
+ ...currentState,
1019
+ ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
1020
+ ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
1021
+ }),
772
1022
  partialize: (state) => ({
773
1023
  phase: state.phase,
774
1024
  mode: state.mode,
@@ -782,6 +1032,7 @@ const useEditor = create<EditorState>()(
782
1032
  splitOrientation: state.splitOrientation,
783
1033
  floorplanSelectionTool: state.floorplanSelectionTool,
784
1034
  gridSnapStep: state.gridSnapStep,
1035
+ magneticSnap: state.magneticSnap,
785
1036
  showReferenceFloor: state.showReferenceFloor,
786
1037
  referenceFloorOffset: state.referenceFloorOffset,
787
1038
  referenceFloorOpacity: state.referenceFloorOpacity,