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
@@ -1,15 +1,15 @@
1
- # `.pxart` Pixel-Art Format (basic-2d kit)
1
+ # Sprite format (`.sprite`, physics-2d kit)
2
2
 
3
- This documents the `.pxart` sprite format as integrated into the `basic-2d`
3
+ This documents the Castle sprite format as integrated into the `physics-2d`
4
4
  kit. The format and its parser/serializer/renderer live in the kit itself, in
5
5
  `engine/pxart.js` (`parseFull`, `serializeFull`, `renderSpriteFrame`, plus
6
6
  the compact-form helpers `parseCompact` / `serializeCompact` / `renderToCanvas`).
7
- `.pxart` is a pure kit-layer concern — the harness (`castle-web-sdk`, the CLI)
7
+ The on-disk JSON format is a pure kit-layer concern — the harness (`castle-web-sdk`, the CLI)
8
8
  has no knowledge of it.
9
9
 
10
10
  A sprite is modeled on Aseprite conventions: a `layers × frames` spreadsheet of
11
11
  cells, one resolution per sprite, an ordered indexed palette, named animation
12
- tags, and Aseprite-style linked cells. A `.pxart` file is a single JSON object.
12
+ tags, and Aseprite-style linked cells. A sprite file is a single JSON object.
13
13
 
14
14
  The format has two **coexisting on-disk forms** — capability tiers, *not*
15
15
  temporal versions — that the kit treats as one in-memory model:
@@ -19,8 +19,9 @@ temporal versions — that the kit treats as one in-memory model:
19
19
  absent). The parser upgrades it into a degenerate one-layer / one-frame
20
20
  Sprite in memory.
21
21
 
22
- > Files in this kit use the extension `.pxart`. They live under `drawings/`
23
- > and are discovered by `engine/files.js`.
22
+ > Files in this kit use the extension `.sprite` under `drawings/`. The legacy
23
+ > extension `.pxart` names the same format and remains readable; create new art
24
+ > as `.sprite`.
24
25
 
25
26
  ---
26
27
 
@@ -104,7 +105,7 @@ only affects what the editor writes back.) The swatch UI renders as an 8-column
104
105
  grid (8 rows for the 64 colors) that scrolls within the inspector.
105
106
 
106
107
  **Agent subset — 16 colors (`AGENT_PALETTE_16`): what the LLM generates with.**
