@ludic-labs/assembly 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +2 -0
  3. package/README.md +50 -2
  4. package/dist/bake.d.ts +80 -0
  5. package/dist/bake.js +214 -0
  6. package/dist/browser.d.ts +89 -0
  7. package/dist/browser.js +534 -0
  8. package/dist/compat.d.ts +17 -0
  9. package/dist/compat.js +153 -0
  10. package/dist/compiler.d.ts +18 -0
  11. package/dist/compiler.js +1264 -0
  12. package/dist/diagnostics.d.ts +5 -0
  13. package/dist/diagnostics.js +8 -0
  14. package/dist/export.d.ts +54 -0
  15. package/dist/export.js +272 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.js +3 -0
  18. package/dist/legacy.d.ts +36 -0
  19. package/dist/legacy.js +180 -0
  20. package/dist/math.d.ts +16 -0
  21. package/dist/math.js +62 -0
  22. package/dist/mesh.d.ts +11 -0
  23. package/dist/mesh.js +153 -0
  24. package/dist/motion.d.ts +63 -0
  25. package/dist/motion.js +1476 -0
  26. package/dist/pixel.d.ts +268 -0
  27. package/dist/pixel.js +942 -0
  28. package/dist/required.d.ts +2 -0
  29. package/dist/required.js +6 -0
  30. package/dist/shapes.d.ts +71 -0
  31. package/dist/shapes.js +396 -0
  32. package/dist/sprite-brief.d.ts +5 -0
  33. package/dist/sprite-brief.js +47 -0
  34. package/dist/sprite-compiler.d.ts +62 -0
  35. package/dist/sprite-compiler.js +1745 -0
  36. package/dist/sprite-decompile.d.ts +68 -0
  37. package/dist/sprite-decompile.js +786 -0
  38. package/dist/sprite-format.d.ts +4 -0
  39. package/dist/sprite-format.js +155 -0
  40. package/dist/sprite-guide.d.ts +40 -0
  41. package/dist/sprite-guide.js +330 -0
  42. package/dist/sprite-scene.d.ts +277 -0
  43. package/dist/sprite-scene.js +664 -0
  44. package/dist/sprite-types.d.ts +110 -0
  45. package/dist/sprite-types.js +1 -0
  46. package/dist/sprite.d.ts +8 -0
  47. package/dist/sprite.js +8 -0
  48. package/dist/svg/schema.d.ts +71 -0
  49. package/dist/svg/schema.js +217 -0
  50. package/dist/svg/types.d.ts +148 -0
  51. package/dist/svg/types.js +1 -0
  52. package/dist/svg.d.ts +26 -0
  53. package/dist/svg.js +539 -0
  54. package/dist/text.d.ts +12 -0
  55. package/dist/text.js +75 -0
  56. package/dist/types.d.ts +478 -0
  57. package/dist/types.js +1 -0
  58. package/docs/svg/README.md +150 -0
  59. package/docs/svg/specification.md +108 -0
  60. package/examples/svg/arcane-shield.json +261 -0
  61. package/examples/svg/aurora-ribbon.json +158 -0
  62. package/examples/svg/bouncing-ember.json +76 -0
  63. package/examples/svg/ember-fountain.json +224 -0
  64. package/examples/svg/lantern-keeper.json +1159 -0
  65. package/examples/svg/night-workshop.json +1120 -0
  66. package/examples/svg/waygate-platform.json +64 -0
  67. package/examples/svg/waygate-spark.json +92 -0
  68. package/examples/svg/waygate-wisp.json +105 -0
  69. package/examples/svg/waygate.json +485 -0
  70. package/package.json +132 -4
  71. package/schemas/asset-assembly-0.1.schema.json +354 -0
  72. package/schemas/asset-assembly-0.2.schema.json +429 -0
  73. package/schemas/asset-assembly-0.3.schema.json +572 -0
  74. package/schemas/motion-set-0.1.schema.json +178 -0
  75. package/schemas/pixel-sprite-0.1.schema.json +892 -0
  76. package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
  77. package/schemas/svg-asset-0.1.schema.json +1157 -0
  78. package/tools/svg-asset.mjs +106 -0
