The value type was already there: StructValue, textOf, sameValue, writeTyped/mergeStruct and the struct port type came with the first part, and Get/Set variable carry any-ports, so a bundle already flowed through the graph unchanged. What was missing was the declaration. PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a fifth kind, variableDef a fifth parameter. A declared field is a primitive - a list field would need a nested editor and therefore the path in the slot address, the same step "a list of structs" is waiting for. coerceValue now returns PropertyValue rather than FieldValue, the first value type that is not a field; none of its five callers break. In the inspector a struct variable is a block, not a tab. The list tab exists because hundreds of rows fit in no inspector row; a struct is the opposite case - few fields that fit under each other. It is the port editor Pack already has, one level down and with a value column: type, key and default value in one place. A placed object sees the same fields as plain value rows: overriding yes, declaring no. Adding or removing a field touches the declaration, the default, the behavior's value and every instance override in one command, so a stored value can never keep a shape its declaration no longer promises. The row handlers read the bundle at the moment of the change rather than closing over a snapshot - a field writes the whole bundle, so with a snapshot the second edited row revives the first one's old value. defSignature carries the fields, otherwise a new row appears only at the next rebuild. Fixes a fixture fault this uncovered: step 10 pushed its text object into the scene without instanceEdits, which overrideSites walks with Object.entries. AddStructFieldCommand is the first caller to reach it from that state; the step was green alone and failed three checks in the full suite. Moves the dynamic-ports note to done - all three of its parts are built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
// 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<string, PropertySchema>();
|
||
const variables = new Map<string, PropertyDef[]>();
|
||
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<string, PropertySchema>): 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<string, InstanceEdit> {
|
||
const edits: Record<string, InstanceEdit> = {};
|
||
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<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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<string, unknown>,
|
||
takenIds: Set<string>,
|
||
): { 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<string, unknown> {
|
||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||
}
|
||
|
||
/**
|
||
* 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<string, FieldValue> = {};
|
||
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;
|
||
}
|