107
- The agent generation path (the `draw` svg-rect → `.pxart` quantizer and the
108
+ The agent generation path (the `draw` svg-rect → `.sprite` quantizer and the
108
109
  prompt the model receives) is constrained to a **fixed 16-color subset of
109
110
  Endesga-64**, so generated art stays coherent. Every color below is a member of
110
111
  `EDG64`. In order:
@@ -188,10 +189,386 @@ When the `playing` prop is `false`, the behavior holds frame 0.
188
189
  - `layers`: an **ordered** array, **bottom → top** (`layers[0]` drawn first).
189
190
  - Each layer: `id`, `name`, `visible` (skipped when hidden), `opacity`
190
191
  (`0.0`–`1.0`), `blendMode` (**`"normal"` only** for now), `kind`
191
- (**`"pixel"` only** for now), and `cells` (its row of the spreadsheet).
192
+ (**`"pixel"` or `"path"`**; missing/unknown values default to `"pixel"`),
193
+ and `cells` (its row of the spreadsheet).
192
194
 
193
195
  `renderSpriteFrame` composites visible layers bottom→top, applying per-layer
194
- opacity, at 1px/cell with `imageSmoothingEnabled = false`.
196
+ opacity, at 1px/cell with `imageSmoothingEnabled = false`. This render path is
197
+ kind-agnostic: every consumer can read the baked `grid`. **Pixels are truth.**
198
+
199
+ ### 6.1 Path layers (stacked shapes)
200
+
201
+ A `"path"` layer may attach editable vector source to any grid cell. The vector
202
+ model is an **ordered stack of shapes** painted back-to-front (painter's
203
+ order).
204
+
205
+ ```json
206
+ {
207
+ "grid": ["...."],
208
+ "path": {
209
+ "shapes": [
210
+ {
211
+ "fill": "a",
212
+ "stroke": null,
213
+ "subpaths": [
214
+ {
215
+ "closed": true,
216
+ "start": [4, 8],
217
+ "segs": [
218
+ { "to": [12, 8], "c1": [2.2917, 2], "c2": [-2.2917, 2] },
219
+ { "to": [4, 8], "c1": [-2.2917, -2], "c2": [2.2917, -2] }
220
+ ]
221
+ }
222
+ ]
223
+ }
224
+ ]
225
+ }
226
+ }
227
+ ```
228
+
229
+ - `shapes` is an ordered array (index 0 = back; later shapes overwrite earlier
230
+ pixels). Each shape has:
231
+ - `fill`: palette key or `null` (no fill).
232
+ - `stroke`: palette key or `null` (no stroke). Stroke is per-shape — whole
233
+ outline or none; there are no per-segment strokes.
234
+ - either `subpaths` — one or more rings/chains, described below — or a `prim`
235
+ key, whose geometry is generated from parameters instead
236
+ ([§6.1.3](#613-primitive-shapes-normative)).
237
+ - A subpath is `{ closed, start, segs }`: one starting anchor and an ordered
238
+ list of segments leaving it. Its anchors are `start` followed by each `to`,
239
+ except that a curved closing segment's `to` restates `start` rather than
240
+ adding an anchor — see closure below.
241
+ - A segment is `{ to, c1?, c2? }`:
242
+ - `to` is the segment's end anchor, in absolute cell units.
243
+ - `c1` and `c2` are **cubic Bézier control offsets, stored relative** — `c1`
244
+ from the segment's start anchor, `c2` from `to`. Relative handles are what
245
+ let an anchor drag move a curve rigidly without rewriting its controls.
246
+ - A segment with neither is a straight line. There is no other curve form:
247
+ circular arcs are materialized into cubics on the way in.
248
+ - **Closure is implicit when it is straight, explicit when it is curved.** This
249
+ is SVG's `Z` with one addition, and it is the canonical form: a straight
250
+ closing segment is never stored.
251
+ - A four-corner rectangle stores **three** segments and reports **four**
252
+ anchors and **four** edges. The edge from the last anchor back to `start` is
253
+ synthesized by `subpathEdgeCount` / `subpathEdgeFrom` /
254
+ `edgeObjectsForSubpath`, so it is fully editable — bend it, split it, stroke
255
+ it — without being on disk.
256
+ - A **curved** close cannot be implied, because handles have nowhere to live.
257
+ It stays as a final segment whose `to` duplicates `start` — the second
258
+ segment in the example above (a lens is two curved edges between two
259
+ anchors).
260
+ - `subpathClosesOnStart` distinguishes the two. When it is true the duplicate
261
+ is **not a distinct anchor**; `subpathVertexCount` and `subpathPoints` omit
262
+ it, and anything that **rebuilds segments by index** must map that segment
263
+ back to vertex 0. Indexing it as `segIndex + 1` puts it past the last vertex,
264
+ so a drag strands it at the old coordinate: the ring gains a phantom anchor
265
+ and a spike back to where the shape used to be. Implicit closure removes this
266
+ hazard for straight closes by removing the duplicate, but curved closes still
267
+ have it.
268
+ - Canonicalization happens in `normalizeSubpath` and at the parse boundary, so
269
+ a redundant straight closing segment from an in-memory constructor is dropped
270
+ on read rather than tolerated. Two forms of the same ring never coexist and
271
+ disagree about edge count.
272
+ - Path data built in memory rather than loaded — a fixture, a generator — has
273
+ not been through that boundary, so anything consuming it must read through
274
+ `normalizeSubpath` or hand it to `parsePathData` first.
275
+ - Multi-subpath fills use **even-odd** winding (donuts / holes). Open subpaths
276
+ are skipped when filling, so a limb strokes but never bounds a region.
277
+ - An open subpath with stroke and no fill is a 1px polyline.
278
+ - **A shape may mix a closed outline with open subpaths, and that is how art
279
+ with internal divisions is written.** The closed ones bound the fill; the open
280
+ ones only stroke. One outline around the whole silhouette, plus a line for
281
+ each internal division:
282
+
283
+ ```json
284
+ {
285
+ "fill": "b",
286
+ "stroke": "a",
287
+ "subpaths": [
288
+ { "closed": true, "start": [5, 5], "segs": [
289
+ { "to": [16, 5] }, { "to": [16, 9] }, { "to": [24, 9] },
290
+ { "to": [24, 19] }, { "to": [16, 19] }, { "to": [16, 23] },
291
+ { "to": [5, 23] } ] },
292
+ { "closed": false, "start": [16, 9], "segs": [{ "to": [16, 19] }] }
293
+ ]
294
+ }
295
+ ```
296
+
297
+ The alternative — two abutting closed shapes — carries two fill keys that have
298
+ to be kept in step, and seams where they meet, because each strokes its own
299
+ side of the shared border. One shape with a divider has a single fill, one
300
+ stroke down the middle, and nothing to keep in step. Anchors shared between
301
+ subpaths are held together by coordinate: the editor moves and deletes every
302
+ anchor sitting on a site as one, so a divider stays welded to the outline it
303
+ divides.
304
+
305
+ The editor writes exactly this when a pen run that starts and ends on an
306
+ outline is spliced into it: the outline takes in the new run, and the stretch
307
+ it displaced is kept as an open subpath rather than discarded.
308
+ - **A boundary is stored once.** An open subpath that a closed subpath in the
309
+ same shape already walks in full is dropped rather than kept beside it
310
+ (`absorbRetracedSubpaths`). The match is a contiguous run of the loop's edges,
311
+ beginning anywhere on it and read either way round, comparing anchor positions
312
+ **and** curvature — so a divider counts as retraced only when it lies exactly
313
+ on the outline, not merely near it.
314
+
315
+ This is the dual of splice, and the pair is what stops editing from
316
+ ratcheting. Splice lifts a run out of a loop and keeps it because it has become
317
+ interior; absorb puts it back when the loop comes round to retrace it, which is
318
+ what deleting the vertex that made it interior does. Without both, every such
319
+ round trip leaves a second copy of an edge that already exists, and the copies
320
+ stroke twice and drift apart on the next drag. Dropping one is safe precisely
321
+ because `stroke` is per-shape, so coincident edges paint identically.
322
+
323
+ Unlike the closure rules above, this is **not** applied at the parse boundary.
324
+ A file keeps a retraced run until an edit commits, so it renders as authored
325
+ right up until the shape is first touched — which means **authored files must
326
+ not rely on one surviving**. Write a division only where it departs from the
327
+ outline; where it coincides, the outline's own edge is already the stroke.
328
+ - Because only closed subpaths bound the fill, **an outline that is opened stops
329
+ filling**. Removing an edge from a ring does exactly that — there is no
330
+ representation for a ring with a gap, so the survivors become an open subpath.
331
+ The shape keeps its `fill` key while open, so closing it again restores the
332
+ fill it had rather than leaving it to be picked a second time.
333
+ - Coordinates are finite floats in cell units, **framed to the canvas**
334
+ ([§6.1.1](#611-coordinate-model-normative)) and rounded to four decimals on
335
+ serialize. The half-cell lattice is authoring guidance, not a storage
336
+ invariant ([§6.1.2](#612-the-half-cell-lattice-authoring-guidance)).
337
+ Off-canvas points are legal and clip during baking.
338
+ - Sagitta/bow ratio detents are an authoring **gesture** only (the bend tool
339
+ magnetizes toward bow/chord ratios at eighth-turn central angles). Storage is
340
+ always cubic segments.
341
+ - Shape objects may carry additional keys for forward-compatible variants. v1
342
+ only understands `fill` / `stroke` / `subpaths` / `prim`.
343
+
344
+ #### 6.1.1 Coordinate model (normative)
345
+
346
+ A sprite cell has **two** ways to say "where": the cel offset (`x`/`y` on the
347
+ cell, see [§7](#7-cells)) and the coordinates inside the cell's content. For a
348
+ pixel cell those compose — a small `grid` placed at an offset. For a path cell
349
+ they would double-count, so the format fixes it:
350
+
351
+ - **Path point coordinates are CANVAS-framed**, not cel-local: `[0, 0]` is the
352
+ canvas origin, and the bake output covers the full `resolution`, never a
353
+ tight bounding box.
354
+ - **A path cell's `x` and `y` MUST be `0`.** Serializers omit them. A nonzero
355
+ offset on a path cell is malformed, not a second positioning mechanism.
356
+ Rather than discard a malformed offset — which would jump the art to the
357
+ origin — the parser FOLDS it into the source, translating every point by
358
+ `(x, y)` and then clearing it. The cell lands in the canonical form with its
359
+ appearance unchanged, so an old or hand-edited file migrates silently and
360
+ correctly.
361
+
362
+ The offset mechanism exists so a pixel cel can retain content pushed outside
363
+ the canvas window without storing a canvas-sized grid of transparency. Path
364
+ cells don't need it: the vector source retains off-canvas geometry exactly (as
365
+ plain numbers) and clips only when baking, so nothing is lost without it.
366
+
367
+ Every cell on a path layer — including freshly converted layers with no
368
+ `path` key yet — MUST carry offset `(0, 0)`. Serializers omit `x`/`y`; nonzero
369
+ offsets on path layers are malformed.
370
+
371
+ **Resize** (`resizeSprite`) reframes and never crops. Pixel cells keep their
372
+ grid and shift their offset by the centered delta. Path cells have no offset
373
+ to shift, so they translate instead: every point moves by the same
374
+ `(dx, dy)` (via `translatePathData`) and the cell re-bakes at the new
375
+ resolution. Segment handles are stored relative and so are unaffected. A path layer
376
+ therefore survives any sequence of resizes losslessly — the source is
377
+ resolution-independent and only the derived `grid` changes.
378
+
379
+ #### 6.1.2 The half-cell lattice (authoring guidance)
380
+
381
+ The **half-cell lattice** — multiples of `0.5`, where integers are pixel
382
+ corners and half-integers are pixel centers — is the natural grid for pixel
383
+ art. Authoring tools **magnetize** toward it when the pointer is close (within
384
+ a screen-space radius, so the pull feels constant regardless of zoom). Alt
385
+ extends magnetism to pixel corners as well as centers.
386
+
387
+ **Storage does not enforce the lattice.** Coordinates round to four decimal
388
+ places on serialize; off-lattice values load faithfully. This lets imported SVG
389
+ and continuous ellipse parameters keep their geometry while still offering a
390
+ predictable snap feel in the editor.
391
+
392
+ For clean rasterization: stroked outlines degrade at exact integer coordinates;
393
+ filled regions are cleanest at integer corners. The SVG emission guide advises
394
+ agents on optimal phases — see `scripts/svg-emission-guide.md`.
395
+
396
+ `bows` are **not** stored — curves are cubic segments (`c1`/`c2` relative
397
+ offsets). The bend tool uses ratio detents as a **gesture** with magnetic pull.
398
+
399
+ Worth knowing before reaching for the curve vocabulary to fix a shape: it is
400
+ usually not what is limiting. Measured against a true ellipse, eight free arcs
401
+ on *exact* anchors land within 0.04 cells, but snapping those anchors to the
402
+ half-cell lattice costs 0.17 — several times more than the vocabulary ceiling.
403
+ The same measurement shows adding anchors can make a shape worse, not better:
404
+ a 4-anchor circle is exact to 0.006 cells, while an 8-anchor one is off by
405
+ 0.343, because the intermediate anchors do not land on the lattice.
406
+
407
+ #### 6.1.3 Primitive shapes (normative)
408
+
409
+ A shape may carry a `prim` key instead of `subpaths`, in which case its geometry
410
+ is **generated from parameters** rather than authored. One primitive exists:
411
+
412
+ ```json
413
+ { "prim": "ellipse", "cx": 8, "cy": 8, "rx": 7, "ry": 4, "fill": "q", "stroke": null }
414
+ ```
415
+
416
+ `cx`, `cy`, `rx`, `ry` are finite floats like any other coordinate, rounded to
417
+ four decimals on serialize; the radii are clamped to `MIN_ELLIPSE_RADIUS`.
418
+ Authoring magnetizes them toward the lattice the way it magnetizes points, but
419
+ storage does not enforce it, so a resize is continuous rather than stepped.
420
+ `fill` and `stroke` mean what they mean for any other shape.
421
+
422
+ **A primitive's `subpaths` are derived on parse and never serialized.** This is
423
+ the whole point of the construct, and it follows from the measurement in §6.1.2:
424
+ anchor placement — not the arc vocabulary — is what deforms an oval, so a
425
+ primitive stores only its *parameters* and lets the curve derived from them run
426
+ free. Writing that curve to disk would expose every intermediate anchor to
427
+ rounding and to any tool that quantizes points, undoing the gain. A 7×4 oval
428
+ derived this way sits within 0.005 cells of a true ellipse against 0.174 for the
429
+ same oval with hand-placed anchors.
430
+
431
+ The ring is traced by sixteen circular arcs whose sagittae are fitted to the
432
+ true ellipse. A circle comes out to a thousandth of a cell — every span really
433
+ is a circular arc — and an oval to about four times that, which is still far
434
+ below the lattice error it replaces.
435
+
436
+ Because the geometry is regenerated, **only the parameters are editable.** The
437
+ four anchors on the ellipse's own axes are exposed as radius handles; the rest
438
+ of the ring is not grabbable, and anchor insertion, deletion, bending, opening,
439
+ and hole-punching are all refused rather than allowed to appear to work and then
440
+ vanish on the next derive. **Detach-to-paths** (materialize the derived ring as
441
+ ordinary subpaths) is planned; until then the refusal is intentional.
442
+ Whole-shape operations — move, flip, quarter-turn,
443
+ duplicate, recolor, reorder — act on the parameters and behave normally.
444
+
445
+ Dragging a radius handle holds the opposite edge still, as in Figma and
446
+ Illustrator; Alt resizes about the centre instead. The difference is felt when
447
+ the pointer is near a lattice site and magnetism engages: anchoring the far edge
448
+ spends the whole snap at the handle, so it advances a cell at a time, while
449
+ resizing about the centre splits it across both sides and the handle tracks the
450
+ pointer twice as finely.
451
+
452
+ Consumers other than the editor need to know none of this: parse hands them an
453
+ ordinary shape with subpaths, so bake, stroke tracing, hit-testing, and the
454
+ vector renderer are unchanged.
455
+
456
+ #### 6.1.4 Stroke canonicality (normative)
457
+
458
+ A stroke exists in three forms, and it matters which one is authoritative for
459
+ what, because they are not cell-identical:
460
+
461
+ | Form | Where | Authoritative for |
462
+ | --- | --- | --- |
463
+ | **Source** — anchors + cubic segments | the `path` object | **all geometry queries**: hit-testing, snapping, selection, measurement |
464
+ | **Traced chain** — 8-connected 1px cells | `traceEdgePixels`, in the bake | the stored `grid` (what every consumer reads) |
465
+ | **Analytic stroke** — 1-cell round-cap/join | `renderer: "vector"` only | display, that frame only |
466
+
467
+ Both the traced chain and the analytic stroke are **derived** from the source;
468
+ neither derives from the other, and they are not required to agree cell for
469
+ cell. The traced chain quantizes to keep 1px stroke discipline; the analytic
470
+ stroke follows the true curve.
471
+
472
+ **Layer invariant:** anything answering "what does this look like / where is it"
473
+ (fill bake, stroke trace, hit-testing, marquee, vector render) flattens the
474
+ stored cubic segments. Sagitta/bow is a **gesture vocabulary only** — the bend
475
+ tool reads and writes it, but representation queries never re-derive a circular
476
+ arc from measured sagitta when true handles are present.
477
+
478
+ The rule that falls out: **never hit-test against baked pixels or against the
479
+ analytic stroke.** Editor queries measure distance to the flattened source
480
+ curve, so selection behaves identically at every finish setting.
481
+ `forEachStrokePixel` is a bake primitive and must not be used for hit-testing —
482
+ under `renderer: "vector"` it reports cells the user isn't looking at.
483
+
484
+ **Normative bake** (`bakePathCell` in `engine/pxartPath.js`):
485
+
486
+ This defines the bake's **output**, not its algorithm. Any implementation that
487
+ produces an identical grid is conformant — bounding-box culling, active-edge
488
+ scanline fills, and dirty-region re-bakes are all permitted and expected, since
489
+ the reference formulation below is superlinear in `resolution` and the format
490
+ allows canvases up to 512. The observable contract is the grid.
491
+
492
+ The kit takes that latitude: it culls each shape to its bounding box and fills
493
+ by scanline, computing edge crossings once per supersample row instead of
494
+ testing every subpixel against every edge. Read literally, step 2a costs
495
+ `area × supersample² × edges` per shape — and since the supersample is squared,
496
+ that grows with the FOURTH power of resolution. A stack that bakes in a
497
+ millisecond at 16×16 took **31 seconds** at 128×128 before the change and takes
498
+ under 100 ms after. Keep the reference formulation as the definition of the
499
+ output; do not implement it literally.
500
+
501
+ 1. Conceptually, one supersampled composite raster covers the whole layer at 4×
502
+ (`FILL_SUPERSAMPLE`).
503
+ 2. For each shape in stack order: (a) its fill covers every subpixel whose
504
+ center is inside the shape under even-odd winding over the flattened
505
+ subpaths (curves via the stored cubic segments), overwriting; (b) its 1px
506
+ stroke chain is computed at **native** resolution by flattening the same
507
+ cubic segments with symmetric quantization (`flattenEdge`), 8-connected
508
+ tracing (`traceEdgePixels`), and direction-invariant `cleanupCorners`, and
509
+ each stroke cell occupies a full `scale×scale` block of that key.
510
+ 3. After all shapes: majority-downsample the composite once to native cells —
511
+ a cell paints only when painted subpixels cover ≥ half of it (ties paint);
512
+ color is the majority key among painted subpixels; on key-count ties prefer
513
+ the key painted **later** in stack order (painter's-order tiebreak).
514
+
515
+ The bake is a **pure function** of `(path, width, height)`: deterministic, with
516
+ no dependence on iteration order, floating-point accumulation across shapes, or
517
+ prior grid contents. Two bakes of equal input produce equal output.
518
+
519
+ Rationale: compositing fills at supersample before the single downsample means
520
+ two abutting shapes' subpixels union across the shared boundary, so boundary
521
+ cells go to whoever owns the majority — no 1px transparent gutters. Stroke
522
+ cells stamped as full blocks keep 1px stroke discipline while still letting a
523
+ later shape's fill cover an earlier shape's stroke.
524
+
525
+ Per-shape order within a shape is fill then stroke (stroke wins over that
526
+ shape's own fill). The bake has no anti-aliasing: every output cell remains an
527
+ exact palette key. Pixels remain the source of truth — `grid` is always baked
528
+ on serialization; runtime and non-path-aware consumers read only pixels.
529
+
530
+ **Finish semantics** (file-level `renderer` + `cornerRadius`, see
531
+ [§10](#10-finish)):
532
+
533
+ - `renderer: "grid"`, `cornerRadius: 0` (Pixel): render the baked `grid`
534
+ nearest-neighbor.
535
+ - `renderer: "grid"`, `cornerRadius > 0` (`¼` / `½`): corner-kernel smoothing on
536
+ the baked grid (same kernel as pixel layers — works for both kinds). The grid
537
+ itself is unchanged.
538
+ - `renderer: "vector"` (Smooth): paint the shape stack **analytically** into the
539
+ supersampled canvas — fills as even-odd canvas paths, strokes as 1-cell-wide
540
+ analytic strokes with round caps and round joins along the true curves,
541
+ shapes in stack order.
542
+
543
+ Only the last one departs from the baked grid, and only for display — see
544
+ [§6.1.4](#614-stroke-canonicality-normative) for which form is authoritative
545
+ for what.
546
+
547
+ Parser degradation is local and tolerant: a missing/non-array `shapes` drops
548
+ the path object while the baked grid remains valid; malformed shapes/subpaths
549
+ drop individually. `path` and `kind` round-trip through full parse/serialize.
550
+ Palette pruning includes shape fill/stroke keys.
551
+
552
+ Links share the whole resolved cell, including `path`. Each materialized cell
553
+ owns its own copy — resolving a link or re-linking after a frame op must deep
554
+ copy `path`, never alias one object across cells. A path layer always
555
+ serializes in full form (the `kind` mismatch fails compact eligibility on its
556
+ own; nothing hand-maintained enforces it).
557
+
558
+ **Layer-kind conversion is lossy in both directions**, and neither direction is
559
+ reversible except via Undo:
560
+
561
+ - **Pixel → Path** discards the layer's pixels on every frame. There is no
562
+ vectorization; the new path source starts empty and the grid re-bakes
563
+ transparent. The editor confirms first when the layer has opaque pixels.
564
+ - **Path → Pixel** discards the vector source and keeps the baked grid as
565
+ truth. The art survives; its editability does not.
566
+
567
+ Interactive shape editing is **shipped** (`editors/pathTools.js` — pen, select,
568
+ bend, punch, anchor edit, flip/rotate, z-order, recolor), alongside the render
569
+ path (bake + finishes + overlay). The path unit and check suites live in the
570
+ `shape-layers` lab deck, which is where this engine is developed; run them there
571
+ after format, bake, parser, serializer, or model changes.
195
572
 
196
573
  ---
197
574
 
@@ -213,6 +590,24 @@ cell index `f` is the layer's content for frame `f`. A cell is one of:
213
590
  - **Faithful storage:** grids are stored as **packed strings** of palette keys.
214
591
  Generation-only emission shapes (svg-rect, etc.) normalize into this packed
215
592
  form and are never the thing of record.
593
+ - **Cel offset** (`x`/`y` on a `{ grid }` cell, omitted when `0`) places a grid
594
+ that is smaller than — or shifted out of — the canvas window, so a resize can
595
+ reframe without cropping. It applies to **pixel cells only**: a cell carrying
596
+ `path` must have `x`/`y` of `0`
597
+ ([§6.1.1](#611-coordinate-model-normative)).
598
+ - Any helper that rebuilds a cell must **carry `path` through**, and each
599
+ rebuilt cell must own its own **deep copy** — resolving a link chain hands
600
+ several slots the same object, so materializing them without copying leaves
601
+ independent frames sharing one source. Rebuilding via a grid-only constructor
602
+ silently strips the vector source and leaves a stale baked grid behind it.
603
+ - A rebuild that MOVES a path cel (resize, cel-offset change) translates the
604
+ source and re-bakes rather than carrying the grid across unchanged, so the
605
+ grid never disagrees with the points it came from.
606
+ - A rebuild that would **write pixels directly into a path cel** — a paint
607
+ merge — must instead refuse. There is no correct outcome: keeping the source
608
+ desyncs it from the grid (the next path edit reverts the paint), and dropping
609
+ the source destroys the vector art. The pixel tools are gated off path layers
610
+ in the UI, so this is a guard, not a workflow.
216
611
 
217
612
  ---
218
613
 
@@ -224,6 +619,12 @@ cell index `f` is the layer's content for frame `f`. A cell is one of:
224
619
  happen **outside** the file — in this kit, the actor's `Layout` box. The
225
620
  `Sprite` behavior blits the native-resolution frame into the Layout rectangle
226
621
  with smoothing disabled and an optional `tint` multiply.
622
+ - **Path source shares this space**, continuously rather than by cell index: a
623
+ point of `[3, 5]` is the corner where pixels `(2,4)`, `(3,4)`, `(2,5)` and
624
+ `(3,5)` meet, and `[3.5, 5.5]` is the center of pixel `(3,5)`. Both are on
625
+ the half-cell lattice ([§6.1.2](#612-the-half-cell-lattice-normative)).
626
+ Coordinates outside `[0, width] × [0, height]` are legal and retained; they
627
+ clip when baking.
227
628
 
228
629
  ---
229
630
 
@@ -282,51 +683,106 @@ case).
282
683
 
283
684
  ---
284
685
 
285
- ## 10. Corner rounding
686
+ ## 10. Finish
286
687
 
287
688
  ```json
288
689
  "cornerRadius": 0.25
289
690
  ```
290
691
 
291
- - A top-level **number**: a corner-rounding radius, in native-pixel units.
292
- `0` (the default) renders sharp/nearest-neighbor, same as before this field
293
- existed. Any value `> 0` renders rounded, by that amount — see "Rendering"
294
- below. **File-level** — a property of the sprite asset itself, not a
295
- per-actor/per-placement override. There is no equivalent `Sprite` behavior
296
- prop.
692
+ ```json
693
+ "renderer": "vector"
694
+ ```
695
+
696
+ The finish is **two orthogonal fields**: *which renderer* draws the sprite, and
697
+ *how much* corner rounding the grid renderer applies.
698
+
699
+ - **`renderer`**: `"grid"` (default, omitted when default) or `"vector"`.
700
+ `"grid"` renders from the baked `grid`. `"vector"` renders each path layer
701
+ analytically from its shape stack and each pixel layer with the corner kernel
702
+ at `MAX_CORNER_RADIUS` — see [§6.1](#61-path-layers-stacked-shapes).
703
+ - **`cornerRadius`**: **always a number**, in native-pixel units, clamped to
704
+ `[0, MAX_CORNER_RADIUS]` on read. Default `0`, omitted when `0`. It is the
705
+ rounding amount the grid renderer applies; the vector renderer uses it for
706
+ its pixel layers.
707
+
708
+ Both are **file-level** — properties of the sprite asset itself, not
709
+ per-actor/per-placement overrides. There is no equivalent `Sprite` behavior
710
+ prop for either.
711
+
712
+ > **Why two fields.** These were briefly one: `cornerRadius` held either a
713
+ > number or the string `"smooth"`. That union is retired because a categorical
714
+ > mode and a continuous amount don't share a slot cleanly — every
715
+ > `cornerRadius > 0` test in the codebase silently read `"smooth"` as *false*
716
+ > (`'smooth' > 0` is `NaN`-comparison false), so a smooth sprite took the sharp
717
+ > branch anywhere the string wasn't special-cased, and `clampCornerRadius` had
718
+ > to return a string to stay lossless. Splitting them restores the invariant
719
+ > that **`cornerRadius` is a number everywhere**, which is what makes the
720
+ > "a value, not a flag" argument below actually hold.
721
+
297
722
  - **A value, not a flag**, deliberately: the amount of rounding is itself
298
723
  part of the portable file format, not a fixed code-side constant every
299
- smooth sprite would otherwise be stuck with. `MAX_CORNER_RADIUS` (0.5, in
724
+ rounded sprite would otherwise be stuck with. `MAX_CORNER_RADIUS` (0.5, in
300
725
  `engine/pxart.js`) is the ceiling — two corner cuts on the same
301
- 1-native-pixel edge must not overlap and any value is clamped to
302
- `[0, MAX_CORNER_RADIUS]` on read. The `PxArtEditor` UI curates this down to
303
- a 3-way segmented control (`0` / `¼` / `½`, see `CornerRadiusBar` in
304
- `editors/pixelInspector.jsx`) so picking a value never means hunting a
305
- slider, but the format itself isn't limited to those three any in-range
306
- number is valid, e.g. from hand-edited JSON or a future finer-grained UI.
307
- - **`0` is the default** and is never written to disk: `serializeFull` and
308
- `serializeCompact` both OMIT the field when it's `0`, so existing (and
309
- newly authored, unsmoothed) files stay byte-identical to their
310
- pre-`cornerRadius`-field shape.
311
- - **Present in BOTH on-disk forms.** `parseCompact` reads a top-level
312
- `cornerRadius` alongside `palette`/`grid`; `parseFull`'s native-full branch
313
- reads it alongside `resolution`/`layers`; `upgradeCompactToFull` carries a
314
- compact file's `cornerRadius` through into the upgraded Sprite. This means
315
- the kit's save path which picks compact vs. full by a serialize →
316
- re-parse round-trip (`serializeModel` in `editors/pxArtEditorModel.js`, see
317
- [§9](#9-compact-shorthand)) never drops the field: a single-layer/frame
726
+ 1-native-pixel edge must not overlap. The `PxArtEditor` UI curates the pair
727
+ down to a 4-way segmented control (`Pixel` / `¼` / `½` / `Smooth`, see
728
+ `FinishBar` in `editors/pixelInspector.jsx`) so picking a finish never means
729
+ hunting a slider, but the format itself isn't limited to those four: any
730
+ in-range radius is valid under either renderer, e.g. from hand-edited JSON or
731
+ a future finer-grained UI.
732
+
733
+ | UI preset | `renderer` | `cornerRadius` |
734
+ | --- | --- | --- |
735
+ | Pixel | omitted (`"grid"`) | omitted (`0`) |
736
+ | ¼ | omitted (`"grid"`) | `0.25` |
737
+ | ½ | omitted (`"grid"`) | `0.5` |
738
+ | Smooth | `"vector"` | omitted (`0`) |
739
+ - **Defaults are never written to disk:** `serializeFull` and
740
+ `serializeCompact` both OMIT `cornerRadius` when it's `0` and `renderer` when
741
+ it's `"grid"`, so existing (and newly authored, unsmoothed) files stay
742
+ byte-identical to their pre-finish shape. A plain pixel sprite carries
743
+ neither field.
744
+ - **Present in BOTH on-disk forms.** `parseCompact` reads both fields
745
+ alongside `palette`/`grid`; `parseFull`'s native-full branch reads them
746
+ alongside `resolution`/`layers`; `upgradeCompactToFull` carries a compact
747
+ file's finish through into the upgraded Sprite. This means the kit's save
748
+ path — which picks compact vs. full by a serialize → re-parse round-trip
749
+ (`serializeModel` in `editors/pxArtEditorModel.js`, see
750
+ [§9](#9-compact-shorthand)) — never drops them: a single-layer/frame
318
751
  rounded sprite still serializes compact, with `cornerRadius: 0.25` alongside
319
- `palette`/`grid`.
320
- - **Parser tolerance:** any non-finite-number value (missing field, `null`, a
321
- typo) defaults to `0`. Numbers are clamped to `[0, MAX_CORNER_RADIUS]`.
322
- **Legacy back-compat:** this field used to be called `render` first a
323
- `"pixel"` | `"smooth"` string enum, then (briefly) a bare numeric radius
324
- under that same key. Both migrate on read: a legacy `render: "smooth"`
325
- becomes a fixed radius (`0.25`); a legacy numeric `render` value is read
326
- as-is; anything else (including the original `render: "pixel"`) defaults to
327
- `0`. This keeps old files rendering rounded rather than silently reverting
328
- to sharp. Detection of compact vs. full form ([§1](#1-form-discriminator))
329
- is unaffected neither key is ever used as a structural signal.
752
+ `palette`/`grid`. The compact candidate must be built with BOTH fields and
753
+ the eligibility check must compare BOTH: that check compares an explicit list
754
+ of fields rather than the whole parsed object, so a field it does not name is
755
+ a field the compact form is free to silently drop. A mismatch on either one
756
+ forces the full form.
757
+ - **Parser tolerance:** `renderer` is the exact string `"vector"` or else
758
+ `"grid"` (missing, `null`, a typo anything unrecognized reads as `"grid"`).
759
+ `cornerRadius` reads as a number clamped to `[0, MAX_CORNER_RADIUS]`, and
760
+ **any non-number defaults to `0`**; the parser never yields a non-numeric
761
+ `cornerRadius`, so downstream `cornerRadius > 0` tests are always meaningful.
762
+ **Legacy back-compat**, all migrating on read and none of them structural
763
+ signals (compact-vs-full detection, [§1](#1-form-discriminator), is
764
+ unaffected):
765
+
766
+ | Legacy on disk | Reads as |
767
+ | --- | --- |
768
+ | `cornerRadius: "smooth"` | `renderer: "vector"`, `cornerRadius: 0` |
769
+ | `cornerRadius: <number>` | `cornerRadius` clamped, `renderer: "grid"` |
770
+ | `render: "smooth"` (oldest enum) | `cornerRadius: 0.25`, `renderer: "grid"` |
771
+ | `render: <number>` (brief numeric phase) | that radius, `renderer: "grid"` |
772
+ | `render: "pixel"`, or anything else | `cornerRadius: 0`, `renderer: "grid"` |
773
+
774
+ Old files therefore keep rendering rounded rather than silently reverting to
775
+ sharp, and a file written before the split still selects the vector renderer.
776
+ - **Asking "is this sprite smoothed?"** Use `displayFinish(sprite)` from
777
+ `engine/pxart.js`, never a bare `cornerRadius` test. The vector renderer
778
+ carries `cornerRadius: 0`, so `cornerRadius === 0` reads a Smooth sprite as
779
+ Pixel. In `PxArtEditor` that predicate decides whether the native 1px/cell
780
+ canvas paints at all, and getting it wrong drew the blocky bake on top of the
781
+ analytic render — both finishes visible at once. `displayFinish` also returns
782
+ the radius to draw with, which is `MAX_CORNER_RADIUS` under the vector
783
+ renderer (pixel layers still go through the corner kernel there), not the
784
+ sprite's own field. `finish-check` guards this by asserting the interactive
785
+ canvas is empty at every non-Pixel finish.
330
786
  - **Rendering.** `cornerRadius === 0` renders as before: 1px/cell,
331
787
  nearest-neighbor (`renderSpriteFrame`, `imageSmoothingEnabled = false`).
332
788
  `cornerRadius > 0` renders through `engine/pxartSmooth.js`'s
@@ -348,8 +804,29 @@ case).
348
804
  a corner is always a same-block recoloring, never a shape that could leave
349
805
  a gap against its neighbors. This suits small pixel-art sprites, not
350
806
  general raster upscaling.
351
- - `behaviors/Sprite.jsx` blits a `cornerRadius > 0` sprite's cached canvas
352
- with `imageSmoothingEnabled = true` (vs. `false` for `cornerRadius === 0`),
807
+ - **Rendering, `renderer: "vector"`.** `renderSmoothCompositeFrame` renders each
808
+ layer as smoothly as its data allows at one shared supersample scale, then
809
+ composites bottom-to-top with visibility and opacity. Path layers paint
810
+ their shape stack analytically (even-odd fills + 1-cell-wide round-cap
811
+ strokes in stack order) — see §6.1. Canvas anti-aliasing is intentional in
812
+ this display-only stage and never changes the stored grid. Pixel layers use
813
+ the corner kernel at `MAX_CORNER_RADIUS`. Under `renderer: "grid"` — at every
814
+ `cornerRadius`, including `¼` and `½` — path layers render the normative
815
+ `bakePathCell` grid unchanged.
816
+ - **Timeline thumbnails show the sprite's chosen finish**, via
817
+ `renderSmoothLayerCell` — a Smooth sprite reads as Smooth in the timeline
818
+ rather than as its bake. Only the plain Pixel finish takes the nearest-neighbor
819
+ blit. Measured at a 43px thumb, the analytic path render is *cheaper* than the
820
+ blit (0.06 ms vs 0.09 ms), because it draws shapes straight into the thumb
821
+ instead of baking a frame and upscaling it; the corner kernel behind `¼` / `½`
822
+ is the expensive one at ~0.4 ms, roughly 4-14x the blit. That cost is only
823
+ affordable because thumbs redraw on cel content and nothing else — see the
824
+ dependency list on `CellThumb`'s layout effect, which is load-bearing, not
825
+ incidental. It gates on the serialized `text`, since `deriveSprite` hands back
826
+ a fresh `sprite` object every render.
827
+ - `behaviors/Sprite.jsx` blits a smoothed sprite's cached canvas
828
+ with `imageSmoothingEnabled = true` (vs. `false` for the plain
829
+ `renderer: "grid"`, `cornerRadius === 0` case),
353
830
  passing the sprite's own `cornerRadius` value through as
354
831
  `renderSmoothSpriteFrame`'s `cornerRadius` option; the offscreen canvas
355
832
  cache itself is unaffected (still one WeakMap entry per sprite object —
@@ -363,15 +840,24 @@ case).
363
840
  ## 11. Reserved / out of scope
364
841
 
365
842
  Named so the format can grow without a breaking change, but **not built**:
366
- tilemap/tileset layers (a future `kind`), slices, layer groups, vector/avatar
367
- layers, and rich blend modes (beyond `"normal"`). Reserving the `kind` and
368
- `blendMode` discriminators keeps these additive later, not breaking.
843
+ tilemap/tileset layers (a future `kind`), slices, layer groups, avatar layers,
844
+ and rich blend modes (beyond `"normal"`). Reserving the `kind` and `blendMode`
845
+ discriminators keeps these additive later, not breaking — `kind: "path"`
846
+ ([§6.1](#61-path-layers-stacked-shapes)) is the first cash-in on that
847
+ reservation and shipped without a format break, which is the intended pattern
848
+ for the rest.
849
+
850
+ Within path layers specifically, **not built**: per-segment strokes, stroke
851
+ widths other than 1 cell, stroke alignment (inside/center/outside), gradients,
852
+ and non-circular curve types. The `shapes[]` entries tolerate unknown keys
853
+ precisely so variants like these stay additive; v1 understands only
854
+ `fill` / `stroke` / `subpaths`.
369
855
 
370
856
  ---
371
857
 
372
858
  ## Appendix — sample
373
859
 
374
- `drawings/cauldron.pxart` is a 16×16 multi-layer animated sprite referenced by
860
+ `drawings/cauldron.sprite` is a 16×16 multi-layer animated sprite referenced by
375
861
  the `pig` actor in `scenes/main.scene` (via `blueprints/cauldron.scene`)
376
862
  through the `Sprite` behavior — a working rendered example of the format in
377
863
  this kit.