@@ -0,0 +1,1745 @@
1
+ import { requireValue } from "./required.js";
2
+ // Host-independent compiler for the pixel-sprite/0.1 notation.
3
+ //
4
+ // A document declares a palette, optional parts and stamps, cels and
5
+ // animations. A cel is a canvas built by ordered layers in whole-pixel
6
+ // coordinates. Literal `pixels` rows can state any indexed image exactly; the
7
+ // other operations are shorthands that compile to the same pixels. A cel can
8
+ // start from another cel or from a 3D render supplied by the caller through
9
+ // `options.renderBase`, so this module performs no IO and stays
10
+ // host-independent. Every pixel records the part that owns it.
11
+ export const SPRITE_FORMAT = "pixel-sprite/0.1";
12
+ export const SPRITE_LIMITS = Object.freeze({
13
+ dimension: 1024,
14
+ cels: 4096,
15
+ layers: 512,
16
+ expandedOperations: 8192,
17
+ pixels: 16777216,
18
+ sceneDimension: 2048,
19
+ stamps: 256,
20
+ colors: 4096,
21
+ specks: 4096,
22
+ });
23
+ /** Reserved colour reference: paint transparent. */
24
+ export const CLEAR = "clear";
25
+ /** Predefined key in `pixels` rows and stamps: leave the pixel unchanged. */
26
+ export const SKIP_KEY = ".";
27
+ const NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
28
+ const HEX = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/;
29
+ const CORNERS = ["top-left", "top-right", "bottom-left", "bottom-right", "center"];
30
+ const DIRS = { "upper-left": [-1, -1], "upper-right": [1, -1], "lower-left": [-1, 1], "lower-right": [1, 1] };
31
+ const DOC_FIELDS = [
32
+ "format",
33
+ "id",
34
+ "asset",
35
+ "palette",
36
+ "parts",
37
+ "stamps",
38
+ "recipes",
39
+ "views",
40
+ "cels",
41
+ "animations",
42
+ "notes",
43
+ ];
44
+ const CEL_FIELDS = ["size", "pivot", "base", "flip", "view", "exempt", "layers", "notes"];
45
+ /** Guide parts smaller than this many pixels are not required in a view. */
46
+ export const GUIDE_MIN_AREA = 3;
47
+ /** Bounding-box overlap below which an authored part is reported as drifting from its guide. */
48
+ export const GUIDE_MIN_IOU = 0.35;
49
+ const LAYER_FIELDS = {
50
+ pixels: ["at", "part", "onPart"],
51
+ rect: ["fill", "round", "part"],
52
+ ellipse: ["fill", "part"],
53
+ line: ["color", "part"],
54
+ stamp: ["at", "flip", "part", "onPart"],
55
+ outline: ["parts", "side", "corners", "against"],
56
+ bevel: ["parts", "width", "dir", "through", "keep"],
57
+ speck: ["parts", "count", "seed", "inset"],
58
+ mirror: ["parts"],
59
+ recolor: ["parts"],
60
+ repaint: ["parts", "keep"],
61
+ move: ["parts"],
62
+ silhouette: ["fill", "part"],
63
+ shade: ["parts", "guides"],
64
+ recipe: [],
65
+ use: ["at", "flip", "part"],
66
+ };
67
+ const OPS = Object.keys(LAYER_FIELDS);
68
+ // ---------- diagnostics ----------
69
+ /** Escapes a document key for use as a JSON Pointer segment. */
70
+ export const ptr = (key) => String(key).replace(/~/g, "~0").replace(/\//g, "~1");
71
+ function diag(list, severity, code, path, message, extra = {}) {
72
+ list.push({ severity, code, path, message, ...extra });
73
+ }
74
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
75
+ const isInt = (v) => Number.isInteger(v);
76
+ const isPoint = (v) => Array.isArray(v) && v.length === 2 && v.every(Number.isSafeInteger);
77
+ // ---------- colour ----------
78
+ export function parseHex(hex) {
79
+ if (typeof hex !== "string")
80
+ return null;
81
+ const m = HEX.exec(hex);
82
+ if (!m)
83
+ return null;
84
+ const n = parseInt(requireValue(m[1]), 16);
85
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255, m[2] ? parseInt(m[2], 16) : 255];
86
+ }
87
+ export function toHex([r, g, b, a]) {
88
+ const h = (v) => v.toString(16).padStart(2, "0");
89
+ return `#${h(r)}${h(g)}${h(b)}${a === 255 ? "" : h(a)}`;
90
+ }
91
+ const srgbToLinear = (c) => {
92
+ const v = c / 255;
93
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
94
+ };
95
+ export function rgbToOklab([r, g, b]) {
96
+ const lr = srgbToLinear(r), lg = srgbToLinear(g), lb = srgbToLinear(b);
97
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
98
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
99
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
100
+ return [
101
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
102
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
103
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
104
+ ];
105
+ }
106
+ /** Resolves palette declarations into a reference resolver. */
107
+ function buildPalette(palette, d) {
108
+ const colors = new Map();
109
+ const ramps = new Map();
110
+ const keys = new Map([[SKIP_KEY, null]]);
111
+ let keyWidth = 1;
112
+ const empty = {
113
+ resolve: (_ref) => null,
114
+ keys,
115
+ keyWidth,
116
+ all: [],
117
+ locked: false,
118
+ ramps,
119
+ colors,
120
+ };
121
+ if (palette === undefined)
122
+ palette = {};
123
+ if (!isObj(palette)) {
124
+ diag(d, "error", "SPRITE_PALETTE", "/palette", "palette must be an object");
125
+ return empty;
126
+ }
127
+ for (const k of Object.keys(palette))
128
+ if (!["colors", "ramps", "keys", "keyWidth", "lock"].includes(k))
129
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `/palette/${ptr(k)}`, `unknown palette field ${k}`);
130
+ if (palette.keyWidth !== undefined) {
131
+ if (![1, 2, 3].includes(palette.keyWidth))
132
+ diag(d, "error", "SPRITE_KEY_WIDTH", "/palette/keyWidth", "keyWidth must be 1, 2 or 3");
133
+ else
134
+ keyWidth = palette.keyWidth;
135
+ }
136
+ for (const key of ["colors", "ramps", "keys"])
137
+ if (palette[key] !== undefined && !isObj(palette[key]))
138
+ diag(d, "error", "SPRITE_PALETTE", `/palette/${key}`, `${key} must be an object`);
139
+ for (const [name, hex] of Object.entries(isObj(palette.colors) ? palette.colors : {})) {
140
+ const c = typeof hex === "string" ? parseHex(hex) : null;
141
+ if (!NAME.test(name) || name === CLEAR)
142
+ diag(d, "error", "SPRITE_NAME", `/palette/colors/${ptr(name)}`, `invalid colour name ${name}`);
143
+ else if (!c)
144
+ diag(d, "error", "SPRITE_COLOR", `/palette/colors/${ptr(name)}`, "expected #rrggbb or #rrggbbaa", {
145
+ received: hex,
146
+ });
147
+ else
148
+ colors.set(name, c);
149
+ }
150
+ for (const [name, steps] of Object.entries(isObj(palette.ramps) ? palette.ramps : {})) {
151
+ if (!NAME.test(name) || colors.has(name) || name === CLEAR) {
152
+ diag(d, "error", "SPRITE_NAME", `/palette/ramps/${ptr(name)}`, `invalid or duplicate ramp name ${name}`);
153
+ continue;
154
+ }
155
+ if (!Array.isArray(steps) || !steps.length) {
156
+ diag(d, "error", "SPRITE_RAMP", `/palette/ramps/${ptr(name)}`, "a ramp is a non-empty array of colours");
157
+ continue;
158
+ }
159
+ const parsed = steps.map((hex, i) => {
160
+ const c = typeof hex === "string" ? parseHex(hex) : null;
161
+ if (!c)
162
+ diag(d, "error", "SPRITE_COLOR", `/palette/ramps/${ptr(name)}/${i}`, "expected #rrggbb or #rrggbbaa", {
163
+ received: hex,
164
+ });
165
+ return c;
166
+ });
167
+ ramps.set(name, parsed);
168
+ }
169
+ const locked = palette.lock === true;
170
+ if (palette.lock !== undefined && typeof palette.lock !== "boolean")
171
+ diag(d, "error", "SPRITE_PALETTE", "/palette/lock", "lock must be true or false");
172
+ const resolve = (ref) => {
173
+ if (ref === null)
174
+ return null;
175
+ if (ref === CLEAR)
176
+ return CLEAR;
177
+ if (typeof ref !== "string")
178
+ return null;
179
+ if (ref.startsWith("#"))
180
+ return locked ? null : (parseHex(ref) ?? null);
181
+ if (colors.has(ref))
182
+ return colors.get(ref) ?? null;
183
+ const dot = ref.lastIndexOf(".");
184
+ if (dot > 0) {
185
+ const ramp = ramps.get(ref.slice(0, dot));
186
+ const i = Number(ref.slice(dot + 1));
187
+ if (ref.slice(dot + 1).length > 0 && ramp && isInt(i) && i >= 0 && i < ramp.length)
188
+ return ramp[i] ?? null;
189
+ }
190
+ return null;
191
+ };
192
+ for (const [key, ref] of Object.entries(isObj(palette.keys) ? palette.keys : {})) {
193
+ if (key.length !== keyWidth || key === SKIP_KEY.repeat(keyWidth) || /["\\\s]/.test(key)) {
194
+ diag(d, "error", "SPRITE_KEY", `/palette/keys/${ptr(key)}`, `keys must be ${keyWidth} printable characters`);
195
+ continue;
196
+ }
197
+ const c = resolve(ref);
198
+ if (c === null)
199
+ diag(d, "error", locked && typeof ref === "string" && ref.startsWith("#")
200
+ ? "SPRITE_PALETTE_LOCKED"
201
+ : "SPRITE_COLOR_REF", `/palette/keys/${ptr(key)}`, locked && typeof ref === "string" && ref.startsWith("#")
202
+ ? "the palette is locked: refer to a named colour or ramp step"
203
+ : `unknown colour reference ${JSON.stringify(ref)}`);
204
+ else
205
+ keys.set(key, c);
206
+ }
207
+ if (keyWidth > 1)
208
+ keys.set(SKIP_KEY.repeat(keyWidth), null);
209
+ const all = [...colors.values(), ...[...ramps.values()].flat()].filter((c) => c !== null);
210
+ const colourCount = new Set([...all, ...keys.values()].filter(Array.isArray).map((c) => toHex(c))).size;
211
+ if (colourCount > SPRITE_LIMITS.colors)
212
+ diag(d, "error", "SPRITE_BUDGET", "/palette", `more than ${SPRITE_LIMITS.colors} colours`);
213
+ return { resolve, keys, keyWidth, all, locked, ramps, colors };
214
+ }
215
+ // ---------- canvas ----------
216
+ export class SpriteCanvas {
217
+ width;
218
+ height;
219
+ rgba;
220
+ owners;
221
+ constructor(width, height) {
222
+ this.width = width;
223
+ this.height = height;
224
+ this.rgba = new Uint8Array(width * height * 4);
225
+ this.owners = new Int32Array(width * height).fill(-1);
226
+ }
227
+ clone() {
228
+ const c = new SpriteCanvas(this.width, this.height);
229
+ c.rgba.set(this.rgba);
230
+ c.owners.set(this.owners);
231
+ return c;
232
+ }
233
+ inside(x, y) {
234
+ return x >= 0 && y >= 0 && x < this.width && y < this.height;
235
+ }
236
+ opaque(x, y) {
237
+ return this.inside(x, y) && (this.rgba[(y * this.width + x) * 4 + 3] ?? 0) > 0;
238
+ }
239
+ owner(x, y) {
240
+ return this.inside(x, y) ? (this.owners[y * this.width + x] ?? -1) : -1;
241
+ }
242
+ color(x, y) {
243
+ const i = (y * this.width + x) * 4;
244
+ return [this.rgba[i] ?? 0, this.rgba[i + 1] ?? 0, this.rgba[i + 2] ?? 0, this.rgba[i + 3] ?? 0];
245
+ }
246
+ /** Paints one pixel; CLEAR erases it. Returns false when outside the canvas. */
247
+ set(x, y, c, owner) {
248
+ if (!this.inside(x, y))
249
+ return false;
250
+ const p = y * this.width + x;
251
+ if (c === CLEAR) {
252
+ this.rgba.fill(0, p * 4, p * 4 + 4);
253
+ this.owners[p] = -1;
254
+ return true;
255
+ }
256
+ this.rgba.set(c, p * 4);
257
+ if (c[3] === 0) {
258
+ this.rgba.fill(0, p * 4, p * 4 + 4);
259
+ this.owners[p] = -1;
260
+ }
261
+ else if (owner != null)
262
+ this.owners[p] = owner;
263
+ return true;
264
+ }
265
+ flip(axis) {
266
+ const out = new SpriteCanvas(this.width, this.height);
267
+ for (let y = 0; y < this.height; y++)
268
+ for (let x = 0; x < this.width; x++) {
269
+ const sx = axis.includes("x") ? this.width - 1 - x : x;
270
+ const sy = axis.includes("y") ? this.height - 1 - y : y;
271
+ const s = sy * this.width + sx, t = y * this.width + x;
272
+ out.rgba.set(this.rgba.subarray(s * 4, s * 4 + 4), t * 4);
273
+ out.owners[t] = this.owners[s] ?? -1;
274
+ }
275
+ return out;
276
+ }
277
+ }
278
+ /** Deterministic 32-bit PRNG (mulberry32). */
279
+ export function mulberry32(seed) {
280
+ let a = seed >>> 0;
281
+ return () => {
282
+ a = (a + 0x6d2b79f5) >>> 0;
283
+ let t = a;
284
+ t = Math.imul(t ^ (t >>> 15), t | 1);
285
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
286
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
287
+ };
288
+ }
289
+ /** Integer points of a Bresenham segment, inclusive. */
290
+ export function bresenham([x0, y0], [x1, y1]) {
291
+ if (![x0, y0, x1, y1].every(Number.isSafeInteger))
292
+ return [];
293
+ const pts = [];
294
+ const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1, dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
295
+ let err = dx + dy, x = x0, y = y0;
296
+ for (;;) {
297
+ pts.push([x, y]);
298
+ if (x === x1 && y === y1)
299
+ break;
300
+ const e2 = 2 * err;
301
+ if (e2 >= dy) {
302
+ err += dy;
303
+ x += sx;
304
+ }
305
+ if (e2 <= dx) {
306
+ err += dx;
307
+ y += sy;
308
+ }
309
+ }
310
+ return pts;
311
+ }
312
+ // Clip in the major-axis step domain, preserving Bresenham's original phase.
313
+ // BigInt keeps the rounding exact even when safe endpoints span an unsafe delta.
314
+ function clippedLine(a, b, width, height) {
315
+ const ax = BigInt(a[0]), ay = BigInt(a[1]);
316
+ const bx = BigInt(b[0]), by = BigInt(b[1]);
317
+ const abs = (n) => (n < 0n ? -n : n);
318
+ const dx = abs(bx - ax), dy = abs(by - ay);
319
+ const sx = ax < bx ? 1n : -1n, sy = ay < by ? 1n : -1n;
320
+ const horizontal = dx >= dy;
321
+ const major = horizontal ? dx : dy, minor = horizontal ? dy : dx;
322
+ const start = horizontal ? ax : ay, sign = horizontal ? sx : sy;
323
+ const end = BigInt((horizontal ? width : height) - 1);
324
+ const low = sign > 0n ? -start : start - end;
325
+ const high = sign > 0n ? end - start : start;
326
+ const first = low > 0n ? low : 0n, last = high < major ? high : major;
327
+ const points = [];
328
+ for (let k = first; k <= last; k++) {
329
+ const offset = major === 0n ? 0n : (k * minor + major / 2n) / major;
330
+ const x = Number(ax + sx * (horizontal ? k : offset));
331
+ const y = Number(ay + sy * (horizontal ? offset : k));
332
+ if (x >= 0 && x < width && y >= 0 && y < height)
333
+ points.push([x, y]);
334
+ }
335
+ return points;
336
+ }
337
+ // ---------- compiler ----------
338
+ /**
339
+ * Compiles a pixel-sprite/0.1 document.
340
+ * `SPRITE_GUIDE_HIDDEN_REF` is an error for referencing an occluded guide part;
341
+ * `SPRITE_GUIDE_HIDDEN` warns about an authored drawing of such a part.
342
+ * Every user key in diagnostic paths is JSON Pointer escaped. Expanded recipes
343
+ * retain their calling path: /cels/<cel>/layers/<i>/recipe(<name>)/<j>.
344
+ * Resolver failures always emit an error, even if they also carry warnings.
345
+ */
346
+ export function compileSprite(doc, options = {}) {
347
+ const d = [];
348
+ /** Resolved part anchors, in order: { path, part, point }. */
349
+ const anchors = [];
350
+ /** Per cel with a view: how each authored part compares with its layout guide. */
351
+ const guideReport = [];
352
+ const guides = new Map();
353
+ const result = {
354
+ ok: false,
355
+ diagnostics: d,
356
+ parts: [],
357
+ cels: new Map(),
358
+ animations: new Map(),
359
+ anchors,
360
+ guides,
361
+ guideReport,
362
+ };
363
+ if (!isObj(doc)) {
364
+ diag(d, "error", "SPRITE_STRUCTURE", "", "document must be an object");
365
+ return result;
366
+ }
367
+ if (doc.format !== SPRITE_FORMAT)
368
+ diag(d, "error", "SPRITE_FORMAT", "/format", `expected ${SPRITE_FORMAT}`, { received: doc.format });
369
+ for (const k of Object.keys(doc))
370
+ if (!DOC_FIELDS.includes(k))
371
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `/${ptr(k)}`, `unknown field ${k}`);
372
+ for (const key of ["id", "asset"])
373
+ if (doc[key] !== undefined && typeof doc[key] !== "string")
374
+ diag(d, "error", "SPRITE_STRUCTURE", `/${key}`, `${key} must be a string`);
375
+ const pal = buildPalette(doc.palette, d);
376
+ // Parts: declared names are enforced; render bases add their own part names.
377
+ const parts = [];
378
+ const partIndex = new Map();
379
+ const declared = doc.parts === undefined ? null : new Set();
380
+ const addPart = (name) => {
381
+ if (!partIndex.has(name)) {
382
+ partIndex.set(name, parts.length);
383
+ parts.push(name);
384
+ }
385
+ return requireValue(partIndex.get(name));
386
+ };
387
+ if (doc.parts !== undefined) {
388
+ const names = Array.isArray(doc.parts) ? doc.parts : isObj(doc.parts) ? Object.keys(doc.parts) : null;
389
+ if (!names)
390
+ diag(d, "error", "SPRITE_PARTS", "/parts", "parts must be an array or object of names");
391
+ for (const n of names ?? []) {
392
+ if (typeof n !== "string" || !NAME.test(n.replace(/\//g, "_")))
393
+ diag(d, "error", "SPRITE_NAME", "/parts", `invalid part name ${JSON.stringify(n)}`);
394
+ else {
395
+ declared?.add(n);
396
+ addPart(n);
397
+ }
398
+ }
399
+ }
400
+ // Part contracts: { ramps, colors } list the only colours a part may use.
401
+ const contracts = new Map();
402
+ if (isObj(doc.parts))
403
+ for (const [name, spec] of Object.entries(doc.parts)) {
404
+ if (!isObj(spec)) {
405
+ diag(d, "error", "SPRITE_PARTS", `/parts/${ptr(name)}`, "a part declaration is an object");
406
+ continue;
407
+ }
408
+ for (const k of Object.keys(spec))
409
+ if (!["ramps", "colors", "notes"].includes(k))
410
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `/parts/${ptr(name)}/${ptr(k)}`, `unknown part field ${k}`);
411
+ if (spec.ramps === undefined && spec.colors === undefined)
412
+ continue;
413
+ const allowed = new Set();
414
+ for (const key of ["ramps", "colors"]) {
415
+ if (spec[key] !== undefined && !Array.isArray(spec[key]))
416
+ diag(d, "error", "SPRITE_PARTS", `/parts/${ptr(name)}/${key}`, `${key} must be an array`);
417
+ }
418
+ for (const [i, r] of (Array.isArray(spec.ramps) ? spec.ramps : []).entries()) {
419
+ const steps = pal.ramps.get(r);
420
+ if (!steps)
421
+ diag(d, "error", "SPRITE_COLOR_REF", `/parts/${ptr(name)}/ramps/${i}`, `unknown ramp ${JSON.stringify(r)}`);
422
+ else
423
+ for (const c of steps)
424
+ if (c)
425
+ allowed.add(toHex(c));
426
+ }
427
+ for (const [i, ref] of (Array.isArray(spec.colors) ? spec.colors : []).entries()) {
428
+ const c = pal.resolve(ref);
429
+ if (!Array.isArray(c))
430
+ diag(d, "error", "SPRITE_COLOR_REF", `/parts/${ptr(name)}/colors/${i}`, `unknown colour ${JSON.stringify(ref)}`);
431
+ else
432
+ allowed.add(toHex(c));
433
+ }
434
+ contracts.set(name, allowed);
435
+ }
436
+ const paletteHex = new Set(pal.all.map((c) => toHex(c)));
437
+ // Final-pixel checks: part contracts, and the palette lock.
438
+ const checkColours = (canvas, path) => {
439
+ const bad = new Map();
440
+ let off = 0;
441
+ for (let q = 0; q < canvas.owners.length; q++) {
442
+ if (!(canvas.rgba[q * 4 + 3] ?? 0))
443
+ continue;
444
+ const hex = toHex([
445
+ requireValue(canvas.rgba[q * 4]),
446
+ requireValue(canvas.rgba[q * 4 + 1]),
447
+ requireValue(canvas.rgba[q * 4 + 2]),
448
+ requireValue(canvas.rgba[q * 4 + 3]),
449
+ ]);
450
+ if (pal.locked && !paletteHex.has(hex))
451
+ off++;
452
+ const name = parts[canvas.owners[q] ?? -1];
453
+ const allowed = name === undefined ? null : contracts.get(name);
454
+ if (allowed && !allowed.has(hex))
455
+ bad.set(name, (bad.get(name) ?? 0) + 1);
456
+ }
457
+ for (const [name, n] of bad)
458
+ diag(d, "warning", "SPRITE_PART_COLOUR", path, `${n} pixel(s) of ${name} use colours outside its ramps`, {
459
+ part: name,
460
+ received: n,
461
+ });
462
+ if (off)
463
+ diag(d, "error", "SPRITE_OFF_PALETTE", path, `${off} pixel(s) use colours outside the locked palette`, {
464
+ received: off,
465
+ });
466
+ };
467
+ const partOf = (name, path) => {
468
+ if (name === undefined)
469
+ return null;
470
+ if (typeof name !== "string") {
471
+ diag(d, "error", "SPRITE_PART", path, "part must be a string");
472
+ return null;
473
+ }
474
+ if (declared && !declared.has(name) && !partIndex.has(name)) {
475
+ diag(d, "error", "SPRITE_UNKNOWN_PART", path, `unknown part ${name}`, { part: name });
476
+ return null;
477
+ }
478
+ return addPart(name);
479
+ };
480
+ const partList = (names, path) => {
481
+ if (names === undefined)
482
+ return null;
483
+ if (!Array.isArray(names) || !names.length) {
484
+ diag(d, "error", "SPRITE_PARTS", path, "parts must be a non-empty array");
485
+ return null;
486
+ }
487
+ return new Set(names.map((n, i) => partOf(n, `${path}/${i}`)).filter((i) => i !== null));
488
+ };
489
+ const color = (ref, path) => {
490
+ const c = pal.resolve(ref);
491
+ if (c === null && pal.locked && typeof ref === "string" && ref.startsWith("#"))
492
+ diag(d, "error", "SPRITE_PALETTE_LOCKED", path, "the palette is locked: refer to a named colour or ramp step");
493
+ else if (c === null)
494
+ diag(d, "error", "SPRITE_COLOR_REF", path, `unknown colour reference ${JSON.stringify(ref)}`);
495
+ return c ?? null;
496
+ };
497
+ // Rows of keys (pixels layers and stamps) become a sparse grid.
498
+ const parseRows = (rows, path) => {
499
+ if (!Array.isArray(rows) || !rows.length || rows.some((r) => typeof r !== "string")) {
500
+ diag(d, "error", "SPRITE_ROWS", path, "rows must be a non-empty array of strings");
501
+ return null;
502
+ }
503
+ const w = (rows[0] ?? "").length;
504
+ if (w === 0 || w % pal.keyWidth) {
505
+ diag(d, "error", "SPRITE_ROW_LENGTH", `${path}/0`, `row length must be a positive multiple of ${pal.keyWidth}`);
506
+ return null;
507
+ }
508
+ const cols = w / pal.keyWidth;
509
+ const grid = [];
510
+ let bad = false;
511
+ rows.forEach((row, y) => {
512
+ if (row.length !== w) {
513
+ diag(d, "error", "SPRITE_ROW_LENGTH", `${path}/${y}`, `row ${y} has ${row.length} characters, expected ${w}`, {
514
+ received: row.length,
515
+ expected: w,
516
+ });
517
+ bad = true;
518
+ return;
519
+ }
520
+ for (let x = 0; x < cols; x++) {
521
+ const key = row.slice(x * pal.keyWidth, (x + 1) * pal.keyWidth);
522
+ if (!pal.keys.has(key)) {
523
+ diag(d, "error", "SPRITE_UNKNOWN_KEY", `${path}/${y}`, `unknown key ${JSON.stringify(key)} at column ${x}`, {
524
+ received: key,
525
+ });
526
+ bad = true;
527
+ continue;
528
+ }
529
+ const c = pal.keys.get(key);
530
+ if (c !== null)
531
+ grid.push([x, y, c]);
532
+ }
533
+ });
534
+ return bad ? null : { cols, rows: rows.length, grid };
535
+ };
536
+ const stamps = new Map();
537
+ if (doc.stamps !== undefined) {
538
+ if (!isObj(doc.stamps) || Object.keys(doc.stamps).length > SPRITE_LIMITS.stamps)
539
+ diag(d, "error", "SPRITE_STAMPS", "/stamps", `stamps must be an object of at most ${SPRITE_LIMITS.stamps}`);
540
+ else
541
+ for (const [name, s] of Object.entries(doc.stamps)) {
542
+ if (!NAME.test(name) || !isObj(s) || Object.keys(s).some((k) => k !== "pixels")) {
543
+ diag(d, "error", "SPRITE_STAMPS", `/stamps/${ptr(name)}`, "a stamp is { pixels: [rows] }");
544
+ continue;
545
+ }
546
+ const g = parseRows(s.pixels, `/stamps/${ptr(name)}/pixels`);
547
+ if (g)
548
+ stamps.set(name, g);
549
+ }
550
+ }
551
+ if (!isObj(doc.cels) || !Object.keys(doc.cels).length) {
552
+ diag(d, "error", "SPRITE_CELS", "/cels", "cels must be a non-empty object");
553
+ return result;
554
+ }
555
+ const celSpecs = doc.cels;
556
+ const celIds = Object.keys(celSpecs);
557
+ if (celIds.length > SPRITE_LIMITS.cels) {
558
+ diag(d, "error", "SPRITE_BUDGET", "/cels", `more than ${SPRITE_LIMITS.cels} cels`);
559
+ return result;
560
+ }
561
+ let allocatedPixels = 0;
562
+ const allocationLimit = Symbol("sprite pixel budget");
563
+ const reservePixels = (width, height, path) => {
564
+ if (allocatedPixels + width * height > SPRITE_LIMITS.pixels) {
565
+ diag(d, "error", "SPRITE_BUDGET", path, `more than ${SPRITE_LIMITS.pixels} allocated canvas pixels`);
566
+ throw allocationLimit;
567
+ }
568
+ allocatedPixels += width * height;
569
+ };
570
+ const allocate = (width, height, path) => {
571
+ reservePixels(width, height, path);
572
+ return new SpriteCanvas(width, height);
573
+ };
574
+ // Views: each names a projection of the linked 3D asset. The caller's
575
+ // `guide` resolver turns it into a layout guide (canvas size and, per part,
576
+ // a bounding box, visible area, depth rank and mask) shared by every cel
577
+ // drawn in that view.
578
+ // Diagnostics from a resolver (for example the /pixel renderer) become errors
579
+ // whose paths point into this document.
580
+ const foreign = (x, prefix) => ({
581
+ ...x,
582
+ severity: x.severity ?? "error",
583
+ path: x.path?.startsWith(prefix) ? x.path : `${prefix}${x.path ?? ""}`,
584
+ });
585
+ const views = isObj(doc.views) ? doc.views : {};
586
+ if (doc.views !== undefined && !isObj(doc.views))
587
+ diag(d, "error", "SPRITE_VIEWS", "/views", "views must be an object of view specifications");
588
+ const guideOf = (view, path) => {
589
+ if (guides.has(view))
590
+ return guides.get(view) ?? null;
591
+ if (!Object.hasOwn(views, view) || !isObj(views[view])) {
592
+ diag(d, "error", "SPRITE_UNKNOWN_VIEW", path, `unknown view ${view}`);
593
+ guides.set(view, null);
594
+ return null;
595
+ }
596
+ if (!options.guide) {
597
+ diag(d, "error", "SPRITE_GUIDE_UNAVAILABLE", path, "no guide resolver was supplied");
598
+ guides.set(view, null);
599
+ return null;
600
+ }
601
+ try {
602
+ const g = options.guide(views[view], `/views/${ptr(view)}`);
603
+ validateGuide(g);
604
+ guides.set(view, g);
605
+ return g;
606
+ }
607
+ catch (e) {
608
+ for (const x of resolverErrors(e, "SPRITE_GUIDE"))
609
+ d.push(foreign(x, `/views/${ptr(view)}`));
610
+ guides.set(view, null);
611
+ return null;
612
+ }
613
+ };
614
+ let currentGuide = null;
615
+ /**
616
+ * The guide part `name`, or null with a diagnostic. With `skipHidden`, a part
617
+ * the guide reports as hidden or cut in this view returns null silently: mask
618
+ * operations (`silhouette`, `shade`) have nothing to paint there, and one
619
+ * recipe can then list every part for every view.
620
+ */
621
+ const guidePart = (name, path, skipHidden = false) => {
622
+ if (!currentGuide) {
623
+ diag(d, "error", "SPRITE_GUIDE_UNAVAILABLE", path, "this cel has no view, so guide references are unavailable");
624
+ return null;
625
+ }
626
+ const g = Object.hasOwn(currentGuide.parts, name) ? currentGuide.parts[name] : null;
627
+ if (!g) {
628
+ const hidden = currentGuide.hidden?.includes(name);
629
+ if (hidden && skipHidden)
630
+ return null;
631
+ diag(d, "error", hidden ? "SPRITE_GUIDE_HIDDEN_REF" : "SPRITE_GUIDE_UNKNOWN_PART", path, hidden ? `part ${name} is hidden in this view` : `the guide has no part ${name}`, { part: name });
632
+ return null;
633
+ }
634
+ return g;
635
+ };
636
+ const recipes = isObj(doc.recipes) ? doc.recipes : {};
637
+ if (doc.recipes !== undefined && !isObj(doc.recipes))
638
+ diag(d, "error", "SPRITE_RECIPES", "/recipes", "recipes must be an object of layer lists");
639
+ for (const [name, list] of Object.entries(recipes))
640
+ if (!NAME.test(name) || !Array.isArray(list))
641
+ diag(d, "error", "SPRITE_RECIPES", `/recipes/${ptr(name)}`, "a recipe is a named array of layers");
642
+ const recipeStack = [];
643
+ let operations = 0;
644
+ const pending = new Set();
645
+ const failed = new Set();
646
+ const compileCel = (id) => {
647
+ if (result.cels.has(id))
648
+ return result.cels.get(id) ?? null;
649
+ const path = `/cels/${ptr(id)}`;
650
+ if (pending.has(id)) {
651
+ diag(d, "error", "SPRITE_BASE_CYCLE", path, `cel ${id} inherits from itself`);
652
+ return null;
653
+ }
654
+ if (failed.has(id))
655
+ return null;
656
+ const spec = celSpecs[id];
657
+ failed.add(id);
658
+ if (!isObj(spec)) {
659
+ diag(d, "error", "SPRITE_CEL", path, "a cel must be an object");
660
+ return null;
661
+ }
662
+ if (pending.size >= 256) {
663
+ diag(d, "error", "SPRITE_DEPTH", path, "cels nest more than 256 levels through use");
664
+ return null;
665
+ }
666
+ pending.add(id);
667
+ for (const k of Object.keys(spec))
668
+ if (!CEL_FIELDS.includes(k))
669
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `${path}/${ptr(k)}`, `unknown cel field ${k}`);
670
+ if (spec.exempt !== undefined &&
671
+ (!Array.isArray(spec.exempt) || spec.exempt.some((n) => typeof n !== "string")))
672
+ diag(d, "error", "SPRITE_EXEMPT", `${path}/exempt`, "exempt is a list of part names");
673
+ let canvas = null;
674
+ let pivot = null;
675
+ let view = null;
676
+ if (typeof spec.base === "string") {
677
+ if (!Object.hasOwn(celSpecs, spec.base))
678
+ diag(d, "error", "SPRITE_UNKNOWN_CEL", `${path}/base`, `unknown base cel ${spec.base}`);
679
+ else {
680
+ const base = compileCel(spec.base);
681
+ if (base) {
682
+ canvas = allocate(base.canvas.width, base.canvas.height, path);
683
+ canvas.rgba.set(base.canvas.rgba);
684
+ canvas.owners.set(base.canvas.owners);
685
+ pivot = base.pivot;
686
+ view = base.view;
687
+ }
688
+ }
689
+ }
690
+ else if (isObj(spec.base) && isObj(spec.base.render)) {
691
+ for (const k of Object.keys(spec.base))
692
+ if (k !== "render")
693
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `${path}/base/${ptr(k)}`, `unknown base field ${k}`);
694
+ if (!options.renderBase)
695
+ diag(d, "error", "SPRITE_RENDER_UNAVAILABLE", `${path}/base`, "no renderBase resolver was supplied");
696
+ else {
697
+ try {
698
+ const r = options.renderBase(spec.base.render, `${path}/base/render`);
699
+ validateRender(r);
700
+ canvas = allocate(r.width, r.height, path);
701
+ canvas.rgba.set(r.rgba);
702
+ const map = r.names.map((n) => addPart(n));
703
+ r.owners.forEach((o, i) => (requireValue(canvas).owners[i] = o >= 0 ? (map[o] ?? -1) : -1));
704
+ if (spec.base.render.snap === true)
705
+ snapToPalette(canvas, pal.all);
706
+ }
707
+ catch (e) {
708
+ if (e === allocationLimit)
709
+ throw e;
710
+ for (const x of resolverErrors(e, "SPRITE_RENDER"))
711
+ d.push(foreign(x, `${path}/base/render`));
712
+ }
713
+ }
714
+ }
715
+ else if (spec.base !== undefined)
716
+ diag(d, "error", "SPRITE_BASE", `${path}/base`, "base must be a cel id or { render: {...} }");
717
+ if (spec.size !== undefined) {
718
+ const ok = Array.isArray(spec.size) &&
719
+ spec.size.length === 2 &&
720
+ spec.size.every((v) => isInt(v) && v >= 1 && v <= SPRITE_LIMITS.dimension);
721
+ if (!ok)
722
+ diag(d, "error", "SPRITE_SIZE", `${path}/size`, `size must be [w, h] within 1..${SPRITE_LIMITS.dimension}`);
723
+ else if (canvas && (canvas.width !== spec.size[0] || canvas.height !== spec.size[1]))
724
+ diag(d, "error", "SPRITE_SIZE", `${path}/size`, "size differs from the base cel", {
725
+ received: spec.size,
726
+ expected: [canvas.width, canvas.height],
727
+ });
728
+ else if (!canvas)
729
+ canvas = allocate(spec.size[0], spec.size[1], path);
730
+ }
731
+ if (spec.view !== undefined) {
732
+ if (typeof spec.view !== "string")
733
+ diag(d, "error", "SPRITE_VIEW", `${path}/view`, "view must be a view name");
734
+ else
735
+ view = spec.view;
736
+ }
737
+ const guide = view === null ? null : guideOf(view, `${path}/view`);
738
+ if (guide) {
739
+ if (!canvas)
740
+ canvas = allocate(guide.width, guide.height, path);
741
+ else if (canvas.width !== guide.width || canvas.height !== guide.height)
742
+ diag(d, "error", "SPRITE_SIZE", path, `the cel size differs from the ${view} guide`, {
743
+ received: [canvas.width, canvas.height],
744
+ expected: [guide.width, guide.height],
745
+ });
746
+ }
747
+ if (!canvas) {
748
+ if (spec.base === undefined && spec.size === undefined && view === null)
749
+ diag(d, "error", "SPRITE_SIZE", path, "a cel needs a size, a base or a view");
750
+ pending.delete(id);
751
+ return null;
752
+ }
753
+ if (spec.flip !== undefined) {
754
+ if (!["x", "y", "xy"].includes(spec.flip))
755
+ diag(d, "error", "SPRITE_FLIP", `${path}/flip`, 'flip must be "x", "y" or "xy"');
756
+ else {
757
+ reservePixels(canvas.width, canvas.height, path);
758
+ canvas = canvas.flip(spec.flip);
759
+ if (spec.view === undefined)
760
+ view = null;
761
+ if (pivot && spec.flip.includes("x"))
762
+ pivot = [canvas.width - 1 - pivot[0], pivot[1]];
763
+ if (pivot && spec.flip.includes("y"))
764
+ pivot = [pivot[0], canvas.height - 1 - pivot[1]];
765
+ }
766
+ }
767
+ if (spec.pivot !== undefined) {
768
+ if (!isPoint(spec.pivot))
769
+ diag(d, "error", "SPRITE_PIVOT", `${path}/pivot`, "pivot must be [x, y] integers");
770
+ else
771
+ pivot = spec.pivot;
772
+ }
773
+ pivot ??= [Math.floor(canvas.width / 2), canvas.height - 1];
774
+ const layers = spec.layers === undefined ? [] : spec.layers;
775
+ if (!Array.isArray(layers) || layers.length > SPRITE_LIMITS.layers)
776
+ diag(d, "error", "SPRITE_LAYERS", `${path}/layers`, `layers must be an array of at most ${SPRITE_LIMITS.layers}`);
777
+ else {
778
+ const previous = currentGuide;
779
+ currentGuide = view === null ? null : guideOf(view, `${path}/view`);
780
+ const previousOperations = operations;
781
+ operations = 0;
782
+ for (const [i, layer] of layers.entries()) {
783
+ applyLayer(canvas, layer, `${path}/layers/${i}`);
784
+ if (operations > SPRITE_LIMITS.expandedOperations)
785
+ break;
786
+ }
787
+ operations = previousOperations;
788
+ if (currentGuide)
789
+ checkGuide(canvas, currentGuide, id, path, spec.exempt);
790
+ currentGuide = previous;
791
+ }
792
+ if (contracts.size || pal.locked)
793
+ checkColours(canvas, path);
794
+ pending.delete(id);
795
+ const cel = { id, canvas, pivot, view };
796
+ failed.delete(id);
797
+ result.cels.set(id, cel);
798
+ return cel;
799
+ };
800
+ // Resolves `at` as [x, y] or { part, corner, offset } against the current canvas.
801
+ const resolveAt = (canvas, at, path) => {
802
+ if (at === undefined)
803
+ return { point: [0, 0], anchor: null };
804
+ if (isPoint(at))
805
+ return { point: at, anchor: null };
806
+ if (isObj(at) && typeof at.guide === "string") {
807
+ const extra = Object.keys(at).filter((k) => !["guide", "corner", "offset"].includes(k));
808
+ if (extra.length)
809
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `${path}/${ptr(extra[0])}`, "unknown anchor field");
810
+ const corner = at.corner ?? "top-left";
811
+ const off = at.offset ?? [0, 0];
812
+ if (!CORNERS.includes(corner) || !isPoint(off)) {
813
+ diag(d, "error", "SPRITE_ANCHOR", path, `corner is one of ${CORNERS.join(", ")}; offset is [dx, dy]`);
814
+ return null;
815
+ }
816
+ const g = guidePart(at.guide, `${path}/guide`);
817
+ if (!g)
818
+ return null;
819
+ const [x, y, w, h] = g.bbox;
820
+ const corners = {
821
+ "top-left": [x, y],
822
+ "top-right": [x + w - 1, y],
823
+ "bottom-left": [x, y + h - 1],
824
+ "bottom-right": [x + w - 1, y + h - 1],
825
+ center: [x + Math.floor((w - 1) / 2), y + Math.floor((h - 1) / 2)],
826
+ };
827
+ const base = requireValue(corners[corner]);
828
+ const point = [base[0] + off[0], base[1] + off[1]];
829
+ anchors.push({ path, guide: at.guide, point });
830
+ return { point, anchor: partIndex.get(at.guide) };
831
+ }
832
+ if (isObj(at) && typeof at.part === "string") {
833
+ const extra = Object.keys(at).filter((k) => !["part", "corner", "offset"].includes(k));
834
+ if (extra.length)
835
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `${path}/${ptr(extra[0])}`, "unknown anchor field");
836
+ const corner = at.corner ?? "top-left";
837
+ if (!CORNERS.includes(corner)) {
838
+ diag(d, "error", "SPRITE_ANCHOR", `${path}/corner`, `corner must be one of ${CORNERS.join(", ")}`);
839
+ return null;
840
+ }
841
+ const off = at.offset ?? [0, 0];
842
+ if (!isPoint(off)) {
843
+ diag(d, "error", "SPRITE_ANCHOR", `${path}/offset`, "offset must be [dx, dy] integers");
844
+ return null;
845
+ }
846
+ const p = partOf(at.part, `${path}/part`);
847
+ if (p === null)
848
+ return null;
849
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
850
+ for (let y = 0; y < canvas.height; y++)
851
+ for (let x = 0; x < canvas.width; x++)
852
+ if (canvas.owner(x, y) === p && canvas.opaque(x, y)) {
853
+ x0 = Math.min(x0, x);
854
+ y0 = Math.min(y0, y);
855
+ x1 = Math.max(x1, x);
856
+ y1 = Math.max(y1, y);
857
+ }
858
+ if (x0 === Infinity) {
859
+ diag(d, "error", "SPRITE_ANCHOR_MISSING", `${path}/part`, `part ${at.part} has no pixels to anchor to`, {
860
+ part: at.part,
861
+ });
862
+ return null;
863
+ }
864
+ const corners = {
865
+ "top-left": [x0, y0],
866
+ "top-right": [x1, y0],
867
+ "bottom-left": [x0, y1],
868
+ "bottom-right": [x1, y1],
869
+ center: [Math.floor((x0 + x1) / 2), Math.floor((y0 + y1) / 2)],
870
+ };
871
+ const base = requireValue(corners[corner]);
872
+ const point = [base[0] + off[0], base[1] + off[1]];
873
+ anchors.push({ path, part: at.part, point });
874
+ return { point, anchor: p };
875
+ }
876
+ diag(d, "error", "SPRITE_AT", path, "at must be [x, y] or { part, corner, offset }");
877
+ return null;
878
+ };
879
+ // Paints a list of [x, y, colour], reporting out-of-bounds and off-part pixels.
880
+ const paint = (canvas, pts, owner, path, onPart) => {
881
+ let outside = 0, offPart = 0;
882
+ for (const [x, y, c] of pts) {
883
+ if (onPart != null && !(canvas.owner(x, y) === onPart && canvas.opaque(x, y)))
884
+ offPart++;
885
+ if (!canvas.set(x, y, c, owner))
886
+ outside++;
887
+ }
888
+ if (outside)
889
+ diag(d, "error", "SPRITE_OUT_OF_BOUNDS", path, `${outside} pixel(s) fall outside the ${canvas.width}x${canvas.height} canvas`, {
890
+ received: outside,
891
+ });
892
+ if (offPart)
893
+ diag(d, "warning", "SPRITE_PATCH_OFF_PART", path, `${offPart} pixel(s) land outside the anchor part`, {
894
+ received: offPart,
895
+ });
896
+ };
897
+ // Colours listed in `keep` are protected: never painted, and outside the region.
898
+ const keepColours = (refs, path) => {
899
+ if (refs === undefined)
900
+ return () => false;
901
+ if (!Array.isArray(refs) || !refs.length) {
902
+ diag(d, "error", "SPRITE_KEEP", path, "keep must be a non-empty array of colours");
903
+ return () => false;
904
+ }
905
+ const cs = refs
906
+ .map((r, i) => color(r, `${path}/${i}`))
907
+ .filter((c) => Array.isArray(c));
908
+ return (canvas, x, y) => {
909
+ const c = canvas.color(x, y);
910
+ return cs.some((k) => k.every((v, i) => v === c[i]));
911
+ };
912
+ };
913
+ const regionSets = (canvas, set) => {
914
+ // Pixel indices by owner, restricted to `set` when given.
915
+ const by = new Map();
916
+ for (let p = 0; p < canvas.owners.length; p++) {
917
+ if ((canvas.rgba[p * 4 + 3] ?? 0) === 0)
918
+ continue;
919
+ const o = canvas.owners[p] ?? -1;
920
+ if (set && !set.has(o))
921
+ continue;
922
+ if (!by.has(o))
923
+ by.set(o, []);
924
+ by.get(o).push(p);
925
+ }
926
+ return by;
927
+ };
928
+ // Compares each authored part with the view's layout guide. Exaggeration is
929
+ // allowed; only parts that drift far from their projected box, guide parts
930
+ // left out, and parts drawn although hidden in this view are reported.
931
+ function checkGuide(canvas, guide, celId, path, exempt) {
932
+ const skip = new Set(Array.isArray(exempt) ? exempt : []);
933
+ const boxes = new Map();
934
+ for (let p = 0; p < canvas.owners.length; p++) {
935
+ if (!(canvas.rgba[p * 4 + 3] ?? 0))
936
+ continue;
937
+ const name = parts[canvas.owners[p] ?? -1];
938
+ if (name === undefined)
939
+ continue;
940
+ const x = p % canvas.width, y = Math.floor(p / canvas.width);
941
+ const b = boxes.get(name) ?? { x0: x, y0: y, x1: x, y1: y, area: 0 };
942
+ b.x0 = Math.min(b.x0, x);
943
+ b.y0 = Math.min(b.y0, y);
944
+ b.x1 = Math.max(b.x1, x);
945
+ b.y1 = Math.max(b.y1, y);
946
+ b.area++;
947
+ boxes.set(name, b);
948
+ }
949
+ const iou = (a, [gx, gy, gw, gh]) => {
950
+ const ix = Math.max(0, Math.min(a.x1 + 1, gx + gw) - Math.max(a.x0, gx));
951
+ const iy = Math.max(0, Math.min(a.y1 + 1, gy + gh) - Math.max(a.y0, gy));
952
+ const inter = ix * iy;
953
+ return inter / ((a.x1 - a.x0 + 1) * (a.y1 - a.y0 + 1) + gw * gh - inter);
954
+ };
955
+ const rows = [];
956
+ for (const [name, b] of boxes) {
957
+ if (skip.has(name))
958
+ continue;
959
+ const g = Object.hasOwn(guide.parts, name) ? guide.parts[name] : null;
960
+ const authored = [b.x0, b.y0, b.x1 - b.x0 + 1, b.y1 - b.y0 + 1];
961
+ if (!g) {
962
+ if (guide.hidden?.includes(name))
963
+ diag(d, "warning", "SPRITE_GUIDE_HIDDEN", path, `part ${name} is drawn but hidden in this view`, {
964
+ part: name,
965
+ });
966
+ continue;
967
+ }
968
+ // An outside outline adds one pixel around a part (and is owned by it), so the
969
+ // guide box grown by one pixel counts too; otherwise thin parts always "drift".
970
+ const [gx, gy, gw, gh] = g.bbox;
971
+ const overlap = Math.max(iou(b, g.bbox), iou(b, [gx - 1, gy - 1, gw + 2, gh + 2]));
972
+ rows.push({
973
+ cel: celId,
974
+ part: name,
975
+ guide: g.bbox,
976
+ authored,
977
+ overlap,
978
+ guideArea: g.area,
979
+ area: b.area,
980
+ });
981
+ if (g.area >= GUIDE_MIN_AREA && overlap < GUIDE_MIN_IOU)
982
+ diag(d, "warning", "SPRITE_GUIDE_DRIFT", path, `part ${name} drifts from its guide box`, {
983
+ part: name,
984
+ received: authored,
985
+ expected: g.bbox,
986
+ });
987
+ }
988
+ for (const [name, g] of Object.entries(guide.parts)) {
989
+ if (g.instance || g.area < GUIDE_MIN_AREA || skip.has(name))
990
+ continue;
991
+ const drawn = [...boxes.keys()].some((n) => n === name || n.startsWith(`${name}/`));
992
+ if (!drawn) {
993
+ rows.push({
994
+ cel: celId,
995
+ part: name,
996
+ guide: g.bbox,
997
+ authored: null,
998
+ overlap: 0,
999
+ guideArea: g.area,
1000
+ area: 0,
1001
+ });
1002
+ diag(d, "warning", "SPRITE_GUIDE_MISSING", path, `guide part ${name} (${g.area} px) is not drawn`, {
1003
+ part: name,
1004
+ });
1005
+ }
1006
+ }
1007
+ guideReport.push(...rows);
1008
+ }
1009
+ function applyLayer(canvas, layer, path) {
1010
+ if (++operations > SPRITE_LIMITS.expandedOperations) {
1011
+ if (operations === SPRITE_LIMITS.expandedOperations + 1)
1012
+ diag(d, "error", "SPRITE_BUDGET", path, `more than ${SPRITE_LIMITS.expandedOperations} expanded operations per cel`);
1013
+ return;
1014
+ }
1015
+ if (!isObj(layer)) {
1016
+ diag(d, "error", "SPRITE_LAYER", path, "a layer must be an object");
1017
+ return;
1018
+ }
1019
+ const ops = OPS.filter((k) => k in layer);
1020
+ if (ops.length !== 1) {
1021
+ diag(d, "error", "SPRITE_LAYER_OP", path, `a layer needs exactly one of ${OPS.join(", ")}`, {
1022
+ received: ops,
1023
+ });
1024
+ return;
1025
+ }
1026
+ const op = requireValue(ops[0]);
1027
+ for (const k of Object.keys(layer))
1028
+ if (k !== op && !requireValue(LAYER_FIELDS[op]).includes(k))
1029
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `${path}/${ptr(k)}`, `unknown field ${k} for ${op}`);
1030
+ if (layer.onPart !== undefined && typeof layer.onPart !== "boolean" && typeof layer.onPart !== "string")
1031
+ diag(d, "error", "SPRITE_LAYER", `${path}/onPart`, "onPart must be a boolean or part name");
1032
+ if (layer.corners !== undefined && typeof layer.corners !== "boolean")
1033
+ diag(d, "error", "SPRITE_OUTLINE", `${path}/corners`, "corners must be boolean");
1034
+ const owner = partOf(layer.part, `${path}/part`);
1035
+ const W = canvas.width, H = canvas.height;
1036
+ switch (op) {
1037
+ case "pixels":
1038
+ case "stamp": {
1039
+ if (op === "stamp" && typeof layer.stamp !== "string") {
1040
+ diag(d, "error", "SPRITE_UNKNOWN_STAMP", `${path}/stamp`, "stamp must be a string");
1041
+ return;
1042
+ }
1043
+ const g = op === "pixels"
1044
+ ? parseRows(layer.pixels, `${path}/pixels`)
1045
+ : (stamps.get(layer.stamp) ??
1046
+ (diag(d, "error", "SPRITE_UNKNOWN_STAMP", `${path}/stamp`, `unknown stamp ${layer.stamp}`),
1047
+ null));
1048
+ if (!g)
1049
+ return;
1050
+ const flip = layer.flip === undefined ? "" : layer.flip;
1051
+ if (!["", "x", "y", "xy"].includes(flip)) {
1052
+ diag(d, "error", "SPRITE_FLIP", `${path}/flip`, 'flip must be "x", "y" or "xy"');
1053
+ return;
1054
+ }
1055
+ if (Array.isArray(layer.at) && layer.at.length === 0) {
1056
+ diag(d, "error", "SPRITE_AT", `${path}/at`, "at must be a point or a non-empty list of points");
1057
+ return;
1058
+ }
1059
+ const ats = op === "stamp" && Array.isArray(layer.at) && layer.at.every(isPoint) ? layer.at : [layer.at];
1060
+ const namedOnPart = typeof layer.onPart === "string" ? partOf(layer.onPart, `${path}/onPart`) : null;
1061
+ for (const at of ats) {
1062
+ const r = resolveAt(canvas, at, `${path}/at`);
1063
+ if (!r)
1064
+ continue;
1065
+ // onPart: true checks the anchor part (or the layer's part); a name checks that part.
1066
+ const onPart = layer.onPart === true
1067
+ ? (r.anchor ?? owner)
1068
+ : typeof layer.onPart === "string"
1069
+ ? namedOnPart
1070
+ : null;
1071
+ const pts = g.grid.map(([x, y, c]) => [
1072
+ r.point[0] + (flip.includes("x") ? g.cols - 1 - x : x),
1073
+ r.point[1] + (flip.includes("y") ? g.rows - 1 - y : y),
1074
+ c,
1075
+ ]);
1076
+ paint(canvas, pts, owner, path, onPart);
1077
+ }
1078
+ return;
1079
+ }
1080
+ case "rect":
1081
+ case "ellipse": {
1082
+ let b = layer[op];
1083
+ if (isObj(b) && typeof b.guide === "string") {
1084
+ const grow = b.grow === undefined ? 0 : b.grow;
1085
+ const extra = Object.keys(b).filter((k) => !["guide", "grow"].includes(k));
1086
+ if (extra.length || !Number.isSafeInteger(grow)) {
1087
+ diag(d, "error", "SPRITE_SHAPE", `${path}/${op}`, "guide bounds are { guide, grow? } with integer grow");
1088
+ return;
1089
+ }
1090
+ const g = guidePart(b.guide, `${path}/${op}/guide`);
1091
+ if (!g)
1092
+ return;
1093
+ const [gx, gy, gw, gh] = g.bbox;
1094
+ b = [gx - grow, gy - grow, gw + 2 * grow, gh + 2 * grow];
1095
+ }
1096
+ if (!(Array.isArray(b) && b.length === 4 && b.every(Number.isSafeInteger) && b[2] > 0 && b[3] > 0)) {
1097
+ diag(d, "error", "SPRITE_SHAPE", `${path}/${op}`, `${op} must be [x, y, w, h] with positive size`);
1098
+ return;
1099
+ }
1100
+ const c = color(layer.fill, `${path}/fill`);
1101
+ if (c === null)
1102
+ return;
1103
+ const [x, y, w, h] = b;
1104
+ const pts = [];
1105
+ const round = layer.round === undefined ? 0 : layer.round;
1106
+ if (op === "rect" && ![0, 1].includes(round)) {
1107
+ diag(d, "error", "SPRITE_SHAPE", `${path}/round`, "round is 0 or 1");
1108
+ return;
1109
+ }
1110
+ for (let j = Math.max(0, -y); j < Math.min(h, H - y); j++)
1111
+ for (let i = Math.max(0, -x); i < Math.min(w, W - x); i++) {
1112
+ if (op === "rect" && round === 1 && (i === 0 || i === w - 1) && (j === 0 || j === h - 1))
1113
+ continue;
1114
+ if (op === "ellipse") {
1115
+ const u = (i + 0.5 - w / 2) / (w / 2), v = (j + 0.5 - h / 2) / (h / 2);
1116
+ if (u * u + v * v > 1)
1117
+ continue;
1118
+ }
1119
+ pts.push([x + i, y + j, c]);
1120
+ }
1121
+ paint(canvas, pts, owner, path);
1122
+ if (op === "rect") {
1123
+ const corners = round === 1 ? Math.min(2, w) * Math.min(2, h) : 0;
1124
+ const count = w * h - corners - pts.length;
1125
+ if (count > 0)
1126
+ diag(d, "error", "SPRITE_OUT_OF_BOUNDS", path, `${count} pixel(s) fall outside the ${W}x${H} canvas`, { received: count });
1127
+ }
1128
+ else {
1129
+ // The widest row and tallest column bound the actual ellipse pixels.
1130
+ const extent = (span, cross) => {
1131
+ const v = cross % 2 === 0 ? 1 / cross : 0;
1132
+ return Math.ceil((span / 2) * (1 - Math.sqrt(1 - v * v)) - 0.5);
1133
+ };
1134
+ const ix = extent(w, h), iy = extent(h, w);
1135
+ const outside = x + ix < 0 || x + w - 1 - ix >= W || y + iy < 0 || y + h - 1 - iy >= H;
1136
+ if (outside)
1137
+ diag(d, "error", "SPRITE_OUT_OF_BOUNDS", path, `ellipse pixels fall outside the ${W}x${H} canvas`);
1138
+ }
1139
+ return;
1140
+ }
1141
+ case "line": {
1142
+ const ptsIn = layer.line;
1143
+ if (!Array.isArray(ptsIn) || ptsIn.length < 2 || !ptsIn.every(isPoint)) {
1144
+ diag(d, "error", "SPRITE_SHAPE", `${path}/line`, "line must be at least two [x, y] points");
1145
+ return;
1146
+ }
1147
+ const c = color(layer.color, `${path}/color`);
1148
+ if (c === null)
1149
+ return;
1150
+ const seen = new Set();
1151
+ const pts = [];
1152
+ for (let i = 1; i < ptsIn.length; i++)
1153
+ for (const [x, y] of clippedLine(requireValue(ptsIn[i - 1]), requireValue(ptsIn[i]), W, H)) {
1154
+ const k = `${x},${y}`;
1155
+ if (!seen.has(k)) {
1156
+ seen.add(k);
1157
+ pts.push([x, y, c]);
1158
+ }
1159
+ }
1160
+ paint(canvas, pts, owner, path);
1161
+ if (ptsIn.some(([x, y]) => !canvas.inside(x, y)))
1162
+ diag(d, "error", "SPRITE_OUT_OF_BOUNDS", path, `line pixels fall outside the ${W}x${H} canvas`);
1163
+ return;
1164
+ }
1165
+ case "outline": {
1166
+ const c = color(layer.outline, `${path}/outline`);
1167
+ if (c === null)
1168
+ return;
1169
+ const set = partList(layer.parts, `${path}/parts`);
1170
+ const side = layer.side === undefined ? "outside" : layer.side;
1171
+ const against = layer.against === undefined ? "empty" : layer.against;
1172
+ if (!["outside", "inside"].includes(side)) {
1173
+ diag(d, "error", "SPRITE_OUTLINE", `${path}/side`, "side is outside or inside");
1174
+ return;
1175
+ }
1176
+ if (!["empty", "any"].includes(against)) {
1177
+ diag(d, "error", "SPRITE_OUTLINE", `${path}/against`, "against is empty or any");
1178
+ return;
1179
+ }
1180
+ const nb = layer.corners === true
1181
+ ? [
1182
+ [-1, 0],
1183
+ [1, 0],
1184
+ [0, -1],
1185
+ [0, 1],
1186
+ [-1, -1],
1187
+ [1, -1],
1188
+ [-1, 1],
1189
+ [1, 1],
1190
+ ]
1191
+ : [
1192
+ [-1, 0],
1193
+ [1, 0],
1194
+ [0, -1],
1195
+ [0, 1],
1196
+ ];
1197
+ const inT = (x, y) => canvas.opaque(x, y) && (!set || set.has(canvas.owner(x, y)));
1198
+ const pts = [];
1199
+ for (let y = 0; y < H; y++)
1200
+ for (let x = 0; x < W; x++) {
1201
+ if (side === "outside") {
1202
+ if (inT(x, y))
1203
+ continue;
1204
+ if (against === "empty" && canvas.opaque(x, y))
1205
+ continue;
1206
+ const n = nb.find(([dx, dy]) => inT(x + dx, y + dy));
1207
+ if (n)
1208
+ pts.push([x, y, canvas.owner(x + n[0], y + n[1])]);
1209
+ }
1210
+ else {
1211
+ if (!inT(x, y))
1212
+ continue;
1213
+ const edge = nb.some(([dx, dy]) => against === "empty"
1214
+ ? !canvas.opaque(x + dx, y + dy)
1215
+ : !canvas.opaque(x + dx, y + dy) || canvas.owner(x + dx, y + dy) !== canvas.owner(x, y));
1216
+ if (edge)
1217
+ pts.push([x, y, canvas.owner(x, y)]);
1218
+ }
1219
+ }
1220
+ for (const [x, y, o] of pts)
1221
+ canvas.set(x, y, c, o);
1222
+ return;
1223
+ }
1224
+ case "bevel": {
1225
+ const b = layer.bevel;
1226
+ if (!isObj(b) ||
1227
+ Object.keys(b).some((k) => !["light", "shadow"].includes(k)) ||
1228
+ !Object.keys(b).length) {
1229
+ diag(d, "error", "SPRITE_BEVEL", `${path}/bevel`, "bevel is { light?, shadow? }");
1230
+ return;
1231
+ }
1232
+ const light = b.light === undefined ? null : color(b.light, `${path}/bevel/light`);
1233
+ const shadow = b.shadow === undefined ? null : color(b.shadow, `${path}/bevel/shadow`);
1234
+ if ((b.light !== undefined && light === null) || (b.shadow !== undefined && shadow === null))
1235
+ return;
1236
+ const width = layer.width === undefined ? 1 : layer.width;
1237
+ if (!isInt(width) || width < 1 || width > 8) {
1238
+ diag(d, "error", "SPRITE_BEVEL", `${path}/width`, "width is 1..8");
1239
+ return;
1240
+ }
1241
+ const dirName = layer.dir ?? "upper-left";
1242
+ const dir = Object.hasOwn(DIRS, dirName) ? DIRS[dirName] : null;
1243
+ if (!dir) {
1244
+ diag(d, "error", "SPRITE_BEVEL", `${path}/dir`, `dir is one of ${Object.keys(DIRS).join(", ")}`);
1245
+ return;
1246
+ }
1247
+ const set = partList(layer.parts, `${path}/parts`);
1248
+ // Parts listed in `through` count as the same surface for edge tests
1249
+ // (an occluding nozzle does not create an edge), but are not painted.
1250
+ const through = partList(layer.through, `${path}/through`) ?? new Set();
1251
+ const kept = keepColours(layer.keep, `${path}/keep`);
1252
+ const same = (x, y, o) => canvas.opaque(x, y) &&
1253
+ !kept(canvas, x, y) &&
1254
+ (canvas.owner(x, y) === o || through.has(canvas.owner(x, y)));
1255
+ const out = [];
1256
+ for (const [o, pix] of regionSets(canvas, set)) {
1257
+ if (through.has(o))
1258
+ continue;
1259
+ for (const p of pix) {
1260
+ const x = p % W, y = (p / W) | 0;
1261
+ if (kept(canvas, x, y))
1262
+ continue;
1263
+ // The nearest edge toward the light is lit, the nearest away from it
1264
+ // is shaded; vertical edges decide before horizontal ones.
1265
+ let side = null;
1266
+ for (const [ax, ay] of [
1267
+ [0, dir[1]],
1268
+ [dir[0], 0],
1269
+ ]) {
1270
+ for (let k = 1; k <= width && side === null; k++) {
1271
+ if (!same(x + ax * k, y + ay * k, o))
1272
+ side = "light";
1273
+ else if (!same(x - ax * k, y - ay * k, o))
1274
+ side = "shadow";
1275
+ }
1276
+ if (side !== null)
1277
+ break;
1278
+ }
1279
+ const tone = side === "light" ? light : side === "shadow" ? shadow : null;
1280
+ if (tone)
1281
+ out.push([x, y, tone]);
1282
+ }
1283
+ }
1284
+ for (const [x, y, c] of out)
1285
+ canvas.set(x, y, c);
1286
+ return;
1287
+ }
1288
+ case "speck": {
1289
+ const c = color(layer.speck, `${path}/speck`);
1290
+ if (c === null)
1291
+ return;
1292
+ const count = layer.count ?? 1, seed = layer.seed ?? 1, inset = layer.inset ?? 1;
1293
+ if (!isInt(count) ||
1294
+ count < 1 ||
1295
+ count > SPRITE_LIMITS.specks ||
1296
+ !isInt(seed) ||
1297
+ !isInt(inset) ||
1298
+ inset < 0) {
1299
+ diag(d, "error", "SPRITE_SPECK", path, `count is 1..${SPRITE_LIMITS.specks}; seed and inset are integers`);
1300
+ return;
1301
+ }
1302
+ const set = partList(layer.parts, `${path}/parts`);
1303
+ const cand = [];
1304
+ for (const [o, pix] of regionSets(canvas, set))
1305
+ for (const p of pix) {
1306
+ const x = p % W, y = (p / W) | 0;
1307
+ if (inset > Math.min(x, y, W - 1 - x, H - 1 - y))
1308
+ continue;
1309
+ let ok = true;
1310
+ for (let dy = -inset; dy <= inset && ok; dy++)
1311
+ for (let dx = -inset; dx <= inset && ok; dx++)
1312
+ if (!(canvas.opaque(x + dx, y + dy) && canvas.owner(x + dx, y + dy) === o))
1313
+ ok = false;
1314
+ if (ok)
1315
+ cand.push(p);
1316
+ }
1317
+ cand.sort((a, b) => a - b);
1318
+ const rnd = mulberry32(seed);
1319
+ for (let i = cand.length - 1; i > 0; i--) {
1320
+ const j = Math.floor(rnd() * (i + 1));
1321
+ [cand[i], cand[j]] = [cand[j], cand[i]];
1322
+ }
1323
+ const picked = new Set();
1324
+ for (const p of cand) {
1325
+ if (picked.size >= count)
1326
+ break;
1327
+ const x = p % W, y = (p / W) | 0;
1328
+ let near = false;
1329
+ for (let dy = -1; dy <= 1 && !near; dy++)
1330
+ for (let dx = -1; dx <= 1 && !near; dx++) {
1331
+ const nx = x + dx, ny = y + dy;
1332
+ if (nx >= 0 && ny >= 0 && nx < W && ny < H && picked.has(ny * W + nx))
1333
+ near = true;
1334
+ }
1335
+ if (!near)
1336
+ picked.add(p);
1337
+ }
1338
+ if (picked.size < count)
1339
+ diag(d, "warning", "SPRITE_SPECK_SHORT", path, `placed ${picked.size} of ${count} specks`, {
1340
+ received: picked.size,
1341
+ expected: count,
1342
+ });
1343
+ for (const p of picked)
1344
+ canvas.set(p % W, (p / W) | 0, c);
1345
+ return;
1346
+ }
1347
+ case "mirror": {
1348
+ if (!["x", "y"].includes(layer.mirror)) {
1349
+ diag(d, "error", "SPRITE_MIRROR", `${path}/mirror`, 'mirror is "x" (left onto right) or "y" (top onto bottom)');
1350
+ return;
1351
+ }
1352
+ const set = partList(layer.parts, `${path}/parts`);
1353
+ reservePixels(W, H, path);
1354
+ const src = canvas.clone();
1355
+ const sel = (x, y) => !set || (src.opaque(x, y) && set.has(src.owner(x, y)));
1356
+ for (let y = 0; y < H; y++)
1357
+ for (let x = 0; x < W; x++) {
1358
+ const right = layer.mirror === "x" ? x >= W - Math.floor(W / 2) : y >= H - Math.floor(H / 2);
1359
+ if (!right)
1360
+ continue;
1361
+ const sx = layer.mirror === "x" ? W - 1 - x : x, sy = layer.mirror === "y" ? H - 1 - y : y;
1362
+ if (set && src.opaque(x, y) && set.has(src.owner(x, y)))
1363
+ canvas.set(x, y, CLEAR);
1364
+ if (sel(sx, sy)) {
1365
+ const c = src.color(sx, sy);
1366
+ canvas.set(x, y, c[3] ? c : CLEAR, src.owner(sx, sy));
1367
+ }
1368
+ }
1369
+ return;
1370
+ }
1371
+ case "recolor": {
1372
+ const map = layer.recolor;
1373
+ if (!isObj(map) || !Object.keys(map).length) {
1374
+ diag(d, "error", "SPRITE_RECOLOR", `${path}/recolor`, "recolor is { fromColour: toColour }");
1375
+ return;
1376
+ }
1377
+ const pairs = Object.entries(map).map(([from, to]) => [
1378
+ color(from, `${path}/recolor/${ptr(from)}`),
1379
+ color(to, `${path}/recolor/${ptr(from)}`),
1380
+ ]);
1381
+ if (pairs.some(([a, b]) => a === null || b === null))
1382
+ return;
1383
+ const set = partList(layer.parts, `${path}/parts`);
1384
+ for (const [, pix] of regionSets(canvas, set))
1385
+ for (const p of pix) {
1386
+ const x = p % W, y = (p / W) | 0;
1387
+ const c = canvas.color(x, y);
1388
+ const hit = pairs.find(([a]) => a != null && a !== CLEAR && a.every((v, i) => v === c[i]));
1389
+ if (hit)
1390
+ canvas.set(x, y, requireValue(hit[1]));
1391
+ }
1392
+ return;
1393
+ }
1394
+ case "repaint": {
1395
+ const c = color(layer.repaint, `${path}/repaint`);
1396
+ if (c === null)
1397
+ return;
1398
+ const set = partList(layer.parts, `${path}/parts`);
1399
+ if (!set) {
1400
+ diag(d, "error", "SPRITE_PARTS", `${path}/parts`, "repaint needs parts");
1401
+ return;
1402
+ }
1403
+ const kept = keepColours(layer.keep, `${path}/keep`);
1404
+ for (const [, pix] of regionSets(canvas, set))
1405
+ for (const p of pix)
1406
+ if (!kept(canvas, p % W, (p / W) | 0))
1407
+ canvas.set(p % W, (p / W) | 0, c);
1408
+ return;
1409
+ }
1410
+ case "silhouette": {
1411
+ const names = typeof layer.silhouette === "string" ? [layer.silhouette] : layer.silhouette;
1412
+ if (!Array.isArray(names) ||
1413
+ (layer.parts !== undefined && !names.length) ||
1414
+ names.some((n) => typeof n !== "string")) {
1415
+ diag(d, "error", "SPRITE_SILHOUETTE", `${path}/silhouette`, "silhouette is a guide part name or a list");
1416
+ return;
1417
+ }
1418
+ const c = color(layer.fill, `${path}/fill`);
1419
+ if (c === null)
1420
+ return;
1421
+ names.forEach((n, i) => {
1422
+ const g = guidePart(n, `${path}/silhouette${typeof layer.silhouette === "string" ? "" : `/${i}`}`, true);
1423
+ if (!g)
1424
+ return;
1425
+ const o = owner ?? partOf(n, `${path}/silhouette`);
1426
+ for (const q of g.mask)
1427
+ canvas.set(q % W, Math.floor(q / W), c, o);
1428
+ });
1429
+ return;
1430
+ }
1431
+ case "shade": {
1432
+ // Repaints guide part masks with the tone the guide's light gives each
1433
+ // pixel: role 1 deep, 2 shadow, 3 mid, 4 light become ramp steps 0–3
1434
+ // (clamped to the ramp). Role 0 (the guide's outline) is left as it is.
1435
+ const spec = layer.shade;
1436
+ if (layer.guides !== undefined &&
1437
+ (!isObj(layer.guides) || Object.values(layer.guides).some((n) => typeof n !== "string"))) {
1438
+ diag(d, "error", "SPRITE_SHADE", `${path}/guides`, "guides maps authored parts to guide names");
1439
+ return;
1440
+ }
1441
+ if (isObj(spec) && Object.values(spec).some((r) => typeof r !== "string" || !r.length)) {
1442
+ diag(d, "error", "SPRITE_SHADE", `${path}/shade`, "shade map values must be non-empty ramp names");
1443
+ return;
1444
+ }
1445
+ const guideName = (n) => isObj(layer.guides) && Object.hasOwn(layer.guides, n) ? layer.guides[n] : n;
1446
+ if (!currentGuide) {
1447
+ diag(d, "error", "SPRITE_GUIDE_UNAVAILABLE", `${path}/shade`, "shade needs a cel drawn in a view");
1448
+ return;
1449
+ }
1450
+ const guide = currentGuide;
1451
+ const roles = guide.roles;
1452
+ if (!roles) {
1453
+ diag(d, "error", "SPRITE_SHADE", `${path}/shade`, "the guide has no tone roles");
1454
+ return;
1455
+ }
1456
+ const declaredRamp = (n) => isObj(doc.parts) && Object.hasOwn(doc.parts, n) && isObj(doc.parts[n])
1457
+ ? (doc.parts[n].ramps?.[0] ?? null)
1458
+ : null;
1459
+ let pairs;
1460
+ if (typeof spec === "string" || spec === true) {
1461
+ let names = layer.parts;
1462
+ if (names === undefined && spec === true)
1463
+ names = Object.keys(isObj(doc.parts) ? doc.parts : {}).filter((n) => declaredRamp(n) !== null && Object.hasOwn(guide.parts, guideName(n)));
1464
+ if (!Array.isArray(names) ||
1465
+ (layer.parts !== undefined && !names.length) ||
1466
+ names.some((n) => typeof n !== "string")) {
1467
+ diag(d, "error", "SPRITE_SHADE", `${path}/parts`, "shade needs parts: a list of guide part names");
1468
+ return;
1469
+ }
1470
+ pairs = names.map((n) => [
1471
+ n,
1472
+ spec === true ? declaredRamp(n) : spec,
1473
+ ]);
1474
+ }
1475
+ else if (isObj(spec) && Object.keys(spec).length) {
1476
+ // `parts` beside a { part: ramp } map limits it to those parts.
1477
+ const listed = layer.parts;
1478
+ if (listed !== undefined &&
1479
+ (!Array.isArray(listed) || !listed.length || listed.some((n) => typeof n !== "string"))) {
1480
+ diag(d, "error", "SPRITE_SHADE", `${path}/parts`, "parts is a list of guide part names");
1481
+ return;
1482
+ }
1483
+ const only = Array.isArray(listed) ? listed : null;
1484
+ pairs = Object.entries(spec).filter(([n]) => !only || only.includes(n));
1485
+ }
1486
+ else {
1487
+ diag(d, "error", "SPRITE_SHADE", `${path}/shade`, "shade is a ramp name, { part: ramp } or true");
1488
+ return;
1489
+ }
1490
+ for (const [n, rampName] of pairs) {
1491
+ const ramp = typeof rampName === "string" ? (pal.ramps.get(rampName) ?? null) : null;
1492
+ if (!ramp) {
1493
+ diag(d, "error", "SPRITE_COLOR_REF", `${path}/shade`, `part ${n} has no ramp ${typeof rampName === "string" ? rampName : "(none declared)"}`, { part: n });
1494
+ continue;
1495
+ }
1496
+ const g = guidePart(guideName(n), `${path}/shade`, true);
1497
+ if (!g)
1498
+ continue;
1499
+ const o = partOf(n, `${path}/shade`);
1500
+ for (const q of g.mask) {
1501
+ const role = roles[q] ?? -1;
1502
+ if (role < 1)
1503
+ continue;
1504
+ const c = ramp[Math.min(role - 1, ramp.length - 1)];
1505
+ if (c)
1506
+ canvas.set(q % W, Math.floor(q / W), c, o);
1507
+ }
1508
+ }
1509
+ return;
1510
+ }
1511
+ case "use": {
1512
+ // Draws another cel as a component: its pixels and their owners, at an offset.
1513
+ if (typeof layer.use !== "string" || !Object.hasOwn(celSpecs, layer.use)) {
1514
+ diag(d, "error", "SPRITE_UNKNOWN_CEL", `${path}/use`, `unknown cel ${JSON.stringify(layer.use)}`);
1515
+ return;
1516
+ }
1517
+ const src = compileCel(layer.use);
1518
+ if (!src)
1519
+ return;
1520
+ const flip = layer.flip === undefined ? "" : layer.flip;
1521
+ if (!["", "x", "y", "xy"].includes(flip)) {
1522
+ diag(d, "error", "SPRITE_FLIP", `${path}/flip`, 'flip must be "x", "y" or "xy"');
1523
+ return;
1524
+ }
1525
+ const r = resolveAt(canvas, layer.at, `${path}/at`);
1526
+ if (!r)
1527
+ return;
1528
+ const sc = src.canvas;
1529
+ let outside = 0;
1530
+ for (let y = 0; y < sc.height; y++)
1531
+ for (let x = 0; x < sc.width; x++) {
1532
+ if (!sc.opaque(x, y))
1533
+ continue;
1534
+ const tx = r.point[0] + (flip.includes("x") ? sc.width - 1 - x : x);
1535
+ const ty = r.point[1] + (flip.includes("y") ? sc.height - 1 - y : y);
1536
+ if (!canvas.set(tx, ty, sc.color(x, y), owner ?? sc.owner(x, y)))
1537
+ outside++;
1538
+ }
1539
+ if (outside)
1540
+ diag(d, "error", "SPRITE_OUT_OF_BOUNDS", path, `${outside} pixel(s) of ${layer.use} fall outside the canvas`, {
1541
+ received: outside,
1542
+ });
1543
+ return;
1544
+ }
1545
+ case "recipe": {
1546
+ const name = layer.recipe;
1547
+ const list = typeof name === "string" && Object.hasOwn(recipes, name) ? recipes[name] : null;
1548
+ if (!Array.isArray(list)) {
1549
+ diag(d, "error", "SPRITE_UNKNOWN_RECIPE", `${path}/recipe`, `unknown recipe ${JSON.stringify(name)}`);
1550
+ return;
1551
+ }
1552
+ if (recipeStack.includes(name) || recipeStack.length >= 8) {
1553
+ diag(d, "error", "SPRITE_RECIPE_CYCLE", `${path}/recipe`, `recipe ${name} expands into itself or nests too deeply`);
1554
+ return;
1555
+ }
1556
+ recipeStack.push(name);
1557
+ for (const [i, l] of list.entries()) {
1558
+ applyLayer(canvas, l, `${path}/recipe(${ptr(name)})/${i}`);
1559
+ if (operations > SPRITE_LIMITS.expandedOperations)
1560
+ break;
1561
+ }
1562
+ recipeStack.pop();
1563
+ return;
1564
+ }
1565
+ case "move": {
1566
+ const m = layer.move;
1567
+ if (!isPoint(m)) {
1568
+ diag(d, "error", "SPRITE_MOVE", `${path}/move`, "move is [dx, dy] integers");
1569
+ return;
1570
+ }
1571
+ const set = partList(layer.parts, `${path}/parts`);
1572
+ if (!set) {
1573
+ diag(d, "error", "SPRITE_PARTS", `${path}/parts`, "move needs parts");
1574
+ return;
1575
+ }
1576
+ const moved = [];
1577
+ for (const [o, pix] of regionSets(canvas, set))
1578
+ for (const p of pix)
1579
+ moved.push([p % W, (p / W) | 0, canvas.color(p % W, (p / W) | 0), o]);
1580
+ for (const [x, y] of moved)
1581
+ canvas.set(x, y, CLEAR);
1582
+ let outside = 0;
1583
+ for (const [x, y, c, o] of moved)
1584
+ if (!canvas.set(x + m[0], y + m[1], c, o))
1585
+ outside++;
1586
+ if (outside)
1587
+ diag(d, "error", "SPRITE_OUT_OF_BOUNDS", path, `${outside} moved pixel(s) fall outside the canvas`, {
1588
+ received: outside,
1589
+ });
1590
+ return;
1591
+ }
1592
+ }
1593
+ }
1594
+ // Compile bases before the cels that inherit from them, so long chains do
1595
+ // not recurse and the result does not depend on key order.
1596
+ const depth = new Map();
1597
+ const depthOf = (start) => {
1598
+ const chain = [];
1599
+ const seen = new Set();
1600
+ let id = start;
1601
+ while (typeof id === "string" && Object.hasOwn(celSpecs, id) && !depth.has(id) && !seen.has(id)) {
1602
+ seen.add(id);
1603
+ chain.push(id);
1604
+ id = isObj(celSpecs[id]) ? celSpecs[id].base : null;
1605
+ }
1606
+ let base = typeof id === "string" && depth.has(id) ? depth.get(id) : 0;
1607
+ for (let i = chain.length - 1; i >= 0; i--)
1608
+ depth.set(chain[i], ++base);
1609
+ return depth.get(start) ?? 0;
1610
+ };
1611
+ const order = celIds.map((id, i) => [depthOf(id), i, id]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
1612
+ try {
1613
+ for (const [, , id] of order)
1614
+ compileCel(id);
1615
+ }
1616
+ catch (e) {
1617
+ if (e !== allocationLimit)
1618
+ throw e;
1619
+ }
1620
+ if (doc.animations !== undefined) {
1621
+ if (!isObj(doc.animations))
1622
+ diag(d, "error", "SPRITE_ANIMATIONS", "/animations", "animations must be an object");
1623
+ else
1624
+ for (const [name, a] of Object.entries(doc.animations)) {
1625
+ const p = `/animations/${ptr(name)}`;
1626
+ if (!isObj(a) || !Array.isArray(a.cels) || !a.cels.length) {
1627
+ diag(d, "error", "SPRITE_ANIMATION", p, "an animation is { cels: [...], ms, loop? }");
1628
+ continue;
1629
+ }
1630
+ for (const k of Object.keys(a))
1631
+ if (!["cels", "ms", "loop"].includes(k))
1632
+ diag(d, "error", "SPRITE_UNKNOWN_FIELD", `${p}/${ptr(k)}`, `unknown field ${k}`);
1633
+ a.cels.forEach((c, i) => {
1634
+ if (typeof c !== "string" || !Object.hasOwn(celSpecs, c))
1635
+ diag(d, "error", "SPRITE_UNKNOWN_CEL", `${p}/cels/${i}`, `unknown cel ${JSON.stringify(c)}`);
1636
+ });
1637
+ const ms = Array.isArray(a.ms) ? a.ms : a.cels.map(() => a.ms ?? 100);
1638
+ if (ms.length !== a.cels.length || !ms.every((v) => isInt(v) && v > 0))
1639
+ diag(d, "error", "SPRITE_ANIMATION", `${p}/ms`, "ms is a positive integer or one per cel");
1640
+ const sizes = new Set(a.cels
1641
+ .map((c) => result.cels.get(c))
1642
+ .filter((c) => c !== undefined)
1643
+ .map((c) => `${c.canvas.width}x${c.canvas.height}`));
1644
+ if (sizes.size > 1)
1645
+ diag(d, "error", "SPRITE_ANIMATION", `${p}/cels`, "animation cels differ in size");
1646
+ if (a.loop !== undefined && typeof a.loop !== "boolean")
1647
+ diag(d, "error", "SPRITE_ANIMATION", `${p}/loop`, "loop must be boolean");
1648
+ result.animations.set(name, { cels: a.cels, ms, loop: a.loop !== false });
1649
+ }
1650
+ }
1651
+ result.parts = parts;
1652
+ result.ok = !d.some((x) => x.severity === "error");
1653
+ return result;
1654
+ }
1655
+ /** Maps every opaque pixel to the nearest palette colour in OKLab. */
1656
+ export function snapToPalette(canvas, colors) {
1657
+ if (!colors.length)
1658
+ return;
1659
+ const labs = colors.map((c) => [c, rgbToOklab(c)]);
1660
+ const memo = new Map();
1661
+ for (let p = 0; p < canvas.owners.length; p++) {
1662
+ const a = canvas.rgba[p * 4 + 3] ?? 0;
1663
+ if (!a)
1664
+ continue;
1665
+ const key = `${canvas.rgba[p * 4]},${canvas.rgba[p * 4 + 1]},${canvas.rgba[p * 4 + 2]}`;
1666
+ let best = memo.get(key);
1667
+ if (!best) {
1668
+ const lab = rgbToOklab([
1669
+ canvas.rgba[p * 4] ?? 0,
1670
+ canvas.rgba[p * 4 + 1] ?? 0,
1671
+ canvas.rgba[p * 4 + 2] ?? 0,
1672
+ ]);
1673
+ let bd = Infinity;
1674
+ for (const [c, l] of labs) {
1675
+ const dd = (l[0] - lab[0]) ** 2 + (l[1] - lab[1]) ** 2 + (l[2] - lab[2]) ** 2;
1676
+ if (dd < bd) {
1677
+ bd = dd;
1678
+ best = c;
1679
+ }
1680
+ }
1681
+ memo.set(key, best);
1682
+ }
1683
+ canvas.rgba.set([best[0], best[1], best[2], a], p * 4);
1684
+ }
1685
+ }
1686
+ /** Part under a pixel of a compiled cel, or null. */
1687
+ export function pickSprite(result, celId, x, y) {
1688
+ if (!Number.isInteger(x) || !Number.isInteger(y))
1689
+ return null;
1690
+ const cel = result.cels.get(celId);
1691
+ if (!cel || !cel.canvas.opaque(x, y))
1692
+ return null;
1693
+ return result.parts[cel.canvas.owner(x, y)] ?? null;
1694
+ }
1695
+ function invalidResolver(message) {
1696
+ throw new Error(message);
1697
+ }
1698
+ function validSize(value) {
1699
+ return [value.width, value.height].every((n) => Number.isInteger(n) && n >= 1 && n <= SPRITE_LIMITS.dimension);
1700
+ }
1701
+ function validateRender(r) {
1702
+ if (!r || !validSize(r))
1703
+ invalidResolver("render dimensions must be integers within 1..1024");
1704
+ if (!(r.rgba instanceof Uint8Array) ||
1705
+ r.rgba.length !== r.width * r.height * 4 ||
1706
+ !(r.owners instanceof Int32Array) ||
1707
+ r.owners.length !== r.width * r.height ||
1708
+ !Array.isArray(r.names) ||
1709
+ r.names.some((n) => typeof n !== "string") ||
1710
+ r.owners.some((o) => o < -1 || o >= r.names.length))
1711
+ invalidResolver("invalid render rgba, owners or names");
1712
+ }
1713
+ function validateGuide(g) {
1714
+ if (!g || !validSize(g))
1715
+ invalidResolver("guide dimensions must be integers within 1..1024");
1716
+ if (!isObj(g.parts) ||
1717
+ (g.owners !== undefined &&
1718
+ (!(g.owners instanceof Int32Array) || g.owners.length !== g.width * g.height)) ||
1719
+ (g.depth !== undefined && g.depth.length !== g.width * g.height) ||
1720
+ (g.roles !== undefined && (!(g.roles instanceof Int8Array) || g.roles.length !== g.width * g.height)) ||
1721
+ (g.hidden !== undefined && (!Array.isArray(g.hidden) || g.hidden.some((n) => typeof n !== "string"))))
1722
+ invalidResolver("invalid guide parts, owners, depth, roles or hidden");
1723
+ for (const part of Object.values(g.parts)) {
1724
+ if (!isObj(part) ||
1725
+ !Array.isArray(part.bbox) ||
1726
+ part.bbox.length !== 4 ||
1727
+ !part.bbox.every(Number.isFinite) ||
1728
+ part.bbox[2] <= 0 ||
1729
+ part.bbox[3] <= 0 ||
1730
+ !Number.isFinite(part.area) ||
1731
+ part.area < 0 ||
1732
+ !Array.isArray(part.mask) ||
1733
+ part.mask.some((q) => !Number.isInteger(q) || q < 0 || q >= g.width * g.height))
1734
+ invalidResolver("invalid guide part bbox, area or mask");
1735
+ }
1736
+ }
1737
+ function resolverErrors(error, code) {
1738
+ const value = isObj(error) ? error : {};
1739
+ const diagnostics = Array.isArray(value.diagnostics)
1740
+ ? value.diagnostics.filter(isObj)
1741
+ : [];
1742
+ if (!diagnostics.some((d) => d.severity !== "warning"))
1743
+ diagnostics.push({ severity: "error", code, message: String(value.message ?? error) });
1744
+ return diagnostics;
1745
+ }