diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 88e96497..6433940a 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -72,6 +72,7 @@ const PAINT_CURSOR_BADGE_COLOR = '#818cf8' const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8' const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 +const SCENE_READY_FALLBACK_MS = 8000 const EDITOR_HOVER_STYLES: HoverStyles = { default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true }, delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false }, @@ -1059,6 +1060,19 @@ export default function Editor({ setIsViewerSceneReady(ready) }, []) + useEffect(() => { + if (isLoading || isSceneLoading || !hasLoadedInitialScene || isViewerSceneReady) return + + const timer = window.setTimeout(() => { + console.warn('[editor] viewer scene readiness timed out; showing editor shell anyway', { + sceneReadyKey, + }) + setIsViewerSceneReady(true) + }, SCENE_READY_FALLBACK_MS) + + return () => window.clearTimeout(timer) + }, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey]) + const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 9feadd5f..090971fa 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -71,7 +71,7 @@ export function resolveSurfaceColor( // The active scene theme may tint individual roles (e.g. Mediterranean's blue // roof); fall back to the chosen colour preset's palette when it doesn't. const tints = sceneThemeId ? getSceneTheme(sceneThemeId).clayTints : undefined - return tints?.[role] ?? PRESET_PALETTES[preset][role] + return tints?.[role] ?? (PRESET_PALETTES[preset] ?? CLAY_PALETTE)[role] } // DoubleSide on any NodeMaterial inside the MRT scenePass (SSGI's output / diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index c1da8e44..11cc67e3 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -7,6 +7,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { EdgeMode } from '../lib/edge-style' import type { ColorPreset, RenderShading } from '../lib/materials' +import { SCENE_THEME_IDS } from '../lib/scene-themes' export type RenderContext = 'editor' | 'viewer' @@ -114,6 +115,85 @@ type ViewerState = { setInputDragging: (dragging: boolean) => void } +type PersistedViewerState = Partial< + Pick< + ViewerState, + | 'cameraMode' + | 'sceneTheme' + | 'shadingByContext' + | 'textures' + | 'colorPreset' + | 'edges' + | 'shadows' + | 'unit' + | 'levelMode' + | 'wallMode' + | 'projectPreferences' + > +> + +const CAMERA_MODES = ['perspective', 'orthographic'] as const +const RENDER_SHADINGS = ['solid', 'rendered'] as const +const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const +const EDGE_MODES = ['off', 'soft', 'strong'] as const +const UNITS = ['metric', 'imperial'] as const +const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const +const WALL_MODES = ['up', 'cutaway', 'down'] as const + +function pickString(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback +} + +function normalizeShadingByContext(value: unknown): ViewerState['shadingByContext'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + + const next: ViewerState['shadingByContext'] = {} + for (const [context, shading] of Object.entries(value)) { + if (context !== 'editor' && context !== 'viewer') continue + next[context] = pickString(shading, RENDER_SHADINGS, 'rendered') + } + return next +} + +function normalizeProjectPreferences(value: unknown): ViewerState['projectPreferences'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + + const next: ViewerState['projectPreferences'] = {} + for (const [projectId, preferences] of Object.entries(value)) { + if (!preferences || typeof preferences !== 'object' || Array.isArray(preferences)) continue + const record = preferences as Record + next[projectId] = { + ...(typeof record.showScans === 'boolean' ? { showScans: record.showScans } : {}), + ...(typeof record.showGuides === 'boolean' ? { showGuides: record.showGuides } : {}), + ...(typeof record.showGrid === 'boolean' ? { showGrid: record.showGrid } : {}), + } + } + return next +} + +function normalizePersistedViewerState(value: unknown): PersistedViewerState { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const state = value as Record + + return { + cameraMode: pickString( + state.cameraMode, + CAMERA_MODES, + 'perspective', + ), + sceneTheme: pickString(state.sceneTheme, SCENE_THEME_IDS, 'studio'), + shadingByContext: normalizeShadingByContext(state.shadingByContext), + textures: typeof state.textures === 'boolean' ? state.textures : true, + colorPreset: pickString(state.colorPreset, COLOR_PRESETS, 'clay'), + edges: pickString(state.edges, EDGE_MODES, 'soft'), + shadows: typeof state.shadows === 'boolean' ? state.shadows : true, + unit: pickString(state.unit, UNITS, 'metric'), + levelMode: pickString(state.levelMode, LEVEL_MODES, 'stacked'), + wallMode: pickString(state.wallMode, WALL_MODES, 'up'), + projectPreferences: normalizeProjectPreferences(state.projectPreferences), + } +} + const useViewer = create()( persist( (set) => ({ @@ -267,6 +347,10 @@ const useViewer = create()( }), { name: 'viewer-preferences', + merge: (persistedState, currentState) => ({ + ...currentState, + ...normalizePersistedViewerState(persistedState), + }), partialize: (state) => ({ cameraMode: state.cameraMode, sceneTheme: state.sceneTheme,