castle-web-cli 0.4.115 → 0.4.117

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 (55) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +6 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.js +27 -3
  7. package/dist/imports.d.ts +4 -0
  8. package/dist/imports.js +59 -10
  9. package/dist/init.d.ts +3 -0
  10. package/dist/init.js +103 -87
  11. package/dist/install.d.ts +1 -1
  12. package/dist/install.js +26 -24
  13. package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
  14. package/dist/shell/index.html +1 -1
  15. package/dist/vitePlugins.js +1 -1
  16. package/kits/physics-2d/CLAUDE.md +28 -16
  17. package/kits/physics-2d/behaviors/Joints.jsx +1 -1
  18. package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
  19. package/kits/physics-2d/blueprints/ball.scene +1 -1
  20. package/kits/physics-2d/blueprints/block.scene +1 -1
  21. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  22. package/kits/physics-2d/blueprints/crate.scene +1 -1
  23. package/kits/physics-2d/castle.json +11 -3
  24. package/kits/physics-2d/docs/pxart-format.md +537 -51
  25. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  26. package/kits/physics-2d/editors/brushFit.js +535 -0
  27. package/kits/physics-2d/editors/brushShapes.js +140 -0
  28. package/kits/physics-2d/editors/mediaFile.js +12 -1
  29. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  30. package/kits/physics-2d/editors/pathTools.js +1906 -0
  31. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  32. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  33. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  34. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  35. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  36. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  37. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  38. package/kits/physics-2d/engine/assets.js +1 -1
  39. package/kits/physics-2d/engine/blueprint.js +3 -3
  40. package/kits/physics-2d/engine/files.js +3 -1
  41. package/kits/physics-2d/engine/liveReload.js +1 -1
  42. package/kits/physics-2d/engine/physics/jointArt.js +3 -3
  43. package/kits/physics-2d/engine/pxart.js +153 -35
  44. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  45. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  46. package/kits/physics-2d/engine/ui.jsx +22 -1
  47. package/kits/physics-2d/engine/ui.module.css +36 -12
  48. package/kits/physics-2d/package-lock.json +1 -1
  49. package/kits/physics-2d/scripts/draw.mjs +7 -7
  50. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  51. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  52. package/package.json +1 -1
  53. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  54. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  55. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
@@ -171,8 +171,19 @@
171
171
  each owned by exactly one element (no two borders meet), so there are no
172
172
  doubled/thicker seams. The grid's outer top is the toolbar's existing bottom
173
173
  divider; outer right/bottom come from the last cells. */
