@pascal-app/editor 1.0.0-beta.5 → 1.0.0

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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -6,35 +6,17 @@ import {
6
6
  type AnyNodeId,
7
7
  type BrushSettings,
8
8
  type BuildingNode,
9
- type CabinetModuleNode,
10
- type CabinetNode,
11
- type CeilingNode,
12
9
  type ChimneyMaterialRole,
13
- type ChimneyNode,
14
- type ColumnNode,
15
10
  DEFAULT_BRUSH_SETTINGS,
16
- type DoorNode,
17
- type DormerNode,
18
11
  type DormerSurfaceMaterialRole,
19
- type ElevatorNode,
20
- type FenceNode,
21
- type ItemNode,
22
12
  type LevelNode,
23
13
  nodeRegistry,
24
- type RoofNode,
25
- type RoofSegmentNode,
26
14
  type RoofSurfaceMaterialRole,
27
- type SlabNode,
28
15
  type Space,
29
- type SpawnNode,
30
- type StairNode,
31
- type StairSegmentNode,
32
16
  type StairSurfaceMaterialRole,
33
17
  type TerrainVerb,
34
18
  useScene,
35
- type WallNode,
36
19
  type WallSurfaceSide,
37
- type WindowNode,
38
20
  } from '@pascal-app/core'
39
21
  import { useViewer } from '@pascal-app/viewer'
40
22
  import { create } from 'zustand'
@@ -58,6 +40,7 @@ import {
58
40
  DEFAULT_CREATABLE_MEASUREMENT_KIND,
59
41
  normalizeCreatableMeasurementKind,
60
42
  } from '../lib/measurement-kind'
43
+ import type { ModelExport } from '../lib/model-export'
61
44
  import {
62
45
  cyclePaintScope as cyclePaintScopeValue,
63
46
  type PaintHoverInfo,
@@ -80,31 +63,6 @@ const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
80
63
  const MIN_FLOORPLAN_PANE_RATIO = 0.15
81
64
  const MAX_FLOORPLAN_PANE_RATIO = 0.85
82
65
 
83
- function resolveMovingNodeTarget(
84
- node:
85
- | ItemNode
86
- | WindowNode
87
- | DoorNode
88
- | ElevatorNode
89
- | CeilingNode
90
- | ChimneyNode
91
- | ColumnNode
92
- | DormerNode
93
- | SlabNode
94
- | WallNode
95
- | FenceNode
96
- | RoofNode
97
- | RoofSegmentNode
98
- | SpawnNode
99
- | StairNode
100
- | StairSegmentNode
101
- | BuildingNode
102
- | CabinetNode
103
- | CabinetModuleNode,
104
- ) {
105
- return node
106
- }
107
-
108
66
  export type ViewMode = '3d' | '2d' | 'split'
109
67
  export type SplitOrientation = 'horizontal' | 'vertical'
110
68
  export type WorkspaceMode = 'edit' | 'studio'
@@ -127,7 +85,14 @@ export type SnapshotStandardAspect = '16:9' | '9:16' | '4:3' | '3:4' | '1:1'
127
85
 
128
86
  export type CaptureMode =
129
87
  | { mode: 'idle' }
130
- | { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect }
88
+ | {
89
+ mode: 'standard'
90
+ crop?: SnapshotCropMode
91
+ standardAspect?: SnapshotStandardAspect
92
+ /** The host needs this exact output shape (e.g. the publish cover) —
93
+ * hide the crop/aspect switcher instead of merely preselecting it. */
94
+ lockCrop?: boolean
95
+ }
131
96
  | {
132
97
  mode: 'preset'
133
98
  isolated: AnyNodeId[]
@@ -139,6 +104,22 @@ export type CaptureMode =
139
104
  }
140
105
  }
141
106
 
107
+ /**
108
+ * How the first-person camera moves. `walk` is the grounded street-view
109
+ * controller (gravity, collision, door interaction); `drone` is a free camera
110
+ * with no gravity or collision, offered by the snapshot capture overlay so a
111
+ * shot can be framed from anywhere in the scene.
112
+ */
113
+ export type FirstPersonMovementMode = 'walk' | 'drone'
114
+
115
+ /** Degrees. Range of the capture-mode field-of-view control. */
116
+ export const CAPTURE_FOV_MIN = 15
117
+ export const CAPTURE_FOV_MAX = 110
118
+
119
+ function clampCaptureFov(fov: number): number {
120
+ return Math.min(Math.max(Math.round(fov), CAPTURE_FOV_MIN), CAPTURE_FOV_MAX)
121
+ }
122
+
142
123
  export type Phase = 'site' | 'structure' | 'furnish'
