183 lines
9.0 KiB
TypeScript
183 lines
9.0 KiB
TypeScript
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<ArrayBuffer>;
|
|
}
|
|
|
|
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<number>(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<string>();
|
|
const resolved = new Set<string>();
|
|
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<SceneSnapshotIR | null> {
|
|
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<BrowserTransformCachePlaybackResult> {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|