castle-web-cli 0.4.83 → 0.4.85

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 (85) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +26 -0
  4. package/dist/agent.js +317 -74
  5. package/dist/ide.js +35 -13
  6. package/dist/native/loop.js +35 -0
  7. package/dist/native/openrouter.d.ts +7 -0
  8. package/dist/native/openrouter.js +25 -1
  9. package/dist/native/types.d.ts +2 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/{index-C9Zhmien.js → index-BJLaUTJE.js} +60 -58
  14. package/dist/shell/assets/{index-BMkQt27u.css → index-DonnH--m.css} +1 -1
  15. package/dist/shell/index.html +2 -2
  16. package/kits/physics-2d/.prettierrc +8 -0
  17. package/kits/physics-2d/CLAUDE.md +329 -0
  18. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  19. package/kits/physics-2d/behaviors/Collider.jsx +199 -0
  20. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  21. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  22. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  23. package/kits/physics-2d/behaviors/tint.js +47 -0
  24. package/kits/physics-2d/blueprints/ball.scene +14 -0
  25. package/kits/physics-2d/blueprints/block.scene +12 -0
  26. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  27. package/kits/physics-2d/blueprints/crate.scene +14 -0
  28. package/kits/physics-2d/blueprints/goal.scene +12 -0
  29. package/kits/physics-2d/castle.json +13 -0
  30. package/kits/physics-2d/docs/pxart-format.md +377 -0
  31. package/kits/physics-2d/drawings/block.pxart +25 -0
  32. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  33. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  34. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  35. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  37. package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
  38. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  39. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  40. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  41. package/kits/physics-2d/editors/editorHistory.js +157 -0
  42. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  43. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  44. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  45. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  46. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  47. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  48. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  49. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  51. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  52. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  53. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  54. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  55. package/kits/physics-2d/engine/assets.js +15 -0
  56. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  57. package/kits/physics-2d/engine/blueprint.js +521 -0
  58. package/kits/physics-2d/engine/collider.js +196 -0
  59. package/kits/physics-2d/engine/files.js +117 -0
  60. package/kits/physics-2d/engine/liveReload.js +88 -0
  61. package/kits/physics-2d/engine/pxart.js +1032 -0
  62. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  63. package/kits/physics-2d/engine/scene.js +686 -0
  64. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  65. package/kits/physics-2d/engine/ui.jsx +688 -0
  66. package/kits/physics-2d/engine/ui.module.css +2287 -0
  67. package/kits/physics-2d/eslint.config.js +71 -0
  68. package/kits/physics-2d/index.html +24 -0
  69. package/kits/physics-2d/main.jsx +24 -0
  70. package/kits/physics-2d/package-lock.json +2706 -0
  71. package/kits/physics-2d/package.json +42 -0
  72. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  73. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  74. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  75. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  76. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  77. package/kits/physics-2d/physics/controls.js +79 -0
  78. package/kits/physics-2d/physics/index.js +26 -0
  79. package/kits/physics-2d/physics/matterBridge.js +126 -0
  80. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  81. package/kits/physics-2d/scenes/main.scene +12 -0
  82. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  83. package/kits/physics-2d/scripts/draw.mjs +121 -0
  84. package/kits/physics-2d/vite.config.js +1 -0
  85. package/package.json +1 -1
