A track item has always carried an object, and an object needs an asset. Instead of objects without one, a dropped picture or video gets a sprite the composition owns, the way a song owns its sounds (#355). To the rest of the code it is a sprite like any other. - The asset list starts a drag for textures, vector art, pixel art and videos. Dropped on a track, they make the sprite and the item in one undo step. A video item is as long as the video and follows it. - One sprite per picture and composition. A second drop of the same video shares it. Once no item wears it, it goes in the same step. - Deleting a composition takes its sprites along. A duplicate gets sprites of its own. - Pasting into a scene or container turns the sprite into a project asset. Pasting into another composition gives that one a copy. The clipboard carries the sprite, so cut and paste loses nothing. - Owned sprites stay out of the asset list and the pickers. The inspector shows them with a dot in front. - "Make project asset" in the item's menu, on the timeline and in the hierarchy, turns the sprite into an ordinary one. - The first track of the Composition tab gets its top line, as the Song tab's has. Found on the way: loading kept an owner only for the asset types a song can hold. A composition's sprite would have lost its owner and turned up in the list after a reload. Tests: - Unit composition-sprites: 11 new. Without the rule 5 of them are red, without the loading fix the loading test is. - e2e 69 on melektaus-atx: ALL PASSED, 15 checks, 69k new - Gate on melektaus-atx: 305 files, 4714 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1121 lines
46 KiB
TypeScript
1121 lines
46 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,
|
||
type PortSplit,
|
||
isValidPortName,
|
||
fixedPortKeys,
|
||
type NodeDef,
|
||
nodeDef,
|
||
STATE,
|
||
} from '../graph';
|
||
import { isParticleNodeDef } from '../particles/nodes';
|
||
import { isShaderNodeDef } from '../shader/nodes';
|
||
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
||
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
||
import { heldBake } from '../music/bake';
|
||
import { heldStream } from '../music/streams';
|
||
import { heldPatternNotes } from '../music/pattern';
|
||
import { isEmbeddable } from '../music/embedded';
|
||
import { heldSongItems, heldSongTracks } from '../music/song';
|
||
import { heldSend, heldSongGraph } from '../music/routing';
|
||
import { isMasterRoutingDef, isSongRoutingDef } from '../music/routingNodes';
|
||
import { heldRouting } from '../music/projectRouting';
|
||
import { heldLoop, type LoopRange } from '../music/sequence';
|
||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||
import type {
|
||
AnimationKey,
|
||
AnimationTrack,
|
||
Asset,
|
||
BehaviorAsset,
|
||
CompositionAsset,
|
||
ContainerAsset,
|
||
PictureCut,
|
||
PixelArtAsset,
|
||
PixelFrame,
|
||
PixelLayer,
|
||
SceneAsset,
|
||
SoundClip,
|
||
StateMachineAsset,
|
||
TextureFrame,
|
||
GradientStop,
|
||
TilemapLayer,
|
||
VariablesAsset,
|
||
VectorArtAsset,
|
||
VectorGradient,
|
||
VectorPaint,
|
||
VectorPoint,
|
||
VectorRing,
|
||
VectorItem,
|
||
VectorLayer,
|
||
VectorShape,
|
||
} from '../model/Asset';
|
||
import { parseCellKey } from '../model/tilemap';
|
||
import { pluginsOf } from './projectPlugins';
|
||
import { collisionLayersOf } from '../model/collisionLayers';
|
||
import { renderLayersOf } from '../model/renderLayers';
|
||
import { gameLinkZoomOf, gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings';
|
||
import { sceneVisibilityOf } from '../model/sceneVisibility';
|
||
import { symbolScaleOf } from '../model/symbolScale';
|
||
import { masterFxOf, masterVolumeOf } from '../model/master';
|
||
import { decodeCells, encodeCells } from './tilemapCells';
|
||
import { isAssetType } from '../model/AssetTypes';
|
||
import { isFontFile } from '../model/font';
|
||
import { isSoundFile } from '../model/sound';
|
||
import { isVideoFile } from '../model/video';
|
||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||
import { type PropertyBag, type PropertyValue, tagOf, type Vec2 } from '../model/Properties';
|
||
import { type ObjectHolder, PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||
import { heldCompositionClips, heldCompositionItems, heldCompositionTracks } from '../model/composition';
|
||
import { readScope, readSharedScope } from '../model/Scope';
|
||
import type { ObjectGroup, SceneObject } from '../model/SceneObject';
|
||
import {
|
||
type CutSize,
|
||
DEFAULT_FILL,
|
||
isTextureFile,
|
||
normalizeFrameRect,
|
||
setCutSlice,
|
||
setCutSortLine,
|
||
setFrameCollision,
|
||
} from '../model/texture';
|
||
import { drawnCutSize } from '../model/cutTarget';
|
||
import { pointsOf } from '../model/polygon';
|
||
import { canvasSide, PALETTE_MAX } from '../model/pixelart';
|
||
import { isCelFile } from '../model/pixelCel';
|
||
import { newId } from '../ids';
|
||
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
|
||
import { sanitizedCurve } from '../model/curve';
|
||
import { hasParentCycle } from '../model/walk';
|
||
import { syncStructTypes } from '../model/structTypes';
|
||
import { Recorder } from '../util/recorder';
|
||
import {
|
||
DECLARED_PORT_KINDS,
|
||
FIELD_KINDS,
|
||
type FieldKind,
|
||
isTaggedKind,
|
||
VARIABLE_KINDS,
|
||
type VariableKind,
|
||
} from '../properties/kinds';
|
||
import { makeItem } from '../properties/listItems';
|
||
import {
|
||
coerceValue,
|
||
initialOf,
|
||
isValidFieldName,
|
||
isValidVariableName,
|
||
type PropertyDef,
|
||
type PropertySchema,
|
||
rowsNotClaimedBy,
|
||
schemaFor,
|
||
sourceRows,
|
||
type StructFieldDef,
|
||
variableDef,
|
||
} from '../properties/PropertySchema';
|
||
import { FIELD_SHADER_KEY, particleParamDefs } from '../properties/particles';
|
||
import { CLIP_ROWS } from '../properties/sounds';
|
||
import { VIDEO_CLIP_ROWS } from '../properties/videos';
|
||
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' | 'text' | 'camera'>();
|
||
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' || a.type === 'text' || a.type === 'camera')
|
||
wearers.set(a.id, a.type);
|
||
if (a.type === 'behavior' || a.type === 'variables' || a.type === 'struct') {
|
||
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));
|
||
}
|
||
if (a.type === 'shaderfunction') shaderGraphs.set(a.id, normalizeGraph(a.graph, isShaderNodeDef));
|
||
schemas.set(a.id, schemaFor(a.type));
|
||
}
|
||
}
|
||
/**
|
||
* A sprite's, source's, text's or camera's rows end with the parameters of the shader its bag names –
|
||
* an override's, else the asset's. Only a sprite's rows yield to none: a name the others have stays
|
||
* theirs.
|
||
*/
|
||
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 rowsOf = (key: string): PropertySchema => {
|
||
const own = bag[key];
|
||
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[key];
|
||
return shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
|
||
};
|
||
const rows = rowsOf(SHADER_KEY);
|
||
if (wearer === 'sprite') return [...schema, ...rows];
|
||
if (wearer === 'particles') return sourceRows(schema, rows, rowsOf(FIELD_SHADER_KEY));
|
||
return [...schema, ...rowsNotClaimedBy(rows, schema)];
|
||
};
|
||
|
||
// 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') {
|
||
const holder: SceneAsset | ContainerAsset = {
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
objects: normalizeObjects(a.objects, schemaOf),
|
||
};
|
||
normalizeGroups(holder, a.groups);
|
||
assets.push(holder);
|
||
} else if (type === 'composition') {
|
||
// One object per item, all at the top level; an object no item spawns is dropped with it.
|
||
const objects = normalizeObjects(a.objects, schemaOf).map((o) => ({ ...o, parentId: '' }));
|
||
const tracks = heldCompositionTracks(a.tracks);
|
||
const items = heldCompositionItems(a.items, tracks, objects);
|
||
const spawned = new Set(items.map((i) => i.id));
|
||
const composition: CompositionAsset = {
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
objects: objects.filter((o) => spawned.has(o.id)),
|
||
tracks,
|
||
items,
|
||
clips: heldCompositionClips(a.clips, tracks),
|
||
};
|
||
normalizeGroups(composition, a.groups);
|
||
assets.push(composition);
|
||
} else if (type === 'behavior') {
|
||
const declared = variables.get(id) ?? [];
|
||
const behavior: BehaviorAsset = {
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
variables: declared,
|
||
graph: normalizeGraph(a.graph),
|
||
};
|
||
// Only names that are declared, each once, in declaration order.
|
||
const declaredOf = (list: unknown): string[] => {
|
||
const named = new Set(Array.isArray(list) ? list : []);
|
||
return declared.map((v) => v.key).filter((key) => named.has(key));
|
||
};
|
||
const keptVariables = declaredOf(a.keptVariables);
|
||
if (keptVariables.length > 0) behavior.keptVariables = keptVariables;
|
||
const privateVariables = declaredOf(a.privateVariables);
|
||
if (privateVariables.length > 0) behavior.privateVariables = privateVariables;
|
||
assets.push(behavior);
|
||
} else if (type === 'variables') {
|
||
const shared: VariablesAsset = { id, type, name, props, variables: variables.get(id) ?? [] };
|
||
// Scope first: a `scene` asset is never persistent, so the switch that cannot apply is dropped.
|
||
if (readSharedScope(a.scope) === 'scene') shared.scope = 'scene';
|
||
else if (a.persistent === true) shared.persistent = true;
|
||
assets.push(shared);
|
||
} else if (type === 'struct') {
|
||
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),
|
||
};
|
||
const scope = readScope(a.scope);
|
||
if (scope !== 'local') machine.scope = scope;
|
||
// 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' || type === 'shaderfunction') {
|
||
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 === 'soundfx') {
|
||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, isSoundFxNodeDef) });
|
||
} else if (type === 'instrument') {
|
||
const keep = (def: NodeDef) => isInstrumentNodeDef(def) || isSoundFxNodeDef(def);
|
||
const graph = normalizeGraph(a.graph, keep);
|
||
for (const node of graph.nodes)
|
||
if (node.type === INSTRUMENT_SAMPLE) node.params = withSamplePlay(node.params);
|
||
assets.push({ id, type, name, props, graph });
|
||
} 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 : '';
|
||
const clips = normalizeClips(a.clips, takenIds);
|
||
const stream = heldStream(a.stream);
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
file,
|
||
...(clips.length > 0 && { clips }),
|
||
...(stream && { stream }),
|
||
});
|
||
} else if (type === 'video') {
|
||
const clips = normalizeClips(a.clips, takenIds, VIDEO_CLIP_ROWS);
|
||
const size = (value: unknown) =>
|
||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
file: typeof a.file === 'string' && isVideoFile(a.file) ? a.file : '',
|
||
width: Math.round(size(a.width)),
|
||
height: Math.round(size(a.height)),
|
||
duration: size(a.duration),
|
||
fps: size(a.fps),
|
||
audio: a.audio === true,
|
||
...(clips.length > 0 && { clips: clips.map(({ id, name, props }) => ({ id, name, props })) }),
|
||
});
|
||
} 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') {
|
||
const drawing: VectorArtAsset = { id, type, name, props, layers: normalizeVectorLayers(a) };
|
||
readCut(drawing, drawnCutSize(drawing), a);
|
||
assets.push(drawing);
|
||
} else if (type === 'pixelart') {
|
||
const drawing: PixelArtAsset = { id, type, name, props, ...normalizePixelArt(a, takenIds) };
|
||
readCut(drawing, drawnCutSize(drawing), a);
|
||
assets.push(drawing);
|
||
} else if (type === 'palette') {
|
||
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
|
||
} else if (type === 'pattern') {
|
||
const loop = readLoop(a.loop);
|
||
const bake = heldBake(a.bake);
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
notes: heldPatternNotes(a.notes),
|
||
...(loop && { loop }),
|
||
...(bake && { bake }),
|
||
});
|
||
} else if (type === 'song') {
|
||
const loop = readLoop(a.loop);
|
||
const bake = heldBake(a.bake);
|
||
const tracks = heldSongTracks(a.tracks);
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
tracks,
|
||
items: heldSongItems(a.items, tracks),
|
||
...(loop && { loop }),
|
||
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
|
||
...(bake && { bake }),
|
||
});
|
||
} else if (type === 'curve') {
|
||
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
|
||
} 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 });
|
||
}
|
||
const owner = typeof a.owner === 'string' ? a.owner : '';
|
||
if (owner && (isEmbeddable(type) || type === 'sprite')) assets[assets.length - 1]!.owner = owner;
|
||
}
|
||
// One whose song or composition (#419) is gone is kept as a project asset rather than lost (#355).
|
||
const ownerType = new Map(assets.map((a) => [a.id, a.type]));
|
||
for (const asset of assets) {
|
||
if (!asset.owner) continue;
|
||
if (ownerType.get(asset.owner) !== (asset.type === 'sprite' ? 'composition' : 'song')) delete asset.owner;
|
||
}
|
||
|
||
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),
|
||
renderLayers: renderLayersOf(r),
|
||
masterVolume: masterVolumeOf(r),
|
||
masterFx: masterFxOf(r),
|
||
routing: heldRouting(normalizeGraph(r.routing, isMasterRoutingDef), assets, false),
|
||
symbolScale: symbolScaleOf(r),
|
||
playFrom: playFromOf(r),
|
||
whilePlaying: whilePlayingOf(r),
|
||
gameScale: gameScaleOf(r),
|
||
gameLinkZoom: gameLinkZoomOf(r),
|
||
sceneVisibility: sceneVisibilityOf(r),
|
||
};
|
||
// A hand-edited file may have copies that no longer match their Struct asset.
|
||
syncStructTypes(project, new Recorder());
|
||
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),
|
||
...(typeof o.groupId === 'string' && o.groupId ? { groupId: o.groupId } : {}),
|
||
});
|
||
}
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* Hierarchy folders (#241): only those with a member are kept, and a member is a top-level object
|
||
* naming one of them – anything else loses its `groupId`.
|
||
*/
|
||
function normalizeGroups(holder: ObjectHolder, raw: unknown): void {
|
||
const groups = new Map<string, ObjectGroup>();
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const g = asRecord(item);
|
||
if (typeof g.id !== 'string' || !g.id || groups.has(g.id)) continue;
|
||
groups.set(g.id, { id: g.id, name: typeof g.name === 'string' ? g.name : '' });
|
||
}
|
||
const used = new Set<string>();
|
||
for (const object of holder.objects) {
|
||
if (object.groupId === undefined) continue;
|
||
if (!object.parentId && groups.has(object.groupId)) used.add(object.groupId);
|
||
else delete object.groupId;
|
||
}
|
||
const kept = [...groups.values()].filter((g) => used.has(g.id));
|
||
if (kept.length > 0) holder.groups = kept;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
if (def.kind === 'struct') readStructType(def, v.structType);
|
||
if (def.kind === 'list') readItemType(def, v.itemType);
|
||
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') {
|
||
const field: StructFieldDef = { key, kind: 'list' };
|
||
readItemType(field, f.itemType);
|
||
return field;
|
||
}
|
||
if (f.kind === 'struct') {
|
||
const field: StructFieldDef = { key, kind: 'struct', fields: normalizeStructFields(f.fields) };
|
||
readStructType(field, f.structType);
|
||
return field;
|
||
}
|
||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||
}
|
||
|
||
/** A dangling id is kept, as a curve's is: the struct keeps the fields it last had (#253). */
|
||
function readStructType(def: { structType?: string }, raw: unknown): void {
|
||
if (typeof raw === 'string' && raw !== '') def.structType = raw;
|
||
}
|
||
|
||
/** The same for a typed list (#254): gone, it is a mixed list whose items keep their marks. */
|
||
function readItemType(def: { itemType?: string }, raw: unknown): void {
|
||
if (typeof raw === 'string' && raw !== '') def.itemType = raw;
|
||
}
|
||
|
||
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';
|
||
const port: DeclaredPort = writable && p.write === true ? { key, kind, write: true } : { key, kind };
|
||
// A machine that is gone is kept, as a struct type is: the port then offers no states.
|
||
if (kind === 'statemachine' && typeof p.machine === 'string' && p.machine !== '')
|
||
port.machine = p.machine;
|
||
if (kind === 'number' && typeof p.default === 'number' && Number.isFinite(p.default))
|
||
port.default = p.default;
|
||
ports.push(port);
|
||
}
|
||
return ports;
|
||
}
|
||
|
||
/** One split per output; its fields are checked like declared ports, the type is kept even when gone. */
|
||
function normalizeSplits(raw: unknown): PortSplit[] {
|
||
const splits: PortSplit[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const s = asRecord(item);
|
||
const port = typeof s.port === 'string' ? s.port : '';
|
||
if (!port || splits.some((other) => other.port === port)) continue;
|
||
const structType = typeof s.structType === 'string' ? s.structType : '';
|
||
splits.push({ port, structType, fields: normalizePorts(s.fields, [], false) });
|
||
}
|
||
return splits;
|
||
}
|
||
|
||
/** `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;
|
||
const splits = normalizeSplits(n.splits);
|
||
if (splits.length > 0) node.splits = splits;
|
||
if (n.collapsed === true) node.collapsed = true;
|
||
const size = asRecord(n.size);
|
||
const w = toNumber(size.w);
|
||
const h = toNumber(size.h);
|
||
if (w > 0 && h > 0) node.size = { w, h };
|
||
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;
|
||
const send = heldSend(e.send);
|
||
if (send) edge.send = send;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Layers need a unique id and keep their order; a frame id shares the reference space with asset ids,
|
||
* as a texture frame's does. A cel naming a layer that is gone, or no cel file, falls away. An asset
|
||
* left without a layer or a frame gets an empty one, the way a new asset starts.
|
||
*/
|
||
function normalizePixelArt(
|
||
raw: Record<string, unknown>,
|
||
takenIds: Set<string>,
|
||
): { width: number; height: number; layers: PixelLayer[]; frames: PixelFrame[] } {
|
||
const layers: PixelLayer[] = [];
|
||
const layerIds = new Set<string>();
|
||
for (const item of Array.isArray(raw.layers) ? raw.layers : []) {
|
||
const l = asRecord(item);
|
||
const id = typeof l.id === 'string' ? l.id : '';
|
||
if (!id || layerIds.has(id)) continue;
|
||
layerIds.add(id);
|
||
const opacity = asFinite(l.opacity);
|
||
const layer: PixelLayer = {
|
||
id,
|
||
name: typeof l.name === 'string' ? l.name : '',
|
||
opacity: opacity === null ? 1 : Math.max(0, Math.min(1, opacity)),
|
||
};
|
||
if (l.hidden === true) layer.hidden = true;
|
||
layers.push(layer);
|
||
}
|
||
if (layers.length === 0) {
|
||
const layer = { id: newId(), name: 'Layer 1', opacity: 1 };
|
||
layers.push(layer);
|
||
layerIds.add(layer.id);
|
||
}
|
||
const frames: PixelFrame[] = [];
|
||
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
|
||
const f = asRecord(item);
|
||
const id = typeof f.id === 'string' ? f.id : '';
|
||
if (!id || takenIds.has(id)) continue;
|
||
takenIds.add(id);
|
||
const cels: Record<string, string> = {};
|
||
for (const [layerId, file] of Object.entries(asRecord(f.cels)))
|
||
if (layerIds.has(layerId) && typeof file === 'string' && isCelFile(file)) cels[layerId] = file;
|
||
frames.push({ id, cels });
|
||
}
|
||
if (frames.length === 0) {
|
||
let id = newId();
|
||
while (takenIds.has(id)) id = newId();
|
||
takenIds.add(id);
|
||
frames.push({ id, cels: {} });
|
||
}
|
||
return { width: canvasSide(raw.width), height: canvasSide(raw.height), layers, frames };
|
||
}
|
||
|
||
/** A loop of a pattern (#333) or song (#334): two numbers of ticks with room between them, else none. */
|
||
function readLoop(raw: unknown): LoopRange | null {
|
||
const r = asRecord(raw);
|
||
const start = asFinite(r.start);
|
||
const end = asFinite(r.end);
|
||
return start === null || end === null ? null : heldLoop({ start: Math.round(start), end: Math.round(end) });
|
||
}
|
||
|
||
/** Colours as 0xRRGGBB, at most `PALETTE_MAX`; anything that is no colour falls away. */
|
||
function normalizePalette(raw: unknown): number[] {
|
||
const colors: number[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const color = asFinite(item);
|
||
if (color !== null && colors.length < PALETTE_MAX)
|
||
colors.push(Math.max(0, Math.min(0xffffff, Math.floor(color))));
|
||
}
|
||
return colors;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
readCut(frame, rect, f);
|
||
// 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 };
|
||
}
|
||
|
||
/**
|
||
* The clips of a sound (#357) or a video (#402); like a frame's, a clip's id may not repeat one the
|
||
* document has.
|
||
*/
|
||
function normalizeClips(
|
||
raw: unknown,
|
||
takenIds: Set<string>,
|
||
rows: readonly PropertyDef[] = CLIP_ROWS,
|
||
): SoundClip[] {
|
||
const clips: SoundClip[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const c = asRecord(item);
|
||
const id = typeof c.id === 'string' ? c.id : '';
|
||
if (!id || takenIds.has(id)) continue;
|
||
takenIds.add(id);
|
||
const stream = heldStream(c.stream);
|
||
clips.push({
|
||
id,
|
||
name: typeof c.name === 'string' ? c.name : '',
|
||
props: normalizeBag(c.props, rows, true),
|
||
...(stream && { stream }),
|
||
});
|
||
}
|
||
return clips;
|
||
}
|
||
|
||
/**
|
||
* The nine-slice and the sort line of a texture frame or a drawing (#308), measured against `size`.
|
||
* A picture without them is one nobody sliced, and keeps saying so.
|
||
*/
|
||
function readCut(cut: PictureCut, size: CutSize, raw: Record<string, unknown>): void {
|
||
const border = asRecord(raw.border);
|
||
setCutSlice(cut, size, {
|
||
border: {
|
||
left: toNumber(border.left),
|
||
top: toNumber(border.top),
|
||
right: toNumber(border.right),
|
||
bottom: toNumber(border.bottom),
|
||
},
|
||
fill: raw.fill === 'stretch' ? 'stretch' : DEFAULT_FILL,
|
||
});
|
||
setCutSortLine(cut, size, toNumber(raw.sortLine));
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
if (l.sortByY === true) layer.sortByY = true;
|
||
layers.push(layer);
|
||
}
|
||
return layers;
|
||
}
|
||
|
||
/**
|
||
* The layers of a vector art asset (#190). A drawing saved before there were layers holds a flat
|
||
* list of `shapes`; they become its one layer. A layer or an item whose id is taken is dropped, as a
|
||
* doubled tilemap layer is, and so is a group with nothing in it – a group is never empty. A
|
||
* drawing left without a layer gets one, since there has to be somewhere to draw.
|
||
*/
|
||
function normalizeVectorLayers(asset: Record<string, unknown>): VectorLayer[] {
|
||
const taken = new Set<string>();
|
||
const layers: VectorLayer[] = [];
|
||
for (const item of Array.isArray(asset.layers) ? asset.layers : []) {
|
||
const l = asRecord(item);
|
||
if (typeof l.id !== 'string' || !l.id || taken.has(l.id)) continue;
|
||
taken.add(l.id);
|
||
const layer: VectorLayer = {
|
||
id: l.id,
|
||
name: typeof l.name === 'string' && l.name ? l.name : 'Layer',
|
||
items: normalizeVectorItems(l.items, taken),
|
||
};
|
||
if (l.hidden === true) layer.hidden = true;
|
||
layers.push(layer);
|
||
}
|
||
if (layers.length === 0)
|
||
layers.push({ id: newId(), name: 'Layer 1', items: normalizeVectorItems(asset.shapes, taken) });
|
||
return layers;
|
||
}
|
||
|
||
/** Shapes and groups; whatever carries `items` is a group. */
|
||
function normalizeVectorItems(raw: unknown, taken: Set<string>): VectorItem[] {
|
||
const items: VectorItem[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const record = asRecord(item);
|
||
if (typeof record.id !== 'string' || !record.id || taken.has(record.id)) continue;
|
||
taken.add(record.id);
|
||
if (!Array.isArray(record.items)) {
|
||
items.push(normalizeShape(record, record.id));
|
||
continue;
|
||
}
|
||
const inner = normalizeVectorItems(record.items, taken);
|
||
if (inner.length > 0)
|
||
items.push({
|
||
id: record.id,
|
||
name: typeof record.name === 'string' ? record.name : 'Group',
|
||
items: inner,
|
||
});
|
||
}
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* One shape 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.
|
||
*/
|
||
function normalizeShape(s: Record<string, unknown>, id: string): VectorShape {
|
||
const rings: VectorRing[] = [];
|
||
for (const r of Array.isArray(s.rings) ? s.rings : []) {
|
||
const ring = asRecord(r);
|
||
// Closed is what a shape usually is, and what every ring written by the editor says.
|
||
rings.push({ points: normalizePoints(ring.points), closed: ring.closed !== false });
|
||
}
|
||
const shape: VectorShape = {
|
||
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;
|
||
}
|
||
return shape;
|
||
}
|
||
|
||
/** The points of a ring or of a curve (#247); the rules are those of `normalizeShape`. */
|
||
function normalizePoints(raw: unknown): VectorPoint[] {
|
||
const points: VectorPoint[] = [];
|
||
for (const p of Array.isArray(raw) ? raw : []) {
|
||
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);
|
||
}
|
||
return points;
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
// A key with its own `initial` stays missing: written out, the old reading would count as
|
||
// a deviation from what a new asset starts with, and mark every older asset gold.
|
||
if (!sparse && initialOf(def) === def.default) 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 at any depth, each
|
||
* item as the kind it says it is. Only what is no value at all (`null`, a bad tag) is dropped.
|
||
*/
|
||
function asStoredValue(value: unknown): PropertyValue | undefined {
|
||
if (isPrimitive(value)) return value;
|
||
if (Array.isArray(value))
|
||
return value.map(asStoredValue).filter((item): item is PropertyValue => item !== undefined);
|
||
if (!value || typeof value !== 'object') return undefined;
|
||
const tag = tagOf(value);
|
||
if (tag !== undefined) return isTaggedKind(tag) ? makeItem(tag, value as PropertyValue) : undefined;
|
||
const fields: Record<string, PropertyValue> = {};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const stored = asStoredValue(item);
|
||
if (stored !== undefined) fields[key] = stored;
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||
|
||
function toNumber(value: unknown): number {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|