@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -2,11 +2,17 @@
2
2
 
3
3
  import type { AssetInput } from '@pascal-app/core'
4
4
  import {
5
+ type AnyNode,
5
6
  type AnyNodeId,
6
7
  type BuildingNode,
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,
@@ -43,6 +48,30 @@ 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,8 +213,11 @@ type EditorState = {
138
213
  | ItemNode
139
214
  | WindowNode
140
215
  | DoorNode
216
+ | ElevatorNode
141
217
  | CeilingNode
218
+ | ChimneyNode
142
219
  | ColumnNode
220
+ | DormerNode
143
221
  | SlabNode
144
222
  | WallNode
145
223
  | FenceNode
@@ -150,13 +228,23 @@ 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
243
+ | ElevatorNode
158
244
  | CeilingNode
245
+ | ChimneyNode
159
246
  | ColumnNode
247
+ | DormerNode
160
248
  | SlabNode
161
249
  | WallNode
162
250
  | FenceNode
@@ -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,14 +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
205
- // Capture mode (snapshot toolbar — hides panels for clean framing)
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
206
338
  isCaptureMode: boolean
207
- setCaptureMode: (active: boolean) => void
339
+ setCaptureMode: (next: boolean | CaptureMode) => void
208
340
  // View mode (3D only, 2D only, or split 2D+3D)
209
341
  viewMode: ViewMode
210
342
  setViewMode: (mode: ViewMode) => void
@@ -216,10 +348,17 @@ type EditorState = {
216
348
  toggleFloorplanOpen: () => void
217
349
  isFloorplanHovered: boolean
218
350
  setFloorplanHovered: (hovered: boolean) => void
351
+ navigationSyncPose: NavigationSyncPose | null
352
+ publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
219
353
  floorplanSelectionTool: FloorplanSelectionTool
220
354
  setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
221
355
  gridSnapStep: GridSnapStep
222
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
223
362
  showReferenceFloor: boolean
224
363
  toggleReferenceFloor: () => void
225
364
  setShowReferenceFloor: (show: boolean) => void
@@ -234,9 +373,14 @@ type EditorState = {
234
373
  isFirstPersonMode: boolean
235
374
  _viewModeBeforeFirstPerson: ViewMode | null
236
375
  setFirstPersonMode: (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
237
382
  activeSidebarPanel: string
238
383
  setActiveSidebarPanel: (id: string) => void
239
- setIsCaptureMode: (enabled: boolean) => void
240
384
  floorplanPaneRatio: number
241
385
  setFloorplanPaneRatio: (ratio: number) => void
242
386
  // Mobile-only: pixel height of the secondary panel sheet while open (0 when closed).
@@ -257,6 +401,7 @@ type PersistedEditorLayoutState = Pick<
257
401
  | 'splitOrientation'
258
402
  | 'floorplanSelectionTool'
259
403
  | 'gridSnapStep'
404
+ | 'magneticSnap'
260
405
  | 'showReferenceFloor'
261
406
  | 'referenceFloorOffset'
262
407
  | 'referenceFloorOpacity'
@@ -279,6 +424,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
279
424
  splitOrientation: 'horizontal',
280
425
  floorplanSelectionTool: 'click',
281
426
  gridSnapStep: 0.5,
427
+ magneticSnap: true,
282
428
  showReferenceFloor: false,
283
429
  referenceFloorOffset: 1,
284
430
  referenceFloorOpacity: 0.35,
@@ -286,6 +432,10 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
286
432
 
287
433
  const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
288
434
 
435
+ type SelectDefaultBuildingAndLevelOptions = {
436
+ forceGroundLevel?: boolean
437
+ }
438
+
289
439
  function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
290
440
  if (phase === 'site') {
291
441
  return 'select'
@@ -391,6 +541,8 @@ function normalizePersistedEditorLayoutState(
391
541
  gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
392
542
  ? (state?.gridSnapStep as GridSnapStep)
393
543
  : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
544
+ // Default on: only an explicit persisted `false` disables it.
545
+ magneticSnap: state?.magneticSnap !== false,
394
546
  showReferenceFloor: state?.showReferenceFloor === true,
395
547
  referenceFloorOffset:
396
548
  typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -420,44 +572,94 @@ export function hasCustomPersistedEditorUiState(
420
572
  )
421
573
  }
422
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
+
423
607
  /**
424
608
  * Selects the first building and level 0 in the scene.
425
609
  * Safe to call any time — no-ops if already selected or scene is empty.
426
610
  */
427
- export function selectDefaultBuildingAndLevel() {
611
+ export function selectDefaultBuildingAndLevel(options: SelectDefaultBuildingAndLevelOptions = {}) {
428
612
  const viewer = useViewer.getState()
429
613
  const scene = useScene.getState()
430
614
 
431
- 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
432
620
 
433
621
  // If no building selected, find the first one from site's children
434
- if (!buildingId) {
622
+ if (!buildingNode) {
435
623
  const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
436
624
  if (siteNode?.type === 'site') {
437
- const firstBuilding = siteNode.children
438
- .map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
439
- .find((node) => node?.type === 'building')
440
- if (firstBuilding) {
441
- buildingId = firstBuilding.id as BuildingNode['id']
442
- viewer.setSelection({ buildingId })
443
- }
625
+ buildingNode =
626
+ siteNode.children
627
+ .map((childId) => scene.nodes[childId as AnyNodeId])
628
+ .find((node): node is BuildingNode => node?.type === 'building') ?? null
444
629
  }
445
630
  }
446
631
 
447
- // If no level selected, find level 0 in the building
448
- if (buildingId && !viewer.selection.levelId) {
449
- const buildingNode = scene.nodes[buildingId] as BuildingNode
450
- const level0Id = buildingNode.children.find((childId) => {
451
- const levelNode = scene.nodes[childId] as LevelNode
452
- return levelNode?.type === 'level' && levelNode.level === 0
453
- })
454
- if (level0Id) {
455
- viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
456
- } else if (buildingNode.children[0]) {
457
- // Fallback to first level if level 0 doesn't exist
458
- viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
459
- }
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
460
650
  }
651
+
652
+ if (Object.keys(selectionUpdate).length > 0) {
653
+ viewer.setSelection(selectionUpdate)
654
+ }
655
+ }
656
+
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>()(
@@ -488,12 +690,9 @@ const useEditor = create<EditorState>()(
488
690
  set({ mode: 'select', tool: null, catalogCategory: null })
489
691
  }
490
692
 
491
- const viewer = useViewer.getState()
492
-
493
693
  switch (phase) {
494
694
  case 'site':
495
- // In Site mode, we zoom out and deselect specific levels/buildings
496
- viewer.resetSelection()
695
+ selectSiteFloorplanContext()
497
696
  break
498
697
 
499
698
  case 'structure':
@@ -534,6 +733,17 @@ const useEditor = create<EditorState>()(
534
733
  },
535
734
  tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
536
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
+ }),
537
747
  structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
538
748
  setStructureLayer: (layer) => {
539
749
  const { mode } = get()
@@ -559,21 +769,40 @@ const useEditor = create<EditorState>()(
559
769
  | ItemNode
560
770
  | WindowNode
561
771
  | DoorNode
772
+ | ElevatorNode
562
773
  | CeilingNode
774
+ | ColumnNode
563
775
  | SlabNode
564
776
  | WallNode
565
777
  | FenceNode
566
778
  | RoofNode
567
779
  | RoofSegmentNode
780
+ | SpawnNode
568
781
  | StairNode
569
782
  | StairSegmentNode
570
783
  | BuildingNode
571
784
  | null,
572
- 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 }),
573
800
  movingWallEndpoint: null,
574
801
  setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
575
802
  movingFenceEndpoint: null,
576
803
  setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
804
+ activeHandleDrag: null,
805
+ setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
577
806
  curvingWall: null,
578
807
  setCurvingWall: (wall) => set({ curvingWall: wall }),
579
808
  curvingFence: null,
@@ -581,12 +810,17 @@ const useEditor = create<EditorState>()(
581
810
  selectedMaterialTarget: null,
582
811
  setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
583
812
  activePaintMaterial: null,
584
- 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 }),
585
817
  activePaintTarget: 'wall',
586
818
  setActivePaintTarget: (target) =>
587
819
  set((state) =>
588
820
  state.activePaintTarget === target ? state : { activePaintTarget: target },
589
821
  ),
822
+ paintEraser: false,
823
+ setPaintEraser: (eraser) => set({ paintEraser: eraser }),
590
824
  primeMaterialPaintFromSelection: () => {
591
825
  const selectedId =
592
826
  useViewer.getState().selection.selectedIds.length === 1
@@ -657,6 +891,14 @@ const useEditor = create<EditorState>()(
657
891
  setSpaces: (spaces) => set({ spaces }),
658
892
  editingHole: null,
659
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
+ ),
660
902
  isPreviewMode: false,
661
903
  setPreviewMode: (preview) => {
662
904
  if (preview) {
@@ -667,8 +909,13 @@ const useEditor = create<EditorState>()(
667
909
  set({ isPreviewMode: false })
668
910
  }
669
911
  },
912
+ captureMode: { mode: 'idle' } as CaptureMode,
670
913
  isCaptureMode: false,
671
- setCaptureMode: (active) => set({ isCaptureMode: active }),
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
+ },
672
919
  viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
673
920
  setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
674
921
  splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
@@ -682,10 +929,20 @@ const useEditor = create<EditorState>()(
682
929
  }),
683
930
  isFloorplanHovered: false,
684
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
+ })),
685
940
  floorplanSelectionTool: 'click' as FloorplanSelectionTool,
686
941
  setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
687
942
  gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
688
943
  setGridSnapStep: (step) => set({ gridSnapStep: step }),
944
+ magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
945
+ setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
689
946
  showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
690
947
  toggleReferenceFloor: () =>
691
948
  set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -721,9 +978,34 @@ const useEditor = create<EditorState>()(
721
978
  })
722
979
  }
723
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
+ },
724
1007
  activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
725
1008
  setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
726
- setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }),
727
1009
  floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
