import type { Asset } from '../model/Asset'; import { type AssetType, 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, schemaFor } 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 types, needed to coerce scene-object overrides in pass 2. const types = new Map(); for (const a of rawAssets) { if (typeof a.id === 'string' && a.id && isAssetType(a.type)) types.set(a.id, a.type); } // Pass 2: build assets. const assets: Asset[] = []; for (const a of rawAssets) { const id = typeof a.id === 'string' ? a.id : ''; const type = types.get(id); if (!type) continue; const name = typeof a.name === 'string' ? a.name : type; const props = normalizeBag(a.props, type, false); if (type === 'scene') { assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, types) }); } 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, types: 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 type = types.get(o.assetId); if (!type || type === '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, type, true), }); } return objects; } function normalizeBag(raw: unknown, type: AssetType, sparse: boolean): PropertyBag { const r = asRecord(raw); const bag: PropertyBag = {}; for (const def of schemaFor(type)) { 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; }