Files
feuerwerk/src/core/store/serialize.ts
T
rootandClaude Fable 5.1 d4b1ffa051 Z-order via position.z and reset-to-default for asset properties
- Position is now { x, y, z }; higher z draws on top, ties keep hierarchy order
- Viewport sorts the objects layer deterministically (syncDrawOrder)
- Inspector shows a Z field; asset properties that differ from the schema
  default are marked bold with a reset button (ResetAssetPropertyCommand)
- isModified() helper, unit tests, E2E checks and seed updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:42:18 +02:00

99 lines
3.6 KiB
TypeScript

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<string, AssetType>();
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<string, AssetType>): 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<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;
}