// Importing the graph package index is what registers the built-in nodes. import { createEmptyGraph, DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type Graph, type GraphEdge, type GraphNode, isValidPortName, nodeDef, } from '../graph'; import type { Asset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile, normalizeFrameRect } from '../model/texture'; import { hasParentCycle } from '../model/walk'; import { coerceValue, COMMON_PROPS, isCommonProp, LIST_ITEM_KINDS, type ListItemKind, type PropertyDef, type PropertySchema, schemaFor, type StructFieldDef, 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 objects are removed. */ 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, needed to coerce 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, [...COMMON_PROPS, ...vars]); } else { schemas.set(a.id, schemaFor(a.type)); } } // Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture` // is handed the ids already spoken for. const assets: Asset[] = []; const takenIds = new Set(schemas.keys()); 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' || type === 'container') { 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 if (type === 'texture') { assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) }); } 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); const offset = asRecord(o.editorOffset); 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) }, editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) }, overrides: normalizeBag(o.overrides, schema, true), // `ownerId` is the pre-hierarchy name of the same link. parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '', instanceEdits: normalizeInstanceEdits(o.instanceEdits), }); } // Parents must exist in the same scene, differ from the object itself and not form cycles. const byId = new Map(objects.map((o) => [o.id, o])); for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = ''; // Cut one link per cycle: an object already unlinked here is a root for the objects checked after it. const parentOf = (o: SceneObject) => (o.parentId ? byId.get(o.parentId) : undefined); for (const o of objects) if (hasParentCycle(o, parentOf)) o.parentId = ''; return objects; } /** * Deviations of container content, keyed by template path. Values are not coerced against a schema: * which asset a path leads to depends on container assets that may not be built yet, and resolving * drops what the schema does not declare anyway. */ function normalizeInstanceEdits(raw: unknown): Record { const edits: Record = {}; for (const [path, value] of Object.entries(asRecord(raw))) { if (!path) continue; const e = asRecord(value); const edit = emptyEdit(); if (typeof e.name === 'string') edit.name = e.name; const position = asRecord(e.position); for (const axis of ['x', 'y', 'z'] as const) if (typeof position[axis] === 'number' && Number.isFinite(position[axis])) edit.position[axis] = position[axis]; const offset = asRecord(e.editorOffset); if (typeof offset.x === 'number' || typeof offset.y === 'number') edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) }; for (const [key, v] of Object.entries(asRecord(e.overrides))) { const value = asStoredValue(v); if (value !== undefined) edit.overrides[key] = value; } if (!isEmptyEdit(edit)) edits[path] = edit; } return edits; } 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'; // Common property keys are reserved – a variable of that name would shadow the switch. if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue; seen.add(key); const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)) ? (v.items as ListItemKind) : undefined; const def = variableDef(key, kind, undefined, items, normalizeStructFields(v.fields)); def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); defs.push(def); } return defs; } /** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */ function normalizeStructFields(raw: unknown): StructFieldDef[] { const fields: StructFieldDef[] = []; const seen = new Set(); for (const item of Array.isArray(raw) ? raw : []) { const f = asRecord(item); const key = typeof f.key === 'string' ? f.key : ''; if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue; if (!(LIST_ITEM_KINDS as readonly string[]).includes(String(f.kind))) continue; seen.add(key); fields.push({ key, kind: f.kind as ListItemKind }); } return fields; } function normalizePorts(raw: unknown): DeclaredPort[] { const ports: DeclaredPort[] = []; const seen = new Set(); for (const item of Array.isArray(raw) ? raw : []) { const p = asRecord(item); const key = typeof p.key === 'string' ? p.key : ''; if (!isValidPortName(key) || seen.has(key)) continue; seen.add(key); const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind)) ? (p.kind as DeclaredPortKind) : 'number'; ports.push({ key, kind }); } return ports; } 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; nodeIds.add(id); // Params carry both settings and inline input values, so there is no schema to coerce against. const params: PropertyBag = {}; for (const [key, value] of Object.entries(asRecord(n.params))) { const stored = asStoredValue(value); if (stored !== undefined) params[key] = stored; } const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params }; const ports = normalizePorts(n.ports); if (ports.length > 0) node.ports = ports; 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 }, }; if (e.folded === true) edge.folded = true; graph.edges.push(edge); } return graph; } /** * A file name that is not the server's content-addressed form is dropped: a request built from it * would 404, and the texture then loads as "no picture". */ function normalizeTexture( raw: Record, takenIds: Set, ): { file: string; width: number; height: number; frames: TextureFrame[] } { const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : ''; const size = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; const width = size(raw.width); const height = size(raw.height); const frames: TextureFrame[] = []; for (const item of Array.isArray(raw.frames) ? raw.frames : []) { const f = asRecord(item); const id = typeof f.id === 'string' ? f.id : ''; // Frame ids share the reference space with asset ids, so a duplicate would make one // reference mean two pictures. if (!id || takenIds.has(id)) continue; takenIds.add(id); const rect = normalizeFrameRect( { x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) }, width, height, ); frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect }); } return { file, width, height, frames }; } 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) : {}; } /** * A value from an untyped slot (node params, instance edits). Lists and structs are kept but * flattened one level, so nothing deeper than the model allows can enter behind the type's back. */ function asStoredValue(value: unknown): PropertyValue | undefined { const field = asFieldValue(value); if (field !== undefined) return field; if (!value || typeof value !== 'object') return undefined; const fields: Record = {}; for (const [key, item] of Object.entries(value)) { const stored = asFieldValue(item); if (stored !== undefined) fields[key] = stored; } return fields; } function asFieldValue(value: unknown): FieldValue | undefined { if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value; if (Array.isArray(value)) return value.filter( (item): item is number | string | boolean => typeof item === 'number' || typeof item === 'string' || typeof item === 'boolean', ); return undefined; } function toNumber(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; }