Files
workinf_Blender_Wasm/web/protocol/physics-simulation.ts
2026-08-12 19:31:41 -04:00

296 lines
14 KiB
TypeScript

import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
export const PHYSICS_SIMULATION_SCHEMA = 1 as const;
export const PHYSICS_SIMULATION_BUDGET = {
maxSystems: 4_096,
maxDependenciesPerSystem: 1_024,
maxSettings: 256,
maxSettingsBytes: 64 * 1024,
maxFrames: 100_000,
} as const;
export const PHYSICS_FAMILIES = [
"RIGID_BODY",
"SOFT_BODY",
"CLOTH",
"FLUID",
"DYNAMIC_PAINT",
"PARTICLE",
"HAIR",
] as const;
export type PhysicsFamily = typeof PHYSICS_FAMILIES[number];
export type PhysicsExecutionRequest = "METADATA" | "CACHE_MANIFEST" | "CACHE_PLAYBACK" | "LOCAL_SOLVER" | "SERVER_JOB";
export type PhysicsSettingValue = boolean | number | string | null;
export interface PhysicsCacheBindingIR {
cacheKey: string;
source: "BLENDER_DESKTOP_BAKE";
sourceBlendSha256: string;
settingsHash: string;
inputHash: string;
cacheSha256: string;
frameStart: number;
frameEnd: number;
cachedFrames: number[];
status: "COMPLETE" | "PARTIAL";
}
export interface PhysicsSystemIR {
id: string;
family: PhysicsFamily;
ownerObjectId: string;
settingsHash: string;
settings: Record<string, PhysicsSettingValue>;
dependencyIds: string[];
cache?: PhysicsCacheBindingIR;
}
export interface PhysicsSimulationManifestIR {
schemaVersion: typeof PHYSICS_SIMULATION_SCHEMA;
systems: PhysicsSystemIR[];
}
export interface PhysicsFamilyCapabilityIR {
family: PhysicsFamily;
metadata: "LOCAL_BOUNDED";
cacheManifest: "LOCAL_BOUNDED";
cachePlayback: "BLOCKED";
localSolver: "BLOCKED";
serverJob: "BLOCKED";
}
export interface BrowserTransformCacheObjectIR {
objectId: string;
translation: [number, number, number];
rotationQuaternion: [number, number, number, number];
scale: [number, number, number];
}
export interface BrowserTransformCacheFrameIR {
schemaVersion: 1;
frame: number;
objects: BrowserTransformCacheObjectIR[];
}
export class PhysicsSimulationValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "PhysicsSimulationValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const CACHE_KEY = /^[A-Za-z0-9._:-]{1,256}$/;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(value: unknown, name: string, prefix?: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 256 || (prefix && !value.startsWith(prefix))) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is invalid`);
}
return value;
}
function digest(value: unknown, name: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`);
}
return value;
}
function frame(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside the supported frame range`);
}
return value;
}
function parseSettings(value: unknown, systemIndex: number): Record<string, PhysicsSettingValue> {
if (!record(value)) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].settings must be an object`);
const entries = Object.entries(value);
if (entries.length > PHYSICS_SIMULATION_BUDGET.maxSettings) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings exceeds the entry budget`);
}
const settings: Record<string, PhysicsSettingValue> = {};
for (const [name, item] of entries) {
if (name.length === 0 || name.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(name) ||
!(item === null || typeof item === "boolean" || typeof item === "string" || (typeof item === "number" && Number.isFinite(item)))) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].settings.${name} is invalid`);
}
if (typeof item === "string" && item.length > 1_024) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings.${name} exceeds the string budget`);
}
settings[name] = item;
}
if (new TextEncoder().encode(JSON.stringify(settings)).byteLength > PHYSICS_SIMULATION_BUDGET.maxSettingsBytes) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].settings exceeds the byte budget`);
}
return settings;
}
function parseCache(value: unknown, settingsHash: string, systemIndex: number): PhysicsCacheBindingIR | undefined {
if (value === undefined) return undefined;
if (!record(value) || value.source !== "BLENDER_DESKTOP_BAKE" || !CACHE_KEY.test(String(value.cacheKey ?? "")) ||
(value.status !== "COMPLETE" && value.status !== "PARTIAL")) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache is invalid`);
}
const frameStart = frame(value.frameStart, `systems[${systemIndex}].cache.frameStart`);
const frameEnd = frame(value.frameEnd, `systems[${systemIndex}].cache.frameEnd`);
if (frameEnd < frameStart || frameEnd - frameStart + 1 > PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.cachedFrames)) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache frame range exceeds the budget`);
}
const cachedFrames = value.cachedFrames.map((item, frameIndex) => frame(item, `systems[${systemIndex}].cache.cachedFrames[${frameIndex}]`));
if (cachedFrames.length === 0 || cachedFrames.length > PHYSICS_SIMULATION_BUDGET.maxFrames ||
cachedFrames.some((item, index) => item < frameStart || item > frameEnd || (index > 0 && item <= cachedFrames[index - 1]))) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames must be unique, ordered and in range`);
}
if (value.status === "COMPLETE" && (cachedFrames.length !== frameEnd - frameStart + 1 || cachedFrames.some((item, index) => item !== frameStart + index))) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] declares an incomplete cache as COMPLETE`);
}
const cacheSettingsHash = digest(value.settingsHash, `systems[${systemIndex}].cache.settingsHash`);
if (cacheSettingsHash !== settingsHash) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] cache settings do not match the current system`);
}
return {
cacheKey: value.cacheKey as string,
source: "BLENDER_DESKTOP_BAKE",
sourceBlendSha256: digest(value.sourceBlendSha256, `systems[${systemIndex}].cache.sourceBlendSha256`),
settingsHash: cacheSettingsHash,
inputHash: digest(value.inputHash, `systems[${systemIndex}].cache.inputHash`),
cacheSha256: digest(value.cacheSha256, `systems[${systemIndex}].cache.cacheSha256`),
frameStart,
frameEnd,
cachedFrames,
status: value.status,
};
}
export function parsePhysicsSimulationManifest(value: unknown): PhysicsSimulationManifestIR {
if (!record(value) || value.schemaVersion !== PHYSICS_SIMULATION_SCHEMA || !Array.isArray(value.systems)) {
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported PhysicsSimulation manifest schema");
}
if (value.systems.length > PHYSICS_SIMULATION_BUDGET.maxSystems) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", "Physics system count exceeds the budget");
}
const ids = new Set<string>();
const systems = value.systems.map((item, index): PhysicsSystemIR => {
if (!record(item) || !PHYSICS_FAMILIES.includes(item.family as PhysicsFamily) || !Array.isArray(item.dependencyIds)) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] is invalid`);
}
const id = text(item.id, `systems[${index}].id`, "physics:");
if (ids.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `Duplicate physics system ${id}`);
ids.add(id);
if (item.dependencyIds.length > PHYSICS_SIMULATION_BUDGET.maxDependenciesPerSystem) {
throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${index}].dependencyIds exceeds the budget`);
}
const dependencyIds = item.dependencyIds.map((dependency, dependencyIndex) =>
text(dependency, `systems[${index}].dependencyIds[${dependencyIndex}]`));
if (new Set(dependencyIds).size !== dependencyIds.length) {
throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] contains duplicate dependencies`);
}
const settingsHash = digest(item.settingsHash, `systems[${index}].settingsHash`);
return {
id,
family: item.family as PhysicsFamily,
ownerObjectId: text(item.ownerObjectId, `systems[${index}].ownerObjectId`, "object:"),
settingsHash,
settings: parseSettings(item.settings, index),
dependencyIds,
cache: parseCache(item.cache, settingsHash, index),
};
});
const byId = new Map(systems.map((system) => [system.id, system]));
const active = new Set<string>();
const complete = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_DEPENDENCY_CYCLE", `Physics dependency cycle includes ${id}`);
if (complete.has(id)) return;
active.add(id);
for (const dependency of byId.get(id)?.dependencyIds ?? []) if (byId.has(dependency)) visit(dependency);
active.delete(id);
complete.add(id);
};
for (const system of systems) visit(system.id);
return { schemaVersion: PHYSICS_SIMULATION_SCHEMA, systems };
}
export function physicsCapabilityInventory(): PhysicsFamilyCapabilityIR[] {
return PHYSICS_FAMILIES.map((family) => ({
family,
metadata: "LOCAL_BOUNDED",
cacheManifest: "LOCAL_BOUNDED",
cachePlayback: "BLOCKED",
localSolver: "BLOCKED",
serverJob: "BLOCKED",
}));
}
export function gatePhysicsExecution(family: PhysicsFamily, request: PhysicsExecutionRequest): CapabilityGateResult {
if (request === "METADATA" || request === "CACHE_MANIFEST") return readyGate("N-018", `${family}_${request}`);
const issue = request === "CACHE_PLAYBACK" ?
capabilityIssue("PHYSICS_CACHE_PLAYBACK_UNAVAILABLE", `${family} cache playback is not connected to frame evaluation`) :
request === "LOCAL_SOLVER" ?
capabilityIssue("PHYSICS_SOLVER_UNAVAILABLE", `${family} has no verified local WASM solver`) :
capabilityIssue("PHYSICS_SERVER_UNAVAILABLE", `${family} server job execution is not configured`);
return blockedGate("N-018", `${family}_${request}`, [issue]);
}
export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame: number): { cacheKey: string; frame: number } {
const cache = system.cache;
if (!Number.isSafeInteger(requestedFrame) || !cache || !cache.cachedFrames.includes(requestedFrame)) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Physics cache has no verified frame ${requestedFrame}`);
}
return { cacheKey: cache.cacheKey, frame: requestedFrame };
}
const BROWSER_TRANSFORM_CACHE_MAGIC = 0x31465442; // BTF1
const BROWSER_TRANSFORM_CACHE_HEADER_BYTES = 16;
const BROWSER_TRANSFORM_CACHE_OBJECT_BYTES = 72;
export function decodeBrowserTransformCacheFrame(value: ArrayBuffer): BrowserTransformCacheFrameIR {
if (!(value instanceof ArrayBuffer) || value.byteLength < BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame is truncated");
}
const view = new DataView(value);
if (view.getUint32(0, true) !== BROWSER_TRANSFORM_CACHE_MAGIC || view.getUint16(4, true) !== 1 || view.getUint16(6, true) !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported browser transform cache frame schema");
}
const frameNumber = view.getInt32(8, true);
const count = view.getUint32(12, true);
if (frameNumber < -1_000_000 || frameNumber > 1_000_000 || count > PHYSICS_SIMULATION_BUDGET.maxSystems || value.byteLength !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES + count * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame length or count is invalid");
}
const decoder = new TextDecoder("utf-8", { fatal: true });
const objects: BrowserTransformCacheObjectIR[] = [];
const ids = new Set<string>();
for (let index = 0; index < count; index += 1) {
const offset = BROWSER_TRANSFORM_CACHE_HEADER_BYTES + index * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES;
const idLength = view.getUint8(offset);
if (idLength === 0 || idLength > 31) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid ID length`);
let objectId: string;
try { objectId = decoder.decode(new Uint8Array(value, offset + 1, idLength)); }
catch { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has invalid UTF-8`); }
if (!objectId.startsWith("object:") || ids.has(objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid or duplicate ID`);
ids.add(objectId);
const numbers = Array.from({ length: 10 }, (_, component) => view.getFloat32(offset + 32 + component * 4, true));
if (numbers.some((component) => !Number.isFinite(component))) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has non-finite transforms`);
const quaternionLength = Math.hypot(numbers[3], numbers[4], numbers[5], numbers[6]);
if (Math.abs(quaternionLength - 1) > 1e-3 || numbers.slice(7).some((component) => component === 0)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid rotation or scale`);
objects.push({
objectId,
translation: numbers.slice(0, 3) as [number, number, number],
rotationQuaternion: numbers.slice(3, 7) as [number, number, number, number],
scale: numbers.slice(7, 10) as [number, number, number],
});
}
return { schemaVersion: 1, frame: frameNumber, objects };
}