'use client' import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor, { hasCustomPersistedEditorUiState, normalizePersistedEditorUiState, type PersistedEditorUiState, } from '../store/use-editor' export type SceneGraph = { nodes: Record rootNodeIds: string[] } type PersistedSelectionPath = { buildingId: string | null levelId: string | null zoneId: string | null selectedIds: string[] } const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = { buildingId: null, levelId: null, zoneId: null, selectedIds: [], } const SELECTION_STORAGE_KEY = 'pascal-editor-selection' function getSelectionStorageKey(): string { const projectId = useViewer.getState().projectId return projectId ? `${SELECTION_STORAGE_KEY}:${projectId}` : SELECTION_STORAGE_KEY } function getSelectionStorageReadKeys(): string[] { const scopedKey = getSelectionStorageKey() return scopedKey === SELECTION_STORAGE_KEY ? [scopedKey] : [scopedKey, SELECTION_STORAGE_KEY] } function getDefaultLevelIdForBuilding( sceneNodes: Record, buildingId: string | null, ): string | null { if (!buildingId) { return null } const buildingNode = sceneNodes[buildingId] if (buildingNode?.type !== 'building' || !Array.isArray(buildingNode.children)) { return null } let firstLevelId: string | null = null for (const childId of buildingNode.children) { const levelNode = sceneNodes[childId] if (levelNode?.type !== 'level') { continue } firstLevelId ??= levelNode.id if (levelNode.level === 0) { return levelNode.id } } return firstLevelId } function normalizePersistedSelectionPath( selection: Partial | null | undefined, ): PersistedSelectionPath { return { buildingId: typeof selection?.buildingId === 'string' ? selection.buildingId : null, levelId: typeof selection?.levelId === 'string' ? selection.levelId : null, zoneId: typeof selection?.zoneId === 'string' ? selection.zoneId : null, selectedIds: Array.isArray(selection?.selectedIds) ? selection.selectedIds.filter((id): id is string => typeof id === 'string') : [], } } function hasPersistedSelectionValue(selection: PersistedSelectionPath): boolean { return Boolean( selection.buildingId || selection.levelId || selection.zoneId || selection.selectedIds.length > 0, ) } function readPersistedSelection(): PersistedSelectionPath | null { if (typeof window === 'undefined') { return null } try { for (const key of getSelectionStorageReadKeys()) { const rawSelection = window.localStorage.getItem(key) if (!rawSelection) { continue } return normalizePersistedSelectionPath( JSON.parse(rawSelection) as Partial, ) } } catch { return null } return null } export function writePersistedSelection(selection: { buildingId: string | null levelId: string | null zoneId: string | null selectedIds: string[] }) { if (typeof window === 'undefined') { return } try { const sceneNodes = useScene.getState().nodes as Record const normalizedSelection = normalizePersistedSelectionPath(selection) const validatedSelection = getValidatedSelectionForScene(sceneNodes, normalizedSelection) ?? normalizedSelection window.localStorage.setItem(getSelectionStorageKey(), JSON.stringify(validatedSelection)) } catch { // Swallow storage quota errors } } function getEditorUiStateForRestoredSelection( sceneNodes: Record, selection: PersistedSelectionPath, fallbackUiState: PersistedEditorUiState, ): PersistedEditorUiState { if (!selection.levelId) { return { ...fallbackUiState, phase: 'site', mode: fallbackUiState.phase === 'site' ? fallbackUiState.mode : 'select', tool: null, structureLayer: 'elements', catalogCategory: null, } } if (selection.zoneId) { return { ...fallbackUiState, phase: 'structure', mode: 'select', tool: null, structureLayer: 'zones', catalogCategory: null, } } const selectedNodes = selection.selectedIds .map((id) => sceneNodes[id]) .filter((node): node is Record => Boolean(node)) const shouldRestoreFurnishPhase = selectedNodes.length > 0 && selectedNodes.every( (node) => node.type === 'item' && node.asset?.category !== 'door' && node.asset?.category !== 'window', ) return { ...fallbackUiState, phase: shouldRestoreFurnishPhase ? 'furnish' : 'structure', mode: 'select', tool: null, structureLayer: 'elements', catalogCategory: null, } } function getValidatedSelectionForScene( sceneNodes: Record, selection: PersistedSelectionPath, ): PersistedSelectionPath | null { const levelNode = selection.levelId ? sceneNodes[selection.levelId] : null const hasValidLevel = levelNode?.type === 'level' const buildingNodeFromLevel = hasValidLevel && levelNode.parentId ? sceneNodes[levelNode.parentId] : null const explicitBuildingNode = selection.buildingId ? sceneNodes[selection.buildingId] : null const buildingId = buildingNodeFromLevel?.type === 'building' ? buildingNodeFromLevel.id : explicitBuildingNode?.type === 'building' ? explicitBuildingNode.id : null if (!buildingId) { return null } const levelId = hasValidLevel ? levelNode.id : getDefaultLevelIdForBuilding(sceneNodes, buildingId) if (levelId) { const zoneNode = selection.zoneId ? sceneNodes[selection.zoneId] : null const zoneId = zoneNode?.type === 'zone' && resolveLevelId(zoneNode, sceneNodes) === levelId ? zoneNode.id : null const selectedIds = selection.selectedIds.filter((id) => { const node = sceneNodes[id] if (!node) return false if (resolveLevelId(node, sceneNodes) === levelId) return true const def = nodeRegistry.get(node.type) return def?.floorplanScope === 'building' && node.parentId === buildingId }) return { buildingId, levelId, zoneId, selectedIds, } } return { ...EMPTY_PERSISTED_SELECTION, buildingId, } } function getRestoredSelectionForScene( sceneNodes: Record, ): PersistedSelectionPath | null { const persistedSelection = readPersistedSelection() if (!(persistedSelection && hasPersistedSelectionValue(persistedSelection))) { return null } return getValidatedSelectionForScene(sceneNodes, persistedSelection) } export function syncEditorSelectionFromCurrentScene() { const sceneNodes = useScene.getState().nodes as Record const sceneRootIds = useScene.getState().rootNodeIds const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child) const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building') const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level') const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState()) const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState) const restoredSelection = getRestoredSelectionForScene(sceneNodes) const selectionDrivenEditorUiState = restoredSelection ? getEditorUiStateForRestoredSelection(sceneNodes, restoredSelection, restoredEditorUiState) : null if (firstBuilding && firstLevel) { const isEmptyLevel = !firstLevel.children || firstLevel.children.length === 0 // For empty projects (new/blank), always start in structure/build/wall // regardless of persisted state from a previous project if (isEmptyLevel) { useViewer.getState().setSelection({ buildingId: firstBuilding.id, levelId: firstLevel.id, selectedIds: [], zoneId: null, }) useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('elements') useEditor.getState().setMode('build') useEditor.getState().setTool('wall') return } if (shouldRestoreEditorUiState) { if (restoredSelection) { // PersistedSelectionPath carries plain `string` ids (read from // localStorage, no branded-template-literal guarantee). The viewer's // SelectionPath expects branded ids. The runtime values match the // brand; the cast bridges the static gap. useViewer.getState().setSelection(restoredSelection as never) useEditor.setState( restoredEditorUiState.phase === 'site' ? (selectionDrivenEditorUiState ?? restoredEditorUiState) : restoredEditorUiState, ) } else if (restoredEditorUiState.phase === 'site') { useViewer.getState().resetSelection() useEditor.setState(restoredEditorUiState) } else { useViewer.getState().setSelection({ buildingId: firstBuilding.id, levelId: firstLevel.id, selectedIds: [], zoneId: null, }) useEditor.setState(restoredEditorUiState) } return } if (restoredSelection) { useViewer.getState().setSelection(restoredSelection as never) if (selectionDrivenEditorUiState) { useEditor.setState(selectionDrivenEditorUiState) } return } useViewer.getState().setSelection({ buildingId: firstBuilding.id, levelId: firstLevel.id, selectedIds: [], zoneId: null, }) useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('elements') } else { useEditor.getState().setPhase('site') useViewer.getState().setSelection({ buildingId: null, levelId: null, selectedIds: [], zoneId: null, }) } } function resetEditorInteractionState() { useViewer.getState().setHoveredId(null) useViewer.getState().resetSelection() // Clear outliner arrays synchronously so stale Object3D refs from the old // scene don't leak into the post-processing pipeline's outline passes. const outliner = useViewer.getState().outliner outliner.selectedObjects.length = 0 outliner.hoveredObjects.length = 0 sceneRegistry.clear() useEditor.setState({ phase: 'site', mode: 'select', tool: null, structureLayer: 'elements', catalogCategory: null, selectedItem: null, movingNode: null, selectedReferenceId: null, spaces: {}, editingHole: null, hoveredHole: null, isPreviewMode: false, }) } function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is SceneGraph { return ( !!sceneGraph && Object.keys(sceneGraph.nodes ?? {}).length > 0 && (sceneGraph.rootNodeIds?.length ?? 0) > 0 ) } export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { if (hasUsableSceneGraph(sceneGraph)) { const { nodes, rootNodeIds } = sceneGraph useScene.getState().setScene(nodes as any, rootNodeIds as any) } else { useScene.getState().clearScene() } syncEditorSelectionFromCurrentScene() } const LOCAL_STORAGE_KEY = 'pascal-editor-scene' export function saveSceneToLocalStorage(scene: SceneGraph): void { try { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(scene)) } catch { // Swallow storage quota errors } } export function loadSceneFromLocalStorage(): SceneGraph | null { try { const raw = localStorage.getItem(LOCAL_STORAGE_KEY) return raw ? (JSON.parse(raw) as SceneGraph) : null } catch { return null } }