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 +16 -0
- package/cli.ts +36 -4
- package/docs/AUTHORING.md +106 -15
- package/docs/MOTION.md +7 -0
- package/package.json +1 -1
- package/src/compile.ts +126 -7
- package/src/mesh.ts +183 -22
- package/src/render.ts +1 -1
- package/src/types.ts +14 -3
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
"
|
|
700
|
-
|
|
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
|
|
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`
|
|
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:
|
|
1073
|
-
`meshTriangles` (the two halves of the mesh budget `A13` measures
|
|
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.
|
|
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:
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
-
|
|
985
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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.
|
|
941
|
-
*
|
|
942
|
-
*
|
|
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;
|