import { create } from "zustand"; import { type CrossSectionAxis, type ProductionShareState, type Vector3Tuple, } from "../utils/productionShareState"; export const cameraPresetOrder = ["front", "back", "left", "right", "top", "isometric"] as const; export type CameraPreset = (typeof cameraPresetOrder)[number]; export interface CameraPose { position: Vector3Tuple; target: Vector3Tuple; } export const cameraPresets: Record = { front: { position: [0, 0.8, 7.2], target: [0, 0.25, 0] }, back: { position: [0, 0.8, -7.2], target: [0, 0.25, 0] }, left: { position: [-7.2, 0.8, 0], target: [0, 0.25, 0] }, right: { position: [7.2, 0.8, 0], target: [0, 0.25, 0] }, top: { position: [0, 7.8, 0.001], target: [0, 0, 0] }, isometric: { position: [6.2, 4.2, 6.2], target: [0, 0.25, 0] }, }; export interface PlaybackState { isPlaying: boolean; rpm: number; timeScale: number; phaseOffset: number; restartNonce: number; } export interface ProductionViewerState { initialized: boolean; machineId: string; partIds: string[]; selectedPartId: string | null; hoveredPartId: string | null; hiddenPartIds: string[]; partOpacities: Record; explodeDistance: number; wireframe: boolean; annotations: boolean; crossSectionAxis: CrossSectionAxis; crossSectionOffset: number; cameraPreset: CameraPreset; cameraPosition: Vector3Tuple; cameraTarget: Vector3Tuple; playback: PlaybackState; tourStepIndex: number | null; } export interface ProductionViewerActions { initializeMachine: ( machineId: string, partIds: readonly string[], shareState?: Partial, ) => void; selectPart: (partId: string | null) => void; setHoveredPart: (partId: string | null) => void; togglePartVisibility: (partId: string) => void; setPartVisibility: (partId: string, visible: boolean) => void; setPartOpacity: (partId: string, opacity: number) => void; setExplodeDistance: (distance: number) => void; toggleExploded: () => void; setWireframe: (wireframe: boolean) => void; toggleWireframe: () => void; setAnnotations: (annotations: boolean) => void; toggleAnnotations: () => void; setCrossSectionAxis: (axis: CrossSectionAxis) => void; setCrossSectionOffset: (offset: number) => void; setCameraPreset: (preset: CameraPreset) => void; setCameraPose: (position: Vector3Tuple, target?: Vector3Tuple) => void; setPlaying: (isPlaying: boolean) => void; togglePlayback: () => void; setRpm: (rpm: number) => void; setTimeScale: (timeScale: number) => void; restartCycle: () => void; stepCycle: () => void; resetView: (partIds?: readonly string[]) => void; setTourStepIndex: (index: number | null) => void; } export type ProductionViewerStore = ProductionViewerState & ProductionViewerActions; const DEFAULT_PLAYBACK: PlaybackState = { isPlaying: true, rpm: 900, timeScale: 1, phaseOffset: 0, restartNonce: 0, }; function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) { return min; } return Math.min(max, Math.max(min, value)); } function uniqueKnownPartIds(partIds: readonly string[], knownPartIds: readonly string[]): string[] { const known = new Set(knownPartIds); return [...new Set(partIds)].filter((partId) => known.has(partId)); } function buildDefaultOpacities( partIds: readonly string[], sharedOpacities?: Record, ): Record { return partIds.reduce>((opacities, partId) => { opacities[partId] = clampNumber(sharedOpacities?.[partId] ?? 1, 0.08, 1); return opacities; }, {}); } export function isCameraPreset(value: unknown): value is CameraPreset { return typeof value === "string" && cameraPresetOrder.includes(value as CameraPreset); } function poseForPreset(preset: CameraPreset): CameraPose { return cameraPresets[preset] ?? cameraPresets.isometric; } export const useViewerStore = create((set, get) => ({ initialized: false, machineId: "", partIds: [], selectedPartId: null, hoveredPartId: null, hiddenPartIds: [], partOpacities: {}, explodeDistance: 0, wireframe: false, annotations: true, crossSectionAxis: "none", crossSectionOffset: 0, cameraPreset: "isometric", cameraPosition: cameraPresets.isometric.position, cameraTarget: cameraPresets.isometric.target, playback: DEFAULT_PLAYBACK, tourStepIndex: null, initializeMachine: (machineId, partIds, shareState) => { const knownPartIds = [...partIds]; const cameraPreset = isCameraPreset(shareState?.cameraPreset) ? shareState.cameraPreset : "isometric"; const presetPose = poseForPreset(cameraPreset); const sharedHiddenPartIds = shareState?.hiddenPartIds ?? []; const selectedPartId = shareState?.selectedPartId && knownPartIds.includes(shareState.selectedPartId) ? shareState.selectedPartId : null; set({ initialized: true, machineId, partIds: knownPartIds, selectedPartId, hoveredPartId: null, hiddenPartIds: uniqueKnownPartIds(sharedHiddenPartIds, knownPartIds), partOpacities: buildDefaultOpacities(knownPartIds, shareState?.partOpacities), explodeDistance: clampNumber(shareState?.explodeDistance ?? 0, 0, 6), wireframe: shareState?.wireframe ?? false, annotations: shareState?.annotations ?? true, crossSectionAxis: shareState?.crossSectionAxis ?? "none", crossSectionOffset: clampNumber(shareState?.crossSectionOffset ?? 0, -3, 3), cameraPreset, cameraPosition: shareState?.cameraPosition ?? presetPose.position, cameraTarget: shareState?.cameraTarget ?? presetPose.target, playback: { ...DEFAULT_PLAYBACK, isPlaying: shareState?.playing ?? DEFAULT_PLAYBACK.isPlaying, rpm: clampNumber(shareState?.rpm ?? DEFAULT_PLAYBACK.rpm, 1, 12000), timeScale: clampNumber(shareState?.timeScale ?? DEFAULT_PLAYBACK.timeScale, 0.1, 3), }, tourStepIndex: null, }); }, selectPart: (partId) => { const knownPartIds = get().partIds; set({ selectedPartId: partId && knownPartIds.includes(partId) ? partId : null }); }, setHoveredPart: (partId) => { const knownPartIds = get().partIds; set({ hoveredPartId: partId && knownPartIds.includes(partId) ? partId : null }); }, togglePartVisibility: (partId) => { set((state) => { const hidden = new Set(state.hiddenPartIds); if (hidden.has(partId)) { hidden.delete(partId); } else { hidden.add(partId); } return { hiddenPartIds: [...hidden] }; }); }, setPartVisibility: (partId, visible) => { set((state) => { const hidden = new Set(state.hiddenPartIds); if (visible) { hidden.delete(partId); } else { hidden.add(partId); } return { hiddenPartIds: [...hidden] }; }); }, setPartOpacity: (partId, opacity) => { set((state) => ({ partOpacities: { ...state.partOpacities, [partId]: clampNumber(opacity, 0.08, 1), }, })); }, setExplodeDistance: (distance) => set({ explodeDistance: clampNumber(distance, 0, 6) }), toggleExploded: () => { set((state) => ({ explodeDistance: state.explodeDistance > 0.05 ? 0 : 1.8 })); }, setWireframe: (wireframe) => set({ wireframe }), toggleWireframe: () => set((state) => ({ wireframe: !state.wireframe })), setAnnotations: (annotations) => set({ annotations }), toggleAnnotations: () => set((state) => ({ annotations: !state.annotations })), setCrossSectionAxis: (axis) => set({ crossSectionAxis: axis, crossSectionOffset: axis === "none" ? 0 : get().crossSectionOffset }), setCrossSectionOffset: (offset) => set({ crossSectionOffset: clampNumber(offset, -3, 3) }), setCameraPreset: (preset) => { const pose = poseForPreset(preset); set({ cameraPreset: preset, cameraPosition: pose.position, cameraTarget: pose.target, }); }, setCameraPose: (position, target) => { set((state) => ({ cameraPosition: position, cameraTarget: target ?? state.cameraTarget, })); }, setPlaying: (isPlaying) => set((state) => ({ playback: { ...state.playback, isPlaying } })), togglePlayback: () => set((state) => ({ playback: { ...state.playback, isPlaying: !state.playback.isPlaying }, })), setRpm: (rpm) => set((state) => ({ playback: { ...state.playback, rpm: clampNumber(rpm, 1, 12000) }, })), setTimeScale: (timeScale) => set((state) => ({ playback: { ...state.playback, timeScale: clampNumber(timeScale, 0.1, 3) }, })), restartCycle: () => set((state) => ({ playback: { ...state.playback, phaseOffset: 0, isPlaying: true, restartNonce: state.playback.restartNonce + 1, }, })), stepCycle: () => set((state) => ({ playback: { ...state.playback, isPlaying: false, phaseOffset: state.playback.phaseOffset + Math.PI / 8, }, })), resetView: (partIds) => { const knownPartIds = [...(partIds ?? get().partIds)]; const pose = cameraPresets.isometric; set({ selectedPartId: null, hoveredPartId: null, hiddenPartIds: [], partOpacities: buildDefaultOpacities(knownPartIds), explodeDistance: 0, wireframe: false, annotations: true, crossSectionAxis: "none", crossSectionOffset: 0, cameraPreset: "isometric", cameraPosition: pose.position, cameraTarget: pose.target, playback: { ...DEFAULT_PLAYBACK, restartNonce: get().playback.restartNonce + 1, }, tourStepIndex: null, }); }, setTourStepIndex: (index) => set({ tourStepIndex: index }), })); export function buildViewerShareState(state: ProductionViewerState): ProductionShareState { return { machineId: state.machineId, cameraPosition: state.cameraPosition, cameraTarget: state.cameraTarget, cameraPreset: state.cameraPreset, explodeDistance: state.explodeDistance, hiddenPartIds: state.hiddenPartIds, partOpacities: state.partOpacities, selectedPartId: state.selectedPartId, wireframe: state.wireframe, annotations: state.annotations, crossSectionAxis: state.crossSectionAxis, crossSectionOffset: state.crossSectionOffset, rpm: state.playback.rpm, timeScale: state.playback.timeScale, playing: state.playback.isPlaying, }; }