171 lines
8.7 KiB
TypeScript
171 lines
8.7 KiB
TypeScript
import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneIR, SceneNodeIR, SceneSnapshotIR, WorldIR } from "./scene-ir";
|
|
|
|
export interface SceneCollectionDelta<T extends { id: string }> {
|
|
updated: Array<Pick<T, "id"> & Partial<T>>;
|
|
added?: T[];
|
|
removed?: string[];
|
|
}
|
|
|
|
export interface SceneDelta {
|
|
schemaVersion: 1;
|
|
baseRevision: number;
|
|
nextRevision: number;
|
|
nodes?: {
|
|
updated: Array<Pick<SceneNodeIR, "id"> & Partial<Pick<SceneNodeIR, "visible" | "parentId" | "localMatrix" | "transform">>>;
|
|
added?: SceneNodeIR[];
|
|
removed?: string[];
|
|
};
|
|
meshes?: SceneCollectionDelta<MeshSummaryIR>;
|
|
materials?: SceneCollectionDelta<MaterialIR>;
|
|
animations?: SceneCollectionDelta<AnimationIR>;
|
|
cameras?: SceneCollectionDelta<CameraIR>;
|
|
lights?: SceneCollectionDelta<LightIR>;
|
|
worlds?: SceneCollectionDelta<WorldIR>;
|
|
scenes?: SceneCollectionDelta<SceneIR>;
|
|
activeObjectId?: string | null;
|
|
frame?: SceneSnapshotIR["frame"];
|
|
}
|
|
|
|
const collectionFields = ["meshes", "materials", "animations", "cameras", "lights", "worlds", "scenes"] as const;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
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<T extends { id: string }>(before: readonly T[], after: readonly T[]): SceneCollectionDelta<T> | undefined {
|
|
const oldItems = new Map(before.map((item) => [item.id, item]));
|
|
const newItems = new Map(after.map((item) => [item.id, item]));
|
|
const updated: Array<Pick<T, "id"> & Partial<T>> = [];
|
|
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<SceneNodeIR> {
|
|
const change: { id: string } & Partial<SceneNodeIR> = { 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<SceneNodeIR>> = [];
|
|
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<T extends { id: string }>(items: readonly T[], delta: SceneCollectionDelta<T> | 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);
|
|
}
|