spine-rigc 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -373,6 +373,22 @@ A part that matches nowhere is refused by name, two near-equal placements are re
373
373
  as both, and nothing it prints is a score. Fields, the coordinate contract and the
374
374
  limits: [AUTHORING.md §11](docs/AUTHORING.md).
375
375
 
376
+ ## The gallery — four complete rigs over art that ships with them
377
+
378
+ Each directory in [`gallery/`](https://github.com/firejune/rigc/tree/main/gallery) is
379
+ one rig spec, one motion spec and the PNGs they name, small enough to read in one
380
+ sitting. Each stars a single feature, so *how do I do X* has a working answer rather
381
+ than a field table, and each README carries the frame rate it was authored at, what
382
+ was verified, and what writing it cost. Repository material: a clone and
383
+ `bun install` runs them.
384
+
385
+ | Example | Stars | What it is |
386
+ | --- | --- | --- |
387
+ | [`gallery/walk`](https://github.com/firejune/rigc/tree/main/gallery/walk) | `ik` constraints + **`ik` timelines** | Two two-bone leg chains solved to foot targets — the planted leg nailed down, the swinging one let go at the top of its lift |
388
+ | [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) | **`deform` timelines** | A ball squashed about its contact point and stretched along its travel, from two affine transforms written out in the README |
389
+ | [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex) | **`contour` meshes** | A swallow-tailed banner and a serrated leaf: four meshes traced off their own alpha, waved by bone timelines and rippled by a `deform` |
390
+ | [`gallery/ride`](https://github.com/firejune/rigc/tree/main/gallery/ride) | `path` attachments + **path constraints** | A trolley coasting down a drawn rail and rolling back, driven by a `position` timeline, with `groups` + `stagger` keying the wheels and the ears |
391
+
376
392
  ## Commands
377
393
 
378
394
  Every command takes its paths explicitly. `rigc <command> --help` prints its flags, and
package/cli.ts CHANGED
@@ -382,10 +382,41 @@ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> =
382
382
  authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
383
383
  };
384
384
 
385
- /** What a contour mesh measured about its own fit, or nothing for the others. */
385
+ /**
386
+ * What a mesh measured about its own fit against the art it names, or nothing
387
+ * for a mesh with no art to measure against.
388
+ *
389
+ * Printed for authored geometry as well as for a `contour` (issue #277): the
390
+ * figure is a measurement between the emitted triangles and the PNG, so it means
391
+ * the same thing whoever drew the vertices, and the silence was the defect —
392
+ * an octagon rim placed on a round part's silhouette clips its own ink outline
393
+ * at 94.31% and used to print nothing at all.
394
+ *
395
+ * The hole is appended only when there is one, so the common line is unchanged.
396
+ * It is the one figure in the report that a hole moves: `coverage` and
397
+ * `overshoot` are both measured against the FILLED silhouette, so spanning an
398
+ * interior hole is neither missing coverage nor reaching past anything, and an
399
+ * unintentional hole — a gap in the art, a stroke that failed to join — bought
400
+ * fill over transparent pixels with nothing anywhere saying so (issue #275).
401
+ */
386
402
  function meshFit(m: CompileResult['meshes'][number]): string {
387
403
  if (m.coverage === undefined) return '';
388
- return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it`;
404
+ const hole = m.holePixels ? `, enclosing ${m.holePixels}px of hole` : '';
405
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${hole}`;
406
+ }
407
+
408
+ /**
409
+ * The triangle budget a `MESH` line is read against: the rig's, or nothing.
410
+ *
411
+ * 📐 It used to be the literal `80`, which was nobody's budget — the rig quoted
412
+ * in issue #275 declared 64, `A13_MESH_BUDGET` measured against that 64
413
+ * correctly, and the line an author actually reads printed 80. Under the default
414
+ * `--profile spine` `A13` is `PROF`, so the printed number is the only budget
415
+ * figure in the output and it has to be the declared one. A rig that declares
416
+ * none says so in the same words `A13` SKIPs in, rather than being given a wall.
417
+ */
418
+ function meshBudget(rig: CompileResult['rig']): string {
419
+ return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
389
420
  }
390
421
 
391
422
  /**
@@ -471,7 +502,7 @@ function cmdBuild(flags: Record<string, string>): void {
471
502
  for (const m of result.meshes) {
472
503
  console.log(
473
504
  ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
474
- `(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}`,
505
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}`,
475
506
  );
476
507
  }
477
508
  for (const ph of result.physics) {
@@ -1763,7 +1794,8 @@ function cmdExplain(flags: Record<string, string>): void {
1763
1794
  for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
1764
1795
  for (const m of result.meshes) {
1765
1796
  console.log(
1766
- ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1797
+ ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
1798
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1767
1799
  );
1768
1800
  }
1769
1801
  }
package/docs/AUTHORING.md CHANGED
@@ -669,12 +669,44 @@ is weighted; a generated mesh binds only bones that move it) do not apply to one
669
669
  #44; before it was fixed, `A21` reported 40 failures on a correct 40-vertex editor
670
670
  mesh because an absent `meshKinds` entry read as `ring`.
671
671
 
672
+ ⭐ **Coverage is the exception, and it is reported for an authored mesh too.** Those
673
+ rules are about a mesh's **structure** — where its rim is, how its rows pair —
674
+ which rigc cannot know about geometry it did not build. Coverage is a measurement
675
+ between two things it has in front of it: the emitted triangles, and the PNG the
676
+ attachment names with `image`. So any mesh that names one gets the figure on its
677
+ `MESH` line, authored or generated:
678
+
679
+ ```
680
+ MESH ball authored 9 vertices / 8 triangles (budget 8) bones=[ball] attachments=[ball] covers 94.31% of the art, reaching 2.50px past it
681
+ ```
682
+
683
+ **A number, not a bar.** A `contour` under 99.5% is *refused* because rigc
684
+ generated that geometry as a claim about the art; an authored mesh that sits inside
685
+ its art is a legitimate thing to draw — a soft feather, a trimmed hull, a mesh
686
+ meant to bend a core while its edges stretch — so the figure informs and the
687
+ decision stays with the author. A mesh with no `image` reports nothing, because
688
+ there is nothing to measure it against. The silence was worth closing: the line
689
+ above is a round part meshed as a centre vertex plus 8 rim vertices placed on the
690
+ silhouette, and an octagon's sides pass `R · cos(π/8)` from its centre, so 5.7% of
691
+ the drawing — its whole ink outline, between the spokes — was not going to be
692
+ drawn, and every assertion passed (issue #277).
693
+
672
694
  The generators are `ring`, `ribbon` and `contour` (see
673
695
  [`src/mesh.ts`](../src/mesh.ts)); the first two encode a deformation model rather
674
696
  than a table of numbers, which is why they are code invoked by data. A generator
675
697
  is for a skeleton with **no** manifest; a cut that has one invokes the same
676
698
  builders through the manifest's `mesh` block.
677
699
 
700
+ 🚨 **A rig that invokes a generator must declare `invariants.meshSlots` (§3.7).**
701
+ Geometry rigc built is geometry rigc will not ship **unmeasured**: a generated
702
+ mesh counts against that budget, a rig that declares none has a budget of
703
+ **zero**, and the build is refused before the gate —
704
+ `1 mesh slot(s) emitted but the rig "hello" allows 0`. Declare `meshTriangles`
705
+ beside it: without it `A13_MESH_BUDGET` has nothing to measure against and SKIPs,
706
+ and the `MESH` report line has no budget to print. Authored geometry is exempt in
707
+ the other direction and for the same reason — rigc did not draw it, so leaving it
708
+ unmeasured is the author's call (issue #274).
709
+
678
710
  ⭐ **The no-manifest path centres the part window on its own slot bone.** There is
679
711
  no crop to flip against, so `size` (or, for a contour, the PNG's own size) is
680
712
  placed with its centre on the bone the slot names — which is also exactly where a
@@ -690,18 +722,31 @@ vertices to push with a `deform` timeline (§4.11) at the places the silhouette
690
722
  actually is, instead of at four corners.
691
723
 
692
724
  It takes **no geometry and no size**: the shape is traced off the attachment's own
693
- `image`, so there is no number here that can disagree with the pixels.
725
+ `image`, so there is no number here that can disagree with the pixels. The rig
726
+ header still budgets for it, which is the `invariants` block below and not a
727
+ detail of the attachment.
694
728
 
695
729
  ```json
696
- "cape": {
697
- "cape": {
698
- "type": "mesh",
699
- "image": "cape.png",
700
- "generator": { "kind": "contour", "tolerance": 1.5, "margin": 2, "maxVertices": 48 }
730
+ "invariants": { "meshSlots": 1, "meshTriangles": 64 },
731
+ "skins": {
732
+ "default": {
733
+ "cape": {
734
+ "cape": {
735
+ "type": "mesh",
736
+ "image": "cape.png",
737
+ "generator": { "kind": "contour", "tolerance": 1.5, "margin": 2, "maxVertices": 48 }
738
+ }
739
+ }
701
740
  }
702
741
  }
703
742
  ```
704
743
 
744
+ ⭐ **Both keys are the fragment.** Drop the two of them into §1.1's minimal rig in
745
+ place of its `skins`, rename its `box` bone and slot to `cape`, put a
746
+ transparent-margined `cape.png` in `images/`, and it compiles — `invariants`
747
+ included, because without it the generator is refused (issue #274). The
748
+ `meshTriangles` figure is invented; pick the one your renderer can afford.
749
+
705
750
  | Field | Meaning |
706
751
  | --- | --- |
707
752
  | `tolerance` | **required.** Douglas-Peucker tolerance, in part pixels. Bigger spends fewer vertices and cuts more corners |
@@ -722,7 +767,18 @@ the raw outline encloses every art pixel *whole*), simplify it, push it out by
722
767
  against the mask it came from**. `build` and `explain` print what it measured:
723
768
 
724
769
  ```
725
- MESH cape contour 15 vertices / 13 triangles (budget 80) bones=[cape] attachments=[cape] covers 100.00% of the art, reaching 3.16px past it
770
+ MESH cape contour 15 vertices / 13 triangles (budget 64) bones=[cape] attachments=[cape] covers 100.00% of the art, reaching 3.16px past it
771
+ ```
772
+
773
+ The budget in that line is the rig's `invariants.meshTriangles` — `(no budget
774
+ declared)` when it declares none, which is the same distinction `A13` SKIPs on. It
775
+ used to be the literal `80` whatever the rig said, so the line an author reads and
776
+ the assertion that measures could print two different numbers (issue #275). A part
777
+ whose outline encloses a hole says so too, because nothing else in the output
778
+ moves when one appears:
779
+
780
+ ```
781
+ MESH flag_b contour 48 vertices / 46 triangles (budget 96) bones=[flag_b] attachments=[flag_b] covers 100.00% of the art, reaching 2.00px past it, enclosing 848px of hole
726
782
  ```
727
783
 
728
784
  🚨 **It is geometry, not a deformation model.** Every vertex is pinned to the slot
@@ -740,7 +796,7 @@ mesh rather than a rim prefix, and `A28_RIBBON_ROWS_SHARE_WEIGHTS` **SKIPs** wit
740
796
  | --- | --- |
741
797
  | every pixel opaque | `every pixel of the 96x64 part reaches alpha 1, so its silhouette IS the part window and a contour mesh of it is a region attachment with extra vertices` |
742
798
  | two or more islands | `the art is 2 separate islands and one outline can only enclose the largest (529 of 989 px, 53.49%)` — one outline encloses one region; give each island its own slot |
743
- | a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. `explain` reports the coverage either way |
799
+ | a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. `build` and `explain` print the hole in pixels, because `coverage` and `overshoot` are both measured against the FILLED silhouette and neither of them moves when a part gains one |
744
800
  | a **diagonal pinch** — two parts of the art meeting at one pixel corner | `the alpha silhouette pinches to a single point at pixel corner (2,2) … one outline cannot pass through one point twice` |
745
801
  | a neck narrower than `margin` | `the outline crosses itself: edge 0 meets edge 3 after a margin of 3px was pushed out of a silhouette narrower than that` |
746
802
  | more outline than `maxVertices` | `the silhouette simplified to 15 vertices at tolerance 1.5, past the 4 this mesh allows` — refused, never silently decimated |
@@ -757,6 +813,9 @@ Self-intersection is refused; **holes are not cut out**; and nothing here does
757
813
  interior/Steiner points, so a contour mesh bends only where its outline has
758
814
  vertices.
759
815
 
816
+ 🖼️ **Worked example: [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex)** — four contours over real art, with
817
+ the `tolerance`/`margin` sweep that picked their settings and what each one measured.
818
+
760
819
  **Bounding box** ([Spine: bounding boxes](http://esotericsoftware.com/spine-bounding-boxes))
761
820
  and **clipping** ([Spine: clipping](http://esotericsoftware.com/spine-clipping))
762
821
  attachments — a polygon, and nothing else.
@@ -982,6 +1041,9 @@ unresolved `spacingMode` fails the `=== Length` test and spaces bones as though
982
1041
  `Fixed` had been asked for; an unresolved `rotateMode` is neither `Tangent` nor
983
1042
  `ChainScale`, so bones follow the curve and never turn along it.
984
1043
 
1044
+ 🖼️ **Worked example: [`gallery/ride`](https://github.com/firejune/rigc/tree/main/gallery/ride)** — a trolley on a drawn rail, moved
1045
+ by a `position` timeline, with `groups` + `stagger` keying the wheels.
1046
+
985
1047
  #### 3.5.2 `slider` — a value that drives an animation
986
1048
 
987
1049
  **When you need one:** a pose that has to be driven by a value instead of by time —
@@ -1069,9 +1131,9 @@ is not an array. Every field is optional and each is the payload a firing
1069
1131
 
1070
1132
  ### 3.7 `invariants` — what the artifact cannot say about itself
1071
1133
 
1072
- Optional, and only meaningful for rigc's own formations: `meshSlots` and
1073
- `meshTriangles` (the two halves of the mesh budget `A13` measures against),
1074
- `axisBone`, `massBone`, `detached`. Nothing in skeleton JSON records that a
1134
+ Optional with one exception, and only meaningful for rigc's own formations:
1135
+ `meshSlots` and `meshTriangles` (the two halves of the mesh budget `A13` measures
1136
+ against), `axisBone`, `massBone`, `detached`. Nothing in skeleton JSON records that a
1075
1137
  bone carries a cut's axis or that a parentage is forbidden, so the rig spec says it
1076
1138
  and the validator's archetype assertions read it. **An assertion whose field is
1077
1139
  absent reports SKIP, never a pass.** If you are reproducing a foreign skeleton,
@@ -1079,6 +1141,15 @@ leave this out entirely and run `--profile spine` — and expect `PROF` rather t
1079
1141
  that SKIP, because the profile excludes an archetype assertion before its body
1080
1142
  could notice the missing field (§5.2).
1081
1143
 
1144
+ 🚨 **The exception: `meshSlots` is required by a rig that invokes a mesh
1145
+ generator** (`ring`, `ribbon`, `contour` — §3.4), and it is a **compile-time**
1146
+ refusal rather than an assertion. Undeclared means a budget of zero, so the build
1147
+ stops before the gate with `N mesh slot(s) emitted but the rig "X" allows 0`.
1148
+ Geometry rigc built is geometry rigc will not ship unmeasured; geometry the author
1149
+ drew is exempt, because rigc did not draw it. So `A13`'s **SKIP** means *this rig
1150
+ is unmeasured*, not *this budget is inert* — those are two code paths with one
1151
+ name, and reading the SKIP as the whole story is what issue #274 was.
1152
+
1082
1153
  ---
1083
1154
 
1084
1155
  ## 4. The motion spec, field by field
@@ -1369,13 +1440,25 @@ here was measured off a real rig. Copy the shape, not the values.
1369
1440
  | --- | --- | --- |
1370
1441
  | `mix` | 0..1, how much of the solved rotation is applied | `1` |
1371
1442
  | `softness` | distance from full reach at which the bones stop straightening | `0` |
1372
- | `bendPositive` | two-bone bend direction | `true` |
1373
- | `compress` | one-bone IK scales the bone down to reach a close target | `false` |
1374
- | `stretch` | scales the bone up to reach a far target | `false` |
1443
+ | `bendPositive` | two-bone bend direction | **the rig's** (`true` if the rig says nothing) |
1444
+ | `compress` | one-bone IK scales the bone down to reach a close target | **the rig's** (`false` if the rig says nothing) |
1445
+ | `stretch` | scales the bone up to reach a far target | **the rig's** (`false` if the rig says nothing) |
1375
1446
 
1376
1447
  - **`mix` and `softness` are the two curve channels, in that order.** A raw
1377
1448
  `curve` is therefore 8 numbers, and the three booleans are stepped by nature —
1378
1449
  nothing interpolates a bend direction.
1450
+ - 🚨 **The three booleans are the one place a key's absent value is the RIG's and
1451
+ not the format's.** The parser reads them twice with the same defaults — once on
1452
+ the constraint (`SkeletonJson:155`) and once on **every timeline key**
1453
+ (`:912`) — so a key that omits `bendPositive` does not inherit the constraint's
1454
+ value, it asserts `true`. A rig declaring `bendPositive: false` under a timeline
1455
+ that keys only `mix` therefore bent the *other* way for the whole animation,
1456
+ with the field still in the file and inert: four builds differing only in those
1457
+ values posed one pose, and the gate was green throughout (issue #273). rigc now
1458
+ stamps the rig's value onto every emitted key, so the declaration reaches the
1459
+ runtime. **Stating a flag on every key still overrides the rig** — the format
1460
+ keys them per key on purpose, a bend that flips partway through is a real thing
1461
+ to write, and it is what the editor's own export does.
1379
1462
  - `mix` outside `0..1` is a compile error: `IkConstraintPose.mix` is documented as
1380
1463
  a percentage. A **transform** mix is documented *unbounded*, which is why §4.10
1381
1464
  has no such rule — the asymmetry is the runtime's, not ours.
@@ -1404,6 +1487,10 @@ consumer's process, which is late. `A34_CONSTRAINT_TIMELINE_TARGETS` checks the
1404
1487
  same two from the other side, plus one thing the compiler cannot produce and a
1405
1488
  hand-edited file can: an **empty key array**, which the parser skips in silence.
1406
1489
 
1490
+ 🖼️ **Worked example: [`gallery/walk`](https://github.com/firejune/rigc/tree/main/gallery/walk)** — two mirrored two-bone leg chains
1491
+ whose `mix`, `softness` and `bendPositive` are keyed through a stance and a swing,
1492
+ with the README's table of what each key is for.
1493
+
1407
1494
  ### 4.10 `transform` — turning a muted transform constraint on
1408
1495
 
1409
1496
  Same shape as §4.9 and the same absent-means-default rule; six mixes instead of
@@ -1569,6 +1656,9 @@ where the remedy is a line you own. `A35` does **not** refuse it: it is pointed
1569
1656
  other people's files, and a rule stricter than the runtime tells its reader to go
1570
1657
  and break correct data.
1571
1658
 
1659
+ 🖼️ **Worked example: [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash)** — a 9-vertex ball squashed about
1660
+ its contact point, with the two affine transforms its keys were derived from.
1661
+
1572
1662
  ---
1573
1663
 
1574
1664
  ### 4.12 `path` and `slider` timelines — tracks, not their own groups
@@ -1643,6 +1733,7 @@ the frequent ones, verbatim:
1643
1733
  | `key times must strictly increase (at t=…)` | including after `lag` and `stagger` |
1644
1734
  | `animation "A" has two tracks on X.property; merge them into one track` | one timeline per target property |
1645
1735
  | `no stage size: give the rig spec a \`skeleton.width\`/\`skeleton.height\`` | §3.1 |
1736
+ | `N mesh slot(s) emitted but the rig "X" allows 0 — a mesh rigc GENERATED counts against \`invariants.meshSlots\`…` | §3.4 / §3.7 — a rig that invokes a mesh generator declares the budget; undeclared is zero. Add `"invariants": { "meshSlots": N, "meshTriangles": M }` |
1646
1737
  | `drawOrder at t=…: slot "X" is not one this rig emits` / `is offset twice in one key` / `puts it at N, outside the … emitted slots` | §4.7 |
1647
1738
  | `events at t=…: event "X" is not declared in the rig spec's "events" block` | declare it in the rig spec (§3.6), or fix the name |
1648
1739
  | `events: key times must not go backwards` | put the firings in time order (§4.8) |
@@ -1711,7 +1802,7 @@ The report prints one line per assertion:
1711
1802
  | `A10_NO_NAN_AFTER_STEPPING` | both | stepping the animation produced a `NaN` pose. Look for a degenerate curve or a zero scale |
1712
1803
  | `A11_NO_CLIPPING_ATTACHMENTS` | renderer | a clipping attachment; the target renderer skips them silently |
1713
1804
  | `A12_NO_DARK_COLOR` | renderer | a slot `dark` colour or an `rgba2`/`rgb2` timeline; parsed, then ignored |
1714
- | `A13_MESH_BUDGET` | renderer | more mesh slots than the rig's `invariants.meshSlots`, or a mesh over its `invariants.meshTriangles`. Thin the mesh, or raise the budget in the rig spec. **SKIP** when the rig declares neither |
1805
+ | `A13_MESH_BUDGET` | renderer | more mesh slots than the rig's `invariants.meshSlots`, or a mesh over its `invariants.meshTriangles`. Thin the mesh, or raise the budget in the rig spec. **SKIP** when the rig declares neither — which means *unmeasured*, not that the budget is inert: the same `meshSlots` is a **compile-time** refusal for rigc's own generators, before the gate (§3.7, issue #274) |
1715
1806
  | `A14_NO_FULL_FRAME_MESH` | renderer | a mesh spans the whole stage — a full-frame canvas that can never dirty-skip |
1716
1807
  | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | the `idle` animation keys a bone that drives a mesh, directly or as a control bone |
1717
1808
  | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) |
package/docs/MOTION.md CHANGED
@@ -600,6 +600,13 @@ Nothing here is an answer to anything.
600
600
  What *is* real: every command line below was run, and every figure printed in an
601
601
  output block is what the command actually printed.
602
602
 
603
+ 🖼️ **For the same recipe on art that ships, the four
604
+ [`gallery/`](https://github.com/firejune/rigc/tree/main/gallery) examples are worked
605
+ in-betweening material** — `walk` is §3.5's arcs and §3.7's phase offsets on two leg
606
+ chains, `ride` puts the same offsets in `groups` + `stagger`, and `squash` is §3.9's
607
+ pivot written as a `deform` about a contact point. Each README says what every key is
608
+ *for* rather than only what it is, and what looking at the render changed.
609
+
603
610
  ### The request
604
611
 
605
612
  > *"Here are the parts and two pictures of the signal arm — hanging down in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/compile.ts CHANGED
@@ -47,8 +47,10 @@ import {
47
47
  buildRibbonMesh,
48
48
  buildRingMesh,
49
49
  encodeWeightedVertices,
50
+ measureAuthoredMeshFit,
50
51
  MeshError,
51
52
  type MeshBoneRef,
53
+ type MeshFitReport,
52
54
  } from './mesh.ts';
53
55
  import { Plate, readPlate } from '../tools/plate.ts';
54
56
  import {
@@ -1192,16 +1194,33 @@ export function compile(opts: CompileOptions): CompileResult {
1192
1194
  tableFor(skinName)[rigSlot.name] = perSlot;
1193
1195
  }
1194
1196
  }
1195
- // 📐 The implicit budget of 0 is a statement about rigc's own GENERATORS: a rig
1196
- // that declares no `invariants.meshSlots` has not asked rigc to build any mesh,
1197
- // so building one is a mistake. It is not a statement about geometry somebody
1197
+ // 📐 The implicit budget of 0 is a statement about rigc's own GENERATORS:
1198
+ // geometry rigc built is geometry rigc will not ship unmeasured, and
1199
+ // `A13_MESH_BUDGET` has nothing to measure a generated mesh against until the
1200
+ // rig states a budget out loud. It is not a statement about geometry somebody
1198
1201
  // else drew — `RigInvariants.meshTriangles` says the same thing in words:
1199
1202
  // a number baked in here would be one project's frame time masquerading as a
1200
1203
  // property of the format. So authored meshes count against a budget the rig
1201
- // states out loud, and against nothing when it states none.
1204
+ // states out loud, and against nothing when it states none: rigc did not draw
1205
+ // them, so leaving them unmeasured is the author's call (issue #44's rule).
1206
+ // #277's coverage report splits the same way and lands on the other side of it:
1207
+ // both kinds are MEASURED, and only rigc's own output gets a wall.
1208
+ //
1209
+ // ⚠️ The message has to name the field, because the three doc places a reader
1210
+ // checks all read as "you do not need this" and one of them is §3.4's own
1211
+ // worked example (issue #274).
1202
1212
  const budgeted = rig.invariants?.meshSlots === undefined ? meshes.filter((m) => m.kind !== 'authored') : meshes;
1203
1213
  if (budgeted.length > meshBudget) {
1204
- throw new CompileError(`${budgeted.length} mesh slot(s) emitted but the rig "${rig.name}" allows ${meshBudget}`);
1214
+ throw new CompileError(
1215
+ `${budgeted.length} mesh slot(s) emitted but the rig "${rig.name}" allows ${meshBudget}` +
1216
+ (rig.invariants?.meshSlots === undefined
1217
+ ? ' — a mesh rigc GENERATED counts against `invariants.meshSlots`, and this rig declares none, which is a ' +
1218
+ 'budget of 0. Add `"invariants": { "meshSlots": ' +
1219
+ `${budgeted.length}, "meshTriangles": <triangles one mesh may carry> }\` to the rig spec: geometry rigc ` +
1220
+ 'built is geometry it will not ship unmeasured, and `A13_MESH_BUDGET` has nothing to measure against ' +
1221
+ 'until that budget is stated. (Authored geometry is exempt — rigc did not draw it.)'
1222
+ : ' — raise `invariants.meshSlots` in the rig spec if that budget is the thing being changed'),
1223
+ );
1205
1224
  }
1206
1225
 
1207
1226
  // -- 4b. constraints -------------------------------------------------------
@@ -1217,6 +1236,22 @@ export function compile(opts: CompileOptions): CompileResult {
1217
1236
  // constraint of the same name and the parser then throws. Keeping the type
1218
1237
  // beside the name is what lets the refusal say which of the two it is.
1219
1238
  const constraintTypes = new Map<string, string>();
1239
+ /**
1240
+ * ik constraint -> the booleans it declares that the timeline format would
1241
+ * otherwise take away from it. Issue #273.
1242
+ *
1243
+ * 🚨 `SkeletonJson` reads `bendPositive`, `compress` and `stretch` in TWO
1244
+ * places with the same defaults: once on the constraint (`:155`) and once on
1245
+ * **every timeline key** (`:912`). A key that omits one does not inherit the
1246
+ * constraint's value — it asserts the parser's default. So a rig that declares
1247
+ * `bendPositive: false` and an `ik` timeline that keys only `mix` produce a
1248
+ * constraint that bends the other way for the whole animation, with the field
1249
+ * still in the file and inert: four builds differing only in these flags posed
1250
+ * one pose. What goes in this map is only the values that DIFFER from the
1251
+ * per-key default, because a rig that says nothing and a key that says nothing
1252
+ * already agree and there is nothing to carry.
1253
+ */
1254
+ const ikRigFlags = new Map<string, Record<string, boolean>>();
1220
1255
  // Which slots can actually show a path, for the path constraint's own check.
1221
1256
  // Read off the emitted skin tables rather than the spec, so it answers the
1222
1257
  // question the runtime asks: is there an attachment of that type on that slot?
@@ -1243,6 +1278,14 @@ export function compile(opts: CompileOptions): CompileResult {
1243
1278
  constraints.push(buildRigConstraint(spec, constraintCtx));
1244
1279
  constraintNames.add(spec.name);
1245
1280
  constraintTypes.set(spec.name, spec.type);
1281
+ if (spec.type === 'ik') {
1282
+ const carried: Record<string, boolean> = {};
1283
+ for (const flag of CONSTRAINT_TIMELINES.ik.flags) {
1284
+ const declared = spec[flag.field];
1285
+ if (typeof declared === 'boolean' && declared !== flag.dflt) carried[flag.field] = declared;
1286
+ }
1287
+ if (Object.keys(carried).length) ikRigFlags.set(spec.name, carried);
1288
+ }
1246
1289
  }
1247
1290
  withMotionSource(() => {
1248
1291
  for (const [name, spec] of Object.entries(motion.physics ?? {})) {
@@ -1414,7 +1457,14 @@ export function compile(opts: CompileOptions): CompileResult {
1414
1457
  'the group holds one timeline per constraint, so merge them into one',
1415
1458
  );
1416
1459
  }
1417
- const keys = compileConstraintTrack(group, track, motion, animName, anim.duration);
1460
+ const keys = compileConstraintTrack(
1461
+ group,
1462
+ track,
1463
+ motion,
1464
+ animName,
1465
+ anim.duration,
1466
+ ikRigFlags.get(name) ?? {},
1467
+ );
1418
1468
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
1419
1469
  constraintTimelines[group][name] = keys;
1420
1470
  }
@@ -2153,6 +2203,44 @@ function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Att
2153
2203
  return out;
2154
2204
  }
2155
2205
 
2206
+ /**
2207
+ * Measure an authored mesh against the art it names, or report nothing.
2208
+ *
2209
+ * 🚨 A measurement, never a refusal — and that asymmetry with `contour` is the
2210
+ * whole decision (issue #277). A contour mesh is refused under 99.5% because rigc
2211
+ * GENERATED that geometry as a claim about the art: below the bar, rigc's own
2212
+ * arithmetic clipped the drawing. Authored geometry is the author's intent, and a
2213
+ * mesh that sits inside its art is a legitimate thing to draw — a soft feather, a
2214
+ * deliberately trimmed hull, a mesh meant to bend a core while its edges stretch.
2215
+ * Refusing those would be #44's mistake in a new place. So the figure is printed
2216
+ * and the decision stays with the author.
2217
+ *
2218
+ * Nothing is reported in the two cases where there is nothing to compare:
2219
+ * an attachment with no `image` (there is no PNG the mesh is a claim about), and
2220
+ * a part with no art at all (0 of 0 pixels is not a percentage). Neither is an
2221
+ * error here — a mesh with no image is ordinary data, and an all-transparent part
2222
+ * is somebody else's assertion to make.
2223
+ */
2224
+ function measureAuthoredFit(att: RigMeshAttachment, ctx: AttachmentContext): MeshFitReport | null {
2225
+ if (att.image === undefined || att.uvs === undefined || att.triangles === undefined) return null;
2226
+ const region = basename(att.image, '.png');
2227
+ const img = ctx.images.find((im) => im.region === region);
2228
+ if (!img) return null;
2229
+ const plate = partPlate(img);
2230
+ const alpha = new Uint8Array(plate.width * plate.height);
2231
+ for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3];
2232
+ // uvs are the part window in 0..1 — the same normalisation `buildContourMesh`
2233
+ // emits — so the pixel grid they land on is the PLATE's, which is the grid the
2234
+ // alpha was read off. On an imported page that declares a `scale:` the
2235
+ // drawing's size and the plate's differ, and it is the plate that has pixels.
2236
+ const points = [] as Array<[number, number]>;
2237
+ for (let i = 0; i + 1 < att.uvs.length; i += 2) {
2238
+ points.push([att.uvs[i] * plate.width, att.uvs[i + 1] * plate.height]);
2239
+ }
2240
+ const fit = measureAuthoredMeshFit({ width: plate.width, height: plate.height, alpha }, 1, points, att.triangles);
2241
+ return fit.artPixels === 0 ? null : fit;
2242
+ }
2243
+
2156
2244
  function buildRigMesh(
2157
2245
  att: RigMeshAttachment,
2158
2246
  placeholder: string,
@@ -2230,6 +2318,7 @@ function buildRigMesh(
2230
2318
  // read this and skip rather than measuring a ring that was never a ring.
2231
2319
  ctx.meshBones.add(ctx.anchorBone);
2232
2320
  for (const name of boundBones) ctx.meshBones.add(name);
2321
+ const fit = measureAuthoredFit(att, ctx);
2233
2322
  ctx.meshes.push({
2234
2323
  slot: ctx.slotName,
2235
2324
  kind: 'authored',
@@ -2237,6 +2326,8 @@ function buildRigMesh(
2237
2326
  vertices: uvCount / 2,
2238
2327
  triangles: att.triangles.length / 3,
2239
2328
  bones: boundBones.length ? boundBones : [ctx.anchorBone],
2329
+ coverage: fit === null ? undefined : r6(fit.coverage),
2330
+ overshoot: fit?.overshoot,
2240
2331
  });
2241
2332
  return out;
2242
2333
  }
@@ -2417,6 +2508,7 @@ function buildContourAttachment(
2417
2508
  bones: [ctx.anchorBone],
2418
2509
  coverage: geometry.contour?.coverage,
2419
2510
  overshoot: geometry.contour?.overshoot,
2511
+ holePixels: geometry.contour?.holePixels,
2420
2512
  });
2421
2513
  const out: SpineMeshAttachment = {
2422
2514
  type: 'mesh',
@@ -3233,6 +3325,23 @@ function compileEvents(
3233
3325
  * That is reading the format, not inventing a value: it is exactly what the
3234
3326
  * runtime will interpolate, and a bezier built against anything else would
3235
3327
  * describe a curve the player does not play.
3328
+ *
3329
+ * 🚨 `rigFlags` is the same reading applied one level up, for the three ik
3330
+ * booleans (issue #273). The parser reads them per KEY as well as on the
3331
+ * constraint, with the same defaults in both places, so an ik timeline whose keys
3332
+ * omit `bendPositive` does not inherit the rig's — it asserts `true`, and a rig
3333
+ * that declared `false` bends the other way for the whole animation with the
3334
+ * field still sitting in the file. Every key therefore carries the EFFECTIVE
3335
+ * direction: the motion's where the motion states one, and the rig's where it
3336
+ * does not.
3337
+ *
3338
+ * **A motion key may still override.** The format keys these per key on purpose
3339
+ * — they are stepped by nature, and a bend that flips partway through an
3340
+ * animation is a real thing to write — so a track that states a flag on every key
3341
+ * is honoured as written, whatever the rig says. That is also what the editor's
3342
+ * own export does: spineboy-pro declares `bendPositive: false` on both leg chains
3343
+ * and restates it on every key of all six ik timelines that touch them. What
3344
+ * changes here is only the silent case.
3236
3345
  */
3237
3346
  function compileConstraintTrack(
3238
3347
  group: 'ik' | 'transform',
@@ -3240,6 +3349,8 @@ function compileConstraintTrack(
3240
3349
  motion: MotionSpec,
3241
3350
  animName: string,
3242
3351
  duration: number,
3352
+ /** Non-default ik booleans the rig declared, by field. Empty for `transform`. */
3353
+ rigFlags: Record<string, boolean>,
3243
3354
  ): SpineTimelineKey[] {
3244
3355
  const shape = CONSTRAINT_TIMELINES[group];
3245
3356
  const article = group === 'ik' ? 'an' : 'a';
@@ -3316,7 +3427,15 @@ function compileConstraintTrack(
3316
3427
  }
3317
3428
  for (const flag of shape.flags) {
3318
3429
  const v = read(key, flag.field);
3319
- if (v === undefined) continue;
3430
+ if (v === undefined) {
3431
+ // The rig's value, stamped on this key because nothing else will carry
3432
+ // it there. Absent from `rigFlags` means the rig's value IS the per-key
3433
+ // default, so omitting the field says the same thing and the emitted
3434
+ // bytes do not move.
3435
+ const carried = rigFlags[flag.field];
3436
+ if (carried !== undefined) entry[flag.field] = carried;
3437
+ continue;
3438
+ }
3320
3439
  if (typeof v !== 'boolean') {
3321
3440
  throw new CompileError(`${where} (t=${key.t}): ${flag.field} is ${JSON.stringify(v)}, not true or false`);
3322
3441
  }
package/src/mesh.ts CHANGED
@@ -981,8 +981,19 @@ export function traceAlphaOutline(
981
981
  dir = next;
982
982
  }
983
983
 
984
- // Holes: background pixels the island encloses. Flooded from outside the part,
985
- // so "enclosed" is decided by reachability rather than by a winding rule.
984
+ const { filled, holePixels } = fillEnclosed(inside, w, h);
985
+ return { outline, artPixels, islandPixels: sizes[biggest - 1], islands: sizes.length, holePixels, filled };
986
+ }
987
+
988
+ /**
989
+ * A set of pixels plus every background pixel it encloses.
990
+ *
991
+ * Flooded from outside the part, so "enclosed" is decided by REACHABILITY rather
992
+ * than by a winding rule — which is what makes it answer the same question for a
993
+ * mask of one island and a mask of several, and is why the authored-mesh
994
+ * measurement can pass it all the art where the trace passes it one island.
995
+ */
996
+ function fillEnclosed(inside: Uint8Array, w: number, h: number): { filled: Uint8Array; holePixels: number } {
986
997
  const outsideReach = new Uint8Array(w * h);
987
998
  const queue: number[] = [];
988
999
  const seed = (x: number, y: number): void => {
@@ -1018,8 +1029,37 @@ export function traceAlphaOutline(
1018
1029
  holePixels++;
1019
1030
  }
1020
1031
  }
1032
+ return { filled, holePixels };
1033
+ }
1021
1034
 
1022
- return { outline, artPixels, islandPixels: sizes[biggest - 1], islands: sizes.length, holePixels, filled };
1035
+ /**
1036
+ * Which pixels of a `w`x`h` grid a triangle set draws over.
1037
+ *
1038
+ * A pixel counts as covered when its CENTRE is in or on a triangle, which is the
1039
+ * same convention `src/render.ts` rasterises by. A degenerate triangle — zero
1040
+ * doubled area — is skipped rather than given an orientation it does not have.
1041
+ */
1042
+ function rasteriseTriangles(points: Array<[number, number]>, triangles: number[], w: number, h: number): Uint8Array {
1043
+ const covered = new Uint8Array(w * h);
1044
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
1045
+ const a = points[triangles[t]];
1046
+ const b = points[triangles[t + 1]];
1047
+ const c = points[triangles[t + 2]];
1048
+ const twice = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1049
+ if (Math.abs(twice) < 1e-12) continue;
1050
+ const orient = twice > 0 ? 1 : -1;
1051
+ const minX = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]) - 1));
1052
+ const maxX = Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1));
1053
+ const minY = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1));
1054
+ const maxY = Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1));
1055
+ for (let y = minY; y <= maxY; y++) {
1056
+ for (let x = minX; x <= maxX; x++) {
1057
+ if (covered[y * w + x]) continue;
1058
+ if (pointInTriangle([x + 0.5, y + 0.5], a, b, c, orient)) covered[y * w + x] = 1;
1059
+ }
1060
+ }
1061
+ }
1062
+ return covered;
1023
1063
  }