728
1010
  setFloorplanPaneRatio: (ratio) =>
729
1011
  set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
@@ -750,6 +1032,7 @@ const useEditor = create<EditorState>()(
750
1032
  splitOrientation: state.splitOrientation,
751
1033
  floorplanSelectionTool: state.floorplanSelectionTool,
752
1034
  gridSnapStep: state.gridSnapStep,
1035
+ magneticSnap: state.magneticSnap,
753
1036
  showReferenceFloor: state.showReferenceFloor,
754
1037
  referenceFloorOffset: state.referenceFloorOffset,
755
1038
  referenceFloorOpacity: state.referenceFloorOpacity,
@@ -0,0 +1,31 @@
1
+ // Ephemeral store for a placement tool's 2D floor-plan ghost. A registry
2
+ // placement tool (e.g. column / elevator) publishes a fully-positioned,
3
+ // transient preview node on each `grid:move`; the floor-plan
4
+ // placement-preview layer subscribes and renders the node's `def.floorplan`
5
+ // footprint as a faint ghost that follows the cursor. The 3D view already
6
+ // shows a translucent mesh preview, so this only feeds the 2D layer.
7
+ //
8
+ // Editor-only: the read-only viewer route never places nodes. Lives here
9
+ // rather than in `core` for that reason; node-kind tools (e.g. column) reach
10
+ // it through the `@pascal-app/editor` public surface, the same way they
11
+ // already consume `triggerSFX`. Producers clear on commit, cancel, and
12
+ // unmount.
13
+
14
+ import type { AnyNode } from '@pascal-app/core'
15
+ import { create } from 'zustand'
16
+
17
+ type PlacementPreviewState = {
18
+ /** Transient preview node, already positioned + rotated at the (snapped,
19
+ * aligned) cursor. `null` when no placement is active. */
20
+ node: AnyNode | null
21
+ set(node: AnyNode | null): void
22
+ clear(): void
23
+ }
24
+
25
+ const usePlacementPreview = create<PlacementPreviewState>((set) => ({
26
+ node: null,
27
+ set: (node) => set({ node }),
28
+ clear: () => set({ node: null }),
29
+ }))
30
+
31
+ export default usePlacementPreview