@pascal-app/editor 0.4.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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,522 @@
1
+ 'use client'
2
+
3
+ import type { AssetInput } from '@pascal-app/core'
4
+ import {
5
+ type BuildingNode,
6
+ type DoorNode,
7
+ type ItemNode,
8
+ type LevelNode,
9
+ type RoofNode,
10
+ type RoofSegmentNode,
11
+ type Space,
12
+ type StairNode,
13
+ type StairSegmentNode,
14
+ useScene,
15
+ type WindowNode,
16
+ } from '@pascal-app/core'
17
+ import { useViewer } from '@pascal-app/viewer'
18
+ import { create } from 'zustand'
19
+ import { persist } from 'zustand/middleware'
20
+
21
+ const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
22
+ const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
23
+ const MIN_FLOORPLAN_PANE_RATIO = 0.15
24
+ const MAX_FLOORPLAN_PANE_RATIO = 0.85
25
+
26
+ export type ViewMode = '3d' | '2d' | 'split'
27
+ export type SplitOrientation = 'horizontal' | 'vertical'
28
+
29
+ export type Phase = 'site' | 'structure' | 'furnish'
30
+
31
+ export type Mode = 'select' | 'edit' | 'delete' | 'build'
32
+
33
+ // Structure mode tools (building elements)
34
+ export type StructureTool =
35
+ | 'wall'
36
+ | 'room'
37
+ | 'custom-room'
38
+ | 'slab'
39
+ | 'ceiling'
40
+ | 'roof'
41
+ | 'column'
42
+ | 'stair'
43
+ | 'item'
44
+ | 'zone'
45
+ | 'window'
46
+ | 'door'
47
+
48
+ // Furnish mode tools (items and decoration)
49
+ export type FurnishTool = 'item'
50
+
51
+ // Site mode tools
52
+ export type SiteTool = 'property-line'
53
+
54
+ // Catalog categories for furnish mode items
55
+ export type CatalogCategory =
56
+ | 'furniture'
57
+ | 'appliance'
58
+ | 'bathroom'
59
+ | 'kitchen'
60
+ | 'outdoor'
61
+ | 'window'
62
+ | 'door'
63
+
64
+ export type StructureLayer = 'zones' | 'elements'
65
+
66
+ export type FloorplanSelectionTool = 'click' | 'marquee'
67
+
68
+ // Combined tool type
69
+ export type Tool = SiteTool | StructureTool | FurnishTool
70
+
71
+ type EditorState = {
72
+ phase: Phase
73
+ setPhase: (phase: Phase) => void
74
+ mode: Mode
75
+ setMode: (mode: Mode) => void
76
+ tool: Tool | null
77
+ setTool: (tool: Tool | null) => void
78
+ structureLayer: StructureLayer
79
+ setStructureLayer: (layer: StructureLayer) => void
80
+ catalogCategory: CatalogCategory | null
81
+ setCatalogCategory: (category: CatalogCategory | null) => void
82
+ selectedItem: AssetInput | null
83
+ setSelectedItem: (item: AssetInput) => void
84
+ movingNode:
85
+ | ItemNode
86
+ | WindowNode
87
+ | DoorNode
88
+ | RoofNode
89
+ | RoofSegmentNode
90
+ | StairNode
91
+ | StairSegmentNode
92
+ | null
93
+ setMovingNode: (
94
+ node:
95
+ | ItemNode
96
+ | WindowNode
97
+ | DoorNode
98
+ | RoofNode
99
+ | RoofSegmentNode
100
+ | StairNode
101
+ | StairSegmentNode
102
+ | null,
103
+ ) => void
104
+ selectedReferenceId: string | null
105
+ setSelectedReferenceId: (id: string | null) => void
106
+ // Space detection for cutaway mode
107
+ spaces: Record<string, Space>
108
+ setSpaces: (spaces: Record<string, Space>) => void
109
+ // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
110
+ editingHole: { nodeId: string; holeIndex: number } | null
111
+ setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
112
+ // Preview mode (viewer-like experience inside the editor)
113
+ isPreviewMode: boolean
114
+ setPreviewMode: (preview: boolean) => void
115
+ // View mode (3D only, 2D only, or split 2D+3D)
116
+ viewMode: ViewMode
117
+ setViewMode: (mode: ViewMode) => void
118
+ splitOrientation: SplitOrientation
119
+ setSplitOrientation: (orientation: SplitOrientation) => void
120
+ // Toggleable 2D floorplan overlay (backward compat — derived from viewMode)
121
+ isFloorplanOpen: boolean
122
+ setFloorplanOpen: (open: boolean) => void
123
+ toggleFloorplanOpen: () => void
124
+ isFloorplanHovered: boolean
125
+ setFloorplanHovered: (hovered: boolean) => void
126
+ floorplanSelectionTool: FloorplanSelectionTool
127
+ setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
128
+ // First-person walkthrough mode (street view)
129
+ isFirstPersonMode: boolean
130
+ _viewModeBeforeFirstPerson: ViewMode | null
131
+ setFirstPersonMode: (enabled: boolean) => void
132
+ // Development-only camera debug flag for inspecting underside geometry
133
+ allowUndergroundCamera: boolean
134
+ setAllowUndergroundCamera: (enabled: boolean) => void
135
+ activeSidebarPanel: string
136
+ setActiveSidebarPanel: (id: string) => void
137
+ floorplanPaneRatio: number
138
+ setFloorplanPaneRatio: (ratio: number) => void
139
+ }
140
+
141
+ export type PersistedEditorUiState = Pick<
142
+ EditorState,
143
+ 'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen' | 'viewMode'
144
+ >
145
+
146
+ type PersistedEditorLayoutState = Pick<
147
+ EditorState,
148
+ 'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool'
149
+ >
150
+ type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
151
+
152
+ export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
153
+ phase: 'site',
154
+ mode: 'select',
155
+ tool: null,
156
+ structureLayer: 'elements',
157
+ catalogCategory: null,
158
+ isFloorplanOpen: false,
159
+ viewMode: '3d',
160
+ }
161
+
162
+ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = {
163
+ activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
164
+ floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
165
+ splitOrientation: 'horizontal',
166
+ floorplanSelectionTool: 'click',
167
+ }
168
+
169
+ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
170
+ if (phase === 'site') {
171
+ return 'select'
172
+ }
173
+
174
+ return mode === 'build' || mode === 'delete' ? mode : 'select'
175
+ }
176
+
177
+ function normalizeFloorplanPaneRatio(value: unknown): number {
178
+ if (!(typeof value === 'number' && Number.isFinite(value))) {
179
+ return DEFAULT_FLOORPLAN_PANE_RATIO
180
+ }
181
+
182
+ return Math.min(MAX_FLOORPLAN_PANE_RATIO, Math.max(MIN_FLOORPLAN_PANE_RATIO, value))
183
+ }
184
+
185
+ export function normalizePersistedEditorUiState(
186
+ state: Partial<PersistedEditorUiState> | null | undefined,
187
+ ): PersistedEditorUiState {
188
+ const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
189
+ const mode = normalizeModeForPhase(phase, state?.mode)
190
+
191
+ // Migrate old isFloorplanOpen to viewMode
192
+ let viewMode: ViewMode = '3d'
193
+ if (state?.viewMode === '2d' || state?.viewMode === '3d' || state?.viewMode === 'split') {
194
+ viewMode = state.viewMode
195
+ } else if (state?.isFloorplanOpen) {
196
+ viewMode = 'split'
197
+ }
198
+ const isFloorplanOpen = viewMode !== '3d'
199
+
200
+ if (phase === 'site') {
201
+ return {
202
+ ...DEFAULT_PERSISTED_EDITOR_UI_STATE,
203
+ phase,
204
+ mode,
205
+ viewMode,
206
+ isFloorplanOpen,
207
+ }
208
+ }
209
+
210
+ if (phase === 'furnish') {
211
+ return {
212
+ phase,
213
+ mode,
214
+ tool: mode === 'build' ? 'item' : null,
215
+ structureLayer: 'elements',
216
+ catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
217
+ viewMode,
218
+ isFloorplanOpen,
219
+ }
220
+ }
221
+
222
+ const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
223
+
224
+ if (mode !== 'build') {
225
+ return {
226
+ phase,
227
+ mode,
228
+ tool: null,
229
+ structureLayer,
230
+ catalogCategory: null,
231
+ viewMode,
232
+ isFloorplanOpen,
233
+ }
234
+ }
235
+
236
+ if (structureLayer === 'zones') {
237
+ return {
238
+ phase,
239
+ mode,
240
+ tool: 'zone',
241
+ structureLayer,
242
+ catalogCategory: null,
243
+ viewMode,
244
+ isFloorplanOpen,
245
+ }
246
+ }
247
+
248
+ return {
249
+ phase,
250
+ mode,
251
+ tool:
252
+ state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
253
+ structureLayer,
254
+ catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
255
+ viewMode,
256
+ isFloorplanOpen,
257
+ }
258
+ }
259
+
260
+ function normalizePersistedEditorLayoutState(
261
+ state: Partial<PersistedEditorLayoutState> | null | undefined,
262
+ ): PersistedEditorLayoutState {
263
+ return {
264
+ activeSidebarPanel:
265
+ typeof state?.activeSidebarPanel === 'string' && state.activeSidebarPanel.trim()
266
+ ? state.activeSidebarPanel
267
+ : DEFAULT_ACTIVE_SIDEBAR_PANEL,
268
+ floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
269
+ splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
270
+ floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
271
+ }
272
+ }
273
+
274
+ export function hasCustomPersistedEditorUiState(
275
+ state: Partial<PersistedEditorUiState> | null | undefined,
276
+ ): boolean {
277
+ const normalizedState = normalizePersistedEditorUiState(state)
278
+
279
+ return (
280
+ normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase ||
281
+ normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||
282
+ normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
283
+ normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
284
+ normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
285
+ normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen ||
286
+ normalizedState.viewMode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode
287
+ )
288
+ }
289
+
290
+ /**
291
+ * Selects the first building and level 0 in the scene.
292
+ * Safe to call any time — no-ops if already selected or scene is empty.
293
+ */
294
+ export function selectDefaultBuildingAndLevel() {
295
+ const viewer = useViewer.getState()
296
+ const scene = useScene.getState()
297
+
298
+ let buildingId = viewer.selection.buildingId
299
+
300
+ // If no building selected, find the first one from site's children
301
+ if (!buildingId) {
302
+ const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
303
+ if (siteNode?.type === 'site') {
304
+ const firstBuilding = siteNode.children
305
+ .map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
306
+ .find((node) => node?.type === 'building')
307
+ if (firstBuilding) {
308
+ buildingId = firstBuilding.id as BuildingNode['id']
309
+ viewer.setSelection({ buildingId })
310
+ }
311
+ }
312
+ }
313
+
314
+ // If no level selected, find level 0 in the building
315
+ if (buildingId && !viewer.selection.levelId) {
316
+ const buildingNode = scene.nodes[buildingId] as BuildingNode
317
+ const level0Id = buildingNode.children.find((childId) => {
318
+ const levelNode = scene.nodes[childId] as LevelNode
319
+ return levelNode?.type === 'level' && levelNode.level === 0
320
+ })
321
+ if (level0Id) {
322
+ viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
323
+ } else if (buildingNode.children[0]) {
324
+ // Fallback to first level if level 0 doesn't exist
325
+ viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
326
+ }
327
+ }
328
+ }
329
+
330
+ const useEditor = create<EditorState>()(
331
+ persist(
332
+ (set, get) => ({
333
+ phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
334
+ setPhase: (phase) => {
335
+ const currentPhase = get().phase
336
+ if (currentPhase === phase) return
337
+
338
+ set({ phase })
339
+
340
+ const { mode, structureLayer } = get()
341
+
342
+ if (mode === 'build') {
343
+ // Stay in build mode, select the first tool for the new phase
344
+ if (phase === 'site') {
345
+ set({ tool: 'property-line', catalogCategory: null })
346
+ } else if (phase === 'structure' && structureLayer === 'zones') {
347
+ set({ tool: 'zone', catalogCategory: null })
348
+ } else if (phase === 'structure') {
349
+ set({ tool: 'wall', catalogCategory: null })
350
+ } else if (phase === 'furnish') {
351
+ set({ tool: 'item', catalogCategory: 'furniture' })
352
+ }
353
+ } else {
354
+ // Reset to select mode and clear tool/catalog when switching phases
355
+ set({ mode: 'select', tool: null, catalogCategory: null })
356
+ }
357
+
358
+ const viewer = useViewer.getState()
359
+
360
+ switch (phase) {
361
+ case 'site':
362
+ // In Site mode, we zoom out and deselect specific levels/buildings
363
+ viewer.resetSelection()
364
+ break
365
+
366
+ case 'structure':
367
+ selectDefaultBuildingAndLevel()
368
+ break
369
+
370
+ case 'furnish':
371
+ selectDefaultBuildingAndLevel()
372
+ // Furnish mode only supports elements layer, not zones
373
+ set({ structureLayer: 'elements' })
374
+ break
375
+ }
376
+ },
377
+ mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
378
+ setMode: (mode) => {
379
+ set({ mode })
380
+
381
+ const { phase, structureLayer, tool } = get()
382
+
383
+ if (mode === 'build') {
384
+ // Ensure a tool is selected in build mode
385
+ if (!tool) {
386
+ if (phase === 'structure' && structureLayer === 'zones') {
387
+ set({ tool: 'zone' })
388
+ } else if (phase === 'structure' && structureLayer === 'elements') {
389
+ set({ tool: 'wall' })
390
+ } else if (phase === 'furnish') {
391
+ set({ tool: 'item', catalogCategory: 'furniture' })
392
+ }
393
+ }
394
+ }
395
+ // When leaving build mode, clear tool
396
+ else if (tool) {
397
+ set({ tool: null })
398
+ }
399
+ },
400
+ tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
401
+ setTool: (tool) => set({ tool }),
402
+ structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
403
+ setStructureLayer: (layer) => {
404
+ const { mode } = get()
405
+
406
+ if (mode === 'build') {
407
+ const tool = layer === 'zones' ? 'zone' : 'wall'
408
+ set({ structureLayer: layer, tool })
409
+ } else {
410
+ set({ structureLayer: layer, mode: 'select', tool: null })
411
+ }
412
+
413
+ const viewer = useViewer.getState()
414
+ viewer.setSelection({
415
+ selectedIds: [],
416
+ zoneId: null,
417
+ })
418
+ },
419
+ catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
420
+ setCatalogCategory: (category) => set({ catalogCategory: category }),
421
+ selectedItem: null,
422
+ setSelectedItem: (item) => set({ selectedItem: item }),
423
+ movingNode: null as
424
+ | ItemNode
425
+ | WindowNode
426
+ | DoorNode
427
+ | RoofNode
428
+ | RoofSegmentNode
429
+ | StairNode
430
+ | StairSegmentNode
431
+ | null,
432
+ setMovingNode: (node) => set({ movingNode: node }),
433
+ selectedReferenceId: null,
434
+ setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
435
+ spaces: {},
436
+ setSpaces: (spaces) => set({ spaces }),
437
+ editingHole: null,
438
+ setEditingHole: (hole) => set({ editingHole: hole }),
439
+ isPreviewMode: false,
440
+ setPreviewMode: (preview) => {
441
+ if (preview) {
442
+ set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
443
+ // Clear zone/item selection for clean viewer drill-down hierarchy
444
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
445
+ } else {
446
+ set({ isPreviewMode: false })
447
+ }
448
+ },
449
+ viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
450
+ setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
451
+ splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
452
+ setSplitOrientation: (orientation) => set({ splitOrientation: orientation }),
453
+ isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
454
+ setFloorplanOpen: (open) => set({ isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }),
455
+ toggleFloorplanOpen: () =>
456
+ set((state) => {
457
+ const open = !state.isFloorplanOpen
458
+ return { isFloorplanOpen: open, viewMode: open ? 'split' : '3d' }
459
+ }),
460
+ isFloorplanHovered: false,
461
+ setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
462
+ floorplanSelectionTool: 'click' as FloorplanSelectionTool,
463
+ setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
464
+ allowUndergroundCamera: false,
465
+ setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
466
+ isFirstPersonMode: false,
467
+ _viewModeBeforeFirstPerson: null as ViewMode | null,
468
+ setFirstPersonMode: (enabled) => {
469
+ if (enabled) {
470
+ const currentViewMode = get().viewMode
471
+ useViewer.getState().setCameraMode('perspective')
472
+ useViewer.getState().setWallMode('up')
473
+ set({
474
+ isFirstPersonMode: true,
475
+ _viewModeBeforeFirstPerson: currentViewMode,
476
+ viewMode: '3d',
477
+ isFloorplanOpen: false,
478
+ mode: 'select',
479
+ tool: null,
480
+ catalogCategory: null,
481
+ })
482
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
483
+ } else {
484
+ const prevMode = get()._viewModeBeforeFirstPerson
485
+ set({
486
+ isFirstPersonMode: false,
487
+ _viewModeBeforeFirstPerson: null,
488
+ ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
489
+ })
490
+ }
491
+ },
492
+ activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
493
+ setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
494
+ floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
495
+ setFloorplanPaneRatio: (ratio) =>
496
+ set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
497
+ }),
498
+ {
499
+ name: 'pascal-editor-ui-preferences',
500
+ merge: (persistedState, currentState) => ({
501
+ ...currentState,
502
+ ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
503
+ ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
504
+ }),
505
+ partialize: (state) => ({
506
+ phase: state.phase,
507
+ mode: state.mode,
508
+ tool: state.tool,
509
+ structureLayer: state.structureLayer,
510
+ catalogCategory: state.catalogCategory,
511
+ isFloorplanOpen: state.isFloorplanOpen,
512
+ viewMode: state.viewMode,
513
+ activeSidebarPanel: state.activeSidebarPanel,
514
+ floorplanPaneRatio: state.floorplanPaneRatio,
515
+ splitOrientation: state.splitOrientation,
516
+ floorplanSelectionTool: state.floorplanSelectionTool,
517
+ }),
518
+ },
519
+ ),
520
+ )
521
+
522
+ export default useEditor
@@ -0,0 +1,45 @@
1
+ import type { ComponentType } from 'react'
2
+ import { create } from 'zustand'
3
+
4
+ export type PaletteViewProps = {
5
+ onClose: () => void
6
+ onBack: () => void
7
+ }
8
+
9
+ export type PaletteView = {
10
+ /** Unique key — matches a page name or a mode name. */
11
+ key: string
12
+ /**
13
+ * `'page'` — renders inside the cmdk Command shell (list area only).
14
+ * Filtering and keyboard navigation still work.
15
+ *
16
+ * `'mode'` — replaces the entire cmdk shell inside the Dialog.
17
+ * Used for full-screen states like ai-executing or ai-review.
18
+ */
19
+ type: 'page' | 'mode'
20
+ /** Human-readable label shown as the breadcrumb for page views. */
21
+ label?: string
22
+ Component: ComponentType<PaletteViewProps>
23
+ }
24
+
25
+ interface PaletteViewRegistryStore {
26
+ views: Map<string, PaletteView>
27
+ register: (view: PaletteView) => () => void
28
+ }
29
+
30
+ export const usePaletteViewRegistry = create<PaletteViewRegistryStore>((set) => ({
31
+ views: new Map(),
32
+ register: (view) => {
33
+ set((s) => {
34
+ const next = new Map(s.views)
35
+ next.set(view.key, view)
36
+ return { views: next }
37
+ })
38
+ return () =>
39
+ set((s) => {
40
+ const next = new Map(s.views)
41
+ next.delete(view.key)
42
+ return { views: next }
43
+ })
44
+ },
45
+ }))
@@ -0,0 +1,90 @@
1
+ import { create } from 'zustand'
2
+
3
+ export type UploadStatus = 'preparing' | 'uploading' | 'confirming' | 'done' | 'error'
4
+
5
+ export interface UploadEntry {
6
+ status: UploadStatus
7
+ assetType: 'scan' | 'guide'
8
+ fileName: string
9
+ progress: number // 0-100
10
+ error: string | null
11
+ resultUrl: string | null
12
+ }
13
+
14
+ export type UploadHandler = (
15
+ projectId: string,
16
+ levelId: string,
17
+ file: File,
18
+ type: 'scan' | 'guide',
19
+ ) => void
20
+
21
+ interface UploadState {
22
+ uploads: Record<string, UploadEntry>
23
+ uploadHandler: UploadHandler | null
24
+ registerUploadHandler: (handler: UploadHandler) => void
25
+ unregisterUploadHandler: () => void
26
+ startUpload: (levelId: string, assetType: 'scan' | 'guide', fileName: string) => void
27
+ setProgress: (levelId: string, progress: number) => void
28
+ setStatus: (levelId: string, status: UploadStatus) => void
29
+ setError: (levelId: string, error: string) => void
30
+ setResult: (levelId: string, url: string) => void
31
+ clearUpload: (levelId: string) => void
32
+ }
33
+
34
+ export const useUploadStore = create<UploadState>((set) => ({
35
+ uploads: {},
36
+ uploadHandler: null,
37
+ registerUploadHandler: (handler) => set({ uploadHandler: handler }),
38
+ unregisterUploadHandler: () => set({ uploadHandler: null }),
39
+
40
+ startUpload: (levelId, assetType, fileName) =>
41
+ set((s) => ({
42
+ uploads: {
43
+ ...s.uploads,
44
+ [levelId]: {
45
+ status: 'preparing',
46
+ assetType,
47
+ fileName,
48
+ progress: 0,
49
+ error: null,
50
+ resultUrl: null,
51
+ },
52
+ },
53
+ })),
54
+
55
+ setProgress: (levelId, progress) =>
56
+ set((s) => {
57
+ const entry = s.uploads[levelId]
58
+ if (!entry) return s
59
+ return { uploads: { ...s.uploads, [levelId]: { ...entry, progress } } }
60
+ }),
61
+
62
+ setStatus: (levelId, status) =>
63
+ set((s) => {
64
+ const entry = s.uploads[levelId]
65
+ if (!entry) return s
66
+ return { uploads: { ...s.uploads, [levelId]: { ...entry, status } } }
67
+ }),
68
+
69
+ setError: (levelId, error) =>
70
+ set((s) => {
71
+ const entry = s.uploads[levelId]
72
+ if (!entry) return s
73
+ return { uploads: { ...s.uploads, [levelId]: { ...entry, status: 'error' as const, error } } }
74
+ }),
75
+
76
+ setResult: (levelId, url) =>
77
+ set((s) => {
78
+ const entry = s.uploads[levelId]
79
+ if (!entry) return s
80
+ return {
81
+ uploads: { ...s.uploads, [levelId]: { ...entry, status: 'done' as const, resultUrl: url } },
82
+ }
83
+ }),
84
+
85
+ clearUpload: (levelId) =>
86
+ set((s) => {
87
+ const { [levelId]: _, ...rest } = s.uploads
88
+ return { uploads: rest }
89
+ }),
90
+ }))
@@ -0,0 +1,3 @@
1
+ // Pull in React Three Fiber JSX type augmentations (mesh, group, etc.)
2
+ // This import triggers R3F's module augmentation of react/jsx-runtime
3
+ import '@react-three/fiber'
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@pascal/typescript-config/react-library.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "noEmit": true
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["node_modules"]
9
+ }