Files
workinf_Blender_Wasm/web/protocol/physics-cache-playback.ts
2026-08-14 18:08:29 -04:00

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}`);
}
}
}