// 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, 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 { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; import { type PropertyBag, type PropertyValue, tagOf, type Vec2 } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; 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 { 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(); const variables = new Map(); const rawProps = new Map>(); const shaderGraphs = new Map(); const shaderRows = new Map(); const particleGraphs = new Map(); const wearers = new Map(); 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): 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 === '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 === '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)) assets[assets.length - 1]!.owner = owner; } // One whose song is gone is kept as a project asset rather than lost (#355). const songs = new Set(assets.filter((a) => a.type === 'song').map((a) => a.id)); for (const asset of assets) if (asset.owner && !songs.has(asset.owner)) 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) => 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: SceneAsset | ContainerAsset, raw: unknown): void { const groups = new Map(); 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(); 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 { const edits: Record = {}; 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(); 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(); 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): 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(); 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; graph.nodes.push(node); } const edgeIds = new Set(); 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): 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, takenIds: Set, ): { width: number; height: number; layers: PixelLayer[]; frames: PixelFrame[] } { const layers: PixelLayer[] = []; const layerIds = new Set(); 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 = {}; 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, takenIds: Set, ): { 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); like a frame's, a clip's id may not repeat one the document has. */ function normalizeClips(raw: unknown, takenIds: Set): 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, CLIP_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): 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 = {}; 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): VectorLayer[] { const taken = new Set(); 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): 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, 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 { return value && typeof value === 'object' ? (value as Record) : {}; } /** * 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 = {}; 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; }