166 lines
6.8 KiB
TypeScript
166 lines
6.8 KiB
TypeScript
// The graph package index registers the built-in nodes; unknown node types are dropped below.
|
|
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
|
|
import type { Asset } from '../model/Asset';
|
|
import { isAssetType } from '../model/AssetTypes';
|
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
|
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
|
import type { SceneObject } from '../model/SceneObject';
|
|
import { coerceValue, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
|
|
|
|
/**
|
|
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
|
|
* dropped, missing ones get defaults, values are coerced against the schema, dangling
|
|
* scene objects are removed. Future format migrations live here as well.
|
|
*/
|
|
export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|
const r = asRecord(raw);
|
|
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
|
|
|
|
// Pass 1: asset ids and their effective schemas (behaviors declare their own variables),
|
|
// needed to coerce scene-object overrides in pass 2.
|
|
const schemas = new Map<string, PropertySchema>();
|
|
const variables = new Map<string, PropertyDef[]>();
|
|
for (const a of rawAssets) {
|
|
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
|
if (a.type === 'behavior') {
|
|
const vars = normalizeVariables(a.variables);
|
|
variables.set(a.id, vars);
|
|
schemas.set(a.id, vars);
|
|
} else {
|
|
schemas.set(a.id, schemaFor(a.type));
|
|
}
|
|
}
|
|
|
|
// Pass 2: build assets.
|
|
const assets: Asset[] = [];
|
|
for (const a of rawAssets) {
|
|
const id = typeof a.id === 'string' ? a.id : '';
|
|
const schema = schemas.get(id);
|
|
if (!schema || !isAssetType(a.type)) continue;
|
|
const type = a.type;
|
|
const name = typeof a.name === 'string' ? a.name : type;
|
|
const props = normalizeBag(a.props, schema, false);
|
|
if (type === 'scene') {
|
|
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
|
|
} else if (type === 'behavior') {
|
|
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
|
|
} else {
|
|
assets.push({ id, type, name, props });
|
|
}
|
|
}
|
|
|
|
let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : '';
|
|
if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) {
|
|
const first = assets.find((a) => a.type === 'scene');
|
|
activeSceneId = first ? first.id : '';
|
|
}
|
|
|
|
return {
|
|
formatVersion: PROJECT_FORMAT_VERSION,
|
|
id: typeof r.id === 'string' && r.id ? r.id : fallbackId,
|
|
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
|
assets,
|
|
activeSceneId,
|
|
};
|
|
}
|
|
|
|
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
|
|
export function serializeProject(project: Project): unknown {
|
|
return JSON.parse(JSON.stringify(project));
|
|
}
|
|
|
|
function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): SceneObject[] {
|
|
const objects: SceneObject[] = [];
|
|
for (const item of Array.isArray(raw) ? raw : []) {
|
|
const o = asRecord(item);
|
|
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
|
|
const schema = schemas.get(o.assetId);
|
|
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
|
|
const p = asRecord(o.position);
|
|
objects.push({
|
|
id: o.id,
|
|
assetId: o.assetId,
|
|
name: typeof o.name === 'string' ? o.name : '',
|
|
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
|
overrides: normalizeBag(o.overrides, schema, true),
|
|
ownerId: typeof o.ownerId === 'string' ? o.ownerId : '',
|
|
});
|
|
}
|
|
// Owners must exist in the same scene and differ from the object itself.
|
|
const ids = new Set(objects.map((o) => o.id));
|
|
for (const o of objects) if (o.ownerId && (o.ownerId === o.id || !ids.has(o.ownerId))) o.ownerId = '';
|
|
return objects;
|
|
}
|
|
|
|
function normalizeVariables(raw: unknown): PropertyDef[] {
|
|
const defs: PropertyDef[] = [];
|
|
const seen = new Set<string>();
|
|
for (const item of Array.isArray(raw) ? raw : []) {
|
|
const v = asRecord(item);
|
|
const key = typeof v.key === 'string' ? v.key : '';
|
|
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
|
|
seen.add(key);
|
|
const def = variableDef(key, kind);
|
|
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
|
|
defs.push(def);
|
|
}
|
|
return defs;
|
|
}
|
|
|
|
function normalizeGraph(raw: unknown): Graph {
|
|
const g = asRecord(raw);
|
|
const graph = createEmptyGraph();
|
|
const nodeIds = new Set<string>();
|
|
for (const item of Array.isArray(g.nodes) ? g.nodes : []) {
|
|
const n = asRecord(item);
|
|
const id = typeof n.id === 'string' ? n.id : '';
|
|
const type = typeof n.type === 'string' ? n.type : '';
|
|
if (!id || nodeIds.has(id) || !nodeDef(type)) continue; // unknown node types are dropped
|
|
nodeIds.add(id);
|
|
const params: PropertyBag = {};
|
|
for (const [key, value] of Object.entries(asRecord(n.params))) {
|
|
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value;
|
|
}
|
|
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
|
graph.nodes.push(node);
|
|
}
|
|
const edgeIds = new Set<string>();
|
|
for (const item of Array.isArray(g.edges) ? g.edges : []) {
|
|
const e = asRecord(item);
|
|
const from = asRecord(e.from);
|
|
const to = asRecord(e.to);
|
|
const id = typeof e.id === 'string' ? e.id : '';
|
|
if (!id || edgeIds.has(id)) continue;
|
|
if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue;
|
|
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
|
|
edgeIds.add(id);
|
|
const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
|
|
graph.edges.push(edge);
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
|
const r = asRecord(raw);
|
|
const bag: PropertyBag = {};
|
|
for (const def of schema) {
|
|
const value = r[def.key];
|
|
if (value === undefined || value === null) {
|
|
if (!sparse) bag[def.key] = def.default;
|
|
continue;
|
|
}
|
|
bag[def.key] = coerceValue(def, value as PropertyValue);
|
|
}
|
|
return bag;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function toNumber(value: unknown): number {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|