@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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
package/src/lib/scene.ts
ADDED
|
@@ -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,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
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -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
|
+
}))
|