Files
feuerwerk/src/core/store/serialize.ts
T

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