174
- .corner {
174
+ /* Shared sticky left-column width: corner and layer heads must match or
175
+ frame columns drift out of alignment. Content may not force growth
176
+ (min-width:auto would otherwise expand past the flex basis). */
177
+ .corner,
178
+ .layerHead {
175
179
  flex: 0 0 168px;
180
+ width: 168px;
181
+ min-width: 168px;
182
+ max-width: 168px;
183
+ box-sizing: border-box;
184
+ }
185
+
186
+ .corner {
176
187
  border-right: 1px solid var(--castle-inspector-divider);
177
188
  border-bottom: 1px solid var(--castle-inspector-divider);
178
189
  background: var(--castle-workspace-bg);
@@ -225,11 +236,10 @@
225
236
  }
226
237
 
227
238
  .layerHead {
228
- flex: 0 0 168px;
229
239
  display: flex;
230
240
  flex-direction: column;
231
241
  justify-content: center;
232
- gap: 4px;
242
+ gap: 0;
233
243
  padding: 4px 8px;
234
244
  border-right: 1px solid var(--castle-inspector-divider);
235
245
  border-bottom: 1px solid var(--castle-inspector-divider);
@@ -250,6 +260,12 @@
250
260
  gap: 6px;
251
261
  }
252
262
 
263
+ .layerBottomRow {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 6px;
267
+ }
268
+
253
269
  .visBtn {
254
270
  flex: 0 0 auto;
255
271
  width: 22px;
@@ -305,7 +321,9 @@
305
321
  /* Slim opacity slider: a thin custom track + small thumb so the layer header
306
322
  cell stays no taller than the art (thumbnail) cells in its row. */
307
323
  .opacity {
308
- width: 100%;
324
+ flex: 1 1 auto;
325
+ min-width: 0;
326
+ width: auto;
309
327
  height: 12px;
310
328
  margin: 0;
311
329
  cursor: pointer;
@@ -390,6 +408,13 @@
390
408
  display: block;
391
409
  }
392
410
 
411
+ .thumbCanvasSmooth {
412
+ width: 100%;
413
+ height: 100%;
414
+ image-rendering: auto;
415
+ display: block;
416
+ }
417
+
393
418
  /* Empty/absent cell: plain and empty. The cell's own grid separators (see the
394
419
  .corner/.cell border rules) still bound it; there's no dashed interior. */
395
420
  .empty {
@@ -427,7 +452,8 @@
427
452
  /* Fade only the linked cell's thumbnail IMAGE (the canvas) to signal a shared
428
453
  image. Scoped to the canvas inside a linked thumb, so the blue ring
429
454
  (.linkRing box-shadow) and the link badge (.linkBadge) stay full opacity. */
430
- .linkRing .thumbCanvas {
455
+ .linkRing .thumbCanvas,
456
+ .linkRing .thumbCanvasSmooth {
431
457
  opacity: 0.5;
432
458
  }
433
459
 
@@ -14,7 +14,7 @@ export function collectAssets(files) {
14
14
  if (isImageFile(path)) sprites[path] = imageArt(path, url);
15
15
  }
16
16
  for (const [path, text] of Object.entries(files)) {
17
- if (path.endsWith('.pxart')) {
17
+ if (path.endsWith('.sprite') || path.endsWith('.pxart')) {
18
18
  const sprite = parseFull(text);
19
19
  if (sprite) sprites[path] = sprite;
20
20
  }
@@ -372,16 +372,16 @@ export function blankPxArtText() {
372
372
  return serializeCompact({ palette: {}, grid });
373
373
  }
374
374
 
375
- // `drawings/drawing-N.pxart` -- next free numbered slug, so repeated adds don't
375
+ // `drawings/drawing-N.sprite` -- next free numbered slug, so repeated adds don't
376
376
  // collide and a deleted drawing's number isn't reused.
377
377
  function mintDrawingPath(files) {
378
- const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.pxart$`);
378
+ const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.(?:pxart|sprite)$`);
379
379
  let max = 0;
380
380
  for (const path of Object.keys(files ?? {})) {
381
381
  const m = path.match(re);
382
382
  if (m) max = Math.max(max, Number(m[1]));
383
383
  }
384
- return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
384
+ return `${DRAWINGS_DIR}/drawing-${max + 1}.sprite`;
385
385
  }
386
386
 
387
387
  // The z that draws on top of every current actor: max effective z + 1 (0 for
@@ -19,12 +19,13 @@ const rawModules = {
19
19
  '/imports/*/scenes/**/*.scene',
20
20
  '/imports/*/blueprints/**/*.scene',
21
21
  '/imports/*/drawings/**/*.pxart',
22
+ '/imports/*/drawings/**/*.sprite',
22
23
  '/imports/*/behaviors/**/*.jsx',
23
24
  ],
24
25
  { query: '?raw', import: 'default', eager: true }
25
26
  ),
26
27
  ...import.meta.glob(
27
- ['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/behaviors/**/*.jsx'],
28
+ ['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/drawings/**/*.sprite', '/behaviors/**/*.jsx'],
28
29
  { query: '?raw', import: 'default', eager: true }
29
30
  ),
30
31
  };
@@ -99,6 +100,7 @@ export function mediaFilesOfKind(kind) {
99
100
  // `endsWith` per `return`.
100
101
  export function getFileKind(path) {
101
102
  if (path.endsWith('.scene')) return 'scene';
103
+ if (path.endsWith('.sprite')) return 'pxart';
102
104
  if (path.endsWith('.pxart')) return 'pxart';
103
105
  if (path.endsWith('.png')) return 'image';
104
106
  if (path.endsWith('.jpg')) return 'image';
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react';
9
9
  import { onFilesChanged, requestReload } from 'castle-web-sdk';
10
10
  import { initialFiles } from './files';
11
11
 
12
- const DATA_EXTS = ['.scene', '.pxart', '.drawing'];
12
+ const DATA_EXTS = ['.scene', '.pxart', '.sprite', '.drawing'];
13
13
  const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
14
14
 
15
15
  export function isDataFile(path) {
@@ -7,9 +7,9 @@
7
7
  // a small WeakMap caches the native-resolution frame canvas per sprite object.
8
8
 
9
9
  import { resolveDeckFile } from 'castle-web-sdk';
10
- import { renderSpriteFrame } from '../pxart';
10
+ import { displayFinish, renderSpriteFrame } from '../pxart';
11
11
 
12
- export const DEFAULT_JOINT_SPRITE = 'drawings/joint-rope.pxart';
12
+ export const DEFAULT_JOINT_SPRITE = 'drawings/joint-rope.sprite';
13
13
 
14
14
  const canvasCache = new WeakMap();
15
15
  function frameCanvas(sprite) {
@@ -40,7 +40,7 @@ export function drawJointArt(ctx, scene, joint, a, b) {
40
40
  ctx.save();
41
41
  ctx.translate(a.x, a.y);
42
42
  ctx.rotate(Math.atan2(dy, dx));
43
- ctx.imageSmoothingEnabled = sprite.cornerRadius > 0;
43
+ ctx.imageSmoothingEnabled = displayFinish(sprite).smooth;
44
44
  const top = -thickness / 2;
45
45
  if ((joint.fit ?? 'tile') === 'stretch') {
46
46
  ctx.drawImage(canvas, 0, top, len, thickness);
@@ -1,3 +1,11 @@
1
+ import {
2
+ isEmptyPathData,
3
+ parsePathData,
4
+ serializePathData,
5
+ translatePathData,
6
+ bakePathCell,
7
+ } from './pxartPath.js';
8
+
1
9
  // ============================================================================
2
10
  // Castle pixel-art format — COMPACT form ("flat" sprite)
3
11
  // ============================================================================
@@ -41,45 +49,104 @@ export const FULL_FORM = 'full';
41
49
  export const TRANSPARENT = '.';
42
50
 
43
51
  // ---------------------------------------------------------------------------
44
- // cornerRadius (file-level corner-rounding radius, in native-pixel units)
52
+ // Finish: `renderer` (which renderer) + `cornerRadius` (how much rounding)
45
53
  // ---------------------------------------------------------------------------
46
54
  //
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.
55
+ // Two orthogonal file-level fields:
56
+ //
57
+ // renderer "grid" (default) draws the baked `grid`; "vector" draws each
58
+ // path layer analytically from its shape stack and each pixel
59
+ // layer with the corner kernel at MAX_CORNER_RADIUS.
60
+ // cornerRadius ALWAYS a number, in native-pixel units, clamped to
61
+ // [0, MAX_CORNER_RADIUS]. 0 renders sharp/nearest-neighbor.
62
+ //
63
+ // These were briefly one field: `cornerRadius` held either a number or the
64
+ // string "smooth". That union is retired because a categorical mode and a
65
+ // continuous amount do not share a slot cleanly — `'smooth' > 0` is false (NaN
66
+ // comparison), so every `cornerRadius > 0` test in the codebase silently read a
67
+ // smooth sprite as sharp unless it special-cased the string, and
68
+ // clampCornerRadius had to return a string to stay lossless. Splitting them
69
+ // restores the invariant that cornerRadius is a number EVERYWHERE, which is what
70
+ // makes "a value, not a flag" actually hold: the amount of rounding stays part
71
+ // of the portable format instead of a fixed, code-side constant.
53
72
 
54
73
  /** Corner cuts on the same 1-native-pixel edge must not overlap, so radii
55
74
  * above this are clamped on parse (and by the editor's UI). */
56
75
  export const MAX_CORNER_RADIUS = 0.5;
57
76
 
77
+ export const GRID_RENDERER = 'grid';
78
+ export const VECTOR_RENDERER = 'vector';
79
+
80
+ /** True when the sprite draws from vector geometry rather than the baked grid. */
81
+ export function isVectorRenderer(renderer) {
82
+ return renderer === VECTOR_RENDERER;
83
+ }
84
+
85
+ /** Always returns a number in [0, MAX_CORNER_RADIUS]; any non-number is 0. */
58
86
  export function clampCornerRadius(radius) {
87
+ if (typeof radius !== 'number' || !Number.isFinite(radius)) return 0;
59
88
  return Math.min(MAX_CORNER_RADIUS, Math.max(0, radius));
60
89
  }
61
90
 
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.
91
+ /**
92
+ * How a sprite wants to be shown, for callers that draw it.
93
+ *
94
+ * `smooth` is the one every display site needs: it says the supersampled
95
+ * render is in charge, so the native 1px/cell blit must stay out of the way.
96
+ * Testing `cornerRadius` alone is NOT enough — the vector renderer carries a
97
+ * radius of 0, so a `cornerRadius === 0` test reads a Smooth sprite as Pixel
98
+ * and paints the blocky bake over the analytic render.
99
+ *
100
+ * `cornerRadius` is the radius to actually draw with: pixel layers still go
101
+ * through the corner kernel under the vector renderer, at maximum rounding
102
+ * (see renderSmoothCompositeFrame), so this is not always the sprite's field.
103
+ */
104
+ export function displayFinish(sprite) {
105
+ const vector = isVectorRenderer(sprite?.renderer);
106
+ const cornerRadius = vector ? MAX_CORNER_RADIUS : clampCornerRadius(sprite?.cornerRadius);
107
+ return { vector, cornerRadius, smooth: vector || cornerRadius > 0 };
108
+ }
109
+
110
+ // Legacy encodings, all migrating on read. None of these are structural signals,
111
+ // so compact-vs-full detection is unaffected.
112
+ //
113
+ // cornerRadius: "smooth" the retired union -> vector renderer, radius 0
114
+ // render: "smooth" the oldest enum -> grid renderer, radius 0.25
115
+ // render: <number> a brief numeric era -> grid renderer, that radius
116
+ //
117
+ // Old files therefore keep rendering rounded rather than silently reverting to
118
+ // sharp, and a file written before the split still selects the vector renderer.
119
+ // The enum never carried an amount of its own, hence the fixed value here.
67
120
  const LEGACY_SMOOTH_RADIUS = 0.25;
121
+ const LEGACY_SMOOTH = 'smooth';
122
+
123
+ /** Read `renderer` off a parsed JSON object. Anything unrecognized — missing,
124
+ * null, a typo, a future value — reads as the grid renderer. */
125
+ function parseRenderer(data) {
126
+ if (data.renderer === VECTOR_RENDERER) return VECTOR_RENDERER;
127
+ if (data.renderer === GRID_RENDERER) return GRID_RENDERER;
128
+ // Pre-split files spelled the vector renderer as cornerRadius: "smooth".
129
+ if (data.cornerRadius === LEGACY_SMOOTH) return VECTOR_RENDERER;
130
+ return GRID_RENDERER;
131
+ }
68
132
 
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). */
133
+ /** Read `cornerRadius` off a parsed JSON object. Never yields a non-number, so
134
+ * downstream `cornerRadius > 0` tests are always meaningful. */
73
135
  function parseCornerRadius(data) {
74
- if (typeof data.cornerRadius === 'number' && Number.isFinite(data.cornerRadius)) {
75
- return clampCornerRadius(data.cornerRadius);
76
- }
136
+ if (typeof data.cornerRadius === 'number') return clampCornerRadius(data.cornerRadius);
137
+ // cornerRadius: "smooth" carried no amount — it becomes renderer: "vector".
138
+ if (data.cornerRadius === LEGACY_SMOOTH) return 0;
77
139
  const legacy = data.render;
78
- if (typeof legacy === 'number' && Number.isFinite(legacy)) return clampCornerRadius(legacy);
79
- if (legacy === 'smooth') return LEGACY_SMOOTH_RADIUS;
140
+ if (typeof legacy === 'number') return clampCornerRadius(legacy);
141
+ if (legacy === LEGACY_SMOOTH) return LEGACY_SMOOTH_RADIUS;
80
142
  return 0;
81
143
  }
82
144
 
145
+ /** The finish fields for a parsed sprite, both always well-typed. */
146
+ function parseFinish(data) {
147
+ return { renderer: parseRenderer(data), cornerRadius: parseCornerRadius(data) };
148
+ }
149
+
83
150
  /** Parse a .pxart string into a compact PxArt record, or null if it isn't the
84
151
  * compact shape. Detects by STRUCTURE (object `palette` + `grid`); the on-disk
85
152
  * `format` discriminator is optional. Tolerant of ragged rows and a `"."`
@@ -101,7 +168,7 @@ export function parseCompact(content) {
101
168
  if (typeof v === 'string') palette[k] = v;
102
169
  else if (v === null) palette[k] = null;
103
170
  }
104
- return { palette, grid: data.grid, cornerRadius: parseCornerRadius(data) };
171
+ return { palette, grid: data.grid, ...parseFinish(data) };
105
172
  }
106
173
  } catch {
107
174
  /* not valid pxart json */
@@ -109,15 +176,26 @@ export function parseCompact(content) {
109
176
  return null;
110
177
  }
111
178
 
179
+ /** Finish fields for serialization: each omitted at its default, so a plain
180
+ * pixel sprite carries neither and stays byte-identical to its pre-finish
181
+ * shape. */
182
+ function finishFields(art) {
183
+ const radius = clampCornerRadius(art?.cornerRadius);
184
+ return {
185
+ ...(isVectorRenderer(art?.renderer) ? { renderer: VECTOR_RENDERER } : {}),
186
+ ...(radius > 0 ? { cornerRadius: radius } : {}),
187
+ };
188
+ }
189
+
112
190
  /** Serialize a compact PxArt record to a .pxart string. Always stamps
113
- * `format: "compact"`. `cornerRadius` is omitted when it's 0 (sharp), so
191
+ * `format: "compact"`. Finish fields are omitted at their defaults, so
114
192
  * existing (pixel) files stay byte-identical. */
115
193
  export function serializeCompact(art) {
116
194
  const out = {
117
195
  format: COMPACT_FORM,
118
196
  palette: art.palette,
119
197
  grid: art.grid,
120
- ...(art.cornerRadius > 0 ? { cornerRadius: art.cornerRadius } : {}),
198
+ ...finishFields(art),
121
199
  };
122
200
  return JSON.stringify(out, null, 2) + '\n';
123
201
  }
@@ -416,7 +494,20 @@ function parseCell(raw) {
416
494
  if (raw == null) return null;
417
495
  if (isPlainObject(raw)) {
418
496
  if (Array.isArray(raw.grid) && raw.grid.every((r) => typeof r === 'string')) {
419
- return makeGridCell(raw.grid, raw.x, raw.y);
497
+ const cell = makeGridCell(raw.grid, raw.x, raw.y);
498
+ const path = parsePathData(raw.path);
499
+ if (isEmptyPathData(path)) return cell;
500
+ // Cel offset is a pixel-cell mechanism: a path cell's placement lives in
501
+ // its points, so x/y must be 0 (§6.1.1). A file that carries both is
502
+ // normalized by folding the offset into the source, which keeps the art
503
+ // where it was rather than snapping it to the origin.
504
+ const { x = 0, y = 0 } = cell;
505
+ if (!x && !y) return { ...cell, path };
506
+ return {
507
+ grid: cell.grid,
508
+ path: translatePathData(path, x, y),
509
+ _pathNeedsRebake: true,
510
+ };
420
511
  }
421
512
  if (typeof raw.link === 'number' && Number.isInteger(raw.link)) {
422
513
  return { link: raw.link };
@@ -517,7 +608,7 @@ export function parseFull(content) {
517
608
  ? Math.min(1, Math.max(0, l.opacity))
518
609
  : 1,
519
610
  blendMode: 'normal',
520
- kind: 'pixel',
611
+ kind: l.kind === 'path' ? 'path' : 'pixel',
521
612
  cells: rawCells.map(parseCell),
522
613
  };
523
614
  });
@@ -571,6 +662,18 @@ export function parseFull(content) {
571
662
  resolution = inferDimsFromLayers(layers);
572
663
  }
573
664
 
665
+ for (const layer of layers) {
666
+ for (let fi = 0; fi < layer.cells.length; fi++) {
667
+ const cell = layer.cells[fi];
668
+ if (cell?._pathNeedsRebake && cell.path) {
669
+ layer.cells[fi] = {
670
+ grid: bakePathCell(cell.path, resolution.width, resolution.height),
671
+ path: cell.path,
672
+ };
673
+ }
674
+ }
675
+ }
676
+
574
677
  const tags = (Array.isArray(data.tags) ? data.tags : [])
575
678
  .filter(isPlainObject)
576
679
  .map((t) => ({
@@ -590,7 +693,7 @@ export function parseFull(content) {
590
693
  defaultDurationMs,
591
694
  tags,
592
695
  defaultTag,
593
- cornerRadius: parseCornerRadius(data),
696
+ ...parseFinish(data),
594
697
  layers,
595
698
  };
596
699
  }
@@ -624,7 +727,8 @@ export function upgradeCompactToFull(art) {
624
727
  defaultDurationMs: DEFAULT_DURATION_MS,
625
728
  tags: [],
626
729
  defaultTag: undefined,
627
- cornerRadius: art.cornerRadius,
730
+ renderer: art.renderer ?? GRID_RENDERER,
731
+ cornerRadius: clampCornerRadius(art.cornerRadius),
628
732
  layers: [
629
733
  {
630
734
  id: 'layer-0',
@@ -643,13 +747,19 @@ export function upgradeCompactToFull(art) {
643
747
  // full-form serialize
644
748
  // ---------------------------------------------------------------------------
645
749
 
646
- /** Collect the set of palette keys actually used by any { grid } cell. */
750
+ /** Collect palette keys used by grids and editable path source data. */
647
751
  function usedKeysOf(sprite) {
648
752
  const used = new Set();
649
753
  for (const layer of sprite.layers) {
650
754
  for (const cell of layer.cells) {
651
755
  if (cell && 'grid' in cell) {
652
756
  for (const row of cell.grid) for (const ch of row) used.add(ch);
757
+ if (cell.path) {
758
+ for (const shape of cell.path.shapes ?? []) {
759
+ if (shape.fill) used.add(shape.fill);
760
+ if (shape.stroke) used.add(shape.stroke);
761
+ }
762
+ }
653
763
  }
654
764
  }
655
765
  }
@@ -685,16 +795,16 @@ export function serializeFull(sprite) {
685
795
  repeat: t.repeat,
686
796
  })),
687
797
  ...(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 } : {}),
798
+ // Each omitted at its default, so pixel-mode files stay byte-identical to
799
+ // their pre-finish shape.
800
+ ...finishFields(sprite),
691
801
  layers: sprite.layers.map((l) => ({
692
802
  id: l.id,
693
803
  name: l.name,
694
804
  visible: l.visible,
695
805
  opacity: l.opacity,
696
806
  blendMode: l.blendMode,
697
- kind: l.kind,
807
+ ...(l.kind === 'path' ? { kind: 'path' } : {}),
698
808
  cells: l.cells.map((c) => {
699
809
  if (c == null) return null;
700
810
  if ('grid' in c) {
@@ -703,6 +813,10 @@ export function serializeFull(sprite) {
703
813
  const cell = { grid: c.grid };
704
814
  if (c.x) cell.x = c.x;
705
815
  if (c.y) cell.y = c.y;
816
+ if (c.path) {
817
+ const path = serializePathData(c.path);
818
+ if (path) cell.path = path;
819
+ }
706
820
  return cell;
707
821
  }
708
822
  return { link: c.link };
@@ -757,7 +871,7 @@ export function resolveCell(layer, frameIndex) {
757
871
  visited.add(idx);
758
872
  const cell = layer.cells[idx];
759
873
  if (cell == null) return null;
760
- if ('grid' in cell) return { grid: cell.grid, x: cell.x ?? 0, y: cell.y ?? 0 };
874
+ if ('grid' in cell) return { grid: cell.grid, x: cell.x ?? 0, y: cell.y ?? 0, path: cell.path };
761
875
  idx = cell.link;
762
876
  }
763
877
  return null;
@@ -966,7 +1080,7 @@ function normalizeSvgRect(raw, ctx) {
966
1080
  * Euclidean distance. Alpha is ignored for distance. Returns the chosen
967
1081
  * "#rrggbb" (lowercase), or null when the input is invalid or fully
968
1082
  * transparent (alpha === 00). */
969
- function quantizeToPalette(hex, palette) {
1083
+ export function quantizeToPalette(hex, palette) {
970
1084
  const norm = normalizeHex(hex);
971
1085
  if (!norm) return null;
972
1086
  const body = norm.slice(1);
@@ -1000,6 +1114,10 @@ function quantizeToPalette(hex, palette) {
1000
1114
  * RGB Euclidean distance. The returned palette contains only the quantized
1001
1115
  * colors actually used; transparent cells stay transparent ("."). Returns null
1002
1116
  * if the svg has no usable rects.
1117
+ *
1118
+ * For vector SVG (paths, circles, strokes), use `scripts/import-svg.mjs` which
1119
+ * produces a path layer with baked grid, or pass `--pixel` for compact pixel-only
1120
+ * output. svg-rect remains the fast path for one-rect-per-pixel emissions.
1003
1121
  */
1004
1122
  export function svgRectToPxArt(svg, opts) {
1005
1123
  const resolution = opts?.resolution ?? DEFAULT_RESOLUTION;