143
124
 
144
125
  /**
@@ -242,6 +223,14 @@ export type NavigationSyncPoseInput = Omit<NavigationSyncPose, 'revision'>
242
223
  export type KnownTool = SiteTool | StructureTool | FurnishTool
243
224
  export type Tool = KnownTool | (string & {})
244
225
 
226
+ export type ToolMode =
227
+ | { mode: 'select' }
228
+ | { mode: 'edit' }
229
+ | { mode: 'delete' }
230
+ | { mode: 'build'; tool: StructureTool }
231
+ | { mode: 'material-paint' }
232
+ | { mode: 'terrain-sculpt' }
233
+
245
234
  /**
246
235
  * Starting parameters seeded into a draw tool before it mints a node.
247
236
  * A loose param bag — the tool's create path validates it through the
@@ -280,6 +269,9 @@ export type GuideUiState = {
280
269
  type EditorState = {
281
270
  phase: Phase
282
271
  setPhase: (phase: Phase) => void
272
+ toolMode: ToolMode
273
+ armToolMode: (next: ToolMode) => void
274
+ armMaterialPaint: (material?: ActivePaintMaterial) => void
283
275
  mode: Mode
284
276
  setMode: (mode: Mode) => void
285
277
  tool: Tool | null
@@ -310,29 +302,7 @@ type EditorState = {
310
302
  setPlacementDragMode: (dragMode: boolean) => void
311
303
  roofHostDragArmedId: AnyNodeId | null
312
304
  setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void
313
- setMovingNode: (
314
- node:
315
- | ItemNode
316
- | WindowNode
317
- | DoorNode
318
- | ElevatorNode
319
- | CeilingNode
320
- | ChimneyNode
321
- | ColumnNode
322
- | DormerNode
323
- | SlabNode
324
- | WallNode
325
- | FenceNode
326
- | RoofNode
327
- | RoofSegmentNode
328
- | SpawnNode
329
- | StairNode
330
- | StairSegmentNode
331
- | BuildingNode
332
- | CabinetNode
333
- | CabinetModuleNode
334
- | null,
335
- ) => void
305
+ setMovingNode: (node: AnyNode | null) => void
336
306
  /**
337
307
  * Which view (2D floor plan or 3D viewer) most recently completed
338
308
  * the active move — set by the committing or cancelling side just
@@ -510,6 +480,26 @@ type EditorState = {
510
480
  isFirstPersonMode: boolean
511
481
  _viewModeBeforeFirstPerson: ViewMode | null
512
482
  setFirstPersonMode: (enabled: boolean) => void
483
+ // Which first-person controller runs while `isFirstPersonMode` is on. Reset to
484
+ // `walk` whenever first person is left, so the grounded controller stays the
485
+ // default entry point; only the capture overlay arms `drone`.
486
+ firstPersonMovementMode: FirstPersonMovementMode
487
+ setFirstPersonMovementMode: (mode: FirstPersonMovementMode) => void
488
+ // Perspective field of view (degrees) the snapshot capture overlay is driving,
489
+ // and the value its reset affordance returns to. Both are `null` unless the
490
+ // capture camera rig has armed them — i.e. unless capture mode is open on a
491
+ // perspective camera. The rig owns the lifecycle; the overlay only writes
492
+ // `captureFov` through `setCaptureFov`.
493
+ captureFov: number | null
494
+ captureFovBaseline: number | null
495
+ setCaptureFov: (fov: number) => void
496
+ armCaptureFov: (fov: number | null) => void
497
+ // The shutter has fired and the snapshot is being rendered/saved: walk /
498
+ // drone freeze look + movement so a late WASD tap or mouse twitch can't
499
+ // shift the frame out from under the shot. Set by the capture overlay for
500
+ // the whole capturing→saved window.
501
+ captureShutterHold: boolean
502
+ setCaptureShutterHold: (hold: boolean) => void
513
503
  // Workspace mode: 'edit' is the full editing surface; 'studio' is the
514
504
  // render/snapshot surface (clean canvas, no editing chrome or selection).
515
505
  // Entering studio forces a 3D-only view and restores the prior view on exit.
@@ -524,11 +514,20 @@ type EditorState = {
524
514
  // Read by the mobile layout so the viewer container can shrink to preview edits.
525
515
  mobilePanelSheetHeight: number
526
516
  setMobilePanelSheetHeight: (px: number) => void
517
+ modelExport: ModelExport | null
518
+ setModelExport: (modelExport: ModelExport | null) => void
527
519
  }
528
520
 
529
521
  export type PersistedEditorUiState = Pick<
530
522
  EditorState,
531
- 'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' | 'viewMode'
523
+ | 'phase'
524
+ | 'toolMode'
525
+ | 'mode'
526
+ | 'tool'
527
+ | 'structureLayer'
528
+ | 'catalogCategory'
529
+ | 'isFloorplanOpen'
530
+ | 'viewMode'
532
531
  >
533
532
 
534
533
  type PersistedEditorLayoutState = Pick<
@@ -550,6 +549,7 @@ type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
550
549
 
551
550
  export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
552
551
  phase: 'site',
552
+ toolMode: { mode: 'select' },
553
553
  mode: 'select',
554
554
  tool: null,
555
555
  structureLayer: 'elements',
@@ -576,6 +576,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
576
576
  fence: CONTINUATION_PROFILES.fence.default,
577
577
  point: CONTINUATION_PROFILES.point.default,
578
578
  cabinet: CONTINUATION_PROFILES.cabinet.default,
579
+ canopy: CONTINUATION_PROFILES.canopy.default,
579
580
  },
580
581
  showReferenceFloor: false,
581
582
  referenceFloorOffset: 1,
@@ -588,13 +589,67 @@ type SelectDefaultBuildingAndLevelOptions = {
588
589
  forceGroundLevel?: boolean
589
590
  }
590
591
 
592
+ function defaultBuildTool(phase: Phase, structureLayer: StructureLayer): StructureTool {
593
+ if (phase === 'site') return 'property-line'
594
+ if (phase === 'furnish') return 'item'
595
+ return structureLayer === 'zones' ? 'zone' : 'wall'
596
+ }
597
+
598
+ function materializeToolMode(
599
+ mode: Mode,
600
+ tool: unknown,
601
+ phase: Phase,
602
+ structureLayer: StructureLayer,
603
+ ): ToolMode {
604
+ if (mode === 'build') {
605
+ return {
606
+ mode,
607
+ tool:
608
+ typeof tool === 'string' && tool.length > 0
609
+ ? (tool as StructureTool)
610
+ : defaultBuildTool(phase, structureLayer),
611
+ }
612
+ }
613
+
614
+ return { mode } as ToolMode
615
+ }
616
+
617
+ function readPersistedToolMode(state: Partial<PersistedEditorUiState> | null | undefined): {
618
+ mode: Mode | undefined
619
+ tool: unknown
620
+ } {
621
+ const candidate = state?.toolMode as Partial<ToolMode> | undefined
622
+ if (
623
+ candidate?.mode === 'select' ||
624
+ candidate?.mode === 'edit' ||
625
+ candidate?.mode === 'delete' ||
626
+ candidate?.mode === 'build' ||
627
+ candidate?.mode === 'material-paint' ||
628
+ candidate?.mode === 'terrain-sculpt'
629
+ ) {
630
+ return {
631
+ mode: candidate.mode,
632
+ tool: candidate.mode === 'build' ? (candidate as { tool?: unknown }).tool : null,
633
+ }
634
+ }
635
+
636
+ return { mode: state?.mode, tool: state?.tool }
637
+ }
638
+
639
+ function withMaterializedToolMode(
640
+ state: Omit<PersistedEditorUiState, 'toolMode'>,
641
+ ): PersistedEditorUiState {
642
+ return {
643
+ ...state,
644
+ toolMode: materializeToolMode(state.mode, state.tool, state.phase, state.structureLayer),
645
+ }
646
+ }
647
+
591
648
  function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
592
- // The site phase used to hard-return `select`, which made a site-phase brush
593
- // mode unrepresentable. It is an allowlist now rather than a free-for-all:
594
- // `delete` and `material-paint` still have nothing to act on at site scope, so
595
- // letting them survive here would restore a mode with no targets.
649
+ // Site has its own property-line build tool and terrain brush. The remaining
650
+ // modes have nothing to act on at site scope, so they restore as select.
596
651
  if (phase === 'site') {
597
- return mode === 'terrain-sculpt' ? mode : 'select'
652
+ return mode === 'build' || mode === 'terrain-sculpt' ? mode : 'select'
598
653
  }
599
654
 
600
655
  return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
@@ -612,7 +667,8 @@ export function normalizePersistedEditorUiState(
612
667
  state: Partial<PersistedEditorUiState> | null | undefined,
613
668
  ): PersistedEditorUiState {
614
669
  const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
615
- let mode = normalizeModeForPhase(phase, state?.mode)
670
+ const persistedToolMode = readPersistedToolMode(state)
671
+ let mode = normalizeModeForPhase(phase, persistedToolMode.mode)
616
672
 
617
673
  // Migrate old isFloorplanOpen to viewMode
618
674
  let viewMode: ViewMode = '3d'
@@ -631,17 +687,19 @@ export function normalizePersistedEditorUiState(
631
687
  if (mode === 'terrain-sculpt' && viewMode === '2d') mode = 'select'
632
688
 
633
689
  if (phase === 'site') {
634
- return {
635
- ...DEFAULT_PERSISTED_EDITOR_UI_STATE,
690
+ return withMaterializedToolMode({
636
691
  phase,
637
692
  mode,
693
+ tool: mode === 'build' ? 'property-line' : null,
694
+ structureLayer: 'elements',
695
+ catalogCategory: null,
638
696
  viewMode,
639
697
  isFloorplanOpen,
640
- }
698
+ })
641
699
  }
642
700
 
643
701
  if (phase === 'furnish') {
644
- return {
702
+ return withMaterializedToolMode({
645
703
  phase,
646
704
  mode,
647
705
  tool: mode === 'build' ? 'item' : null,
@@ -649,13 +707,13 @@ export function normalizePersistedEditorUiState(
649
707
  catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
650
708
  viewMode,
651
709
  isFloorplanOpen,
652
- }
710
+ })
653
711
  }
654
712
 
655
713
  const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
656
714
 
657
715
  if (mode !== 'build') {
658
- return {
716
+ return withMaterializedToolMode({
659
717
  phase,
660
718
  mode,
661
719
  tool: null,
@@ -663,11 +721,11 @@ export function normalizePersistedEditorUiState(
663
721
  catalogCategory: null,
664
722
  viewMode,
665
723
  isFloorplanOpen,
666
- }
724
+ })
667
725
  }
668
726
 
669
727
  if (structureLayer === 'zones') {
670
- return {
728
+ return withMaterializedToolMode({
671
729
  phase,
672
730
  mode,
673
731
  tool: 'zone',
@@ -675,19 +733,25 @@ export function normalizePersistedEditorUiState(
675
733
  catalogCategory: null,
676
734
  viewMode,
677
735
  isFloorplanOpen,
678
- }
736
+ })
679
737
  }
680
738
 
681
- return {
739
+ const tool =
740
+ persistedToolMode.tool &&
741
+ persistedToolMode.tool !== 'property-line' &&
742
+ persistedToolMode.tool !== 'zone'
743
+ ? (persistedToolMode.tool as Tool)
744
+ : 'wall'
745
+
746
+ return withMaterializedToolMode({
682
747
  phase,
683
748
  mode,
684
- tool:
685
- state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
749
+ tool,
686
750
  structureLayer,
687
- catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
751
+ catalogCategory: tool === 'item' ? (state?.catalogCategory ?? null) : null,
688
752
  viewMode,
689
753
  isFloorplanOpen,
690
- }
754
+ })
691
755
  }
692
756
 
693
757
  // Validate a persisted per-context mode against that context's allowed set
@@ -730,6 +794,9 @@ function normalizeContinuationByContext(
730
794
  cabinet:
731
795
  migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ??
732
796
  CONTINUATION_PROFILES.cabinet.default,
797
+ canopy:
798
+ migrateContinuationMode(state?.continuationByContext?.canopy, 'canopy') ??
799
+ CONTINUATION_PROFILES.canopy.default,
733
800
  }
734
801
  }
735
802
 
@@ -885,21 +952,16 @@ let viewModeBeforeCapture: ViewMode | null = null
885
952
  *
886
953
  * Paint and sculpt are the two modes whose scope lifetime is the *mode*, not a
887
954
  * pointer gesture. Both must be released whenever the mode changes for any
888
- * reason including the phase switch that resets the mode without going through
889
- * `setMode`. A stuck `sculpting` scope would leave selection disabled across the
890
- * whole editor, so this is one function called from every mode transition rather
891
- * than a rule each transition remembers.
955
+ * reason. A stuck `sculpting` scope would leave selection disabled across the
956
+ * whole editor, so the ToolMode transition owns this side effect.
892
957
  *
893
958
  * The eyedropper arm rides along for the same reason: it is one-shot state whose
894
959
  * UI is unmounted the moment sculpt mode ends, so it has to be cleared on every
895
960
  * exit path and not just the one through `setMode`.
896
961
  *
897
- * Every `set({ mode: ... })` in this store must be followed by a call to this —
898
- * see the callers in `setPhase`, `setStructureLayer`, `setPreviewMode`,
899
- * `setFirstPersonMode` and `setWorkspaceMode`. The four view-swapping ones are
900
- * the dangerous class: they unmount `ToolManager` (via `noEditing`), so the
901
- * sculpt tool that would otherwise release the scope on unmount is gone, and a
902
- * scope leaked there is unrecoverable without a reload.
962
+ * View-swapping actions are the dangerous class: they unmount `ToolManager`
963
+ * (via `noEditing`), so the sculpt tool that would otherwise release the scope
964
+ * on unmount is gone, and a leaked scope is unrecoverable without a reload.
903
965
  */
