import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import type { CameraPresetId, CameraSnapshot, CrossSectionState, ExplodedViewState, MachineMetadata, Vec3, ViewerCameraRequest, ViewerDisplaySettings, ViewerHoveredPart, ViewerLoadingState, ViewerPart, ViewerPartRuntimeSettings, ViewerSceneBounds, ViewerUrlSnapshot } from '../types/viewer'; import { DEFAULT_CAMERA_SNAPSHOT, DEFAULT_SCENE_BOUNDS, coerceCameraSnapshot } from '../three/cameraPresets'; export const DEFAULT_EXPLODED_VIEW: ExplodedViewState = { enabled: false, distance: 0 }; export const DEFAULT_CROSS_SECTION: CrossSectionState = { enabled: false, axis: 'x', offset: 0, invert: false, showPlane: false }; export const DEFAULT_DISPLAY_SETTINGS: ViewerDisplaySettings = { wireframe: false, annotations: true, shadows: true, grid: true, ambientOcclusion: true, environment: true, exposure: 1, exploded: DEFAULT_EXPLODED_VIEW, crossSection: DEFAULT_CROSS_SECTION }; export const DEFAULT_LOADING_STATE: ViewerLoadingState = { phase: 'idle', progress: 0 }; export interface ViewerStoreState { activeMachineId: string | null; metadata: MachineMetadata | null; parts: ViewerPart[]; partSettings: Record; selectedPartId: string | null; hoveredPart: ViewerHoveredPart | null; detailDrawerOpen: boolean; display: ViewerDisplaySettings; camera: CameraSnapshot; sceneBounds: ViewerSceneBounds; cameraRequest: ViewerCameraRequest | null; loading: ViewerLoadingState; fps: number; pendingUrlSnapshot: ViewerUrlSnapshot | null; cameraRequestCounter: number; setActiveMachine: ( machineId: string, parts?: ViewerPart[], metadata?: MachineMetadata | null ) => void; registerParts: (parts: ViewerPart[]) => void; setSceneBounds: (bounds: ViewerSceneBounds) => void; setHoveredPart: (hoveredPart: ViewerHoveredPart | null) => void; selectPart: (partId: string | null) => void; clearSelection: () => void; setPartVisibility: (partId: string, visible: boolean) => void; togglePartVisibility: (partId: string) => void; setAllPartsVisibility: (visible: boolean) => void; setPartOpacity: (partId: string, opacity: number) => void; resetPartSettings: () => void; setExplodedView: (patch: Partial) => void; setWireframe: (wireframe: boolean) => void; setAnnotations: (annotations: boolean) => void; setGrid: (grid: boolean) => void; setShadows: (shadows: boolean) => void; setExposure: (exposure: number) => void; setCrossSection: (patch: Partial) => void; requestCameraPreset: (preset: CameraPresetId) => void; requestCameraReset: () => void; requestCameraSnapshot: (snapshot: CameraSnapshot) => void; setCameraSnapshot: (snapshot: CameraSnapshot) => void; setLoadingState: (patch: Partial) => void; setFps: (fps: number) => void; applyUrlSnapshot: (snapshot: ViewerUrlSnapshot) => void; resetViewerState: () => void; } export const useViewerStore = create()( subscribeWithSelector((set, get) => ({ activeMachineId: null, metadata: null, parts: [], partSettings: {}, selectedPartId: null, hoveredPart: null, detailDrawerOpen: false, display: DEFAULT_DISPLAY_SETTINGS, camera: DEFAULT_CAMERA_SNAPSHOT, sceneBounds: DEFAULT_SCENE_BOUNDS, cameraRequest: null, loading: DEFAULT_LOADING_STATE, fps: 0, pendingUrlSnapshot: null, cameraRequestCounter: 0, setActiveMachine: (machineId, parts = [], metadata = null) => { set((state) => { const initialSettings = createPartSettings(parts); const partSettings = applyPartOverrides(initialSettings, parts, state.pendingUrlSnapshot); const selectedPartId = resolveSelectedPartId(parts, state.pendingUrlSnapshot?.selectedPartId); return { activeMachineId: machineId, metadata, parts, partSettings, selectedPartId, hoveredPart: null, detailDrawerOpen: Boolean(selectedPartId), sceneBounds: metadata?.approximateBounds ?? DEFAULT_SCENE_BOUNDS, loading: { phase: parts.length > 0 ? 'ready' : 'idle', progress: parts.length > 0 ? 100 : 0, label: metadata?.title } }; }); }, registerParts: (parts) => { set((state) => { const initialSettings = createPartSettings(parts); const partSettings = applyPartOverrides(initialSettings, parts, state.pendingUrlSnapshot); const selectedPartId = resolveSelectedPartId(parts, state.pendingUrlSnapshot?.selectedPartId); return { parts, partSettings, selectedPartId, detailDrawerOpen: Boolean(selectedPartId) }; }); }, setSceneBounds: (bounds) => { set({ sceneBounds: { center: coerceVec3(bounds.center, DEFAULT_SCENE_BOUNDS.center), radius: Number.isFinite(bounds.radius) && bounds.radius > 0 ? bounds.radius : DEFAULT_SCENE_BOUNDS.radius, min: bounds.min, max: bounds.max } }); }, setHoveredPart: (hoveredPart) => set({ hoveredPart }), selectPart: (partId) => { const { parts } = get(); const exists = partId ? parts.some((part) => part.id === partId) : false; set({ selectedPartId: exists ? partId : null, detailDrawerOpen: exists }); }, clearSelection: () => set({ selectedPartId: null, detailDrawerOpen: false }), setPartVisibility: (partId, visible) => { set((state) => ({ partSettings: { ...state.partSettings, [partId]: { ...resolvePartSettings(state.partSettings[partId]), visible } } })); }, togglePartVisibility: (partId) => { set((state) => { const current = resolvePartSettings(state.partSettings[partId]); return { partSettings: { ...state.partSettings, [partId]: { ...current, visible: !current.visible } } }; }); }, setAllPartsVisibility: (visible) => { set((state) => ({ partSettings: Object.fromEntries( state.parts.map((part) => [ part.id, { ...resolvePartSettings(state.partSettings[part.id]), visible } ]) ) })); }, setPartOpacity: (partId, opacity) => { set((state) => ({ partSettings: { ...state.partSettings, [partId]: { ...resolvePartSettings(state.partSettings[partId]), opacity: clamp(opacity, 0.08, 1) } } })); }, resetPartSettings: () => { set((state) => ({ partSettings: createPartSettings(state.parts) })); }, setExplodedView: (patch) => { set((state) => ({ display: { ...state.display, exploded: { ...state.display.exploded, ...patch, distance: patch.distance === undefined ? state.display.exploded.distance : clamp(patch.distance, 0, 4) } } })); }, setWireframe: (wireframe) => { set((state) => ({ display: { ...state.display, wireframe } })); }, setAnnotations: (annotations) => { set((state) => ({ display: { ...state.display, annotations } })); }, setGrid: (grid) => { set((state) => ({ display: { ...state.display, grid } })); }, setShadows: (shadows) => { set((state) => ({ display: { ...state.display, shadows } })); }, setExposure: (exposure) => { set((state) => ({ display: { ...state.display, exposure: clamp(exposure, 0.35, 1.8) } })); }, setCrossSection: (patch) => { set((state) => ({ display: { ...state.display, crossSection: { ...state.display.crossSection, ...patch, axis: patch.axis ?? state.display.crossSection.axis, offset: patch.offset === undefined ? state.display.crossSection.offset : clamp(patch.offset, -3.5, 3.5) } } })); }, requestCameraPreset: (preset) => { set((state) => ({ cameraRequestCounter: state.cameraRequestCounter + 1, cameraRequest: { id: state.cameraRequestCounter + 1, type: 'preset', preset } })); }, requestCameraReset: () => { set((state) => ({ cameraRequestCounter: state.cameraRequestCounter + 1, cameraRequest: { id: state.cameraRequestCounter + 1, type: 'reset' } })); }, requestCameraSnapshot: (snapshot) => { set((state) => ({ cameraRequestCounter: state.cameraRequestCounter + 1, cameraRequest: { id: state.cameraRequestCounter + 1, type: 'snapshot', snapshot: coerceCameraSnapshot(snapshot) } })); }, setCameraSnapshot: (snapshot) => { set({ camera: coerceCameraSnapshot(snapshot, get().camera) }); }, setLoadingState: (patch) => { set((state) => ({ loading: { ...state.loading, ...patch, progress: patch.progress === undefined ? state.loading.progress : clamp(Math.round(patch.progress), 0, 100) } })); }, setFps: (fps) => set({ fps: Math.max(0, Math.round(fps)) }), applyUrlSnapshot: (snapshot) => { set((state) => { const display = applyDisplayUrlSnapshot(state.display, snapshot); const camera = snapshot.camera ? coerceCameraSnapshot(snapshot.camera, state.camera) : state.camera; const partSettings = state.parts.length > 0 ? applyPartOverrides(state.partSettings, state.parts, snapshot) : state.partSettings; const selectedPartId = state.parts.length > 0 ? resolveSelectedPartId(state.parts, snapshot.selectedPartId) ?? state.selectedPartId : state.selectedPartId; return { pendingUrlSnapshot: { ...state.pendingUrlSnapshot, ...snapshot }, activeMachineId: snapshot.machineId ?? state.activeMachineId, display, camera, partSettings, selectedPartId, detailDrawerOpen: Boolean(selectedPartId) }; }); }, resetViewerState: () => { set({ selectedPartId: null, hoveredPart: null, detailDrawerOpen: false, display: DEFAULT_DISPLAY_SETTINGS, camera: DEFAULT_CAMERA_SNAPSHOT, cameraRequest: null, loading: DEFAULT_LOADING_STATE, fps: 0, pendingUrlSnapshot: null, partSettings: createPartSettings(get().parts) }); } })) ); export function selectSelectedPart(state: ViewerStoreState): ViewerPart | null { return state.selectedPartId ? state.parts.find((part) => part.id === state.selectedPartId) ?? null : null; } export function selectHoveredPart(state: ViewerStoreState): ViewerPart | null { return state.hoveredPart?.partId ? state.parts.find((part) => part.id === state.hoveredPart?.partId) ?? null : null; } export function selectViewerUrlSnapshot(state: ViewerStoreState): ViewerUrlSnapshot { const hiddenPartIds = state.parts .filter((part) => resolvePartSettings(state.partSettings[part.id]).visible === false) .map((part) => part.id); const partOpacity = Object.fromEntries( state.parts .map((part) => [part.id, resolvePartSettings(state.partSettings[part.id]).opacity] as const) .filter(([, opacity]) => Math.abs(opacity - 1) > 0.005) ); return { machineId: state.activeMachineId ?? undefined, camera: state.camera, selectedPartId: state.selectedPartId ?? undefined, exploded: state.display.exploded.enabled ? state.display.exploded : undefined, wireframe: state.display.wireframe || undefined, annotations: state.display.annotations === false ? false : undefined, crossSection: state.display.crossSection.enabled ? state.display.crossSection : undefined, hiddenPartIds: hiddenPartIds.length > 0 ? hiddenPartIds : undefined, partOpacity: Object.keys(partOpacity).length > 0 ? partOpacity : undefined }; } function createPartSettings(parts: ViewerPart[]): Record { return Object.fromEntries( parts.map((part) => [ part.id, { visible: part.defaultVisible ?? true, opacity: clamp(part.defaultOpacity ?? 1, 0.08, 1) } ]) ); } function applyPartOverrides( settings: Record, parts: ViewerPart[], snapshot: ViewerUrlSnapshot | null | undefined ): Record { if (!snapshot) { return settings; } const hiddenPartIds = new Set(snapshot.hiddenPartIds ?? []); const partIds = new Set(parts.map((part) => part.id)); const next = { ...settings }; if (snapshot.hiddenPartIds) { for (const part of parts) { next[part.id] = { ...resolvePartSettings(next[part.id]), visible: !hiddenPartIds.has(part.id) }; } } if (snapshot.partOpacity) { for (const [partId, opacity] of Object.entries(snapshot.partOpacity)) { if (partIds.has(partId)) { next[partId] = { ...resolvePartSettings(next[partId]), opacity: clamp(opacity, 0.08, 1) }; } } } return next; } function applyDisplayUrlSnapshot( current: ViewerDisplaySettings, snapshot: ViewerUrlSnapshot ): ViewerDisplaySettings { return { ...current, wireframe: snapshot.wireframe ?? current.wireframe, annotations: snapshot.annotations ?? current.annotations, exploded: { ...current.exploded, ...(snapshot.exploded ?? {}), distance: snapshot.exploded?.distance === undefined ? current.exploded.distance : clamp(snapshot.exploded.distance, 0, 4) }, crossSection: { ...current.crossSection, ...(snapshot.crossSection ?? {}), offset: snapshot.crossSection?.offset === undefined ? current.crossSection.offset : clamp(snapshot.crossSection.offset, -3.5, 3.5) } }; } function resolveSelectedPartId(parts: ViewerPart[], partId: string | undefined | null): string | null { if (!partId) { return null; } return parts.some((part) => part.id === partId) ? partId : null; } function resolvePartSettings( settings: ViewerPartRuntimeSettings | undefined ): ViewerPartRuntimeSettings { return { visible: settings?.visible ?? true, opacity: clamp(settings?.opacity ?? 1, 0.08, 1) }; } function coerceVec3(value: Vec3 | undefined, fallback: Vec3): Vec3 { if (!value || value.length !== 3) { return fallback; } const [x, y, z] = value.map((entry) => Number(entry)); return Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) ? [x, y, z] : fallback; } function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) { return min; } return Math.min(max, Math.max(min, value)); }