// 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(); const variables = new Map(); 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): 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(); 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(); 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(); 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 { return value && typeof value === 'object' ? (value as Record) : {}; } function toNumber(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; }