import { gsap } from 'gsap'; import type { Material, Mesh, Object3D } from 'three'; import { TAU, clamp, positiveModulo, } from './mechanics'; import type { MachineAnimationContext, MachineAnimationControls, MachineAnimationDirection, MachineAnimationEvent, MachineAnimationEventSubscriber, MachineAnimationModule, MachineAnimationPlayerApi, MachineAnimationPlayerOptions, MachineAnimationSnapshot, MachineAnimationSubscriber, MachineAnimationTickSource, MachineCameraRequest, ReducedMotionStrategy, } from './types'; const DEFAULT_MIN_RPM = 1; const DEFAULT_MAX_RPM = 3000; const DEFAULT_RPM = 60; const DEFAULT_STEP_COUNT = 24; const MAX_FRAME_DELTA_SECONDS = 0.08; function nowSeconds(): number { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now() / 1000; } return Date.now() / 1000; } function materialList(material: Material | Material[] | undefined): Material[] { if (!material) return []; return Array.isArray(material) ? material : [material]; } function traverseMaterials(object: Object3D, visitor: (material: Material) => void): void { object.traverse((child) => { const mesh = child as Mesh; if (!mesh.isMesh) return; materialList(mesh.material).forEach(visitor); }); } export class MachineAnimationPlayer implements MachineAnimationPlayerApi { private module: MachineAnimationModule | null = null; private readonly parts = new Map(); private readonly subscribers = new Set(); private readonly eventSubscribers = new Set(); private readonly highlights = new Map(); private readonly userData: Record = {}; private readonly ticker = () => this.onTicker(); private lastTickSeconds = nowSeconds(); private tickerActive = false; private snapshot: MachineAnimationSnapshot = { moduleId: null, machineId: null, status: 'idle', elapsedSeconds: 0, cycleElapsedSeconds: 0, cycleDurationSeconds: 1, cycleIndex: 0, cycleProgress: 0, rpm: DEFAULT_RPM, minRpm: DEFAULT_MIN_RPM, maxRpm: DEFAULT_MAX_RPM, timeScale: 1, loop: true, stepMode: false, stepIndex: 0, stepCount: DEFAULT_STEP_COUNT, reducedMotion: false, direction: 1, revolutionsElapsed: 0, shaftAngle: 0, cycleAngle: 0, highlightedPartIds: [], phaseLabel: null, }; constructor(options: MachineAnimationPlayerOptions = {}) { if (options.module !== undefined) { this.setModule(options.module); } if (typeof options.rpm === 'number') this.setRpm(options.rpm); if (typeof options.timeScale === 'number') this.setTimeScale(options.timeScale); if (typeof options.loop === 'boolean') this.setLoop(options.loop); if (typeof options.stepMode === 'boolean') this.setStepMode(options.stepMode); if (typeof options.reducedMotion === 'boolean') this.setReducedMotion(options.reducedMotion); if (typeof options.direction === 'number') this.setDirection(options.direction); if (options.autoPlay) this.play(); } setModule(module: MachineAnimationModule | null | undefined): void { const previousModule = this.module; if (previousModule) { previousModule.dispose?.(this.createContext(0, 'dispose')); this.emit({ type: 'disposed' }); } this.stopTicker(); this.module = module ?? null; this.highlights.clear(); this.userData.currentPhase = null; if (!this.module) { this.snapshot = { ...this.snapshot, moduleId: null, machineId: null, status: 'idle', elapsedSeconds: 0, cycleElapsedSeconds: 0, cycleDurationSeconds: 1, cycleIndex: 0, cycleProgress: 0, highlightedPartIds: [], phaseLabel: null, }; this.notify(); return; } const nextRpm = clamp( this.module.defaultRpm || DEFAULT_RPM, this.module.minRpm || DEFAULT_MIN_RPM, this.module.maxRpm || DEFAULT_MAX_RPM, ); this.snapshot = { ...this.snapshot, moduleId: this.module.id, machineId: this.module.machineId, status: 'idle', elapsedSeconds: 0, cycleElapsedSeconds: 0, cycleDurationSeconds: this.getCycleDurationSeconds(nextRpm), cycleIndex: 0, cycleProgress: 0, rpm: nextRpm, minRpm: this.module.minRpm || DEFAULT_MIN_RPM, maxRpm: this.module.maxRpm || DEFAULT_MAX_RPM, loop: this.module.loop ?? true, stepIndex: 0, stepCount: this.module.cycleSteps || DEFAULT_STEP_COUNT, revolutionsElapsed: 0, shaftAngle: 0, cycleAngle: 0, highlightedPartIds: [], phaseLabel: null, }; const context = this.createContext(0, 'initialize'); this.module.initialize?.(context); this.module.update(context); this.emit({ type: 'initialized' }); this.notify(); } registerPart(partId: string, object: Object3D | null): void { if (!object) { this.parts.delete(partId); return; } object.name = object.name || partId; object.userData.mechanicaPartId = partId; this.parts.set(partId, object); if (this.module) { this.module.update(this.createContext(0, 'manual')); } } getSnapshot(): MachineAnimationSnapshot { return { ...this.snapshot, highlightedPartIds: [...this.highlights.keys()] }; } subscribe(subscriber: MachineAnimationSubscriber): () => void { this.subscribers.add(subscriber); subscriber(this.getSnapshot()); return () => { this.subscribers.delete(subscriber); }; } subscribeToEvents(subscriber: MachineAnimationEventSubscriber): () => void { this.eventSubscribers.add(subscriber); return () => { this.eventSubscribers.delete(subscriber); }; } play(): void { if (!this.module) return; if (this.snapshot.status === 'playing') return; this.snapshot = { ...this.snapshot, status: 'playing' }; this.module.onPlay?.(this.createContext(0, 'manual')); this.emit({ type: 'played' }); this.notify(); if (!this.snapshot.stepMode && this.getReducedMotionStrategy() !== 'static-snapshot') { this.startTicker(); } } pause(): void { if (!this.module) return; if (this.snapshot.status !== 'playing') return; this.snapshot = { ...this.snapshot, status: 'paused' }; this.module.onPause?.(this.createContext(0, 'manual')); this.emit({ type: 'paused' }); this.notify(); this.stopTicker(); } resume(): void { if (!this.module) return; if (this.snapshot.status === 'playing') return; this.snapshot = { ...this.snapshot, status: 'playing' }; this.module.onResume?.(this.createContext(0, 'manual')); this.emit({ type: 'resumed' }); this.notify(); if (!this.snapshot.stepMode && this.getReducedMotionStrategy() !== 'static-snapshot') { this.startTicker(); } } restart(autoPlay = false): void { if (!this.module) return; this.stopTicker(); this.highlights.clear(); this.snapshot = { ...this.snapshot, status: autoPlay ? 'playing' : 'paused', elapsedSeconds: 0, cycleElapsedSeconds: 0, cycleIndex: 0, cycleProgress: 0, stepIndex: 0, revolutionsElapsed: 0, shaftAngle: 0, cycleAngle: 0, highlightedPartIds: [], phaseLabel: null, }; const context = this.createContext(0, 'restart'); this.module.onRestart?.(context); this.module.update(context); this.emit({ type: 'restarted' }); this.notify(); if (autoPlay && !this.snapshot.stepMode && this.getReducedMotionStrategy() !== 'static-snapshot') { this.startTicker(); } } stop(): void { this.stopTicker(); if (!this.module) { this.snapshot = { ...this.snapshot, status: 'idle' }; this.notify(); return; } this.snapshot = { ...this.snapshot, status: 'idle' }; this.notify(); } step(steps = 1): void { if (!this.module) return; const stepCount = Math.max(1, this.snapshot.stepCount); const secondsPerStep = this.snapshot.cycleDurationSeconds / stepCount; const delta = secondsPerStep * Math.max(1, Math.abs(Math.round(steps))) * this.snapshot.direction; this.stopTicker(); this.snapshot = { ...this.snapshot, status: 'paused', stepMode: true }; this.advance(delta, 'step', true); this.module.onStep?.(this.createContext(0, 'step')); this.emit({ type: 'stepped' }); } seekSeconds(seconds: number): void { if (!this.module) return; const cycleDuration = this.getCycleDurationSeconds(); const boundedSeconds = this.snapshot.loop ? positiveModulo(seconds, cycleDuration) : Math.max(0, seconds); this.snapshot = { ...this.snapshot, elapsedSeconds: boundedSeconds, }; this.recalculateDerivedTiming('seek'); this.module.update(this.createContext(0, 'seek')); this.notify(); } seekCycleProgress(progress: number): void { if (!this.module) return; const safeProgress = clamp(progress); const cycleBase = Math.floor(this.snapshot.elapsedSeconds / this.snapshot.cycleDurationSeconds) * this.snapshot.cycleDurationSeconds; this.seekSeconds(cycleBase + safeProgress * this.snapshot.cycleDurationSeconds); } setRpm(rpm: number): void { const min = this.module?.minRpm ?? this.snapshot.minRpm; const max = this.module?.maxRpm ?? this.snapshot.maxRpm; const nextRpm = clamp(Number.isFinite(rpm) ? rpm : DEFAULT_RPM, min, max); const previousProgress = this.snapshot.cycleProgress; const nextCycleDuration = this.getCycleDurationSeconds(nextRpm); const cycleBase = Math.floor(this.snapshot.elapsedSeconds / this.snapshot.cycleDurationSeconds) * nextCycleDuration; this.snapshot = { ...this.snapshot, rpm: nextRpm, cycleDurationSeconds: nextCycleDuration, elapsedSeconds: cycleBase + previousProgress * nextCycleDuration, }; this.recalculateDerivedTiming('manual'); this.notify(); } setTimeScale(timeScale: number): void { this.snapshot = { ...this.snapshot, timeScale: clamp(Number.isFinite(timeScale) ? timeScale : 1, 0.1, 3), }; this.notify(); } setLoop(loop: boolean): void { this.snapshot = { ...this.snapshot, loop }; this.notify(); } setStepMode(enabled: boolean): void { this.snapshot = { ...this.snapshot, stepMode: enabled }; if (enabled) { this.stopTicker(); this.snapshot = { ...this.snapshot, status: 'paused' }; } else if (this.snapshot.status === 'playing') { this.startTicker(); } this.notify(); } setReducedMotion(enabled: boolean): void { this.snapshot = { ...this.snapshot, reducedMotion: enabled }; const strategy = this.getReducedMotionStrategy(); if (enabled && strategy === 'static-snapshot') { this.stopTicker(); this.module?.update(this.createContext(0, 'manual')); } else if (this.snapshot.status === 'playing' && !this.snapshot.stepMode) { this.startTicker(); } this.notify(); } setDirection(direction: MachineAnimationDirection): void { if (direction === -1 && this.module && this.module.supportsReverse === false) return; this.snapshot = { ...this.snapshot, direction }; this.notify(); } dispose(): void { this.stopTicker(); if (this.module) { this.module.dispose?.(this.createContext(0, 'dispose')); this.emit({ type: 'disposed' }); } this.parts.clear(); this.highlights.clear(); this.subscribers.clear(); this.eventSubscribers.clear(); this.module = null; } private startTicker(): void { if (this.tickerActive) return; this.lastTickSeconds = nowSeconds(); gsap.ticker.add(this.ticker); this.tickerActive = true; } private stopTicker(): void { if (!this.tickerActive) return; gsap.ticker.remove(this.ticker); this.tickerActive = false; } private onTicker(): void { if (!this.module || this.snapshot.status !== 'playing' || this.snapshot.stepMode) return; const currentSeconds = nowSeconds(); const rawDelta = clamp(currentSeconds - this.lastTickSeconds, 0, MAX_FRAME_DELTA_SECONDS); this.lastTickSeconds = currentSeconds; this.advance(rawDelta * this.snapshot.direction, 'gsap', false); } private advance(deltaSeconds: number, source: MachineAnimationTickSource, ignoreReducedMotion: boolean): void { if (!this.module) return; const motionFactor = ignoreReducedMotion ? 1 : this.getReducedMotionFactor(); const scaledDelta = deltaSeconds * this.snapshot.timeScale * motionFactor; if (Math.abs(scaledDelta) < 0.000001 && source !== 'initialize') { return; } let nextElapsed = this.snapshot.elapsedSeconds + scaledDelta; const cycleDuration = this.getCycleDurationSeconds(); if (this.snapshot.loop) { const wrapSpan = cycleDuration * 100000; nextElapsed = positiveModulo(nextElapsed, wrapSpan); } else { nextElapsed = Math.max(0, nextElapsed); const completed = nextElapsed >= cycleDuration; if (completed) { nextElapsed = cycleDuration; this.snapshot = { ...this.snapshot, status: 'complete' }; this.stopTicker(); this.emit({ type: 'completed' }); } } this.snapshot = { ...this.snapshot, elapsedSeconds: nextElapsed, cycleDurationSeconds: cycleDuration, }; this.recalculateDerivedTiming(source); const context = this.createContext(scaledDelta, source); this.module.onBeforeTick?.(context); this.module.update(context); this.module.onAfterTick?.(context); this.emit({ type: 'updated' }); this.notify(); } private recalculateDerivedTiming(_source: MachineAnimationTickSource): void { const cycleDuration = this.getCycleDurationSeconds(); const elapsed = Math.max(0, this.snapshot.elapsedSeconds); const cycleIndex = Math.floor(elapsed / cycleDuration); const cycleElapsed = positiveModulo(elapsed, cycleDuration); const cycleProgress = cycleDuration > 0 ? cycleElapsed / cycleDuration : 0; const revolutionsElapsed = elapsed * (this.snapshot.rpm / 60); const shaftAngle = revolutionsElapsed * TAU; const cycleAngle = cycleProgress * TAU; const stepCount = Math.max(1, this.snapshot.stepCount); this.snapshot = { ...this.snapshot, cycleDurationSeconds: cycleDuration, cycleIndex, cycleElapsedSeconds: cycleElapsed, cycleProgress, revolutionsElapsed, shaftAngle, cycleAngle, stepIndex: Math.floor(cycleProgress * stepCount) % stepCount, highlightedPartIds: [...this.highlights.keys()], }; } private getCycleDurationSeconds(rpm = this.snapshot.rpm): number { const safeRpm = Math.max(0.001, rpm); const revolutions = this.module?.cycleRevolutions ?? 1; return (60 / safeRpm) * revolutions; } private getReducedMotionStrategy(): ReducedMotionStrategy { if (!this.snapshot.reducedMotion) return 'continue'; return this.module?.reducedMotionStrategy ?? 'slow'; } private getReducedMotionFactor(): number { const strategy = this.getReducedMotionStrategy(); switch (strategy) { case 'pause': case 'static-snapshot': return 0; case 'slow': return 0.2; case 'continue': default: return 1; } } private createContext( deltaSeconds: number, source: MachineAnimationTickSource, ): MachineAnimationContext { const snapshot = this.getSnapshot(); return { ...snapshot, deltaSeconds, source, parts: this.parts, userData: this.userData, getPart: (partId) => this.parts.get(partId), getPartsByPrefix: (prefix) => [...this.parts.entries()] .filter(([partId]) => partId.startsWith(prefix)) .map(([, object]) => object), setPartVisible: (partId, visible) => { const part = this.parts.get(partId); if (part) part.visible = visible; }, setPartOpacity: (partId, opacity) => { const part = this.parts.get(partId); if (!part) return; const safeOpacity = clamp(opacity, 0, 1); traverseMaterials(part, (material) => { material.transparent = safeOpacity < 0.999; material.opacity = safeOpacity; material.depthWrite = safeOpacity > 0.25; material.needsUpdate = true; }); }, setPartEmissive: (partId, color, intensity = 0.45) => { const part = this.parts.get(partId); if (!part) return; traverseMaterials(part, (material) => { const maybeEmissive = material as Material & { emissive?: { set: (value: string | number) => void }; emissiveIntensity?: number; }; maybeEmissive.emissive?.set(color); maybeEmissive.emissiveIntensity = intensity; material.needsUpdate = true; }); }, clearPartEmissive: (partId) => { const part = this.parts.get(partId); if (!part) return; traverseMaterials(part, (material) => { const maybeEmissive = material as Material & { emissive?: { set: (value: string | number) => void }; emissiveIntensity?: number; }; maybeEmissive.emissive?.set(0x000000); maybeEmissive.emissiveIntensity = 0; material.needsUpdate = true; }); }, highlightPart: (partId, intensity = 1) => { this.highlights.set(partId, intensity); this.snapshot = { ...this.snapshot, highlightedPartIds: [...this.highlights.keys()] }; this.emit({ type: 'highlight-change', partIds: [...this.highlights.keys()] }); }, highlightParts: (partIds, intensity = 1) => { partIds.forEach((partId) => this.highlights.set(partId, intensity)); this.snapshot = { ...this.snapshot, highlightedPartIds: [...this.highlights.keys()] }; this.emit({ type: 'highlight-change', partIds: [...this.highlights.keys()] }); }, clearHighlights: () => { this.highlights.clear(); this.snapshot = { ...this.snapshot, highlightedPartIds: [] }; this.emit({ type: 'highlight-change', partIds: [] }); }, setPhaseLabel: (label) => { if (this.snapshot.phaseLabel === label) return; this.snapshot = { ...this.snapshot, phaseLabel: label }; this.emit({ type: 'phase-change', phaseLabel: label ?? undefined }); }, requestCamera: (request: MachineCameraRequest) => { this.emit({ type: 'camera-request', camera: request }); }, emit: (event) => { this.emit(event); }, }; } private emit(event: Omit): void { const completeEvent: MachineAnimationEvent = { ...event, machineId: this.module?.machineId ?? 'unknown-machine', moduleId: this.module?.id ?? 'unknown-module', timestamp: nowSeconds(), }; this.eventSubscribers.forEach((subscriber) => subscriber(completeEvent)); } private notify(): void { const snapshot = this.getSnapshot(); this.subscribers.forEach((subscriber) => subscriber(snapshot)); } } export function createMachineAnimationPlayer( options: MachineAnimationPlayerOptions = {}, ): MachineAnimationPlayer & MachineAnimationControls { return new MachineAnimationPlayer(options); }