- 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>
99 lines
3.6 KiB
TypeScript
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;
|
|
}
|