904
966
  function syncBrushModeScope(mode: Mode): void {
905
967
  const scope = useInteractionScope.getState()
@@ -935,31 +997,18 @@ const useEditor = create<EditorState>()(
935
997
  setPhase: (phase) => {
936
998
  const currentPhase = get().phase
937
999
  if (currentPhase === phase) return
938
-
939
- set({ phase })
940
-
941
- const { mode, structureLayer } = get()
942
-
943
- if (mode === 'build') {
944
- // Stay in build mode, select the first tool for the new phase
945
- if (phase === 'site') {
946
- set({ tool: 'property-line', catalogCategory: null })
947
- } else if (phase === 'structure' && structureLayer === 'zones') {
948
- set({ tool: 'zone', catalogCategory: null })
949
- } else if (phase === 'structure') {
950
- set({ tool: 'wall', catalogCategory: null })
951
- } else if (phase === 'furnish') {
952
- set({ tool: 'item', catalogCategory: 'furniture' })
953
- }
954
- } else {
955
- // Reset to select mode and clear tool/catalog when switching phases
956
- set({ mode: 'select', tool: null, catalogCategory: null })
957
- }
958
-
959
- // Leaving the site phase must drop a held sculpt scope: this branch
960
- // rewrites `mode` without going through `setMode`, so without this a
961
- // stuck `sculpting` scope would disable selection in structure phase.
962
- syncBrushModeScope(get().mode)
1000
+ const wasBuilding = get().toolMode.mode === 'build'
1001
+ const structureLayer = phase === 'furnish' ? 'elements' : get().structureLayer
1002
+ set({
1003
+ phase,
1004
+ structureLayer,
1005
+ catalogCategory: wasBuilding && phase === 'furnish' ? 'furniture' : null,
1006
+ })
1007
+ get().armToolMode(
1008
+ wasBuilding
1009
+ ? { mode: 'build', tool: defaultBuildTool(phase, structureLayer) }
1010
+ : { mode: 'select' },
1011
+ )
963
1012
 
964
1013
  switch (phase) {
965
1014
  case 'site':
@@ -972,64 +1021,99 @@ const useEditor = create<EditorState>()(
972
1021
 
973
1022
  case 'furnish':
974
1023
  selectDefaultBuildingAndLevel()
975
- // Furnish mode only supports elements layer, not zones
976
- set({ structureLayer: 'elements' })
977
1024
  break
978
1025
  }
979
1026
  },
980
- mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
981
- setMode: (mode) => {
982
- // Sculpting is a site-phase mode. Arming it from structure/furnish moves
983
- // the phase rather than failing silently: the user asked for the ground,
984
- // and `normalizeModeForPhase` would otherwise reject the mode on the next
985
- // rehydrate, leaving the UI showing a mode the store does not hold.
986
- if (mode === 'terrain-sculpt' && get().phase !== 'site') {
987
- get().setPhase('site')
988
- }
1027
+ toolMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.toolMode,
1028
+ armToolMode: (requested) => {
1029
+ const current = get()
1030
+ let phase = current.phase
1031
+ let structureLayer = current.structureLayer
1032
+ let viewMode = current.viewMode
1033
+ let isFloorplanOpen = current.isFloorplanOpen
1034
+ const next = materializeToolMode(
1035
+ requested.mode,
1036
+ requested.mode === 'build' ? requested.tool : null,
1037
+ phase,
1038
+ structureLayer,
1039
+ )
989
1040
 
990
- // Same promotion, one axis over. The brush needs the 3D canvas: in `2d`
991
- // the pane is only `display: none`, so the mode would arm, hold its
992
- // scope and show its HUD while no pointer event could ever reach it.
993
- // `split` is the smallest move that satisfies it — a plain `3d` would
994
- // throw away a floorplan the user deliberately opened.
995
- if (mode === 'terrain-sculpt' && get().viewMode === '2d') {
996
- set({ viewMode: 'split', isFloorplanOpen: true })
1041
+ if (next.mode === 'terrain-sculpt') {
1042
+ phase = 'site'
1043
+ structureLayer = 'elements'
1044
+ if (viewMode === '2d') {
1045
+ viewMode = 'split'
1046
+ isFloorplanOpen = true
1047
+ }
1048
+ } else if (next.mode === 'build' && next.tool === 'property-line') {
1049
+ phase = 'site'
1050
+ structureLayer = 'elements'
1051
+ } else if (next.mode === 'build' && next.tool === 'zone') {
1052
+ phase = 'structure'
1053
+ structureLayer = 'zones'
1054
+ } else if (next.mode === 'build' && phase === 'site') {
1055
+ phase = 'structure'
1056
+ structureLayer = 'elements'
1057
+ } else if (next.mode === 'material-paint' && phase === 'site') {
1058
+ phase = 'structure'
1059
+ structureLayer = 'elements'
1060
+ } else if (phase !== 'structure') {
1061
+ structureLayer = 'elements'
997
1062
  }
998
1063
 
999
- set({ mode })
1000
-
1001
- const { phase, structureLayer, tool } = get()
1064
+ const phaseChanged = phase !== current.phase
1065
+ set({
1066
+ toolMode: next,
1067
+ mode: next.mode,
1068
+ tool: next.mode === 'build' ? next.tool : null,
1069
+ ...(phaseChanged ? { phase } : {}),
1070
+ ...(structureLayer !== current.structureLayer ? { structureLayer } : {}),
1071
+ ...(viewMode !== current.viewMode ? { viewMode } : {}),
1072
+ ...(isFloorplanOpen !== current.isFloorplanOpen ? { isFloorplanOpen } : {}),
1073
+ ...(next.mode === 'build' && phase === 'furnish' && !current.catalogCategory
1074
+ ? { catalogCategory: 'furniture' }
1075
+ : {}),
1076
+ })
1002
1077
 
1003
- if (mode === 'build') {
1004
- // Ensure a tool is selected in build mode
1005
- if (!tool) {
1006
- if (phase === 'structure' && structureLayer === 'zones') {
1007
- set({ tool: 'zone' })
1008
- } else if (phase === 'structure' && structureLayer === 'elements') {
1009
- set({ tool: 'wall' })
1010
- } else if (phase === 'furnish') {
1011
- set({ tool: 'item', catalogCategory: 'furniture' })
1012
- }
1013
- }
1014
- } else if (mode === 'material-paint') {
1015
- get().primeMaterialPaintFromSelection()
1016
- }
1017
- // When leaving build mode, clear tool
1018
- else if (tool) {
1019
- set({ tool: null })
1078
+ if (phaseChanged) {
1079
+ if (phase === 'site') selectSiteFloorplanContext()
1080
+ else selectDefaultBuildingAndLevel()
1020
1081
  }
1021
-
1022
- if (mode === 'terrain-sculpt') {
1023
- // Sculpting acts on the ground, never on a node. Clearing the
1024
- // selection on entry is what stops the selected wall's gizmo from
1025
- // sitting under the brush, competing for the same clicks.
1082
+ if (next.mode === 'material-paint') get().primeMaterialPaintFromSelection()
1083
+ if (next.mode === 'terrain-sculpt') {
1026
1084
  useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1027
1085
  }
1028
-
1029
- syncBrushModeScope(mode)
1086
+ syncBrushModeScope(next.mode)
1087
+ },
1088
+ armMaterialPaint: (material) => {
1089
+ get().armToolMode({ mode: 'material-paint' })
1090
+ if (material) get().setActivePaintMaterial(material)
1091
+ },
1092
+ mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
1093
+ setMode: (mode) => {
1094
+ if (mode === 'build') {
1095
+ const { phase, structureLayer, toolMode } = get()
1096
+ get().armToolMode({
1097
+ mode,
1098
+ tool:
1099
+ toolMode.mode === 'build' ? toolMode.tool : defaultBuildTool(phase, structureLayer),
1100
+ })
1101
+ return
1102
+ }
1103
+ get().armToolMode({ mode } as ToolMode)
1030
1104
  },
1031
1105
  tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
1032
- setTool: (tool) => set({ tool }),
1106
+ setTool: (tool) => {
1107
+ if (tool) {
1108
+ get().armToolMode({ mode: 'build', tool })
1109
+ return
1110
+ }
1111
+ if (get().toolMode.mode === 'build' || get().mode === 'build') {
1112
+ get().armToolMode({ mode: 'select' })
1113
+ return
1114
+ }
1115
+ get().armToolMode(materializeToolMode(get().mode, null, get().phase, get().structureLayer))
1116
+ },
1033
1117
  toolDefaults: {},
1034
1118
  setToolDefaults: (tool, defaults) =>
1035
1119
  set((state) => {
@@ -1045,15 +1129,13 @@ const useEditor = create<EditorState>()(
1045
1129
  setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
1046
1130
  structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
1047
1131
  setStructureLayer: (layer) => {
1048
- const { mode } = get()
1049
-
1050
- if (mode === 'build') {
1051
- const tool = layer === 'zones' ? 'zone' : 'wall'
1052
- set({ structureLayer: layer, tool })
1053
- } else {
1054
- set({ structureLayer: layer, mode: 'select', tool: null })
1055
- syncBrushModeScope('select')
1056
- }
1132
+ const wasBuilding = get().toolMode.mode === 'build'
1133
+ set({ structureLayer: layer })
1134
+ get().armToolMode(
1135
+ wasBuilding
1136
+ ? { mode: 'build', tool: layer === 'zones' ? 'zone' : 'wall' }
1137
+ : { mode: 'select' },
1138
+ )
1057
1139
 
1058
1140
  const viewer = useViewer.getState()
1059
1141
  viewer.setSelection({
@@ -1085,7 +1167,7 @@ const useEditor = create<EditorState>()(
1085
1167
  set({ placementDragMode: false })
1086
1168
  return
1087
1169
  }
1088
- const targetNode = resolveMovingNodeTarget(node)
1170
+ const targetNode = node
1089
1171
  const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew)
1090
1172
  if (isNew) {
1091
1173
  scope.begin({
@@ -1095,6 +1177,7 @@ const useEditor = create<EditorState>()(
1095
1177
  nodeType: targetNode.type,
1096
1178
  view: '3d',
1097
1179
  pressDrag: get().placementDragMode,
1180
+ driver: 'move-tool',
1098
1181
  })
1099
1182
  } else {
1100
1183
  scope.begin({
@@ -1236,8 +1319,8 @@ const useEditor = create<EditorState>()(
1236
1319
  isPreviewMode: false,
1237
1320
  setPreviewMode: (preview) => {
1238
1321
  if (preview) {
1239
- set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
1240
- syncBrushModeScope('select')
1322
+ set({ isPreviewMode: true, catalogCategory: null })
1323
+ get().armToolMode({ mode: 'select' })
1241
1324
  // Clear zone/item selection for clean viewer drill-down hierarchy
1242
1325
  useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1243
1326
  } else {
@@ -1250,6 +1333,12 @@ const useEditor = create<EditorState>()(
1250
1333
  const resolved: CaptureMode =
1251
1334
  typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
1252
1335
  const entering = resolved.mode !== 'idle'
1336
+ // Walk / drone framing is a capture-only camera, so leaving capture always
1337
+ // lands back on orbit. Run it first: it restores its own view mode, and
1338
+ // the capture restore below has the final say.
1339
+ if (!entering && get().isFirstPersonMode) {
1340
+ get().setFirstPersonMode(false)
1341
+ }
1253
1342
  set((state) => {
1254
1343
  if (entering) {
1255
1344
  // Force 3D for the shot. Remember the prior mode only on the first
@@ -1381,20 +1470,33 @@ const useEditor = create<EditorState>()(
1381
1470
  _viewModeBeforeFirstPerson: currentViewMode,
1382
1471
  viewMode: '3d',
1383
1472
  isFloorplanOpen: false,
1384
- mode: 'select',
1385
- tool: null,
1386
1473
  catalogCategory: null,
1387
1474
  })
1388
- syncBrushModeScope('select')
1475
+ get().armToolMode({ mode: 'select' })
1389
1476
  } else {
1390
1477
  const prevMode = get()._viewModeBeforeFirstPerson
1391
1478
  set({
1392
1479
  isFirstPersonMode: false,
1480
+ firstPersonMovementMode: 'walk',
1393
1481
  _viewModeBeforeFirstPerson: null,
1394
1482
  ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
1395
1483
  })
1396
1484
  }
1397
1485
  },
1486
+ firstPersonMovementMode: 'walk' as FirstPersonMovementMode,
1487
+ setFirstPersonMovementMode: (mode) => set({ firstPersonMovementMode: mode }),
1488
+ captureFov: null,
1489
+ captureFovBaseline: null,
1490
+ setCaptureFov: (fov) =>
1491
+ set({
1492
+ captureFov: clampCaptureFov(fov),
1493
+ }),
1494
+ armCaptureFov: (fov) => {
1495
+ const captureFov = fov === null ? null : clampCaptureFov(fov)
1496
+ set({ captureFov, captureFovBaseline: captureFov })
1497
+ },
1498
+ captureShutterHold: false,
1499
+ setCaptureShutterHold: (hold) => set({ captureShutterHold: hold }),
1398
1500
  workspaceMode: 'edit' as WorkspaceMode,
1399
1501
  _viewModeBeforeStudio: null as ViewMode | null,
1400
1502
  setWorkspaceMode: (mode) => {
@@ -1406,11 +1508,9 @@ const useEditor = create<EditorState>()(
1406
1508
  _viewModeBeforeStudio: currentViewMode,
1407
1509
  viewMode: '3d',
1408
1510
  isFloorplanOpen: false,
1409
- mode: 'select',
1410
- tool: null,
1411
1511
  catalogCategory: null,
1412
1512
  })
1413
- syncBrushModeScope('select')
1513
+ get().armToolMode({ mode: 'select' })
1414
1514
  // Clear selection so no edit affordances bleed into the clean canvas.
1415
1515
  useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
1416
1516
  } else {
@@ -1429,6 +1529,8 @@ const useEditor = create<EditorState>()(
1429
1529
  set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
1430
1530
  mobilePanelSheetHeight: 0,
1431
1531
  setMobilePanelSheetHeight: (px) => set({ mobilePanelSheetHeight: Math.max(0, px) }),
1532
+ modelExport: null,
1533
+ setModelExport: (modelExport) => set({ modelExport }),
1432
1534
  }),
1433
1535
  {
1434
1536
  name: 'pascal-editor-ui-preferences',
@@ -1454,7 +1556,7 @@ const useEditor = create<EditorState>()(
1454
1556
  : {}),
1455
1557
  }
1456
1558
  },
1457
- // `mode` is persisted, but the interaction scope a brush mode holds is not
1559
+ // `toolMode` is persisted, but the interaction scope a brush mode holds is not
1458
1560
  // — it lives in a separate, non-persisted store. Rehydrating into
1459
1561
  // `terrain-sculpt` (or paint) without re-claiming the scope would restore
1460
1562
  // the brush with selection still enabled, so every dab could grab a wall.
@@ -1463,6 +1565,7 @@ const useEditor = create<EditorState>()(
1463
1565
  },
1464
1566
  partialize: (state) => ({
1465
1567
  phase: state.phase,
1568
+ toolMode: state.toolMode,
1466
1569
  mode: state.mode,
1467
1570
  tool: state.tool,
1468
1571
  structureLayer: state.structureLayer,
@@ -1487,6 +1590,14 @@ const useEditor = create<EditorState>()(
1487
1590
  ),
1488
1591
  )
1489
1592
 
1593
+ export function armToolMode(next: ToolMode): void {
1594
+ useEditor.getState().armToolMode(next)
1595
+ }
1596
+
1597
+ export function armMaterialPaint(material?: ActivePaintMaterial): void {
1598
+ useEditor.getState().armMaterialPaint(material)
1599
+ }
1600
+
1490
1601
  /**
1491
1602
  * Effective magnetic-snap state: the legacy `magneticSnap` flag AND the active
1492
1603
  * context's snapping mode. With exclusive modes, magnetic (alignment axes + wall