A vector shape could only be filled and outlined with one flat colour. Shading, glows and patterned surfaces had to be faked with stacks of shapes. Every shape now has a paint type for its fill and its outline. - Fill type: solid colour, linear gradient, radial gradient or texture. Line type offers the same without texture. - Colour stops are edited on a bar in the inspector, as agreed in the issue. A click on the bar adds a stop in the colour the gradient already has there, so adding never changes the picture. At least two stops remain. - The new Paint tool in the tab shows two handles for the selected shape: axis start and end, centre and radius, or the corners of one texture tile. "Handles: Fill / Outline" picks which paint is edited. - Moving a whole shape moves its gradients and tile with it. Every gesture is one undo step. - A texture fill always tiles, because Pixi turns a non-repeating pattern into a repeating one anyway. When switched on, one tile covers the whole shape. - Only uploaded textures and frames can be a fill, never a drawing. A drawing filled with itself would rasterise itself mid-raster. - The fingerprint of a drawing includes the texture and frame a fill points to, so the picture redraws when the image is swapped. Findings: - graphics.destroy(true) also destroyed the textures of the fills. With a texture fill it deleted the tile slice and the next redraw crashed on reading 'scaleMode' of null. It is now destroy(). - The gradient cache destroyed old gradients itself, which gave 20 Pixi warnings "destroyed while still bound". They are now handed to Pixi's texture GC with autoGarbageCollect switched on. The console of module 47 is empty again, the last CI log had 0 of them. Tests: - ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44), 0 console warnings - 25 new unit tests, 10 new e2e checks in block 47i - Four counter-checks bite, originals restored and verified with cmp - Check sum 1232 in README and wiki, calculated (1222 + 10) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
740 lines
30 KiB
TypeScript
740 lines
30 KiB
TypeScript
// Importing the graph package index is what registers the built-in nodes.
|
||
import {
|
||
ANNOTATION_KINDS,
|
||
annotationList,
|
||
createEmptyGraph,
|
||
type DeclaredPort,
|
||
type GraphAnnotation,
|
||
MIN_ANNOTATION_SIZE,
|
||
type DeclaredPortKind,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
isValidPortName,
|
||
fixedPortKeys,
|
||
type NodeDef,
|
||
nodeDef,
|
||
STATE,
|
||
} from '../graph';
|
||
import { isParticleNodeDef } from '../particles/nodes';
|
||
import { isShaderNodeDef } from '../shader/nodes';
|
||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||
import type {
|
||
AnimationKey,
|
||
AnimationTrack,
|
||
Asset,
|
||
StateMachineAsset,
|
||
TextureFrame,
|
||
GradientStop,
|
||
TilemapLayer,
|
||
VectorGradient,
|
||
VectorPaint,
|
||
VectorPoint,
|
||
VectorRing,
|
||
VectorShape,
|
||
} from '../model/Asset';
|
||
import { parseCellKey } from '../model/tilemap';
|
||
import { pluginsOf } from './projectPlugins';
|
||
import { collisionLayersOf } from '../model/collisionLayers';
|
||
import { decodeCells, encodeCells } from './tilemapCells';
|
||
import { isAssetType } from '../model/AssetTypes';
|
||
import { isFontFile } from '../model/font';
|
||
import { isSoundFile } from '../model/sound';
|
||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||
import {
|
||
type FieldValue,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
type StructValue,
|
||
tagOf,
|
||
type Vec2,
|
||
} from '../model/Properties';
|
||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||
import { readScope } from '../model/Scope';
|
||
import type { SceneObject } from '../model/SceneObject';
|
||
import {
|
||
DEFAULT_FILL,
|
||
isTextureFile,
|
||
normalizeBorders,
|
||
normalizeFrameRect,
|
||
setFrameCollision,
|
||
setFrameSlice,
|
||
} from '../model/texture';
|
||
import { pointsOf } from '../model/polygon';
|
||
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
|
||
import { hasParentCycle } from '../model/walk';
|
||
import {
|
||
DECLARED_PORT_KINDS,
|
||
FIELD_KINDS,
|
||
type FieldKind,
|
||
isTaggedKind,
|
||
VARIABLE_KINDS,
|
||
type VariableKind,
|
||
} from '../properties/kinds';
|
||
import { type FlatItem, makeFlatItem } from '../properties/listItems';
|
||
import {
|
||
coerceValue,
|
||
isValidFieldName,
|
||
isValidVariableName,
|
||
type PropertyDef,
|
||
type PropertySchema,
|
||
rowsNotClaimedBy,
|
||
schemaFor,
|
||
type StructFieldDef,
|
||
variableDef,
|
||
} from '../properties/PropertySchema';
|
||
import { particleParamDefs } from '../properties/particles';
|
||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||
|
||
/**
|
||
* 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[]>();
|
||
const rawProps = new Map<string, Record<string, unknown>>();
|
||
const shaderGraphs = new Map<string, Graph>();
|
||
const shaderRows = new Map<string, PropertyDef[]>();
|
||
const particleGraphs = new Map<string, Graph>();
|
||
const wearers = new Map<string, 'sprite' | 'particles'>();
|
||
for (const a of rawAssets) {
|
||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||
rawProps.set(a.id, asRecord(a.props));
|
||
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type);
|
||
if (a.type === 'behavior' || a.type === 'variables') {
|
||
const vars = normalizeVariables(a.variables);
|
||
variables.set(a.id, vars);
|
||
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and
|
||
// a behavior's own `runInEditor` would be dropped as undeclared.
|
||
schemas.set(a.id, [...schemaFor(a.type), ...vars]);
|
||
} else if (a.type === 'particles') {
|
||
// A source's rows end with its own graph's parameters, so the graph is needed before the
|
||
// overrides of the objects placed from it can be coerced.
|
||
const graph = normalizeGraph(a.graph, isParticleNodeDef);
|
||
particleGraphs.set(a.id, graph);
|
||
schemas.set(a.id, [...schemaFor('particles'), ...particleParamDefs(graph)]);
|
||
} else {
|
||
if (a.type === 'shader') {
|
||
const graph = normalizeGraph(a.graph, isShaderNodeDef);
|
||
shaderGraphs.set(a.id, graph);
|
||
shaderRows.set(a.id, shaderParamDefs(graph));
|
||
}
|
||
schemas.set(a.id, schemaFor(a.type));
|
||
}
|
||
}
|
||
/**
|
||
* A sprite's or source's rows end with the parameters of the shader its bag names – an override's,
|
||
* else the asset's. A source already carries its own graph's rows, so a name it has stays its own.
|
||
*/
|
||
const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => {
|
||
const schema = schemas.get(assetId);
|
||
const wearer = wearers.get(assetId);
|
||
if (!schema || !wearer) return schema;
|
||
const own = bag[SHADER_KEY];
|
||
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY];
|
||
const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
|
||
return [...schema, ...(wearer === 'particles' ? rowsNotClaimedBy(rows, schema) : rows)];
|
||
};
|
||
|
||
// 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 = schemaOf(id, asRecord(a.props));
|
||
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, schemaOf) });
|
||
} else if (type === 'behavior') {
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
variables: variables.get(id) ?? [],
|
||
graph: normalizeGraph(a.graph),
|
||
});
|
||
} else if (type === 'variables') {
|
||
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
|
||
} else if (type === 'statemachine') {
|
||
const machine: StateMachineAsset = {
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
|
||
};
|
||
if (readScope(a.scope) === 'global') machine.scope = 'global';
|
||
// Kept as written even where it names no state any more: the same as a deleted start.
|
||
if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
|
||
assets.push(machine);
|
||
} else if (type === 'shader') {
|
||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||
} else if (type === 'particles') {
|
||
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
|
||
} else if (type === 'texture') {
|
||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||
} else if (type === 'font') {
|
||
const file = typeof a.file === 'string' && isFontFile(a.file) ? a.file : '';
|
||
assets.push({ id, type, name, props, file });
|
||
} else if (type === 'sound') {
|
||
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
|
||
assets.push({ id, type, name, props, file });
|
||
} else if (type === 'tile') {
|
||
// A picture naming nothing is kept, like an animation's dangling root: it reads as missing
|
||
// in the tab and comes back when the texture it named is restored by an undo.
|
||
const pictures = (Array.isArray(a.pictures) ? a.pictures : []).filter(
|
||
(id): id is string => typeof id === 'string' && id !== '',
|
||
);
|
||
assets.push({ id, type, name, props, pictures });
|
||
} else if (type === 'tilemap') {
|
||
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
|
||
} else if (type === 'vectorart') {
|
||
assets.push({ id, type, name, props, shapes: normalizeShapes(a.shapes) });
|
||
} else if (type === 'animation') {
|
||
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
|
||
const root = typeof a.root === 'string' ? a.root : '';
|
||
assets.push({ id, type, name, props, root, tracks: normalizeTracks(a.tracks) });
|
||
} 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 : '';
|
||
}
|
||
|
||
// A dangling start scene reads as none at all – the run then falls back to the active scene,
|
||
// which is also what every project written before there was such a field does.
|
||
const startSceneId =
|
||
typeof r.startSceneId === 'string' && assets.some((a) => a.type === 'scene' && a.id === r.startSceneId)
|
||
? r.startSceneId
|
||
: '';
|
||
|
||
const project: Project = {
|
||
formatVersion: PROJECT_FORMAT_VERSION,
|
||
id: typeof r.id === 'string' && r.id ? r.id : fallbackId,
|
||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||
assets,
|
||
activeSceneId,
|
||
startSceneId,
|
||
// The same reading the editor did before it loaded the plugins (#119), so the model says what
|
||
// is actually running.
|
||
plugins: pluginsOf(r),
|
||
collisionLayers: collisionLayersOf(r),
|
||
};
|
||
return project;
|
||
}
|
||
|
||
/**
|
||
* Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). The cells
|
||
* of a tilemap are the one thing written differently from how they are held: see `tilemapCells.ts`.
|
||
*/
|
||
export function serializeProject(project: Project): unknown {
|
||
const copy = JSON.parse(JSON.stringify(project)) as Project;
|
||
for (const asset of copy.assets)
|
||
if (asset.type === 'tilemap')
|
||
(asset as { layers: unknown[] }).layers = asset.layers.map(({ cells, ...layer }) => ({
|
||
...layer,
|
||
...encodeCells(cells),
|
||
}));
|
||
return copy;
|
||
}
|
||
|
||
function normalizeObjects(
|
||
raw: unknown,
|
||
schemaOf: (assetId: string, overrides: Record<string, unknown>) => PropertySchema | undefined,
|
||
): 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 = schemaOf(o.assetId, asRecord(o.overrides));
|
||
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),
|
||
parentId: typeof o.parentId === 'string' ? o.parentId : '',
|
||
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 (!isValidVariableName(key) || seen.has(key)) continue;
|
||
seen.add(key);
|
||
const fields = normalizeStructFields(v.fields);
|
||
const def = variableDef(key, kind, undefined, 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 (!isValidFieldName(key) || seen.has(key)) continue;
|
||
const field = readField(key, f);
|
||
if (!field) continue;
|
||
seen.add(key);
|
||
fields.push(field);
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||
if (f.kind === 'list') return { key, kind: 'list' };
|
||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||
}
|
||
|
||
function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
|
||
const ports: DeclaredPort[] = [];
|
||
const seen = new Set(fixed);
|
||
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(writable && p.write === true ? { key, kind, write: true } : { key, kind });
|
||
}
|
||
return ports;
|
||
}
|
||
|
||
/** `keep` restricts the node types kept: a state machine's graph holds states, a shader's shader nodes, and nothing else. */
|
||
function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): 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 : '';
|
||
const def = nodeDef(type);
|
||
if (!id || nodeIds.has(id) || !def || (keep !== undefined && !keep(def))) 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, fixedPortKeys(def), def.declaresPorts === 'both');
|
||
if (ports.length > 0) node.ports = ports;
|
||
if (n.collapsed === true) node.collapsed = true;
|
||
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;
|
||
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||
if (label) edge.label = label;
|
||
graph.edges.push(edge);
|
||
}
|
||
for (const kind of ANNOTATION_KINDS) {
|
||
const list = normalizeAnnotations(g[kind === 'group' ? 'groups' : 'notes'], nodeIds);
|
||
if (list.length > 0) annotationList(graph, kind).push(...list);
|
||
}
|
||
return graph;
|
||
}
|
||
|
||
/**
|
||
* Annotations share the id space with nodes and with each other: a duplicate would make one
|
||
* selection mean two boxes. A size below the minimum is raised rather than dropped – a box nobody
|
||
* can see is worse than one that is a little larger than it was written.
|
||
*/
|
||
function normalizeAnnotations(raw: unknown, takenIds: Set<string>): GraphAnnotation[] {
|
||
const list: GraphAnnotation[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const a = asRecord(item);
|
||
const id = typeof a.id === 'string' ? a.id : '';
|
||
if (!id || takenIds.has(id)) continue;
|
||
takenIds.add(id);
|
||
const annotation: GraphAnnotation = {
|
||
id,
|
||
x: toNumber(a.x),
|
||
y: toNumber(a.y),
|
||
w: Math.max(toNumber(a.w), MIN_ANNOTATION_SIZE.w),
|
||
h: Math.max(toNumber(a.h), MIN_ANNOTATION_SIZE.h),
|
||
text: typeof a.text === 'string' ? a.text : '',
|
||
};
|
||
if (typeof a.color === 'number' && Number.isFinite(a.color))
|
||
annotation.color = Math.max(0, Math.min(0xffffff, Math.floor(a.color)));
|
||
list.push(annotation);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
);
|
||
const frame: TextureFrame = { id, name: typeof f.name === 'string' ? f.name : '', ...rect };
|
||
// Against the frame, not the image: the borders are distances inside the cut-out. A frame
|
||
// without them is one nobody sliced, and keeps saying so.
|
||
const border = asRecord(f.border);
|
||
setFrameSlice(frame, {
|
||
border: normalizeBorders(
|
||
{
|
||
left: toNumber(border.left),
|
||
top: toNumber(border.top),
|
||
right: toNumber(border.right),
|
||
bottom: toNumber(border.bottom),
|
||
},
|
||
rect.w,
|
||
rect.h,
|
||
),
|
||
fill: f.fill === 'stretch' ? 'stretch' : DEFAULT_FILL,
|
||
});
|
||
// Corners that are no numbers fall away; what is left is kept only as an outline (#141).
|
||
const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3);
|
||
if (outlines.length > 0) setFrameCollision(frame, outlines);
|
||
frames.push(frame);
|
||
}
|
||
return { file, width, height, frames };
|
||
}
|
||
|
||
/**
|
||
* A frame's stored outlines: a list of corner lists, or – as saved before a frame held several –
|
||
* one corner list, which is one outline.
|
||
*/
|
||
function collisionOutlines(raw: unknown): Vec2[][] {
|
||
if (!Array.isArray(raw)) return [];
|
||
if (raw.some((item) => !Array.isArray(item))) return [pointsOf(raw as PropertyValue)];
|
||
return raw.map((outline) => pointsOf(outline as PropertyValue));
|
||
}
|
||
|
||
/**
|
||
* The rows of an animation. Keys hold flat values only – a row animates one property – and are
|
||
* sorted onto whole frames; a second row on the same target and property, or with an id already
|
||
* taken, is dropped like a doubled node.
|
||
*/
|
||
function normalizeTracks(raw: unknown): AnimationTrack[] {
|
||
const tracks: AnimationTrack[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const t = asRecord(item);
|
||
if (typeof t.id !== 'string' || !t.id || typeof t.property !== 'string' || !t.property) continue;
|
||
const target = typeof t.target === 'string' ? t.target : '';
|
||
if (tracks.some((o) => o.id === t.id || (o.target === target && o.property === t.property))) continue;
|
||
const keys: AnimationKey[] = [];
|
||
for (const k of Array.isArray(t.keys) ? t.keys : []) {
|
||
const key = asRecord(k);
|
||
const frame = Number(key.frame);
|
||
if (!Number.isFinite(frame) || !isPrimitive(key.value)) continue;
|
||
const kept: AnimationKey = { frame, value: key.value };
|
||
// `linear` is what no field means, so it is not kept; an unknown one reads as linear, too.
|
||
if (isKeyInterpolation(key.interpolation) && key.interpolation !== 'linear')
|
||
kept.interpolation = key.interpolation;
|
||
keys.push(kept);
|
||
}
|
||
const track: AnimationTrack = { id: t.id, target, property: t.property, keys: normalizeKeys(keys) };
|
||
if (t.relative === true) track.relative = true;
|
||
tracks.push(track);
|
||
}
|
||
return tracks;
|
||
}
|
||
|
||
/**
|
||
* The layers of a tilemap. A cell whose key is not a pair of whole numbers, or which names nothing,
|
||
* is dropped; one naming a deleted tile is kept, like an animation's dangling row, so an undo
|
||
* brings the drawing back. A doubled layer id is dropped as a doubled node is.
|
||
*/
|
||
function normalizeLayers(raw: unknown): TilemapLayer[] {
|
||
const layers: TilemapLayer[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const l = asRecord(item);
|
||
if (typeof l.id !== 'string' || !l.id || layers.some((o) => o.id === l.id)) continue;
|
||
const stored = decodeCells(l);
|
||
const cells: Record<string, string> = {};
|
||
for (const [key, value] of Object.entries(stored))
|
||
if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value;
|
||
const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells };
|
||
if (l.hidden === true) layer.hidden = true;
|
||
if (typeof l.z === 'number' && Number.isFinite(l.z) && l.z !== 0) layer.z = l.z;
|
||
layers.push(layer);
|
||
}
|
||
return layers;
|
||
}
|
||
|
||
/**
|
||
* The shapes of a vector art asset (#129). A point whose coordinates are no numbers falls away, as
|
||
* a frame's outline corners do, and so does a handle that is not a pair of numbers – the point then
|
||
* reads as a corner, which is what a shape drawn without handles says anyway. A shape left without
|
||
* any drawable ring is kept: it is an outline someone is still drawing, and the rasteriser passes
|
||
* over it. A doubled shape id is dropped as a doubled layer is.
|
||
*/
|
||
function normalizeShapes(raw: unknown): VectorShape[] {
|
||
const shapes: VectorShape[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const s = asRecord(item);
|
||
if (typeof s.id !== 'string' || !s.id || shapes.some((o) => o.id === s.id)) continue;
|
||
const rings: VectorRing[] = [];
|
||
for (const r of Array.isArray(s.rings) ? s.rings : []) {
|
||
const ring = asRecord(r);
|
||
const points: VectorPoint[] = [];
|
||
for (const p of Array.isArray(ring.points) ? ring.points : []) {
|
||
const point = asRecord(p);
|
||
const x = asFinite(point.x);
|
||
const y = asFinite(point.y);
|
||
if (x === null || y === null) continue;
|
||
const kept: VectorPoint = { x, y };
|
||
for (const side of ['in', 'out'] as const) {
|
||
const handle = normalizeHandle(point[side]);
|
||
if (handle) kept[side] = handle;
|
||
}
|
||
if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode;
|
||
points.push(kept);
|
||
}
|
||
// Closed is what a shape usually is, and what every ring written by the editor says.
|
||
rings.push({ points, closed: ring.closed !== false });
|
||
}
|
||
const shape: VectorShape = {
|
||
id: s.id,
|
||
name: typeof s.name === 'string' ? s.name : 'Shape',
|
||
rings,
|
||
fill: normalizeColor(s.fill, SHAPE_FILL_DEFAULT),
|
||
opacity: normalizeOpacity(s.opacity),
|
||
};
|
||
const paint = normalizePaint(s.paint);
|
||
if (paint) shape.paint = paint;
|
||
const stroke = asRecord(s.stroke);
|
||
const width = asFinite(stroke.width);
|
||
if (width !== null && width > 0) {
|
||
shape.stroke = { color: normalizeColor(stroke.color, 0), width };
|
||
const line = normalizePaint(stroke.paint);
|
||
if (line && line.type !== 'texture') shape.stroke.paint = line;
|
||
}
|
||
shapes.push(shape);
|
||
}
|
||
return shapes;
|
||
}
|
||
|
||
/**
|
||
* A number as written, or null – unlike `toNumber`, which reads anything unusable as 0. A coordinate
|
||
* of 0 is a place on the canvas, so "broken" and "the left edge" must not come out the same.
|
||
*/
|
||
function asFinite(value: unknown): number | null {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||
}
|
||
|
||
/**
|
||
* A gradient or picture fill (#186). One that cannot be drawn as written – no ends, an unknown type,
|
||
* fewer than two stops – is dropped and the shape reads as the solid colour it kept all along. A
|
||
* texture id is kept even when nothing answers to it: the drawing falls back to its colour and
|
||
* shows it, as a sprite shows a texture that has gone.
|
||
*/
|
||
function normalizePaint(raw: unknown): VectorPaint | null {
|
||
const p = asRecord(raw);
|
||
const from = normalizeHandle(p.from);
|
||
const to = normalizeHandle(p.to);
|
||
if (!from || !to) return null;
|
||
if (p.type === 'texture')
|
||
return typeof p.texture === 'string' ? { type: 'texture', from, to, texture: p.texture } : null;
|
||
if (p.type !== 'linear' && p.type !== 'radial') return null;
|
||
const stops: GradientStop[] = [];
|
||
for (const item of Array.isArray(p.stops) ? p.stops : []) {
|
||
const stop = asRecord(item);
|
||
const offset = asFinite(stop.offset);
|
||
if (offset === null) continue;
|
||
stops.push({
|
||
offset: Math.max(0, Math.min(1, offset)),
|
||
color: normalizeColor(stop.color, 0),
|
||
alpha: normalizeOpacity(stop.alpha),
|
||
});
|
||
}
|
||
const gradient: VectorGradient = { type: p.type, from, to, stops };
|
||
return stops.length >= 2 ? gradient : null;
|
||
}
|
||
|
||
function normalizeHandle(raw: unknown): Vec2 | undefined {
|
||
const handle = asRecord(raw);
|
||
const x = asFinite(handle.x);
|
||
const y = asFinite(handle.y);
|
||
return x !== null && y !== null ? { x, y } : undefined;
|
||
}
|
||
|
||
function normalizeColor(raw: unknown, fallback: number): number {
|
||
const value = asFinite(raw);
|
||
return value === null ? fallback : Math.max(0, Math.min(0xffffff, Math.floor(value)));
|
||
}
|
||
|
||
function normalizeOpacity(raw: unknown): number {
|
||
const value = asFinite(raw);
|
||
return value === null ? 1 : Math.max(0, Math.min(1, value));
|
||
}
|
||
|
||
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 {
|
||
if (isPrimitive(value)) return value;
|
||
if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined);
|
||
return asStoredStruct(value, asStoredField);
|
||
}
|
||
|
||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||
|
||
/** A primitive, or the one object a flat position may hold: a tagged item. */
|
||
const asFlatItem = (value: unknown): FlatItem | undefined => {
|
||
if (isPrimitive(value)) return value;
|
||
const tag = tagOf(value);
|
||
return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined;
|
||
};
|
||
|
||
/** A list item: a flat one, or a struct of flat ones. */
|
||
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
|
||
asFlatItem(value) ?? asStoredStruct(value, asFlatItem);
|
||
|
||
/** A struct field: a flat item or a flat list. A field that is itself a struct is dropped. */
|
||
const asStoredField = (value: unknown): FieldValue | undefined =>
|
||
Array.isArray(value) ? value.map(asFlatItem).filter((item) => item !== undefined) : asFlatItem(value);
|
||
|
||
function asStoredStruct(
|
||
value: unknown,
|
||
ofField: (item: unknown) => FieldValue | undefined,
|
||
): StructValue | undefined {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||
const fields: Record<string, FieldValue> = {};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const stored = ofField(item);
|
||
if (stored !== undefined) fields[key] = stored;
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
function toNumber(value: unknown): number {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|