@@ -0,0 +1,1032 @@
1
+ // ============================================================================
2
+ // Castle pixel-art format — COMPACT form ("flat" sprite)
3
+ // ============================================================================
4
+ //
5
+ // `.pxart` files come in two COEXISTING forms (capability tiers, not temporal
6
+ // versions). The on-disk discriminator is a string `format` field:
7
+ // `"compact"` or `"full"`. The COMPACT form is intentionally minimal: one
8
+ // shared palette + one grid of single-character keys.
9
+ //
10
+ // {
11
+ // "format": "compact",
12
+ // "palette": { "y": "#ffe11a", "o": "#e8b800", ".": null },
13
+ // "grid": ["..ooyyoo..", ".oyyyyyyo.", "oyyyyyyyyo"]
14
+ // }
15
+ //
16
+ // Rules:
17
+ // - `palette` maps a SINGLE-CHARACTER key -> "#rrggbb" (or "#rrggbbaa"), or
18
+ // null for transparent.
19
+ // - The char "." is reserved for transparency. It is transparent whether or
20
+ // not it appears in `palette` (a `"." : null` entry is allowed but optional).
21
+ // - `grid` is an array of strings, one per row. Each character is a palette
22
+ // key. Width = the longest row; ragged rows are padded with "." (transparent).
23
+ // Height = grid.length.
24
+ // - On serialize we always stamp `format: "compact"`. The parser detects the
25
+ // form by STRUCTURE (object `palette` + `grid`), TOLERATES a missing
26
+ // `format` discriminator, and tolerates ragged rows.
27
+ //
28
+ // ---------------------------------------------------------------------------
29
+ // The FULL form (layers + frames + tags) lives in the second half of this file.
30
+ // A compact file is equivalent to a full sprite with a single pixel layer whose
31
+ // single frame's cel grid === `grid`. In memory the kit ALWAYS works on the full
32
+ // model: `parseFull` upgrades a compact file into the full model on read.
33
+ //
34
+ // This kit is plain JavaScript: the format is described in comments and the
35
+ // runtime behavior below is the source of truth. Do NOT read/write
36
+ // layers/frames/animation in compact-form code.
37
+ // ============================================================================
38
+
39
+ export const COMPACT_FORM = 'compact';
40
+ export const FULL_FORM = 'full';
41
+ export const TRANSPARENT = '.';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // cornerRadius (file-level corner-rounding radius, in native-pixel units)
45
+ // ---------------------------------------------------------------------------
46
+ //
47
+ // `cornerRadius` is a plain number: 0 renders sharp/nearest-neighbor (the
48
+ // historical/default look, 1px/cell); anything > 0 is a corner-rounding
49
+ // radius passed straight through to `pxartSmooth.js`'s local corner kernel.
50
+ // A VALUE rather than a "pixel" | "smooth" flag, so the amount of rounding is
51
+ // itself part of the portable file format instead of a fixed, code-side
52
+ // constant every smooth sprite is stuck with.
53
+
54
+ /** Corner cuts on the same 1-native-pixel edge must not overlap, so radii
55
+ * above this are clamped on parse (and by the editor's UI). */
56
+ export const MAX_CORNER_RADIUS = 0.5;
57
+
58
+ export function clampCornerRadius(radius) {
59
+ return Math.min(MAX_CORNER_RADIUS, Math.max(0, radius));
60
+ }
61
+
62
+ // This field used to be called `render`: first a "pixel" | "smooth" string
63
+ // enum, then (briefly) a bare numeric radius under that same key. Both
64
+ // migrate on read so files from either era keep rendering rounded rather
65
+ // than silently reverting to sharp; "smooth" specifically migrates to this
66
+ // fixed value, since the string enum never carried an amount of its own.
67
+ const LEGACY_SMOOTH_RADIUS = 0.25;
68
+
69
+ /** Read the corner radius off a parsed JSON object, preferring the current
70
+ * `cornerRadius` key and falling back to the legacy `render` key (see
71
+ * above). Defaults to 0 (sharp) for anything else (missing field, typo, a
72
+ * future value this parser doesn't know yet). */
73
+ function parseCornerRadius(data) {
74
+ if (typeof data.cornerRadius === 'number' && Number.isFinite(data.cornerRadius)) {
75
+ return clampCornerRadius(data.cornerRadius);
76
+ }
77
+ const legacy = data.render;
78
+ if (typeof legacy === 'number' && Number.isFinite(legacy)) return clampCornerRadius(legacy);
79
+ if (legacy === 'smooth') return LEGACY_SMOOTH_RADIUS;
80
+ return 0;
81
+ }
82
+
83
+ /** Parse a .pxart string into a compact PxArt record, or null if it isn't the
84
+ * compact shape. Detects by STRUCTURE (object `palette` + `grid`); the on-disk
85
+ * `format` discriminator is optional. Tolerant of ragged rows and a `"."`
86
+ * palette entry. */
87
+ export function parseCompact(content) {
88
+ try {
89
+ const data = JSON.parse(content);
90
+ if (
91
+ data &&
92
+ typeof data.palette === 'object' &&
93
+ data.palette !== null &&
94
+ !Array.isArray(data.palette) &&
95
+ Array.isArray(data.grid) &&
96
+ data.grid.every((r) => typeof r === 'string')
97
+ ) {
98
+ const palette = {};
99
+ for (const [k, v] of Object.entries(data.palette)) {
100
+ // Accept string hex or explicit null; ignore other junk values.
101
+ if (typeof v === 'string') palette[k] = v;
102
+ else if (v === null) palette[k] = null;
103
+ }
104
+ return { palette, grid: data.grid, cornerRadius: parseCornerRadius(data) };
105
+ }
106
+ } catch {
107
+ /* not valid pxart json */
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /** Serialize a compact PxArt record to a .pxart string. Always stamps
113
+ * `format: "compact"`. `cornerRadius` is omitted when it's 0 (sharp), so
114
+ * existing (pixel) files stay byte-identical. */
115
+ export function serializeCompact(art) {
116
+ const out = {
117
+ format: COMPACT_FORM,
118
+ palette: art.palette,
119
+ grid: art.grid,
120
+ ...(art.cornerRadius > 0 ? { cornerRadius: art.cornerRadius } : {}),
121
+ };
122
+ return JSON.stringify(out, null, 2) + '\n';
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // cells <-> grid
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** Rectangularize: ragged rows pad with transparent; empty art gets 1×1. */
130
+ export function toCells(art) {
131
+ const rows = Math.max(art.grid.length, 1);
132
+ const cols = Math.max(...art.grid.map((r) => r.length), 1);
133
+ const cells = [];
134
+ for (let y = 0; y < rows; y++) {
135
+ const row = art.grid[y] ?? '';
136
+ cells.push(Array.from({ length: cols }, (_, x) => row[x] ?? TRANSPARENT));
137
+ }
138
+ return cells;
139
+ }
140
+
141
+ /** Build a PxArt from a palette + cell matrix, dropping unused palette keys. */
142
+ export function fromCells(palette, cells) {
143
+ const used = new Set();
144
+ for (const row of cells) for (const ch of row) used.add(ch);
145
+ const gc = {};
146
+ for (const [k, v] of Object.entries(palette)) {
147
+ if (used.has(k)) gc[k] = v;
148
+ }
149
+ return { palette: gc, grid: cells.map((r) => r.join('')) };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // dimensions / colors
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export function dimsOf(art) {
157
+ const cells = toCells(art);
158
+ const height = cells.length;
159
+ const width = cells[0]?.length ?? 0;
160
+ const used = new Set();
161
+ for (const row of cells) {
162
+ for (const ch of row) {
163
+ if (ch !== TRANSPARENT && colorForKey(art.palette, ch)) used.add(ch);
164
+ }
165
+ }
166
+ return { width, height, colors: used.size };
167
+ }
168
+
169
+ /** Resolve a grid char to a CSS color, or null when transparent. "." is always
170
+ * transparent; an absent key or a null palette value is transparent too. */
171
+ export function colorForKey(palette, key) {
172
+ if (key === TRANSPARENT) return null;
173
+ const v = palette[key];
174
+ return typeof v === 'string' ? v : null;
175
+ }
176
+
177
+ /** '#abc' | 'abc' | '#aabbcc' | 'aabbccdd' -> normalized lowercase '#...' , or
178
+ * null if not a valid 3/6/8-digit hex color. */
179
+ export function normalizeHex(input) {
180
+ let s = input.trim().toLowerCase();
181
+ if (s.startsWith('#')) s = s.slice(1);
182
+ if (/^[0-9a-f]{3}$/.test(s)) s = [...s].map((c) => c + c).join('');
183
+ if (!/^([0-9a-f]{6}|[0-9a-f]{8})$/.test(s)) return null;
184
+ return '#' + s;
185
+ }
186
+
187
+ /** Single-char keys usable in a packed grid, excluding "." (reserved for
188
+ * transparency). Letters + digits give 62 slots — far more than a small sprite
189
+ * ever needs — and the few trailing symbols are JSON-safe fallbacks. Used by
190
+ * representation normalizers that arrive with raw hex colors (svg-rect,
191
+ * index-array) and must mint keys for our packed compact grid. */
192
+ export const KEY_ALPHABET =
193
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&*+=?@';
194
+
195
+ /** Mint stable single-char palette keys for a list of (possibly repeated, or
196
+ * un-normalized) hex colors. Returns the deduped `palette` (key -> normalized
197
+ * hex) plus `keyOf`, a map from normalized hex -> assigned key. Invalid hex and
198
+ * duplicates are skipped. If colors exceed the alphabet they collapse onto the
199
+ * last key (acceptable for the tiny sprites this harness deals with). */
200
+ export function allocatePaletteKeys(colors) {
201
+ const keyOf = new Map();
202
+ const palette = {};
203
+ let i = 0;
204
+ for (const raw of colors) {
205
+ const hex = normalizeHex(raw);
206
+ if (!hex || keyOf.has(hex)) continue;
207
+ const key = KEY_ALPHABET[Math.min(i, KEY_ALPHABET.length - 1)];
208
+ i++;
209
+ keyOf.set(hex, key);
210
+ palette[key] = hex;
211
+ }
212
+ return { palette, keyOf };
213
+ }
214
+
215
+ /** lospec.com/palette-list/aap-64 — the recommended default palette. */
216
+ export const AAP64 = [
217
+ '#060608', '#141013', '#3b1725', '#73172d', '#b4202a', '#df3e23', '#fa6a0a', '#f9a31b',
218
+ '#ffd541', '#fffc40', '#d6f264', '#9cdb43', '#59c135', '#14a02e', '#1a7a3e', '#24523b',
219
+ '#122020', '#143464', '#285cc4', '#249fde', '#20d6c7', '#a6fcdb', '#ffffff', '#fef3c0',
220
+ '#fad6b8', '#f5a097', '#e86a73', '#bc4a9b', '#793a80', '#403353', '#242234', '#221c1a',
221
+ '#322b28', '#71413b', '#bb7547', '#dba463', '#f4d29c', '#dae0ea', '#b3b9d1', '#8b93af',
222
+ '#6d758d', '#4a5462', '#333941', '#422433', '#5b3138', '#8e5252', '#ba756a', '#e9b5a3',
223
+ '#e3e6ff', '#b9bffb', '#849be4', '#588dbe', '#477d85', '#23674e', '#328464', '#5daf8d',
224
+ '#92dcba', '#cdf7e2', '#e4d2aa', '#c7b08b', '#a08662', '#796755', '#5a4e44', '#423934',
225
+ ];
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // rendering
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /** Draw the sprite at 1 device-pixel per cell with smoothing disabled. The
232
+ * canvas is sized to the sprite's exact pixel dimensions; scale it up for
233
+ * display with CSS `image-rendering: pixelated` (see PxSprite in the UI).
234
+ * Transparent cells are left clear so a backdrop/checker can show through. */
235
+ export function renderToCanvas(art, canvas) {
236
+ const cells = toCells(art);
237
+ const rows = cells.length;
238
+ const cols = cells[0]?.length ?? 1;
239
+ canvas.width = cols;
240
+ canvas.height = rows;
241
+ const ctx = canvas.getContext('2d');
242
+ if (!ctx) return;
243
+ ctx.imageSmoothingEnabled = false;
244
+ ctx.clearRect(0, 0, cols, rows);
245
+ for (let y = 0; y < rows; y++) {
246
+ for (let x = 0; x < cols; x++) {
247
+ const color = colorForKey(art.palette, cells[y][x]);
248
+ if (color) {
249
+ ctx.fillStyle = color;
250
+ ctx.fillRect(x, y, 1, 1);
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ // ============================================================================
257
+ // Castle pixel-art format — FULL form (layered + animated "Sprite")
258
+ // ============================================================================
259
+ //
260
+ // The FULL form is a SUPERSET of the compact form. Everything above
261
+ // (parseCompact, serializeCompact, renderToCanvas, dimsOf, toCells, fromCells,
262
+ // normalizeHex, allocatePaletteKeys, AAP64, ...) keeps its exact compact-form
263
+ // signatures/behavior. The full surface below introduces a separate in-memory
264
+ // model (`Sprite`) plus its own parse/serialize/render functions. A compact file
265
+ // upgrades into a Sprite in memory (one layer, one frame).
266
+ //
267
+ // Canonical schema is documented in docs/pxart-format.md. The model here mirrors
268
+ // it: one resolution per sprite, an ORDERED palette of { key, hex }, a frames[]
269
+ // timeline, named tags, and layers (bottom->top) whose cells row is
270
+ // dense-with-null (`{ grid }` | `{ link }` | null), Aseprite-style.
271
+ // ============================================================================
272
+
273
+ /** Default per-frame duration when none is supplied (ms). */
274
+ export const DEFAULT_DURATION_MS = 100;
275
+
276
+ /** Valid sprite resolutions: power-of-two from 16 to 512, inclusive. */
277
+ export const RESOLUTION_MIN = 16;
278
+ export const RESOLUTION_MAX = 512;
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // resolution helpers
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /** The valid resolution dimensions (powers of two, 16..512). */
285
+ export const RESOLUTION_STEPS = (() => {
286
+ const out = [];
287
+ for (let v = RESOLUTION_MIN; v <= RESOLUTION_MAX; v *= 2) out.push(v);
288
+ return out;
289
+ })();
290
+
291
+ /** Snap an arbitrary dimension to the nearest valid power-of-two in 16..512
292
+ * (ties round up). Non-finite/<=0 input snaps to the minimum. Used for
293
+ * EXPLICIT full-form resolutions; compact-shorthand upgrade preserves native
294
+ * dims. */
295
+ export function snapResolutionDim(n) {
296
+ if (!Number.isFinite(n) || n <= 0) return RESOLUTION_MIN;
297
+ const clamped = Math.min(RESOLUTION_MAX, Math.max(RESOLUTION_MIN, n));
298
+ let best = RESOLUTION_STEPS[0];
299
+ let bestDist = Infinity;
300
+ for (const step of RESOLUTION_STEPS) {
301
+ const dist = Math.abs(step - clamped);
302
+ // strict `<` keeps the smaller step on an exact tie distance; to round ties
303
+ // UP we accept an equal distance only when the candidate is larger.
304
+ if (dist < bestDist || (dist === bestDist && step > best)) {
305
+ best = step;
306
+ bestDist = dist;
307
+ }
308
+ }
309
+ return best;
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // format defaults + curated palette
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /** Default sprite resolution for new/generated art (16×16). */
317
+ export const DEFAULT_RESOLUTION = { width: 16, height: 16 };
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Two-tier Endesga-64 palette system
321
+ //
322
+ // EDG64 is the FULL painting palette (Lospec "Endesga 64", canonical order) the
323
+ // editor offers to USERS. AGENT_PALETTE_16 is a fixed 16-color SUBSET of EDG64
324
+ // — the constrained set the LLM generation path is told to use and that the
325
+ // `draw` svg-rect quantizer snaps to. Every AGENT_PALETTE_16 color is a member
326
+ // of EDG64.
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /** lospec.com/palette-list/endesga-64 — the full 64-color painting palette.
330
+ * Lospec order, except the lead bright red (#ff0040) is moved down among the
331
+ * other reds (4th from last) so the swatch grid opens on the grayscale ramp.
332
+ * The editor exposes ALL of these as swatches. */
333
+ export const EDG64 = [
334
+ '#131313', '#1b1b1b', '#272727', '#3d3d3d', '#5d5d5d', '#858585', '#b4b4b4', '#ffffff',
335
+ '#c7cfdd', '#92a1b9', '#657392', '#424c6e', '#2a2f4e', '#1a1932', '#0e071b', '#1c121c',
336
+ '#391f21', '#5d2c28', '#8a4836', '#bf6f4a', '#e69c69', '#f6ca9f', '#f9e6cf', '#edab50',
337
+ '#e07438', '#c64524', '#8e251d', '#ff5000', '#ed7614', '#ffa214', '#ffc825', '#ffeb57',
338
+ '#d3fc7e', '#99e65f', '#5ac54f', '#33984b', '#1e6f50', '#134c4c', '#0c2e44', '#00396d',
339
+ '#0069aa', '#0098dc', '#00cdf9', '#0cf1ff', '#94fdff', '#fdd2ed', '#f389f5', '#db3ffd',
340
+ '#7a09fa', '#3003d9', '#0c0293', '#03193f', '#3b1443', '#622461', '#93388f', '#ca52c9',
341
+ '#c85086', '#f68187', '#f5555d', '#ea323c', '#ff0040', '#c42430', '#891e2b', '#571c27',
342
+ ].map((hex) => normalizeHex(hex) ?? hex);
343
+
344
+ /** A fixed 16-color SUBSET of EDG64 — the constrained palette the agent
345
+ * generation path is told to use, and the target the svg-rect quantizer snaps
346
+ * to. Order is the agent-facing order (warm skins → reds → warm → greens →
347
+ * teals → neutrals → light → cyans → blue). Every entry is a member of EDG64. */
348
+ export const AGENT_PALETTE_16 = [
349
+ '#e69c69', '#bf6f4a', '#8a4836', '#391f21',
350
+ '#891e2b', '#ea323c', '#ffa214', '#ffeb57',
351
+ '#5ac54f', '#1e6f50', '#134c4c', '#657392',
352
+ '#c7cfdd', '#ffffff', '#0cf1ff', '#0098dc',
353
+ ].map((hex) => normalizeHex(hex) ?? hex);
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // full-form parse
357
+ // ---------------------------------------------------------------------------
358
+
359
+ function isPlainObject(v) {
360
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
361
+ }
362
+
363
+ /** Normalize a loosely-typed palette array into ordered { key, hex } entries.
364
+ * Drops "." entries, invalid hex, non-single-char keys, and duplicate keys
365
+ * (first wins). Order is preserved. */
366
+ function parsePaletteArray(raw) {
367
+ const out = [];
368
+ if (!Array.isArray(raw)) return out;
369
+ const seen = new Set();
370
+ for (const entry of raw) {
371
+ if (!isPlainObject(entry)) continue;
372
+ const key = entry.key;
373
+ if (typeof key !== 'string' || key.length !== 1 || key === TRANSPARENT) continue;
374
+ if (seen.has(key)) continue;
375
+ const hex = typeof entry.hex === 'string' ? normalizeHex(entry.hex) : null;
376
+ if (!hex) continue;
377
+ seen.add(key);
378
+ out.push({ key, hex });
379
+ }
380
+ return out;
381
+ }
382
+
383
+ function parseDirection(raw) {
384
+ return raw === 'reverse' || raw === 'pingpong' ? raw : 'forward';
385
+ }
386
+
387
+ /** Coerce a loosely-typed offset component to a finite integer, defaulting 0. */
388
+ function coerceOffset(raw) {
389
+ return typeof raw === 'number' && Number.isFinite(raw) ? Math.trunc(raw) : 0;
390
+ }
391
+
392
+ /** Crop a grid so neither axis exceeds RESOLUTION_MAX (512). A cel image may be
393
+ * larger than the canvas (off-canvas content), but it is hard-capped at the
394
+ * same 512/axis ceiling as the canvas resolution. Returns the input untouched
395
+ * when already within the cap. */
396
+ function capGridDims(grid) {
397
+ const tooTall = grid.length > RESOLUTION_MAX;
398
+ const tooWide = grid.some((r) => r.length > RESOLUTION_MAX);
399
+ if (!tooTall && !tooWide) return grid;
400
+ const rows = tooTall ? grid.slice(0, RESOLUTION_MAX) : grid;
401
+ return rows.map((r) => (r.length > RESOLUTION_MAX ? r.slice(0, RESOLUTION_MAX) : r));
402
+ }
403
+
404
+ /** Build a { grid } cell, attaching x/y only when nonzero (additive: a zero
405
+ * offset serializes as a bare { grid }, identical to pre-offset files). */
406
+ function makeGridCell(grid, rawX, rawY) {
407
+ const capped = capGridDims(grid);
408
+ const x = coerceOffset(rawX);
409
+ const y = coerceOffset(rawY);
410
+ return x || y ? { grid: capped, x, y } : { grid: capped };
411
+ }
412
+
413
+ /** Coerce a loosely-typed cell into a SpriteCell. Reads an optional integer
414
+ * (x, y) offset on grid cells (default 0,0) and caps grid dims at 512/axis. */
415
+ function parseCell(raw) {
416
+ if (raw == null) return null;
417
+ if (isPlainObject(raw)) {
418
+ if (Array.isArray(raw.grid) && raw.grid.every((r) => typeof r === 'string')) {
419
+ return makeGridCell(raw.grid, raw.x, raw.y);
420
+ }
421
+ if (typeof raw.link === 'number' && Number.isInteger(raw.link)) {
422
+ return { link: raw.link };
423
+ }
424
+ }
425
+ // Tolerate a bare frame-index number as a link, or a bare row array as a grid.
426
+ if (typeof raw === 'number' && Number.isInteger(raw)) return { link: raw };
427
+ if (Array.isArray(raw) && raw.every((r) => typeof r === 'string')) {
428
+ return makeGridCell(raw, undefined, undefined);
429
+ }
430
+ return null;
431
+ }
432
+
433
+ /** Infer canvas dimensions from a set of layers, used ONLY as a fallback when a
434
+ * full file omits an explicit `resolution`. The canvas is independent of cel
435
+ * image size, so an oversized cel grid (off-canvas content) must NOT inflate
436
+ * the canvas past the 512 ceiling — clamp each axis to RESOLUTION_MAX. */
437
+ function inferDimsFromLayers(layers) {
438
+ let width = 0;
439
+ let height = 0;
440
+ for (const layer of layers) {
441
+ for (const cell of layer.cells) {
442
+ if (cell && 'grid' in cell) {
443
+ height = Math.max(height, cell.grid.length);
444
+ for (const row of cell.grid) width = Math.max(width, row.length);
445
+ }
446
+ }
447
+ }
448
+ return {
449
+ width: Math.min(Math.max(width, 1), RESOLUTION_MAX),
450
+ height: Math.min(Math.max(height, 1), RESOLUTION_MAX),
451
+ };
452
+ }
453
+
454
+ /** Decide whether a parsed JSON object is the COMPACT form. Detection is by
455
+ * STRUCTURE — full = layered/animated shape (layers / array palette / frames /
456
+ * resolution); compact = flat `grid` over an object palette. The optional
457
+ * `format` discriminator only breaks a tie when structure is ambiguous, so a
458
+ * missing or mislabeled field never causes a silent mis-detection. */
459
+ function detectCompactForm(data) {
460
+ const looksFull =
461
+ Array.isArray(data.layers) ||
462
+ Array.isArray(data.palette) ||
463
+ Array.isArray(data.frames) ||
464
+ isPlainObject(data.resolution);
465
+ const looksCompact = isPlainObject(data.palette) && Array.isArray(data.grid);
466
+ if (looksCompact && !looksFull) return true;
467
+ if (looksFull && !looksCompact) return false;
468
+ const declared = typeof data.format === 'string' ? data.format : null;
469
+ if (declared === COMPACT_FORM) return true;
470
+ if (declared === FULL_FORM) return false;
471
+ return looksCompact; // last resort: a bare grid upgrades
472
+ }
473
+
474
+ /**
475
+ * Parse a .pxart string into the full Sprite model, or null if it isn't valid
476
+ * pixel art. Handles BOTH on-disk forms, detecting by STRUCTURE:
477
+ * - the FULL form (resolution / array palette / frames / layers), parsed
478
+ * natively, and
479
+ * - the COMPACT shorthand ({ palette: {...}, grid: [...] }), upgraded in
480
+ * memory.
481
+ *
482
+ * The optional `format` discriminator ("compact" | "full") is used as a hint to
483
+ * disambiguate genuinely ambiguous shapes, but clear structure always wins so a
484
+ * mislabeled or undiscriminated file still parses without silent mis-detection.
485
+ *
486
+ * Tolerant: missing `format`, missing/short cells arrays, ragged rows, null
487
+ * cells, bare link numbers, and out-of-range explicit resolutions (snapped).
488
+ */
489
+ export function parseFull(content) {
490
+ let data;
491
+ try {
492
+ data = JSON.parse(content);
493
+ } catch {
494
+ return null;
495
+ }
496
+ if (!isPlainObject(data)) return null;
497
+
498
+ if (detectCompactForm(data)) {
499
+ // Compact shorthand path: reuse the compact parser then upgrade.
500
+ const compact = parseCompact(content);
501
+ return compact ? upgradeCompactToFull(compact) : null;
502
+ }
503
+
504
+ const palette = parsePaletteArray(data.palette);
505
+
506
+ // Layers (tolerant). A full file with no layers but a top-level grid still
507
+ // works via the compact path above; here we require a layers array to proceed.
508
+ const rawLayers = Array.isArray(data.layers) ? data.layers : [];
509
+ const layers = rawLayers.filter(isPlainObject).map((l, i) => {
510
+ const rawCells = Array.isArray(l.cells) ? l.cells : [];
511
+ return {
512
+ id: typeof l.id === 'string' && l.id ? l.id : `layer-${i}`,
513
+ name: typeof l.name === 'string' && l.name ? l.name : `Layer ${i + 1}`,
514
+ visible: l.visible !== false,
515
+ opacity:
516
+ typeof l.opacity === 'number' && Number.isFinite(l.opacity)
517
+ ? Math.min(1, Math.max(0, l.opacity))
518
+ : 1,
519
+ blendMode: 'normal',
520
+ kind: 'pixel',
521
+ cells: rawCells.map(parseCell),
522
+ };
523
+ });
524
+
525
+ // Frame count: max of explicit frames[] and the longest cells row.
526
+ const rawFrames = Array.isArray(data.frames) ? data.frames : [];
527
+ let frameCount = rawFrames.length;
528
+ for (const layer of layers) frameCount = Math.max(frameCount, layer.cells.length);
529
+ frameCount = Math.max(frameCount, 1);
530
+
531
+ const defaultDurationMs =
532
+ typeof data.defaultDurationMs === 'number' && Number.isFinite(data.defaultDurationMs)
533
+ ? data.defaultDurationMs
534
+ : DEFAULT_DURATION_MS;
535
+
536
+ // A frame's `durationMs` is an OPTIONAL override: keep it only when the raw
537
+ // frame supplies a finite value, otherwise leave it undefined so the frame
538
+ // inherits the global `defaultDurationMs`. Padded frames (beyond rawFrames)
539
+ // inherit too.
540
+ const frames = [];
541
+ for (let f = 0; f < frameCount; f++) {
542
+ const raw = rawFrames[f];
543
+ if (
544
+ isPlainObject(raw) &&
545
+ typeof raw.durationMs === 'number' &&
546
+ Number.isFinite(raw.durationMs)
547
+ ) {
548
+ frames.push({ durationMs: raw.durationMs });
549
+ } else {
550
+ frames.push({});
551
+ }
552
+ }
553
+
554
+ // Pad/truncate every layer's cells to exactly frameCount (dense-with-null).
555
+ for (const layer of layers) {
556
+ if (layer.cells.length < frameCount) {
557
+ while (layer.cells.length < frameCount) layer.cells.push(null);
558
+ } else if (layer.cells.length > frameCount) {
559
+ layer.cells.length = frameCount;
560
+ }
561
+ }
562
+
563
+ // Resolution: snap an explicit value; otherwise infer from the largest grid.
564
+ let resolution;
565
+ if (isPlainObject(data.resolution)) {
566
+ resolution = {
567
+ width: snapResolutionDim(Number(data.resolution.width)),
568
+ height: snapResolutionDim(Number(data.resolution.height)),
569
+ };
570
+ } else {
571
+ resolution = inferDimsFromLayers(layers);
572
+ }
573
+
574
+ const tags = (Array.isArray(data.tags) ? data.tags : [])
575
+ .filter(isPlainObject)
576
+ .map((t) => ({
577
+ name: typeof t.name === 'string' ? t.name : '',
578
+ from: typeof t.from === 'number' && Number.isInteger(t.from) ? t.from : 0,
579
+ to: typeof t.to === 'number' && Number.isInteger(t.to) ? t.to : 0,
580
+ direction: parseDirection(t.direction),
581
+ repeat: typeof t.repeat === 'number' && Number.isInteger(t.repeat) ? t.repeat : 0,
582
+ }));
583
+
584
+ const defaultTag = typeof data.defaultTag === 'string' ? data.defaultTag : undefined;
585
+
586
+ return {
587
+ resolution,
588
+ palette,
589
+ frames,
590
+ defaultDurationMs,
591
+ tags,
592
+ defaultTag,
593
+ cornerRadius: parseCornerRadius(data),
594
+ layers,
595
+ };
596
+ }
597
+
598
+ /** Upgrade a compact PxArt record (flat palette+grid) into the full Sprite
599
+ * model: one pixel layer, one frame, resolution = the grid's NATIVE dims (not
600
+ * snapped). */
601
+ export function upgradeCompactToFull(art) {
602
+ const cells = toCells(art);
603
+ const height = cells.length;
604
+ const width = cells[0]?.length ?? 1;
605
+
606
+ // Ordered palette from the compact record, in insertion order, dropping
607
+ // transparent/null/invalid entries and "." (reserved).
608
+ const palette = [];
609
+ const seen = new Set();
610
+ for (const [key, value] of Object.entries(art.palette)) {
611
+ if (key.length !== 1 || key === TRANSPARENT || seen.has(key)) continue;
612
+ if (typeof value !== 'string') continue;
613
+ const hex = normalizeHex(value);
614
+ if (!hex) continue;
615
+ seen.add(key);
616
+ palette.push({ key, hex });
617
+ }
618
+
619
+ return {
620
+ resolution: { width, height },
621
+ palette,
622
+ // The single upgraded frame INHERITS the global (no override).
623
+ frames: [{}],
624
+ defaultDurationMs: DEFAULT_DURATION_MS,
625
+ tags: [],
626
+ defaultTag: undefined,
627
+ cornerRadius: art.cornerRadius,
628
+ layers: [
629
+ {
630
+ id: 'layer-0',
631
+ name: 'Layer 1',
632
+ visible: true,
633
+ opacity: 1,
634
+ blendMode: 'normal',
635
+ kind: 'pixel',
636
+ cells: [{ grid: [...art.grid] }],
637
+ },
638
+ ],
639
+ };
640
+ }
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // full-form serialize
644
+ // ---------------------------------------------------------------------------
645
+
646
+ /** Collect the set of palette keys actually used by any { grid } cell. */
647
+ function usedKeysOf(sprite) {
648
+ const used = new Set();
649
+ for (const layer of sprite.layers) {
650
+ for (const cell of layer.cells) {
651
+ if (cell && 'grid' in cell) {
652
+ for (const row of cell.grid) for (const ch of row) used.add(ch);
653
+ }
654
+ }
655
+ }
656
+ return used;
657
+ }
658
+
659
+ /** Serialize a Sprite to a canonical .pxart string. Always stamps
660
+ * `format: "full"`, drops unused palette entries, and emits packed-string
661
+ * grids. Palette order is preserved for the entries that survive. */
662
+ export function serializeFull(sprite) {
663
+ const used = usedKeysOf(sprite);
664
+ const palette = sprite.palette
665
+ .filter((e) => used.has(e.key))
666
+ .map((e) => ({ key: e.key, hex: e.hex }));
667
+
668
+ const out = {
669
+ format: FULL_FORM,
670
+ resolution: { width: sprite.resolution.width, height: sprite.resolution.height },
671
+ palette,
672
+ // Write `durationMs` only for OVERRIDDEN frames; inherited frames (undefined)
673
+ // serialize as a bare `{}` and re-parse as inheriting the global.
674
+ frames: sprite.frames.map((f) =>
675
+ typeof f.durationMs === 'number' && Number.isFinite(f.durationMs)
676
+ ? { durationMs: f.durationMs }
677
+ : {},
678
+ ),
679
+ defaultDurationMs: sprite.defaultDurationMs,
680
+ tags: sprite.tags.map((t) => ({
681
+ name: t.name,
682
+ from: t.from,
683
+ to: t.to,
684
+ direction: t.direction,
685
+ repeat: t.repeat,
686
+ })),
687
+ ...(sprite.defaultTag !== undefined ? { defaultTag: sprite.defaultTag } : {}),
688
+ // Omitted when it's 0 (sharp), so pixel-mode files stay byte-identical to
689
+ // their pre-smoothing shape.
690
+ ...(sprite.cornerRadius > 0 ? { cornerRadius: sprite.cornerRadius } : {}),
691
+ layers: sprite.layers.map((l) => ({
692
+ id: l.id,
693
+ name: l.name,
694
+ visible: l.visible,
695
+ opacity: l.opacity,
696
+ blendMode: l.blendMode,
697
+ kind: l.kind,
698
+ cells: l.cells.map((c) => {
699
+ if (c == null) return null;
700
+ if ('grid' in c) {
701
+ // Emit x/y only when nonzero so a zero-offset cel round-trips to the
702
+ // exact pre-offset shape (forward/backward compatible).
703
+ const cell = { grid: c.grid };
704
+ if (c.x) cell.x = c.x;
705
+ if (c.y) cell.y = c.y;
706
+ return cell;
707
+ }
708
+ return { link: c.link };
709
+ }),
710
+ })),
711
+ };
712
+ return JSON.stringify(out, null, 2) + '\n';
713
+ }
714
+
715
+ // ---------------------------------------------------------------------------
716
+ // linked-cell resolution & helpers
717
+ // ---------------------------------------------------------------------------
718
+
719
+ /** Number of frames in the sprite. */
720
+ export function frameCount(sprite) {
721
+ return sprite.frames.length;
722
+ }
723
+
724
+ /** Effective pixel dimensions of the sprite (its resolution). */
725
+ export function spriteDims(sprite) {
726
+ return { width: sprite.resolution.width, height: sprite.resolution.height };
727
+ }
728
+
729
+ /** Build a key -> hex lookup from the ordered palette (first key wins). */
730
+ export function paletteLookup(palette) {
731
+ const map = new Map();
732
+ for (const { key, hex } of palette) if (!map.has(key)) map.set(key, hex);
733
+ return map;
734
+ }
735
+
736
+ /** Resolve a grid char to a CSS color using an ordered palette, or null when
737
+ * transparent. "." and unknown keys are transparent. */
738
+ export function colorForKeyV2(lookup, key) {
739
+ if (key === TRANSPARENT) return null;
740
+ return lookup.get(key) ?? null;
741
+ }
742
+
743
+ /**
744
+ * Resolve the effective cel (image grid + offset) for a (layer, frameIndex)
745
+ * cell, following same-layer `link` references to the underlying `{ grid }`.
746
+ * A link shares the SOURCE cel's image AND offset (a fully shared cel). Returns
747
+ * null if the cell is empty / unresolvable. Guards against cycles and invalid
748
+ * indices (returns null rather than looping).
749
+ */
750
+ export function resolveCell(layer, frameIndex) {
751
+ const visited = new Set();
752
+ let idx = frameIndex;
753
+ // Bound the walk by cells.length as a hard backstop in addition to `visited`.
754
+ for (let steps = 0; steps <= layer.cells.length; steps++) {
755
+ if (idx < 0 || idx >= layer.cells.length) return null;
756
+ if (visited.has(idx)) return null;
757
+ visited.add(idx);
758
+ const cell = layer.cells[idx];
759
+ if (cell == null) return null;
760
+ if ('grid' in cell) return { grid: cell.grid, x: cell.x ?? 0, y: cell.y ?? 0 };
761
+ idx = cell.link;
762
+ }
763
+ return null;
764
+ }
765
+
766
+ /**
767
+ * Resolve just the effective image grid for a (layer, frameIndex) cell (offset
768
+ * dropped). Thin wrapper over `resolveCell` kept for call sites that only need
769
+ * the pixels (e.g. linkability checks, timeline thumbnails).
770
+ */
771
+ export function resolveCellGrid(layer, frameIndex) {
772
+ return resolveCell(layer, frameIndex)?.grid ?? null;
773
+ }
774
+
775
+ /**
776
+ * Visit each pixel of a resolved cel (image grid + x/y offset) that lands inside
777
+ * the [0,width) x [0,height) canvas window. `visit(cx, cy, key)` receives the
778
+ * canvas coordinates and the grid char at that cel pixel. The single home for
779
+ * the cel-offset clip math, shared by the frame renderer and the editor's
780
+ * canvas-window readback so the two never drift.
781
+ */
782
+ export function forEachCelPixel(resolved, width, height, visit) {
783
+ const { grid, x: ox, y: oy } = resolved;
784
+ for (let gy = 0; gy < grid.length; gy++) {
785
+ const cy = gy + oy;
786
+ if (cy < 0 || cy >= height) continue;
787
+ const row = grid[gy];
788
+ for (let gx = 0; gx < row.length; gx++) {
789
+ const cx = gx + ox;
790
+ if (cx < 0 || cx >= width) continue;
791
+ visit(cx, cy, row[gx]);
792
+ }
793
+ }
794
+ }
795
+
796
+ // ---------------------------------------------------------------------------
797
+ // full-form rendering — composite a single frame
798
+ // ---------------------------------------------------------------------------
799
+
800
+ /**
801
+ * Render ONE frame of a Sprite to a canvas, compositing layers bottom->top.
802
+ * Sizes the canvas to the sprite resolution, respects per-layer `visible` and
803
+ * `opacity`, treats blendMode "normal", resolves linked cells, and leaves
804
+ * `null` cells / transparent keys clear. Uses the same 1px-per-cell,
805
+ * imageSmoothingEnabled=false approach as the compact renderer; scale up with CSS
806
+ * `image-rendering: pixelated` for display.
807
+ */
808
+ export function renderSpriteFrame(sprite, frameIndex, canvas) {
809
+ const { width, height } = sprite.resolution;
810
+ canvas.width = width;
811
+ canvas.height = height;
812
+ const ctx = canvas.getContext('2d');
813
+ if (!ctx) return;
814
+ ctx.imageSmoothingEnabled = false;
815
+ ctx.clearRect(0, 0, width, height);
816
+
817
+ const lookup = paletteLookup(sprite.palette);
818
+ const prevAlpha = ctx.globalAlpha;
819
+ for (const layer of sprite.layers) {
820
+ if (!layer.visible || layer.opacity <= 0) continue;
821
+ const resolved = resolveCell(layer, frameIndex);
822
+ if (!resolved) continue;
823
+ ctx.globalAlpha = Math.min(1, Math.max(0, layer.opacity));
824
+ // Blit the cel image at its (x, y) offset, CLIPPING to the canvas window.
825
+ // The cel image may be larger than / offset outside the canvas; only the
826
+ // portion that lands inside [0,width) x [0,height) is drawn.
827
+ forEachCelPixel(resolved, width, height, (cx, cy, key) => {
828
+ const color = colorForKeyV2(lookup, key);
829
+ if (color) {
830
+ ctx.fillStyle = color;
831
+ ctx.fillRect(cx, cy, 1, 1);
832
+ }
833
+ });
834
+ }
835
+ ctx.globalAlpha = prevAlpha;
836
+ }
837
+
838
+ // ===========================================================================
839
+ // svg-rect decode (generation path)
840
+ // ===========================================================================
841
+ //
842
+ // The CLI generation worker prompts an LLM to emit pixel art as a tiny <svg>
843
+ // with one <rect> per pixel. We snap those rects onto a cell grid by (x,y) and
844
+ // cell size, mint a palette from the distinct fills, and emit PxArt. Direct
845
+ // rect->cell mapping; if the rects don't land on a clean grid we still snap
846
+ // (round) onto the inferred grid, which degrades gracefully without pulling in a
847
+ // rasterizer. Ported verbatim from castle-px-eval/src/representations.ts.
848
+ // ===========================================================================
849
+
850
+ function parseRects(svg) {
851
+ const rects = [];
852
+ const rectRe = /<rect\b([^>]*?)\/?>/gi;
853
+ const attrRe = /([\w:-]+)\s*=\s*"([^"]*)"|([\w:-]+)\s*=\s*'([^']*)'/g;
854
+ let m;
855
+ while ((m = rectRe.exec(svg))) {
856
+ const attrs = {};
857
+ let a;
858
+ attrRe.lastIndex = 0;
859
+ while ((a = attrRe.exec(m[1]))) {
860
+ const key = (a[1] ?? a[3]).toLowerCase();
861
+ attrs[key] = a[2] ?? a[4];
862
+ }
863
+ const x = Number(attrs.x ?? '0');
864
+ const y = Number(attrs.y ?? '0');
865
+ const w = Number(attrs.width ?? '1');
866
+ const h = Number(attrs.height ?? '1');
867
+ let fill = null;
868
+ const rawFill = attrs.fill ?? styleFill(attrs.style);
869
+ if (rawFill && rawFill !== 'none' && rawFill !== 'transparent') fill = normalizeHex(rawFill);
870
+ if (![x, y, w, h].every(Number.isFinite)) continue;
871
+ rects.push({ x, y, w, h, fill });
872
+ }
873
+
874
+ // Canvas size: prefer viewBox, then width/height attrs on <svg>.
875
+ let canvas = null;
876
+ const vb = svg.match(/viewBox\s*=\s*["']\s*([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s*["']/i);
877
+ if (vb) {
878
+ const w = Number(vb[3]);
879
+ const h = Number(vb[4]);
880
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) canvas = { w, h };
881
+ }
882
+ if (!canvas) {
883
+ const svgTag = svg.match(/<svg\b[^>]*>/i)?.[0] ?? '';
884
+ const w = Number(svgTag.match(/\bwidth\s*=\s*["']?([\d.]+)/i)?.[1]);
885
+ const h = Number(svgTag.match(/\bheight\s*=\s*["']?([\d.]+)/i)?.[1]);
886
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) canvas = { w, h };
887
+ }
888
+ return { rects, canvas };
889
+ }
890
+
891
+ function styleFill(style) {
892
+ if (!style) return undefined;
893
+ return style.match(/fill\s*:\s*([^;]+)/i)?.[1]?.trim();
894
+ }
895
+
896
+ function normalizeSvgRect(raw, ctx) {
897
+ const svgStart = raw.indexOf('<svg');
898
+ const src = svgStart >= 0 ? raw.slice(svgStart) : raw;
899
+ const { rects, canvas } = parseRects(src);
900
+ const filled = rects.filter((r) => r.fill && r.w > 0 && r.h > 0);
901
+ if (!filled.length) return null;
902
+
903
+ // Infer cell size: the smallest rect side is one cell (rects are per-pixel, so
904
+ // this is almost always 1, but models sometimes scale up, e.g. 10px cells).
905
+ let cell = Infinity;
906
+ for (const r of filled) cell = Math.min(cell, r.w, r.h);
907
+ if (!Number.isFinite(cell) || cell <= 0) cell = 1;
908
+
909
+ // Grid extent: from canvas if given, else from the rects' bounding box.
910
+ let cols;
911
+ let rows;
912
+ if (canvas) {
913
+ cols = Math.max(1, Math.round(canvas.w / cell));
914
+ rows = Math.max(1, Math.round(canvas.h / cell));
915
+ } else {
916
+ let maxX = 0;
917
+ let maxY = 0;
918
+ for (const r of filled) {
919
+ maxX = Math.max(maxX, r.x + r.w);
920
+ maxY = Math.max(maxY, r.y + r.h);
921
+ }
922
+ cols = Math.max(1, Math.round(maxX / cell));
923
+ rows = Math.max(1, Math.round(maxY / cell));
924
+ }
925
+ // Guard against absurd canvases (typos like width="1000"); fall back to the
926
+ // target size when available, else clamp.
927
+ const MAX = 256;
928
+ if (cols > MAX || rows > MAX) {
929
+ if (ctx.targetSize) {
930
+ cols = ctx.targetSize.width;
931
+ rows = ctx.targetSize.height;
932
+ } else {
933
+ cols = Math.min(cols, MAX);
934
+ rows = Math.min(rows, MAX);
935
+ }
936
+ }
937
+
938
+ const { palette, keyOf } = allocatePaletteKeys(filled.map((r) => r.fill));
939
+ const cells = Array.from({ length: rows }, () =>
940
+ Array.from({ length: cols }, () => TRANSPARENT),
941
+ );
942
+ for (const r of filled) {
943
+ const hex = normalizeHex(r.fill);
944
+ const key = hex ? keyOf.get(hex) : undefined;
945
+ if (!key) continue;
946
+ const c0 = Math.round(r.x / cell);
947
+ const r0 = Math.round(r.y / cell);
948
+ const cspan = Math.max(1, Math.round(r.w / cell));
949
+ const rspan = Math.max(1, Math.round(r.h / cell));
950
+ for (let dy = 0; dy < rspan; dy++) {
951
+ for (let dx = 0; dx < cspan; dx++) {
952
+ const cx = c0 + dx;
953
+ const cy = r0 + dy;
954
+ if (cy >= 0 && cy < rows && cx >= 0 && cx < cols) cells[cy][cx] = key;
955
+ }
956
+ }
957
+ }
958
+ return fromCells(palette, cells);
959
+ }
960
+
961
+ // ---------------------------------------------------------------------------
962
+ // svg-rect -> palette-quantized PxArt (higher-level generation helper)
963
+ // ---------------------------------------------------------------------------
964
+
965
+ /** Quantize a single hex color to the nearest entry in `palette` by RGB
966
+ * Euclidean distance. Alpha is ignored for distance. Returns the chosen
967
+ * "#rrggbb" (lowercase), or null when the input is invalid or fully
968
+ * transparent (alpha === 00). */
969
+ function quantizeToPalette(hex, palette) {
970
+ const norm = normalizeHex(hex);
971
+ if (!norm) return null;
972
+ const body = norm.slice(1);
973
+ // Preserve full transparency: an 8-digit color with alpha 00 stays transparent.
974
+ if (body.length === 8 && body.slice(6, 8) === '00') return null;
975
+ const r = parseInt(body.slice(0, 2), 16);
976
+ const g = parseInt(body.slice(2, 4), 16);
977
+ const b = parseInt(body.slice(4, 6), 16);
978
+ let best = null;
979
+ let bestDist = Infinity;
980
+ for (const entry of palette) {
981
+ const pn = normalizeHex(entry);
982
+ if (!pn) continue;
983
+ const pr = parseInt(pn.slice(1, 3), 16);
984
+ const pg = parseInt(pn.slice(3, 5), 16);
985
+ const pb = parseInt(pn.slice(5, 7), 16);
986
+ const dist = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
987
+ if (dist < bestDist) {
988
+ bestDist = dist;
989
+ best = '#' + pn.slice(1, 7);
990
+ }
991
+ }
992
+ return best;
993
+ }
994
+
995
+ /**
996
+ * Decode a terse svg-rect emission into a compact PxArt, quantized onto a fixed
997
+ * palette. Runs the svg-rect decode at the target resolution (default
998
+ * DEFAULT_RESOLUTION, 16×16), then maps every resulting color to the nearest
999
+ * color in `palette` (default AGENT_PALETTE_16, the 16-color agent subset) via
1000
+ * RGB Euclidean distance. The returned palette contains only the quantized
1001
+ * colors actually used; transparent cells stay transparent ("."). Returns null
1002
+ * if the svg has no usable rects.
1003
+ */
1004
+ export function svgRectToPxArt(svg, opts) {
1005
+ const resolution = opts?.resolution ?? DEFAULT_RESOLUTION;
1006
+ const palette = opts?.palette ?? AGENT_PALETTE_16;
1007
+
1008
+ const decoded = normalizeSvgRect(svg, { targetSize: resolution });
1009
+ if (!decoded) return null;
1010
+
1011
+ // Map each original palette key to its quantized "#rrggbb" (or drop it).
1012
+ const quantHexByKey = new Map();
1013
+ for (const [key, hex] of Object.entries(decoded.palette)) {
1014
+ if (typeof hex !== 'string') continue;
1015
+ const q = quantizeToPalette(hex, palette);
1016
+ if (q) quantHexByKey.set(key, q);
1017
+ }
1018
+
1019
+ // Allocate fresh single-char keys for the distinct quantized colors used.
1020
+ const { palette: outPalette, keyOf } = allocatePaletteKeys(quantHexByKey.values());
1021
+
1022
+ const cells = toCells(decoded).map((row) =>
1023
+ row.map((ch) => {
1024
+ if (ch === TRANSPARENT) return TRANSPARENT;
1025
+ const q = quantHexByKey.get(ch);
1026
+ if (!q) return TRANSPARENT;
1027
+ return keyOf.get(q) ?? TRANSPARENT;
1028
+ }),
1029
+ );
1030
+
1031
+ return fromCells(outPalette, cells);
1032
+ }