import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneIR, SceneNodeIR, SceneSnapshotIR, WorldIR } from "./scene-ir"; export interface SceneCollectionDelta { updated: Array & Partial>; added?: T[]; removed?: string[]; } export interface SceneDelta { schemaVersion: 1; baseRevision: number; nextRevision: number; nodes?: { updated: Array & Partial>>; added?: SceneNodeIR[]; removed?: string[]; }; meshes?: SceneCollectionDelta; materials?: SceneCollectionDelta; animations?: SceneCollectionDelta; cameras?: SceneCollectionDelta; lights?: SceneCollectionDelta; worlds?: SceneCollectionDelta; scenes?: SceneCollectionDelta; activeObjectId?: string | null; frame?: SceneSnapshotIR["frame"]; } const collectionFields = ["meshes", "materials", "animations", "cameras", "lights", "worlds", "scenes"] as const; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } export function parseSceneDelta(value: unknown): SceneDelta { if (!isRecord(value) || value.schemaVersion !== 1) throw new Error("Unsupported SceneDelta schema"); if (typeof value.baseRevision !== "number" || !Number.isInteger(value.baseRevision) || typeof value.nextRevision !== "number" || !Number.isInteger(value.nextRevision)) { throw new Error("SceneDelta revisions must be integers"); } if (value.nodes !== undefined) { if (!isRecord(value.nodes) || !Array.isArray(value.nodes.updated)) throw new Error("SceneDelta.nodes is invalid"); const added = value.nodes.added ?? []; const removed = value.nodes.removed ?? []; if (!Array.isArray(added) || !Array.isArray(removed) || removed.some((id) => typeof id !== "string")) { throw new Error("SceneDelta node additions/removals are invalid"); } for (const change of value.nodes.updated) { if (!isRecord(change) || typeof change.id !== "string") throw new Error("SceneDelta updated node is invalid"); } for (const node of added) { if (!isRecord(node) || typeof node.id !== "string") throw new Error("SceneDelta added node is invalid"); } } for (const field of collectionFields) { const collection = value[field]; if (collection === undefined) continue; if (!isRecord(collection) || !Array.isArray(collection.updated) || (collection.added !== undefined && !Array.isArray(collection.added)) || (collection.removed !== undefined && (!Array.isArray(collection.removed) || collection.removed.some((id) => typeof id !== "string")))) { throw new Error(`SceneDelta.${field} is invalid`); } } if (value.activeObjectId !== undefined && value.activeObjectId !== null && typeof value.activeObjectId !== "string") { throw new Error("SceneDelta.activeObjectId is invalid"); } if (value.frame !== undefined) { if (!isRecord(value.frame) || typeof value.frame.current !== "number" || !Number.isFinite(value.frame.current) || typeof value.frame.start !== "number" || !Number.isFinite(value.frame.start) || typeof value.frame.end !== "number" || !Number.isFinite(value.frame.end)) { throw new Error("SceneDelta.frame is invalid"); } } return value as unknown as SceneDelta; } function diffCollection(before: readonly T[], after: readonly T[]): SceneCollectionDelta | undefined { const oldItems = new Map(before.map((item) => [item.id, item])); const newItems = new Map(after.map((item) => [item.id, item])); const updated: Array & Partial> = []; const added: T[] = []; const removed: string[] = []; for (const item of after) { const previous = oldItems.get(item.id); if (!previous) added.push(item); else if (JSON.stringify(previous) !== JSON.stringify(item)) updated.push(item); } for (const item of before) if (!newItems.has(item.id)) removed.push(item.id); return updated.length || added.length || removed.length ? { updated, added, removed } : undefined; } function changedNodeFields(before: SceneNodeIR, after: SceneNodeIR): { id: string } & Partial { const change: { id: string } & Partial = { id: after.id }; if (before.visible !== after.visible) change.visible = after.visible; if (before.parentId !== after.parentId) change.parentId = after.parentId; if (JSON.stringify(before.localMatrix) !== JSON.stringify(after.localMatrix)) change.localMatrix = after.localMatrix; if (JSON.stringify(before.transform) !== JSON.stringify(after.transform)) change.transform = after.transform; return change; } export function diffSceneSnapshots(before: SceneSnapshotIR, after: SceneSnapshotIR): SceneDelta { const oldNodes = new Map(before.nodes.map((node) => [node.id, node])); const newNodes = new Map(after.nodes.map((node) => [node.id, node])); const updated: Array<{ id: string } & Partial> = []; const added: SceneNodeIR[] = []; const removed: string[] = []; for (const node of after.nodes) { const previous = oldNodes.get(node.id); if (!previous) added.push(node); else if (JSON.stringify(previous) !== JSON.stringify(node)) updated.push(changedNodeFields(previous, node)); } for (const node of before.nodes) if (!newNodes.has(node.id)) removed.push(node.id); const delta: SceneDelta = { schemaVersion: 1, baseRevision: before.revision, nextRevision: after.revision }; if (updated.length || added.length || removed.length) delta.nodes = { updated, added, removed }; delta.meshes = diffCollection(before.meshes, after.meshes); delta.materials = diffCollection(before.materials, after.materials); delta.animations = diffCollection(before.animations, after.animations); delta.cameras = diffCollection(before.cameras, after.cameras); delta.lights = diffCollection(before.lights, after.lights); delta.worlds = diffCollection(before.worlds, after.worlds); delta.scenes = diffCollection(before.scenes, after.scenes); if (before.activeObjectId !== after.activeObjectId) delta.activeObjectId = after.activeObjectId; if (JSON.stringify(before.frame) !== JSON.stringify(after.frame)) delta.frame = after.frame; return delta; } function applyCollectionDelta(items: readonly T[], delta: SceneCollectionDelta | undefined): T[] { if (!delta) return [...items]; const result = new Map(items.map((item) => [item.id, item])); for (const id of delta.removed ?? []) result.delete(id); for (const item of delta.added ?? []) result.set(item.id, item); for (const change of delta.updated) { const current = result.get(change.id); if (!current) throw new Error(`SceneDelta item not found: ${change.id}`); result.set(change.id, { ...current, ...change }); } return [...result.values()].sort((left, right) => left.id.localeCompare(right.id)); } export function applySceneDelta(snapshot: SceneSnapshotIR, delta: SceneDelta): SceneSnapshotIR { parseSceneDelta(delta); if (snapshot.revision !== delta.baseRevision) throw new Error(`SceneDelta revision conflict: ${snapshot.revision} != ${delta.baseRevision}`); const updated = new Map(snapshot.nodes.map((node) => [node.id, node])); for (const id of delta.nodes?.removed ?? []) updated.delete(id); for (const node of delta.nodes?.added ?? []) updated.set(node.id, node); for (const change of delta.nodes?.updated ?? []) { const current = updated.get(change.id); if (!current) throw new Error(`SceneDelta node not found: ${change.id}`); updated.set(change.id, { ...current, ...change }); } return { ...snapshot, revision: delta.nextRevision, nodes: [...updated.values()].sort((left, right) => left.id.localeCompare(right.id)), meshes: applyCollectionDelta(snapshot.meshes, delta.meshes), materials: applyCollectionDelta(snapshot.materials, delta.materials), animations: applyCollectionDelta(snapshot.animations, delta.animations), cameras: applyCollectionDelta(snapshot.cameras, delta.cameras), lights: applyCollectionDelta(snapshot.lights, delta.lights), worlds: applyCollectionDelta(snapshot.worlds, delta.worlds), scenes: applyCollectionDelta(snapshot.scenes, delta.scenes), activeObjectId: delta.activeObjectId === undefined ? snapshot.activeObjectId : delta.activeObjectId, frame: delta.frame ?? snapshot.frame, }; } export function sceneDeltaRequiresRendererRebuild(delta: SceneDelta): boolean { return Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length || delta.meshes || delta.materials || delta.cameras || delta.lights || delta.worlds || delta.scenes || delta.animations); }