import { decodeBrowserTransformCacheFrame, PhysicsSimulationValidationError, type BrowserTransformCacheObjectIR } from "./physics-simulation"; import type { SceneNodeIR, SceneSnapshotIR } from "./scene-ir"; export interface BrowserTransformCacheFrameSource { readonly frameStart: number; readonly frameEnd: number; readFrame(frame: number, signal: AbortSignal): Promise; } export interface BrowserTransformCachePlaybackResult { status: "COMPLETED" | "CANCELLED"; appliedFrames: number; lastFrame: number | null; } function quaternionFromEuler([x, y, z]: readonly number[]): [number, number, number, number] { const cx = Math.cos(x / 2); const sx = Math.sin(x / 2); const cy = Math.cos(y / 2); const sy = Math.sin(y / 2); const cz = Math.cos(z / 2); const sz = Math.sin(z / 2); return [sx * cy * cz + cx * sy * sz, cx * sy * cz - sx * cy * sz, cx * cy * sz + sx * sy * cz, cx * cy * cz - sx * sy * sz]; } function eulerFromQuaternion([x, y, z, w]: readonly number[]): [number, number, number] { return [ Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)), Math.asin(Math.max(-1, Math.min(1, 2 * (w * y - z * x)))), Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)), ]; } function composeMatrix(translation: readonly number[], quaternion: readonly number[], scale: readonly number[]): number[] { const [x, y, z, w] = quaternion; const x2 = x + x; const y2 = y + y; const z2 = z + z; const xx = x * x2; const xy = x * y2; const xz = x * z2; const yy = y * y2; const yz = y * z2; const zz = z * z2; const wx = w * x2; const wy = w * y2; const wz = w * z2; return [ (1 - (yy + zz)) * scale[0], (xy + wz) * scale[0], (xz - wy) * scale[0], 0, (xy - wz) * scale[1], (1 - (xx + zz)) * scale[1], (yz + wx) * scale[1], 0, (xz + wy) * scale[2], (yz - wx) * scale[2], (1 - (xx + yy)) * scale[2], 0, translation[0], translation[1], translation[2], 1, ]; } function multiplyMatrix(left: readonly number[], right: readonly number[]): number[] { const output = new Array(16); for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) { output[column * 4 + row] = left[row] * right[column * 4] + left[4 + row] * right[column * 4 + 1] + left[8 + row] * right[column * 4 + 2] + left[12 + row] * right[column * 4 + 3]; } return output; } function cacheTransform(node: SceneNodeIR, cached: BrowserTransformCacheObjectIR | undefined): { node: SceneNodeIR; quaternion: [number, number, number, number] } { if (!cached) { return { node: { ...node, transform: { ...node.transform }, localMatrix: [...node.localMatrix], worldMatrix: [...node.worldMatrix] }, quaternion: quaternionFromEuler(node.transform.rotationEuler) }; } const transform = { ...node.transform, translation: [...cached.translation] as [number, number, number], rotationEuler: eulerFromQuaternion(cached.rotationQuaternion), scale: [...cached.scale] as [number, number, number], }; return { node: { ...node, transform, localMatrix: composeMatrix(transform.translation, cached.rotationQuaternion, transform.scale), worldMatrix: [] }, quaternion: cached.rotationQuaternion }; } /** Applies the browser-owned BTF1 transform cache as an immutable SceneIR preview. */ export function applyBrowserTransformCachePreview(snapshot: SceneSnapshotIR, value: ArrayBuffer, expectedFrame: number): SceneSnapshotIR { if (!Number.isSafeInteger(expectedFrame) || expectedFrame < snapshot.frame.start || expectedFrame > snapshot.frame.end) { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${expectedFrame} is outside the scene range`); } const frame = decodeBrowserTransformCacheFrame(value); if (frame.frame !== expectedFrame) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${frame.frame} does not match requested frame ${expectedFrame}`); const sourceById = new Map(snapshot.nodes.map((node) => [node.id, node])); for (const item of frame.objects) if (!sourceById.has(item.objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache references missing ${item.objectId}`); const cachedById = new Map(frame.objects.map((item) => [item.objectId, item])); const states = new Map(snapshot.nodes.map((node) => [node.id, cacheTransform(node, cachedById.get(node.id))])); const resolving = new Set(); const resolved = new Set(); const updateWorld = (id: string): number[] => { const state = states.get(id); if (!state) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy references missing ${id}`); if (resolved.has(id)) return state.node.worldMatrix; if (resolving.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy contains a cycle at ${id}`); resolving.add(id); if (state.node.localMatrix.length !== 16) state.node.localMatrix = composeMatrix(state.node.transform.translation, state.quaternion, state.node.transform.scale); state.node.worldMatrix = state.node.parentId ? multiplyMatrix(updateWorld(state.node.parentId), state.node.localMatrix) : [...state.node.localMatrix]; resolving.delete(id); resolved.add(id); return state.node.worldMatrix; }; for (const node of snapshot.nodes) updateWorld(node.id); return { ...snapshot, frame: { ...snapshot.frame, current: frame.frame }, nodes: snapshot.nodes.map((node) => states.get(node.id)!.node) }; } /** Coordinates exact-frame BTF1 reads while preventing cancelled or superseded reads from publishing. */ export class BrowserTransformCachePlaybackSession { private generation = 0; private controller: AbortController | null = null; constructor( private readonly baseSnapshot: SceneSnapshotIR, private readonly source: BrowserTransformCacheFrameSource, private readonly publish: (preview: SceneSnapshotIR) => void, ) { if (!Number.isSafeInteger(source.frameStart) || !Number.isSafeInteger(source.frameEnd) || source.frameEnd < source.frameStart || source.frameStart < baseSnapshot.frame.start || source.frameEnd > baseSnapshot.frame.end) { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache source range is outside the scene range"); } } cancel(): void { this.generation += 1; this.controller?.abort(); this.controller = null; } async seek(frame: number): Promise { this.validateRange(frame, frame); const generation = this.begin(); const controller = this.controller!; try { const data = await this.source.readFrame(frame, controller.signal); if (!this.isCurrent(generation, controller)) return null; const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame); if (!this.isCurrent(generation, controller)) return null; this.publish(preview); return preview; } catch (error) { if (!this.isCurrent(generation, controller)) return null; throw error; } finally { if (this.generation === generation) this.controller = null; } } async play(frameStart = this.source.frameStart, frameEnd = this.source.frameEnd): Promise { this.validateRange(frameStart, frameEnd); const generation = this.begin(); const controller = this.controller!; let appliedFrames = 0; let lastFrame: number | null = null; try { for (let frame = frameStart; frame <= frameEnd; frame += 1) { const data = await this.source.readFrame(frame, controller.signal); if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame }; const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame); if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame }; this.publish(preview); appliedFrames += 1; lastFrame = frame; } return { status: "COMPLETED", appliedFrames, lastFrame }; } catch (error) { if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame }; throw error; } finally { if (this.generation === generation) this.controller = null; } } private begin(): number { this.controller?.abort(); this.controller = new AbortController(); this.generation += 1; return this.generation; } private isCurrent(generation: number, controller: AbortController): boolean { return generation === this.generation && this.controller === controller && !controller.signal.aborted; } private validateRange(frameStart: number, frameEnd: number): void { if (!Number.isSafeInteger(frameStart) || !Number.isSafeInteger(frameEnd) || frameStart < this.source.frameStart || frameEnd > this.source.frameEnd || frameEnd < frameStart) { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache has no verified range ${frameStart}-${frameEnd}`); } } }