1024
1064
 
1025
1065
  /**
@@ -1049,25 +1089,7 @@ export function measureContourFit(
1049
1089
  ): { coverage: number; overshoot: number; artPixels: number; coveredArt: number } {
1050
1090
  const { width: w, height: h } = mask;
1051
1091
  const art = artOf(mask, threshold);
1052
- const covered = new Uint8Array(w * h);
1053
- for (let t = 0; t + 2 < triangles.length; t += 3) {
1054
- const a = points[triangles[t]];
1055
- const b = points[triangles[t + 1]];
1056
- const c = points[triangles[t + 2]];
1057
- const twice = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1058
- if (Math.abs(twice) < 1e-12) continue;
1059
- const orient = twice > 0 ? 1 : -1;
1060
- const minX = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]) - 1));
1061
- const maxX = Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1));
1062
- const minY = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1));
1063
- const maxY = Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1));
1064
- for (let y = minY; y <= maxY; y++) {
1065
- for (let x = minX; x <= maxX; x++) {
1066
- if (covered[y * w + x]) continue;
1067
- if (pointInTriangle([x + 0.5, y + 0.5], a, b, c, orient)) covered[y * w + x] = 1;
1068
- }
1069
- }
1070
- }
1092
+ const covered = rasteriseTriangles(points, triangles, w, h);
1071
1093
  let artPixels = 0;
1072
1094
  let coveredArt = 0;
1073
1095
  for (let i = 0; i < art.length; i++) {
@@ -1104,6 +1126,145 @@ export function measureContourFit(
1104
1126
  };
1105
1127
  }
1106
1128
 
1129
+ /** What a mesh's triangles measure against the art the attachment names. */
1130
+ export interface MeshFitReport {
1131
+ /** Pixels at or above the threshold. */
1132
+ artPixels: number;
1133
+ /** How many of them a triangle covers. */
1134
+ coveredArt: number;
1135
+ /** `coveredArt / artPixels`, 0..1. */
1136
+ coverage: number;
1137
+ /** Furthest a covered pixel sits outside the filled silhouette, in pixels. */
1138
+ overshoot: number;
1139
+ }
1140
+
1141
+ /**
1142
+ * The same measurement for geometry rigc did NOT build — issue #277.
1143
+ *
1144
+ * ## Why an authored mesh can be measured at all
1145
+ *
1146
+ * Issue #44's lesson was that rigc must not apply a GENERATOR's topology rules to
1147
+ * authored geometry: where a rim is, how rows pair, which edge is pinned. It did
1148
+ * not build the mesh, so it cannot know any of that. Coverage is not topology.
1149
+ * It is a number between two things the compiler has in front of it — the emitted
1150
+ * triangles, and the PNG the attachment names with `image` — and it assumes
1151
+ * nothing whatever about how the vertices are arranged. The defect it catches is
1152
+ * the renderer's, not the author's: texture outside the triangles is not drawn,
1153
+ * so art outside the mesh disappears on every runtime. A 9-vertex fan whose 8 rim
1154
+ * vertices sit exactly on a round part's silhouette loses its whole ink outline
1155
+ * between the spokes — an octagon's sides pass `R·cos(π/8)` from its centre — and
1156
+ * measured 94.31%, five points under the bar the same art would have been refused
1157
+ * at as a `contour`, with nothing in the report saying so.
1158
+ *
1159
+ * ## Two differences from `measureContourFit`, both deliberate
1160
+ *
1161
+ * **The filled silhouette is ALL the art plus what it encloses**, not the largest
1162
+ * island plus what that encloses. A contour is one traced loop and can only ever
1163
+ * enclose one island; an authored mesh over a part drawn as several islands is
1164
+ * ordinary, correct data.
1165
+ *
1166
+ * **The overshoot search has no radius.** `measureContourFit` bounds it because a
1167
+ * contour past its bound is refused and needs no exact figure. An authored mesh
1168
+ * is never refused, so every figure needs a number — hence the exact distance
1169
+ * transform below rather than a neighbourhood search that would have to stop
1170
+ * somewhere.
1171
+ */
1172
+ export function measureAuthoredMeshFit(
1173
+ mask: AlphaMask,
1174
+ threshold: number,
1175
+ points: Array<[number, number]>,
1176
+ triangles: number[],
1177
+ ): MeshFitReport {
1178
+ const { width: w, height: h } = mask;
1179
+ const art = artOf(mask, threshold);
1180
+ const { filled } = fillEnclosed(art, w, h);
1181
+ const covered = rasteriseTriangles(points, triangles, w, h);
1182
+ let artPixels = 0;
1183
+ let coveredArt = 0;
1184
+ for (let i = 0; i < art.length; i++) {
1185
+ if (!art[i]) continue;
1186
+ artPixels++;
1187
+ if (covered[i]) coveredArt++;
1188
+ }
1189
+ const squared = squaredDistanceToSet(filled, w, h);
1190
+ let worst = 0;
1191
+ for (let i = 0; i < covered.length; i++) {
1192
+ if (!covered[i] || filled[i]) continue;
1193
+ if (squared[i] > worst) worst = squared[i];
1194
+ }
1195
+ return {
1196
+ artPixels,
1197
+ coveredArt,
1198
+ coverage: artPixels === 0 ? 0 : coveredArt / artPixels,
1199
+ overshoot: r6(Math.sqrt(worst)),
1200
+ };
1201
+ }
1202
+
1203
+ /**
1204
+ * Exact squared Euclidean distance from every pixel to the nearest set pixel of
1205
+ * `inside`, by the two-pass lower-envelope transform (Felzenszwalb-Huttenlocher).
1206
+ *
1207
+ * Two one-dimensional passes — down each column, then along each row of the
1208
+ * result — because the squared Euclidean distance separates across axes:
1209
+ * `min_p (x-px)² + (y-py)²` is the lower envelope of one parabola per candidate,
1210
+ * and a pass builds that envelope in one sweep. Exact, and linear in the number
1211
+ * of pixels, which is the reason it is here at all: the bounded neighbourhood
1212
+ * search `measureContourFit` uses costs `radius²` per pixel and has to be told
1213
+ * where to stop, and the authored path has nowhere to stop.
1214
+ *
1215
+ * `INF` is one past the furthest two pixels of this grid can be, so a column with
1216
+ * no set pixel survives the first pass as "nothing in this column" rather than as
1217
+ * a distance. A grid with no set pixel at all comes back all `INF`; the one caller
1218
+ * never asks (a mask with no art has no covered-outside pixel to ask about).
1219
+ */
1220
+ function squaredDistanceToSet(inside: Uint8Array, w: number, h: number): Float64Array {
1221
+ const INF = w * w + h * h + 1;
1222
+ const dist = new Float64Array(w * h);
1223
+ for (let i = 0; i < dist.length; i++) dist[i] = inside[i] ? 0 : INF;
1224
+
1225
+ const span = Math.max(w, h);
1226
+ const f = new Float64Array(span);
1227
+ const out = new Float64Array(span);
1228
+ /** The parabolas still on the envelope, as the sample index each rises from. */
1229
+ const v = new Int32Array(span);
1230
+ /** Where consecutive envelope parabolas cross. One longer than `v` by nature. */
1231
+ const z = new Float64Array(span + 1);
1232
+ const envelope = (n: number): void => {
1233
+ let k = 0;
1234
+ v[0] = 0;
1235
+ z[0] = -Infinity;
1236
+ z[1] = Infinity;
1237
+ for (let q = 1; q < n; q++) {
1238
+ let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
1239
+ while (s <= z[k]) {
1240
+ k--;
1241
+ s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
1242
+ }
1243
+ k++;
1244
+ v[k] = q;
1245
+ z[k] = s;
1246
+ z[k + 1] = Infinity;
1247
+ }
1248
+ k = 0;
1249
+ for (let q = 0; q < n; q++) {
1250
+ while (z[k + 1] < q) k++;
1251
+ out[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
1252
+ }
1253
+ };
1254
+
1255
+ for (let x = 0; x < w; x++) {
1256
+ for (let y = 0; y < h; y++) f[y] = dist[y * w + x];
1257
+ envelope(h);
1258
+ for (let y = 0; y < h; y++) dist[y * w + x] = out[y];
1259
+ }
1260
+ for (let y = 0; y < h; y++) {
1261
+ for (let x = 0; x < w; x++) f[x] = dist[y * w + x];
1262
+ envelope(w);
1263
+ for (let x = 0; x < w; x++) dist[y * w + x] = out[x];
1264
+ }
1265
+ return dist;
1266
+ }
1267
+
1107
1268
  /**
1108
1269
  * Build a mesh cut to the part's own alpha silhouette.
1109
1270
  *
package/src/render.ts CHANGED
@@ -330,7 +330,7 @@ export function boneSnapshots(skeleton: Skeleton): BoneSnapshot[] {
330
330
 
331
331
  export interface Quad extends PieceCommon {
332
332
  kind: 'region';
333
- /** World-space corners, in spine-core's region order: br, bl, ul, ur. */
333
+ /** World-space corners, in spine-core's region order: bl, ul, ur, br (verified against computeWorldVertices — the 2026-09-03 run reconstructed this from measurement after the old comment cost it days). */
334
334
  world: number[];
335
335
  /** Page UVs for the same four corners. */
336
336
  uvs: ArrayLike<number>;
package/src/types.ts CHANGED
@@ -937,13 +937,24 @@ export interface CompileResult {
937
937
  triangles: number;
938
938
  bones: string[];
939
939
  /**
940
- * Share of the part's own art the triangles cover, 0..1. Only a `contour`
941
- * mesh has one — it is the only generator whose shape is a claim ABOUT the
942
- * art, so it is the only one with something to measure against it.
940
+ * Share of the part's own art the triangles cover, 0..1.
941
+ *
942
+ * Measured for every mesh that names an `image`, generated or authored: it is
943
+ * a number between two things the compiler has in front of it — the emitted
944
+ * triangles and the PNG — and it assumes nothing about how the vertices are
945
+ * arranged (issue #277). Absent on a mesh with no `image`, which has nothing
946
+ * to be measured against, and on a `ring` or `ribbon`, whose window size
947
+ * comes from the spec rather than from art.
943
948
  */
944
949
  coverage?: number;
945
950
  /** How far past the silhouette that mesh reaches, in part pixels. */
946
951
  overshoot?: number;
952
+ /**
953
+ * Transparent pixels the traced outline encloses — inside the mesh, drawing
954
+ * nothing. Only a `contour` has one: it is a property of the trace, and an
955
+ * authored mesh was not traced.
956
+ */
957
+ holePixels?: number;
947
958
  }>;
948
959
  /** Structural expectations handed to the validator. */
949
960
  rig: RigInfo;