@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,394 @@
1
+ 'use client'
2
+
3
+ import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import useEditor, {
6
+ hasCustomPersistedEditorUiState,
7
+ normalizePersistedEditorUiState,
8
+ type PersistedEditorUiState,
9
+ } from '../store/use-editor'
10
+
11
+ export type SceneGraph = {
12
+ nodes: Record<string, unknown>
13
+ rootNodeIds: string[]
14
+ }
15
+
16
+ type PersistedSelectionPath = {
17
+ buildingId: string | null
18
+ levelId: string | null
19
+ zoneId: string | null
20
+ selectedIds: string[]
21
+ }
22
+
23
+ const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = {
24
+ buildingId: null,
25
+ levelId: null,
26
+ zoneId: null,
27
+ selectedIds: [],
28
+ }
29
+
30
+ const SELECTION_STORAGE_KEY = 'pascal-editor-selection'
31
+
32
+ function getSelectionStorageKey(): string {
33
+ const projectId = useViewer.getState().projectId
34
+ return projectId ? `${SELECTION_STORAGE_KEY}:${projectId}` : SELECTION_STORAGE_KEY
35
+ }
36
+
37
+ function getSelectionStorageReadKeys(): string[] {
38
+ const scopedKey = getSelectionStorageKey()
39
+ return scopedKey === SELECTION_STORAGE_KEY ? [scopedKey] : [scopedKey, SELECTION_STORAGE_KEY]
40
+ }
41
+
42
+ function getDefaultLevelIdForBuilding(
43
+ sceneNodes: Record<string, any>,
44
+ buildingId: string | null,
45
+ ): string | null {
46
+ if (!buildingId) {
47
+ return null
48
+ }
49
+
50
+ const buildingNode = sceneNodes[buildingId]
51
+ if (buildingNode?.type !== 'building' || !Array.isArray(buildingNode.children)) {
52
+ return null
53
+ }
54
+
55
+ let firstLevelId: string | null = null
56
+
57
+ for (const childId of buildingNode.children) {
58
+ const levelNode = sceneNodes[childId]
59
+ if (levelNode?.type !== 'level') {
60
+ continue
61
+ }
62
+
63
+ firstLevelId ??= levelNode.id
64
+
65
+ if (levelNode.level === 0) {
66
+ return levelNode.id
67
+ }
68
+ }
69
+
70
+ return firstLevelId
71
+ }
72
+
73
+ function normalizePersistedSelectionPath(
74
+ selection: Partial<PersistedSelectionPath> | null | undefined,
75
+ ): PersistedSelectionPath {
76
+ return {
77
+ buildingId: typeof selection?.buildingId === 'string' ? selection.buildingId : null,
78
+ levelId: typeof selection?.levelId === 'string' ? selection.levelId : null,
79
+ zoneId: typeof selection?.zoneId === 'string' ? selection.zoneId : null,
80
+ selectedIds: Array.isArray(selection?.selectedIds)
81
+ ? selection.selectedIds.filter((id): id is string => typeof id === 'string')
82
+ : [],
83
+ }
84
+ }
85
+
86
+ function hasPersistedSelectionValue(selection: PersistedSelectionPath): boolean {
87
+ return Boolean(
88
+ selection.buildingId ||
89
+ selection.levelId ||
90
+ selection.zoneId ||
91
+ selection.selectedIds.length > 0,
92
+ )
93
+ }
94
+
95
+ function readPersistedSelection(): PersistedSelectionPath | null {
96
+ if (typeof window === 'undefined') {
97
+ return null
98
+ }
99
+
100
+ try {
101
+ for (const key of getSelectionStorageReadKeys()) {
102
+ const rawSelection = window.localStorage.getItem(key)
103
+ if (!rawSelection) {
104
+ continue
105
+ }
106
+
107
+ return normalizePersistedSelectionPath(
108
+ JSON.parse(rawSelection) as Partial<PersistedSelectionPath>,
109
+ )
110
+ }
111
+ } catch {
112
+ return null
113
+ }
114
+
115
+ return null
116
+ }
117
+
118
+ export function writePersistedSelection(selection: {
119
+ buildingId: string | null
120
+ levelId: string | null
121
+ zoneId: string | null
122
+ selectedIds: string[]
123
+ }) {
124
+ if (typeof window === 'undefined') {
125
+ return
126
+ }
127
+
128
+ try {
129
+ const sceneNodes = useScene.getState().nodes as Record<string, any>
130
+ const normalizedSelection = normalizePersistedSelectionPath(selection)
131
+ const validatedSelection =
132
+ getValidatedSelectionForScene(sceneNodes, normalizedSelection) ?? normalizedSelection
133
+
134
+ window.localStorage.setItem(getSelectionStorageKey(), JSON.stringify(validatedSelection))
135
+ } catch {
136
+ // Swallow storage quota errors
137
+ }
138
+ }
139
+
140
+ function getEditorUiStateForRestoredSelection(
141
+ sceneNodes: Record<string, any>,
142
+ selection: PersistedSelectionPath,
143
+ fallbackUiState: PersistedEditorUiState,
144
+ ): PersistedEditorUiState {
145
+ if (!selection.levelId) {
146
+ return {
147
+ ...fallbackUiState,
148
+ phase: 'site',
149
+ mode: fallbackUiState.phase === 'site' ? fallbackUiState.mode : 'select',
150
+ tool: null,
151
+ structureLayer: 'elements',
152
+ catalogCategory: null,
153
+ }
154
+ }
155
+
156
+ if (selection.zoneId) {
157
+ return {
158
+ ...fallbackUiState,
159
+ phase: 'structure',
160
+ mode: 'select',
161
+ tool: null,
162
+ structureLayer: 'zones',
163
+ catalogCategory: null,
164
+ }
165
+ }
166
+
167
+ const selectedNodes = selection.selectedIds
168
+ .map((id) => sceneNodes[id])
169
+ .filter((node): node is Record<string, any> => Boolean(node))
170
+
171
+ const shouldRestoreFurnishPhase =
172
+ selectedNodes.length > 0 &&
173
+ selectedNodes.every(
174
+ (node) =>
175
+ node.type === 'item' &&
176
+ node.asset?.category !== 'door' &&
177
+ node.asset?.category !== 'window',
178
+ )
179
+
180
+ return {
181
+ ...fallbackUiState,
182
+ phase: shouldRestoreFurnishPhase ? 'furnish' : 'structure',
183
+ mode: 'select',
184
+ tool: null,
185
+ structureLayer: 'elements',
186
+ catalogCategory: null,
187
+ }
188
+ }
189
+
190
+ function getValidatedSelectionForScene(
191
+ sceneNodes: Record<string, any>,
192
+ selection: PersistedSelectionPath,
193
+ ): PersistedSelectionPath | null {
194
+ const levelNode = selection.levelId ? sceneNodes[selection.levelId] : null
195
+ const hasValidLevel = levelNode?.type === 'level'
196
+ const buildingNodeFromLevel =
197
+ hasValidLevel && levelNode.parentId ? sceneNodes[levelNode.parentId] : null
198
+ const explicitBuildingNode = selection.buildingId ? sceneNodes[selection.buildingId] : null
199
+ const buildingId =
200
+ buildingNodeFromLevel?.type === 'building'
201
+ ? buildingNodeFromLevel.id
202
+ : explicitBuildingNode?.type === 'building'
203
+ ? explicitBuildingNode.id
204
+ : null
205
+
206
+ if (!buildingId) {
207
+ return null
208
+ }
209
+
210
+ const levelId = hasValidLevel
211
+ ? levelNode.id
212
+ : getDefaultLevelIdForBuilding(sceneNodes, buildingId)
213
+
214
+ if (levelId) {
215
+ const zoneNode = selection.zoneId ? sceneNodes[selection.zoneId] : null
216
+ const zoneId =
217
+ zoneNode?.type === 'zone' && resolveLevelId(zoneNode, sceneNodes) === levelId
218
+ ? zoneNode.id
219
+ : null
220
+
221
+ const selectedIds = selection.selectedIds.filter((id) => {
222
+ const node = sceneNodes[id]
223
+ return Boolean(node) && resolveLevelId(node, sceneNodes) === levelId
224
+ })
225
+
226
+ return {
227
+ buildingId,
228
+ levelId,
229
+ zoneId,
230
+ selectedIds,
231
+ }
232
+ }
233
+
234
+ return {
235
+ ...EMPTY_PERSISTED_SELECTION,
236
+ buildingId,
237
+ }
238
+ }
239
+
240
+ function getRestoredSelectionForScene(
241
+ sceneNodes: Record<string, any>,
242
+ ): PersistedSelectionPath | null {
243
+ const persistedSelection = readPersistedSelection()
244
+ if (!(persistedSelection && hasPersistedSelectionValue(persistedSelection))) {
245
+ return null
246
+ }
247
+
248
+ return getValidatedSelectionForScene(sceneNodes, persistedSelection)
249
+ }
250
+
251
+ export function syncEditorSelectionFromCurrentScene() {
252
+ const sceneNodes = useScene.getState().nodes as Record<string, any>
253
+ const sceneRootIds = useScene.getState().rootNodeIds
254
+ const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
255
+ const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
256
+ const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
257
+ const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
258
+ const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState())
259
+ const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState)
260
+ const restoredSelection = getRestoredSelectionForScene(sceneNodes)
261
+ const selectionDrivenEditorUiState = restoredSelection
262
+ ? getEditorUiStateForRestoredSelection(sceneNodes, restoredSelection, restoredEditorUiState)
263
+ : null
264
+
265
+ if (firstBuilding && firstLevel) {
266
+ const isEmptyLevel = !firstLevel.children || firstLevel.children.length === 0
267
+
268
+ // For empty projects (new/blank), always start in structure/build/wall
269
+ // regardless of persisted state from a previous project
270
+ if (isEmptyLevel) {
271
+ useViewer.getState().setSelection({
272
+ buildingId: firstBuilding.id,
273
+ levelId: firstLevel.id,
274
+ selectedIds: [],
275
+ zoneId: null,
276
+ })
277
+ useEditor.getState().setPhase('structure')
278
+ useEditor.getState().setStructureLayer('elements')
279
+ useEditor.getState().setMode('build')
280
+ useEditor.getState().setTool('wall')
281
+ return
282
+ }
283
+
284
+ if (shouldRestoreEditorUiState) {
285
+ if (restoredSelection) {
286
+ useViewer.getState().setSelection(restoredSelection)
287
+ useEditor.setState(
288
+ restoredEditorUiState.phase === 'site'
289
+ ? (selectionDrivenEditorUiState ?? restoredEditorUiState)
290
+ : restoredEditorUiState,
291
+ )
292
+ } else if (restoredEditorUiState.phase === 'site') {
293
+ useViewer.getState().resetSelection()
294
+ useEditor.setState(restoredEditorUiState)
295
+ } else {
296
+ useViewer.getState().setSelection({
297
+ buildingId: firstBuilding.id,
298
+ levelId: firstLevel.id,
299
+ selectedIds: [],
300
+ zoneId: null,
301
+ })
302
+ useEditor.setState(restoredEditorUiState)
303
+ }
304
+ return
305
+ }
306
+
307
+ if (restoredSelection) {
308
+ useViewer.getState().setSelection(restoredSelection)
309
+ if (selectionDrivenEditorUiState) {
310
+ useEditor.setState(selectionDrivenEditorUiState)
311
+ }
312
+ return
313
+ }
314
+
315
+ useViewer.getState().setSelection({
316
+ buildingId: firstBuilding.id,
317
+ levelId: firstLevel.id,
318
+ selectedIds: [],
319
+ zoneId: null,
320
+ })
321
+ useEditor.getState().setPhase('structure')
322
+ useEditor.getState().setStructureLayer('elements')
323
+ } else {
324
+ useEditor.getState().setPhase('site')
325
+ useViewer.getState().setSelection({
326
+ buildingId: null,
327
+ levelId: null,
328
+ selectedIds: [],
329
+ zoneId: null,
330
+ })
331
+ }
332
+ }
333
+
334
+ function resetEditorInteractionState() {
335
+ useViewer.getState().setHoveredId(null)
336
+ useViewer.getState().resetSelection()
337
+ // Clear outliner arrays synchronously so stale Object3D refs from the old
338
+ // scene don't leak into the post-processing pipeline's outline passes.
339
+ const outliner = useViewer.getState().outliner
340
+ outliner.selectedObjects.length = 0
341
+ outliner.hoveredObjects.length = 0
342
+ sceneRegistry.clear()
343
+ useEditor.setState({
344
+ phase: 'site',
345
+ mode: 'select',
346
+ tool: null,
347
+ structureLayer: 'elements',
348
+ catalogCategory: null,
349
+ selectedItem: null,
350
+ movingNode: null,
351
+ selectedReferenceId: null,
352
+ spaces: {},
353
+ editingHole: null,
354
+ isPreviewMode: false,
355
+ })
356
+ }
357
+
358
+ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is SceneGraph {
359
+ return (
360
+ !!sceneGraph &&
361
+ Object.keys(sceneGraph.nodes ?? {}).length > 0 &&
362
+ (sceneGraph.rootNodeIds?.length ?? 0) > 0
363
+ )
364
+ }
365
+
366
+ export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
367
+ if (hasUsableSceneGraph(sceneGraph)) {
368
+ const { nodes, rootNodeIds } = sceneGraph
369
+ useScene.getState().setScene(nodes as any, rootNodeIds as any)
370
+ } else {
371
+ useScene.getState().clearScene()
372
+ }
373
+
374
+ syncEditorSelectionFromCurrentScene()
375
+ }
376
+
377
+ const LOCAL_STORAGE_KEY = 'pascal-editor-scene'
378
+
379
+ export function saveSceneToLocalStorage(scene: SceneGraph): void {
380
+ try {
381
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(scene))
382
+ } catch {
383
+ // Swallow storage quota errors
384
+ }
385
+ }
386
+
387
+ export function loadSceneFromLocalStorage(): SceneGraph | null {
388
+ try {
389
+ const raw = localStorage.getItem(LOCAL_STORAGE_KEY)
390
+ return raw ? (JSON.parse(raw) as SceneGraph) : null
391
+ } catch {
392
+ return null
393
+ }
394
+ }
@@ -0,0 +1,2 @@
1
+ export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus'
2
+ export { playSFX, SFX, type SFXName, updateSFXVolumes } from '../sfx-player'
@@ -0,0 +1,49 @@
1
+ import mitt from 'mitt'
2
+ import { playSFX } from './sfx-player'
3
+
4
+ /**
5
+ * SFX-specific events that tools can trigger
6
+ */
7
+ type SFXEvents = {
8
+ 'sfx:grid-snap': undefined
9
+ 'sfx:item-delete': undefined
10
+ 'sfx:item-pick': undefined
11
+ 'sfx:item-place': undefined
12
+ 'sfx:item-rotate': undefined
13
+ 'sfx:structure-build': undefined
14
+ 'sfx:structure-delete': undefined
15
+ }
16
+
17
+ /**
18
+ * Dedicated event emitter for SFX
19
+ * Tools should use this to trigger sound effects
20
+ */
21
+ export const sfxEmitter = mitt<SFXEvents>()
22
+
23
+ let sfxBusInitialized = false
24
+
25
+ /**
26
+ * Initialize SFX Bus - connects SFX events to actual sound playback.
27
+ * Safe to call multiple times; re-registration is a no-op once initialized.
28
+ */
29
+ export function initSFXBus() {
30
+ if (sfxBusInitialized) return
31
+ sfxBusInitialized = true
32
+ // Map SFX events to sound playback
33
+ sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
34
+ sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
35
+ sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
36
+ sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
37
+ sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
38
+ sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
39
+ sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
40
+ }
41
+
42
+ /**
43
+ * Helper function to trigger SFX events from tools
44
+ * @example
45
+ * triggerSFX('sfx:item-place')
46
+ */
47
+ export function triggerSFX(event: keyof SFXEvents) {
48
+ sfxEmitter.emit(event)
49
+ }
@@ -0,0 +1,60 @@
1
+ import { Howl } from 'howler'
2
+ import useAudio from '../store/use-audio'
3
+
4
+ // SFX sound definitions
5
+ export const SFX = {
6
+ gridSnap: '/audios/sfx/grid_snap.mp3',
7
+ itemDelete: '/audios/sfx/item_delete.mp3',
8
+ itemPick: '/audios/sfx/item_pick.mp3',
9
+ itemPlace: '/audios/sfx/item_place.mp3',
10
+ itemRotate: '/audios/sfx/item_rotate.mp3',
11
+ structureBuild: '/audios/sfx/structure_build.mp3',
12
+ structureDelete: '/audios/sfx/structure_delete.mp3',
13
+ } as const
14
+
15
+ export type SFXName = keyof typeof SFX
16
+
17
+ // Preload all SFX sounds
18
+ const sfxCache = new Map<SFXName, Howl>()
19
+
20
+ // Initialize all sounds
21
+ Object.entries(SFX).forEach(([name, path]) => {
22
+ const sound = new Howl({
23
+ src: [path],
24
+ preload: true,
25
+ volume: 0.5, // Will be adjusted by the bus
26
+ })
27
+ sfxCache.set(name as SFXName, sound)
28
+ })
29
+
30
+ /**
31
+ * Play a sound effect with volume based on audio settings
32
+ */
33
+ export function playSFX(name: SFXName) {
34
+ const sound = sfxCache.get(name)
35
+ if (!sound) {
36
+ console.warn(`SFX not found: ${name}`)
37
+ return
38
+ }
39
+
40
+ const { masterVolume, sfxVolume, muted } = useAudio.getState()
41
+
42
+ if (muted) return
43
+
44
+ // Calculate final volume (masterVolume and sfxVolume are 0-100)
45
+ const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
46
+ sound.volume(finalVolume)
47
+ sound.play()
48
+ }
49
+
50
+ /**
51
+ * Update all cached SFX volumes (useful when settings change)
52
+ */
53
+ export function updateSFXVolumes() {
54
+ const { masterVolume, sfxVolume } = useAudio.getState()
55
+ const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
56
+
57
+ sfxCache.forEach((sound) => {
58
+ sound.volume(finalVolume)
59
+ })
60
+ }
@@ -0,0 +1,43 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
7
+
8
+ export const isDevelopment =
9
+ process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
10
+
11
+ export const isProduction =
12
+ process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
13
+
14
+ export const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
15
+
16
+ /**
17
+ * Base URL for the application
18
+ * Uses NEXT_PUBLIC_* variables which are available at build time
19
+ */
20
+ export const BASE_URL = (() => {
21
+ // Development: localhost
22
+ if (isDevelopment) {
23
+ return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
24
+ }
25
+
26
+ // Preview deployments: use Vercel branch URL
27
+ if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
28
+ return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
29
+ }
30
+
31
+ // Production: use custom domain or Vercel production URL
32
+ if (isProduction) {
33
+ return (
34
+ process.env.NEXT_PUBLIC_APP_URL ||
35
+ (process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
36
+ ? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
37
+ : 'https://editor.pascal.app')
38
+ )
39
+ }
40
+
41
+ // Fallback (should never reach here in normal operation)
42
+ return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
43
+ })()
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import { create } from 'zustand'
4
+ import { persist } from 'zustand/middleware'
5
+
6
+ interface AudioState {
7
+ masterVolume: number
8
+ sfxVolume: number
9
+ radioVolume: number
10
+ isRadioPlaying: boolean
11
+ muted: boolean
12
+ autoplay: boolean
13
+ setMasterVolume: (v: number) => void
14
+ setSfxVolume: (v: number) => void
15
+ setRadioVolume: (v: number) => void
16
+ setRadioPlaying: (v: boolean) => void
17
+ toggleRadioPlaying: () => void
18
+ toggleMute: () => void
19
+ setAutoplay: (v: boolean) => void
20
+ }
21
+
22
+ const useAudio = create<AudioState>()(
23
+ persist(
24
+ (set) => ({
25
+ masterVolume: 70,
26
+ sfxVolume: 50,
27
+ radioVolume: 25,
28
+ isRadioPlaying: false,
29
+ muted: false,
30
+ autoplay: true,
31
+ setMasterVolume: (v) => set({ masterVolume: v }),
32
+ setSfxVolume: (v) => set({ sfxVolume: v }),
33
+ setRadioVolume: (v) => set({ radioVolume: v }),
34
+ setRadioPlaying: (v) => set({ isRadioPlaying: v }),
35
+ toggleRadioPlaying: () => set((state) => ({ isRadioPlaying: !state.isRadioPlaying })),
36
+ toggleMute: () => set((state) => ({ muted: !state.muted })),
37
+ setAutoplay: (v) => set({ autoplay: v }),
38
+ }),
39
+ {
40
+ name: 'pascal-audio-settings',
41
+ },
42
+ ),
43
+ )
44
+
45
+ export default useAudio
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from 'react'
2
+ import { create } from 'zustand'
3
+
4
+ export type CommandAction = {
5
+ id: string
6
+ /** Static string or a function evaluated at render time (for reactive labels). */
7
+ label: string | (() => string)
8
+ group: string
9
+ icon?: ReactNode
10
+ keywords?: string[]
11
+ shortcut?: string[]
12
+ /** Static string or a function evaluated at render time (for reactive badges). */
13
+ badge?: string | (() => string)
14
+ /** Show a chevron to indicate this action navigates to a sub-page. */
15
+ navigate?: boolean
16
+ /** Called at render time — returning false disables the item. */
17
+ when?: () => boolean
18
+ execute: () => void
19
+ }
20
+
21
+ interface CommandRegistryStore {
22
+ actions: CommandAction[]
23
+ /** Register actions and return an unsubscribe function. */
24
+ register: (actions: CommandAction[]) => () => void
25
+ }
26
+
27
+ export const useCommandRegistry = create<CommandRegistryStore>((set) => ({
28
+ actions: [],
29
+ register: (newActions) => {
30
+ const ids = newActions.map((a) => a.id)
31
+ set((s) => ({
32
+ actions: [...s.actions.filter((a) => !ids.includes(a.id)), ...newActions],
33
+ }))
34
+ return () => set((s) => ({ actions: s.actions.filter((a) => !ids.includes(a.id)) }))
35
+ },
36
+ }))