spine-rigc 0.32.0 → 0.33.1
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 +11 -9
- package/cli.ts +50 -8
- package/docs/AUTHORING.md +67 -10
- package/docs/INGEST.md +17 -7
- package/docs/SPEC_COVERAGE.md +2 -0
- package/package.json +1 -1
- package/skills/ingest/SKILL.md +6 -5
- package/src/check.ts +12 -0
- package/src/ingest.ts +54 -23
- package/src/validate.ts +158 -6
package/README.md
CHANGED
|
@@ -516,7 +516,7 @@ commands take it and what its default is.
|
|
|
516
516
|
| `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
|
|
517
517
|
| `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
|
|
518
518
|
| `validate <dir>` | re-gates artifacts already on disk |
|
|
519
|
-
| `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. `--stage x,y,w,h`
|
|
519
|
+
| `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
|
|
520
520
|
| `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
|
|
521
521
|
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
|
|
522
522
|
| `preview --candidate <dir>` | one self-contained `.html` that plays it |
|
|
@@ -533,9 +533,9 @@ first three work on any reference you have, and `bench` is a repository workflow
|
|
|
533
533
|
and `bun run fetch-examples`. The reasoning behind them is in
|
|
534
534
|
[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
|
|
535
535
|
|
|
536
|
-
`build` and `validate` both default to `--profile spine` — the
|
|
536
|
+
`build` and `validate` both default to `--profile spine` — the 34 validity rules, which
|
|
537
537
|
ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
|
|
538
|
-
adds all
|
|
538
|
+
adds all 49: the other 15 are one renderer's policy and one canvas budget's, and they
|
|
539
539
|
fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
|
|
540
540
|
⇒ **That reason is about foreign data and does not carry to a rig you are authoring
|
|
541
541
|
yourself: author under `--profile spine-html` and read the extra 15 as findings, and
|
|
@@ -595,10 +595,12 @@ One thing it drops on purpose and says so: a path attachment's `lengths`, which
|
|
|
595
595
|
|
|
596
596
|
⚠️ **Two values are not in a skeleton at all.**
|
|
597
597
|
|
|
598
|
-
- **The stage.** `skeleton.width`/`height
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
598
|
+
- **The stage.** `skeleton.width`/`height`. A skeleton that declares none is carried as
|
|
599
|
+
declaring none — the rig spec states `"width": null, "height": null` and the rebuild
|
|
600
|
+
carries no box either, byte for byte
|
|
601
|
+
([#714](https://github.com/firejune/rigc/issues/714)) — and `--stage x,y,w,h` is how a
|
|
602
|
+
caller *adds* one, recorded as a judgement. It is not derivable — posing the rig gives
|
|
603
|
+
the *animated* extent, which is a different number from the setup box. ⛔ **And `--stage` beside a box the file already states is refused
|
|
602
604
|
too**, for the opposite reason: two sources for one value, where the file is the record
|
|
603
605
|
of what was measured. It used to be read after the box and therefore never
|
|
604
606
|
([#626](https://github.com/firejune/rigc/issues/626)). ⚠️ This said *"an editor export carries none"* until #594 measured
|
|
@@ -681,7 +683,7 @@ letting `A17` blame the editor for the harness's own doing.
|
|
|
681
683
|
| 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
|
|
682
684
|
| 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
|
|
683
685
|
| 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
|
|
684
|
-
| 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the
|
|
686
|
+
| 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 49 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
|
|
685
687
|
| 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
|
|
686
688
|
| 🗺️ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 has to mean before the number is claimed — conditions rather than a feature list, because direction here comes from what users hit |
|
|
687
689
|
| 📐 [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md) | **the doctrine** — why the validator's messages are the product, why nothing reaches disk before green, why no number is ever invented, and what a change has to keep. [CONTRIBUTING.md](https://github.com/firejune/rigc/blob/main/CONTRIBUTING.md) calls it worth ten minutes before a first patch. Repository material |
|
|
@@ -738,7 +740,7 @@ quality."* All six, with their verdicts, are in
|
|
|
738
740
|
[docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
|
|
739
741
|
|
|
740
742
|
The whole dossier — the yardstick, `diff` and `check` and what neither of them can
|
|
741
|
-
see, every rung, the run viewer, the
|
|
743
|
+
see, every rung, the run viewer, the 49 assertions and the selftest behind them — is
|
|
742
744
|
[docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
|
|
743
745
|
Live rung status is
|
|
744
746
|
[docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
|
package/cli.ts
CHANGED
|
@@ -1746,6 +1746,46 @@ function readPositiveNumber(flags: Record<string, string>, key: string, fallback
|
|
|
1746
1746
|
return value;
|
|
1747
1747
|
}
|
|
1748
1748
|
|
|
1749
|
+
/**
|
|
1750
|
+
* Does this skeleton declare a setup stage — a numeric width AND height?
|
|
1751
|
+
*
|
|
1752
|
+
* Read off the loaded `SkeletonData` for `render` and off the file's header for
|
|
1753
|
+
* `preview`, which never loads one; both are the same two fields, because
|
|
1754
|
+
* `SkeletonJson` copies them across unconditionally (`SkeletonJson.js:70-73`),
|
|
1755
|
+
* so a header that omits them leaves `undefined` on a field typed `number`.
|
|
1756
|
+
*/
|
|
1757
|
+
function declaresSetupStage(header: { width?: unknown; height?: unknown }): boolean {
|
|
1758
|
+
return typeof header.width === 'number' && typeof header.height === 'number';
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/** The `skeleton` block of a skeleton file's text, or an empty one where it has none. */
|
|
1762
|
+
function skeletonHeaderOf(skeletonText: string): { width?: unknown; height?: unknown } {
|
|
1763
|
+
const root = JSON.parse(skeletonText) as { skeleton?: { width?: unknown; height?: unknown } };
|
|
1764
|
+
return root.skeleton ?? {};
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* What `render` and `preview` say of their framing on a skeleton that declares
|
|
1769
|
+
* no stage (issue #714).
|
|
1770
|
+
*
|
|
1771
|
+
* ⚠️ **Neither frames to a stage on ANY skeleton**, so this is not a fallback
|
|
1772
|
+
* being announced: `framingViewport` is the union of every animation's posed
|
|
1773
|
+
* bounds, and the Spine Web Player's `calculateAnimationViewport` samples the
|
|
1774
|
+
* playing animation's bounds whenever its config states no viewport box, which
|
|
1775
|
+
* `buildPreview` never does. The line is printed only where a stage is absent
|
|
1776
|
+
* because that is the one case where a reader can ask *what box stood in for
|
|
1777
|
+
* it* — and the answer has to be "none", said, rather than a rectangle that looks
|
|
1778
|
+
* like a default. On a staged skeleton the output is the bytes it always was.
|
|
1779
|
+
*/
|
|
1780
|
+
const STAGELESS_FRAMING = {
|
|
1781
|
+
render:
|
|
1782
|
+
'framing the posed extent of every animation, padded — this skeleton declares no stage, and nothing stands ' +
|
|
1783
|
+
'in for one: render frames to the posed extent whether or not a stage is declared',
|
|
1784
|
+
preview:
|
|
1785
|
+
"framing the Spine Web Player's own: the posed extent of the animation it plays — this skeleton declares no " +
|
|
1786
|
+
'stage, and nothing stands in for one: the player frames that way whether or not a stage is declared',
|
|
1787
|
+
} as const;
|
|
1788
|
+
|
|
1749
1789
|
/**
|
|
1750
1790
|
* render — the frame series, drawn by the same rasteriser `check` measures with.
|
|
1751
1791
|
*
|
|
@@ -1788,6 +1828,7 @@ function cmdRender(flags: Record<string, string>): void {
|
|
|
1788
1828
|
const sampled: Map<string, Frame[]> =
|
|
1789
1829
|
only === undefined ? sampleAll(data, fps, pose) : new Map([[only, sampleAnimation(data, only, fps, pose)]]);
|
|
1790
1830
|
console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
|
|
1831
|
+
if (!declaresSetupStage(data)) console.log(` .. ${STAGELESS_FRAMING.render}`);
|
|
1791
1832
|
|
|
1792
1833
|
mkdirSync(outRoot, { recursive: true });
|
|
1793
1834
|
const sets: FrameSet[] = [];
|
|
@@ -1903,6 +1944,7 @@ function cmdPreview(flags: Record<string, string>): void {
|
|
|
1903
1944
|
for (const page of pages) {
|
|
1904
1945
|
console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
|
|
1905
1946
|
}
|
|
1947
|
+
if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
|
|
1906
1948
|
|
|
1907
1949
|
const html = buildPreview({
|
|
1908
1950
|
skeletonText,
|
|
@@ -3263,9 +3305,10 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
3263
3305
|
'--images <dir>` on every rebuild), `none` states width/height only for `build --atlas-in <pack>` to ' +
|
|
3264
3306
|
'resolve (default: loose)',
|
|
3265
3307
|
stage:
|
|
3266
|
-
'the setup bounding box — `skeleton.x,y,width,height` —
|
|
3308
|
+
'the setup bounding box — `skeleton.x,y,width,height` — to ADD to a skeleton that declares none. It cannot be ' +
|
|
3267
3309
|
'derived: posing the rig gives the ANIMATED extent, which is a different number from the setup box, so this ' +
|
|
3268
|
-
"is the caller's value, and without it the
|
|
3310
|
+
"is the caller's value, and without it the absence is carried: the spec states `\"width\": null, \"height\": " +
|
|
3311
|
+
'null` and the rebuild declares no stage either. ⚠️ An editor export MAY ' +
|
|
3269
3312
|
'carry none; every editor export measured for this project carries one and ingest reads it straight through, ' +
|
|
3270
3313
|
'so the flag is for a file that really has none rather than for editor exports as a class. ⛔ Beside a ' +
|
|
3271
3314
|
'skeleton that already declares a box it is REFUSED rather than ignored: two sources for one value, and the ' +
|
|
@@ -3677,12 +3720,11 @@ const USAGE = [
|
|
|
3677
3720
|
' rigc ingest hero.json --out specs/ --images parts/ rig.json + motion.json',
|
|
3678
3721
|
'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
|
|
3679
3722
|
'It reads the skeleton and nothing else — no .spine project, no binary .skel, no',
|
|
3680
|
-
'atlas — so
|
|
3681
|
-
'setup stage
|
|
3682
|
-
'
|
|
3683
|
-
'
|
|
3684
|
-
'
|
|
3685
|
-
'no flag.',
|
|
3723
|
+
'atlas — so how the spec reaches the art is the caller\'s (--art) and is not guessed.',
|
|
3724
|
+
'A setup stage the skeleton states is read; one it does not state is carried as',
|
|
3725
|
+
'absent, and --stage is how a caller adds a box to such a file — beside a box the',
|
|
3726
|
+
'file states, the flag is refused rather than ignored. --images <dir> WRITES the rig',
|
|
3727
|
+
'spec\'s own images directory, relative to --out, so the rebuild needs no flag.',
|
|
3686
3728
|
'Everything the spec format cannot hold is printed as a named finding and',
|
|
3687
3729
|
'exits non-zero, with both files still written, because a spec plus a list of what',
|
|
3688
3730
|
'is missing from it beats no spec at all.',
|
package/docs/AUTHORING.md
CHANGED
|
@@ -169,7 +169,7 @@ What the flags mean:
|
|
|
169
169
|
| `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
|
|
170
170
|
| `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
|
|
171
171
|
| `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
|
|
172
|
-
| `--profile` | `spine` = the
|
|
172
|
+
| `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
|
|
173
173
|
| `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` is the one command that takes it more than once** — repeat it 2–4 times, one per pane, labelled A, B, C, D in the order given; everywhere else a repeat is a typo and is refused |
|
|
174
174
|
| `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview`, and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote`, so is a name that only *some* candidates have |
|
|
175
175
|
| `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
|
|
@@ -573,7 +573,7 @@ repository builds on every run.
|
|
|
573
573
|
| `--art loose` (default) | name an `image` per attachment — `<path or placeholder>.png` — so the rebuild resolves loose PNGs and rigc measures them |
|
|
574
574
|
| `--art none` | state `width`/`height` only, so the rebuild is `build --atlas-in <pack.atlas>` and every part resolves out of the pack |
|
|
575
575
|
| `--images <dir>` | **write** the rig spec's own `images` directory, spelled relative to `--out`, so the rebuild is a plain `build --rig … --motion … --out …`. Without it the field is left out and every `image` resolves against `--out` itself, which holds the specs and no art — so every rebuild has to repeat `build --images <dir>`. Refused together with `--art none`, which writes no `image` for it to be the base of |
|
|
576
|
-
| `--stage x,y,w,h` |
|
|
576
|
+
| `--stage x,y,w,h` | a setup bounding box to **add** to a skeleton that declares none — without it the absence is carried as `"width": null, "height": null` ([#714](https://github.com/firejune/rigc/issues/714)). An editor export *may* be such a file; every export under `examples/` carries a box and `ingest` reads it straight through — so passing the flag at one of them is **refused**, naming both boxes, rather than silently doing nothing ([#626](https://github.com/firejune/rigc/issues/626)) |
|
|
577
577
|
| `--name <n>` | the rig spec's `name`, which the motion spec's `archetype` must equal (default: the file's basename) |
|
|
578
578
|
|
|
579
579
|
⚠️ **`ingest --images` and `build --images` point opposite ways.** `build --images`
|
|
@@ -586,9 +586,13 @@ where the parts are in one convention.
|
|
|
586
586
|
🚨 **The stage is one of the two values `ingest` will not guess.** A skeleton JSON
|
|
587
587
|
*need not* carry `skeleton.width`/`height`, and when it does not rigc cannot derive
|
|
588
588
|
one — posing the rig gives the *animated* extent, which is a different number from the
|
|
589
|
-
editor's setup box. So a file that declares none is
|
|
590
|
-
|
|
591
|
-
the
|
|
589
|
+
editor's setup box. So a file that declares none is **carried as declaring none**: the
|
|
590
|
+
spec states `"width": null, "height": null` (§3.1), the rebuild emits no box, and
|
|
591
|
+
nothing is recorded, because the rebuild is the file that was read
|
|
592
|
+
([#714](https://github.com/firejune/rigc/issues/714)). `--stage x,y,w,h` *adds* a box —
|
|
593
|
+
supply it from the project the file came from, or from the editor's own canvas — and
|
|
594
|
+
is recorded as a `NO_STAGE` judgement. A header stating **half** a stage (an origin
|
|
595
|
+
with no extent) is a `NO_STAGE` blocker: the spec holds a stage as four fields or none.
|
|
592
596
|
|
|
593
597
|
⛔ **The flag is refused beside a box the file states.** Two sources for one value, and
|
|
594
598
|
the file is the one that was measured — so `ingest` names both boxes and stops rather
|
|
@@ -609,7 +613,8 @@ early return on every one of them, and not one needs the flag. What an editor ex
|
|
|
609
613
|
round trip with a header of `hash`, `spine`, `images`, `audio` and **no box at all** —
|
|
610
614
|
the editor preserves the absence rather than inventing a stage
|
|
611
615
|
([#616](https://github.com/firejune/rigc/issues/616)). So `--stage` is for a file that
|
|
612
|
-
really has none, and this repository's corpus holds no example of one
|
|
616
|
+
really has none, and this repository's corpus holds no example of one; a production
|
|
617
|
+
corpus measured for [#714](https://github.com/firejune/rigc/issues/714) holds 48 of 48. It is still the
|
|
613
618
|
value that costs least to get wrong: `diff` reports the box as `stage_present` and
|
|
614
619
|
`stage_box` ([#578](https://github.com/firejune/rigc/issues/578)) and both are
|
|
615
620
|
`(reported)`, so nothing on the ladder consults them.
|
|
@@ -1079,6 +1084,23 @@ Three readings stay apart, and the middle one is the point of the other two:
|
|
|
1079
1084
|
| one `null`, one number | refused — a stage has both extents or neither, and which half was meant is not derivable |
|
|
1080
1085
|
| the pair `null` **and** an `x` or `y` | refused — an origin for a box that is not there |
|
|
1081
1086
|
|
|
1087
|
+
What each tool does without one — every reader of the stage in the tree, measured
|
|
1088
|
+
for [#714](https://github.com/firejune/rigc/issues/714), and not one of them puts a
|
|
1089
|
+
number where the box would be:
|
|
1090
|
+
|
|
1091
|
+
| Reader | With a stage | Without one |
|
|
1092
|
+
| --- | --- | --- |
|
|
1093
|
+
| `build` | emits `x`/`y`/`width`/`height` | emits none of them |
|
|
1094
|
+
| `A14_NO_FULL_FRAME_MESH` | fails a mesh as big as the stage | **SKIP**, by name |
|
|
1095
|
+
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | exempts the one image that covers the stage | exempts nothing, and says so |
|
|
1096
|
+
| `diff` | `stage_present` and `stage_box` | `stage_present` 1/1 when neither side declares one (agreement), `stage_box` 0/0 |
|
|
1097
|
+
| `explain` | `stage W x H` | `stage none declared` |
|
|
1098
|
+
| `render` | frames the posed extent of every animation | the same frames, plus a line saying the viewport is the posed extent and no stage |
|
|
1099
|
+
| `preview` | the Spine Web Player frames the posed extent of the animation it plays | the same, plus the same line |
|
|
1100
|
+
| `check` | fits the candidate's world box from what it draws | the same figures, plus a note saying so |
|
|
1101
|
+
| `build --pack` | never reads it | the same pages and atlas |
|
|
1102
|
+
| `ingest` | reads it straight through | writes `"width": null, "height": null`; `--stage` adds one |
|
|
1103
|
+
|
|
1082
1104
|
⚠️ A stage-less skeleton is **unmeasured, not certified**: `A14_NO_FULL_FRAME_MESH`
|
|
1083
1105
|
reports **SKIP** on one, because there is no full frame for a mesh to span. And
|
|
1084
1106
|
`rigc diff` reports it — `skeleton.stage_present` and `skeleton.stage_box`, in the
|
|
@@ -4064,7 +4086,11 @@ exactly what a mix that was 0 at setup needs said.
|
|
|
4064
4086
|
above 1 is a real editor idiom rather than a mistake.
|
|
4065
4087
|
- ⚠️ A mix is only read by the runtime if the constraint declares the matching
|
|
4066
4088
|
`properties` mapping (§3.5). Keying `mixScaleY` on a constraint that maps
|
|
4067
|
-
rotation only is dead data — legal, loaded, and it moves nothing.
|
|
4089
|
+
rotation only is dead data — legal, loaded, and it moves nothing. ⚠️ So is every
|
|
4090
|
+
mix a key **omits**, and that one looks like a rescue: the parser reads an
|
|
4091
|
+
omitted mix as 1, so a key of `mixRotate: 0` alone on a rotate-only constraint
|
|
4092
|
+
carries five mixes of 1 that nothing reads. `A48` judges only the mixes of the
|
|
4093
|
+
properties the constraint drives (§4.12).
|
|
4068
4094
|
- The refusals are §4.9's, with `transform` in place of `ik`.
|
|
4069
4095
|
|
|
4070
4096
|
### 4.11 `deform` — moving an attachment's vertices
|
|
@@ -4942,6 +4968,35 @@ constraint declaring `mixGlobal`, since only the physics family has one. The ref
|
|
|
4942
4968
|
says both halves — `path constraint "P" has mixRotate 0, mixX 0 and mixY 0 at setup
|
|
4943
4969
|
and none of the 2 animations keys its mix above 0; …` — and names both repairs.
|
|
4944
4970
|
|
|
4971
|
+
⚠️ **The same question of an `ik` and a `transform` constraint is `A47` and `A48`**
|
|
4972
|
+
([#765](https://github.com/firejune/rigc/issues/765)); until then no assertion asked
|
|
4973
|
+
it, and a rig resting either kind muted with nothing keying it gated green with 0
|
|
4974
|
+
failures. [measured] on generated fixtures, an ik at `mix` 0 and a transform at
|
|
4975
|
+
every mix 0, each with nothing keying it and each keyed to 0 only, pose every bone
|
|
4976
|
+
exactly where the same rig with no constraint does. They read the timelines through
|
|
4977
|
+
the same helper as `A23`/`A36`/`A37`, so a lifted Bezier between two keys of 0 is a
|
|
4978
|
+
rescue and a 0-only timeline is not, with two differences that are the runtime's:
|
|
4979
|
+
|
|
4980
|
+
- **Live is `!== 0`, not `> 0`.** `IkConstraint.update` returns on `mix === 0`, and
|
|
4981
|
+
a transform applies a property only when its own mix `!== 0`, so a negative mix
|
|
4982
|
+
runs the constraint inverted. [measured] five transform constraints across four of
|
|
4983
|
+
the editor's example exports (`6-arcs-pro`, `8-follow-through-pro-ball`,
|
|
4984
|
+
`sack-pro` twice, `spineboy-pro`) rest at `mixX` = `mixY` = −1 with nothing keying
|
|
4985
|
+
them, each moves its bones against the same constraint at every mix 0, and a
|
|
4986
|
+
`> 0` reading refuses all five. `A36`/`A37` still read `> 0`.
|
|
4987
|
+
- **A transform is judged on the mixes of the properties it drives.**
|
|
4988
|
+
`TransformConstraint.update` returns early only when all six mixes are 0, but a
|
|
4989
|
+
property is applied by its own mix, and a key omitting a mix reads it as 1
|
|
4990
|
+
(§4.10). [measured] a rotate-only transform keyed to `mixRotate: 0` alone — five
|
|
4991
|
+
mixes of 1 on the loaded timeline — and one keyed to `mixX: 1` both pose exactly
|
|
4992
|
+
where no constraint does, and both are refused. A transform whose `properties`
|
|
4993
|
+
name no `to` at all is refused with its own sentence: no mix it carries is read.
|
|
4994
|
+
|
|
4995
|
+
`ik constraint "reach" has mix 0 at setup and none of the 1 animation keys its mix
|
|
4996
|
+
above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it
|
|
4997
|
+
above 0, or key its mix above 0 in an animation`. A rig resting at 0 and keyed up by
|
|
4998
|
+
the animation that needs it — spineboy's aim — is refused by neither.
|
|
4999
|
+
|
|
4945
5000
|
### 4.13 `sequence` — which frame of a numbered series shows
|
|
4946
5001
|
|
|
4947
5002
|
The other attachment timeline, beside `deform` and for the same reason: its key is
|
|
@@ -5413,10 +5468,12 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
|
|
|
5413
5468
|
| `A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET` | both | two or more sliders whose animations key the same timeline, where a later one is not `additive` — it writes that property outright at `mix: 1` and every earlier slider on it is dead (§3.5.2). Also fires when the shared timeline **cannot** be applied additively (a slot colour, an attachment swap, a draw order, an ik mix, a path's `spacing`, most physics properties), where `"additive": true` is not the fix and one of the two has to go. ⭐ Which of the two it is, is **posed rather than read off `Timeline.additive`**: the shared timeline is applied twice with `add` set and the detail says what it did ([#655](https://github.com/firejune/rigc/issues/655) — two classes declare that flag falsely about themselves, so a path constraint's `mix` and a slider's `time` were refused although they compose). The detail names the bone or slot and the property, every slider keying it in `constraints` order with its flag, which one wins today, and the class that was posed. Four shapes are deliberately not findings: a slider below `mix: 1` or with its `mix` keyed (the apply is then a lerp from the current pose, not an overwrite), two `skinRequired` sliders no skin activates together, two sliders on different properties, and a shared timeline that writes **nothing a pose holds** — an `events` timeline fires no event under a slider (`firedEvents` is null), so neither slider has anything there for the other to erase. **SKIP** when fewer than two sliders are at full authority; a PASS means two were compared |
|
|
5414
5469
|
| `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` | both | a physics constraint driving a component the **Spine editor** cannot hold, on a rig that declared `invariants.editorRoundTrip` (§3.7). The editor's physics model holds `x` and `y` only, with no cap on how many at once, so a constraint driving `rotate`, `scaleX` or `shearX` is imported, exported and handed back driving **nothing** — measured over three rigs and twelve constraints with the predictions written first ([#540](https://github.com/firejune/rigc/issues/540)). The detail names the constraint and each component. ⚠️ rigc's own output is correct — every runtime plays a rotation jiggle — so this is opt-in and the default is *not* silence: on a rig that declares nothing it **SKIPs**, and the SKIP names the constraint and the component anyway, so an author learns without having asked. Fix by driving the constraint in `x`/`y`, or by dropping the declaration if the rig never goes near the editor. Disjoint from `A23_PHYSICS_CONSTRAINT_EFFECTIVE` by construction: A23 refuses an **empty** driven set, which is what comes back from the editor, and this refuses a non-empty one that will not survive going in. **SKIP** also when the rig declares the editor and carries no physics constraint at all |
|
|
5415
5470
|
| `A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER` | both | a slider whose animation keys a property of a constraint **at or before it** in `constraints` (§3.5.2) — a slider's `mix` or `time`, an ik or transform mix, a path `position`, `spacing` or `mix`, any physics value. That array is the update order for every kind, and each constraint reads its own applied pose when its turn comes — `Slider.update` takes `mix` as the alpha it applies with and `time` as the time it applies at, `PhysicsConstraint.update` returns on `mix` 0 before reading the rest — so the key lands after the only read of it and `Posed.resetConstrained` discards it before the next frame: what the driven constraint drives is dead at every position of the driving dial, although its pose still holds the number ([#658](https://github.com/firejune/rigc/issues/658), [#665](https://github.com/firejune/rigc/issues/665)). The detail names the slider, the driven constraint with its kind, both array indices, the property, the runtime class whose `update` reads it, and the animation the key sits in. Fix by moving the driver earlier, or by keying that property from a slider that already is. **The two indices equal is the same failure**: a slider cannot key its own `mix` or `time`, and one muted at setup that keys its own `mix` up never applies anything at all — `A37` is silent there, because it asks whether *an* animation keys the mix and not which one. **Two shapes it deliberately leaves out**, both measured: a `physics` `reset` key, which fires on a crossed frame time and so never fires from a slider at all, in either order — the reorder would repair nothing; and a physics timeline naming no constraint, which is every physics constraint declaring that property global and IS refused for the ones already run. Disjoint from `A40` by construction: `A40` asks who writes a shared property last and excludes every slider whose `mix` is keyed, this asks whether anything reads what was written. **SKIP** when the skeleton declares no slider, and when no slider's animation keys a constraint property — that SKIP names any `reset` keys it found — a pass means a driver and a driven were compared |
|
|
5416
|
-
| `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | both | a slot's `dark` (§3.3) or an `rgba2` / `rgb2` timeline (§4.4) that the runtime does not hold as the file states it. Three shapes, all of which parse in silence: a `dark` the slot reader **drops** — it takes the field through a truthiness test, so `""` is discarded without a word and the slot renders with one colour; a `dark` that is **not six hex digits** — `Color.setFromString` slices fixed offsets and stores whatever `parseInt` gives back, so `"4020"` loads a channel of `NaN`; and an `rgba2` or `rgb2` timeline on a slot with **no `dark` at all**, where the runtime allocates no dark colour and the first `state.apply` throws in the consumer's process. The keyed half is read by posing: the animation is stepped to each key's own time and the posed `color` and `darkColor` are compared against the hex the key states, to half a quantisation step (`1/510`). The detail names the slot, the value found and the value required. ⚠️ The required value is parsed **here** and not through `Color.fromString`, because a check that read it out of the parser it is checking would agree with that parser whatever it did. `compile.ts` refuses the third shape outright in a rig rigc builds; this is the same fact held against a skeleton it did not write. An `rgb2` key's light colour is compared over its three channels only: the light alpha is not its to state, and that it is left where it was is measured in the selftest (`S85`). **SKIP** when no slot declares a `dark` and no animation keys an `rgba2` or `rgb2` — there is then no two-colour tint to read back |
|
|
5471
|
+
| `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | both | a slot's `dark` (§3.3) or an `rgba2` / `rgb2` timeline (§4.4) that the runtime does not hold as the file states it. Three shapes, all of which parse in silence: a `dark` the slot reader **drops** — it takes the field through a truthiness test, so `""` is discarded without a word and the slot renders with one colour; a `dark` that is **not six hex digits** — `Color.setFromString` slices fixed offsets and stores whatever `parseInt` gives back, so `"4020"` loads a channel of `NaN`; and an `rgba2` or `rgb2` timeline on a slot with **no `dark` at all**, where the runtime allocates no dark colour and the first `state.apply` throws in the consumer's process. The keyed half is read by posing: the animation is stepped to each key's own time — at the key **as the runtime stores it**: spine-core keeps key times as 32-bit floats, so a key at `0.2` is posed at `0.20000000298…`, the later of the two, and not one float step before it, where a first key still shows the setup value and a stepped key the one before (a correct file was refused that way until [#771](https://github.com/firejune/rigc/issues/771)), and the posed `color` and `darkColor` are compared against the hex the key states, to half a quantisation step (`1/510`). The detail names the slot, the value found and the value required. ⚠️ The required value is parsed **here** and not through `Color.fromString`, because a check that read it out of the parser it is checking would agree with that parser whatever it did. `compile.ts` refuses the third shape outright in a rig rigc builds; this is the same fact held against a skeleton it did not write. An `rgb2` key's light colour is compared over its three channels only: the light alpha is not its to state, and that it is left where it was is measured in the selftest (`S85`). **SKIP** when no slot declares a `dark` and no animation keys an `rgba2` or `rgb2` — there is then no two-colour tint to read back |
|
|
5417
5472
|
| `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | both | a **linked mesh** (§3.4) — `type: "linkedmesh"`, or a `type: "mesh"` carrying `source` — that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by **nothing at all** and `setSourceMesh` fills the attachment with the source's arrays instead: the file says one mesh and every runtime draws another, in silence. The detail names the attachment by skin, slot and placeholder, every key it states, the `source` and where the parser looks for it — the two defaults spelled out, because an omitted `skin` is the **default** skin rather than the one the link is written in — and the shape the keys describe beside the shape the attachment loaded. ⚠️ **`width`/`height` are not part of this.** `setSourceMesh` overwrites both with the source's, so they are as dead at runtime — but the parser reads them (`:569-570`), the format carries them on a link and rigc emits them, so refusing them would refuse every link rigc writes (§3.4). `compile.ts` refuses the same shape outright in a rig rigc builds (§5.1); this is that fact held against a skeleton it did not write, and `ingest` reports it as `ATTACHMENT_LINK_GEOMETRY` ([INGEST §2.0](INGEST.md)). **SKIP** when no attachment in the skeleton takes its geometry from another — which is almost every skeleton, so a pass here means a link was read ([#710](https://github.com/firejune/rigc/issues/710)) |
|
|
5418
|
-
| `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | both | an `rgb` or `alpha` timeline (§4.4) the runtime does not hold as the file states it, in one of two shapes that both parse in silence. **A channel keyed twice**: another colour timeline of the same slot in the same animation poses a channel this one poses — `rgba` beside `alpha` is the shape a converter leaves when it writes a separable `rgb` back as `rgba` next to the `alpha` it kept. Every colour timeline poses its channels at every time, the setup value before its first key included, so the one the file states later overwrites the other everywhere; the detail names both timelines, the channel, and which one survives. **A key not posed as written**: the animation is stepped to each key's own time and the posed r g b (for `rgb`, against the hex, to half a quantisation step) or alpha (for `alpha`, against `value`, whose absence the parser reads as 0) is compared — a colour that is not six hex digits loads as NaN, and a key whose time another key repeats is read by nothing. ⚠️ An `rgb` alone written as an `rgba` holding the setup alpha is **not** caught and cannot be from the file: it is a correct `rgba`, and the difference shows only under another track that moves the alpha. The loaded timeline class and the channels a separable timeline leaves alone are measured in the selftest (`S83`–`S85`) rather than here, because against the linked parser neither can come out wrong. The channel table is `SLOT_COLOR_CHANNELS` in `src/timelines.ts`, shared with the compiler's refusal and held to the runtime's own property ids (`S89`). **SKIP** when no animation keys an `rgb` or `alpha` — there is then no separable slot colour to read back |
|
|
5419
|
-
| `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | both | a **numbered series** (§3.4.3, §4.13) that the runtime does not show as the file states it. Every shape below loads without a word, measured on spine-core 4.3.13 ([#729](https://github.com/firejune/rigc/issues/729)). **The block**: a `sequence` with no `count` (`readSequence` reads 0, and the attachment holds no region) or a `setup` at or past `count` (`Sequence.resolveIndex` clamps it to the last frame). **The keys**: a `mode` outside the seven — `hold`, `once`, `loop`, `pingpong`, `onceReverse`, `loopReverse`, `pingpongReverse` — loads as `hold`; an `index` that is fractional (`index << 4` truncates it) or past the end (clamped); an advancing mode at an effective delay of 0 (the parser carries a key's `delay` from the key before; `(time - keyTime) / 0` is Infinity and `Infinity \| 0` is 0, so it never advances); a timeline on an attachment that carries no block (the parser gives every region a one-region series, so every mode shows it). **The pose**: every key is stepped to mid-frame sample times — enough to wrap every mode — and the region the slot shows is held to the frame the file's own statement gives, the arithmetic of `SequenceTimeline.applyToSlot` and the names of `Sequence.getPath` transcribed rather than read off the loaded timeline, so the check is not the runtime agreeing with itself. Before the first key the frame is `setup`. ⚠️ A sample where the slot shows another attachment is not compared, because the runtime writes nothing there; a timeline with no comparable sample is counted in `stats.sequenceSamplesUnshown`. `compile.ts` refuses every one of these shapes in a spec (§5.1); this is them held against a skeleton it did not write. **SKIP** when no attachment carries a `sequence` block and no animation keys a `sequence` timeline |
|
|
5473
|
+
| `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | both | an `rgb` or `alpha` timeline (§4.4) the runtime does not hold as the file states it, in one of two shapes that both parse in silence. **A channel keyed twice**: another colour timeline of the same slot in the same animation poses a channel this one poses — `rgba` beside `alpha` is the shape a converter leaves when it writes a separable `rgb` back as `rgba` next to the `alpha` it kept. Every colour timeline poses its channels at every time, the setup value before its first key included, so the one the file states later overwrites the other everywhere; the detail names both timelines, the channel, and which one survives. **A key not posed as written**: the animation is stepped to each key's own time — at the key **as the runtime stores it**: spine-core keeps key times as 32-bit floats, so a key at `0.2` is posed at `0.20000000298…`, the later of the two, and not one float step before it, where a first key still shows the setup value and a stepped key the one before (a correct file was refused that way until [#771](https://github.com/firejune/rigc/issues/771)), and the posed r g b (for `rgb`, against the hex, to half a quantisation step) or alpha (for `alpha`, against `value`, whose absence the parser reads as 0) is compared — a colour that is not six hex digits loads as NaN, and a key whose time another key repeats is read by nothing. ⚠️ An `rgb` alone written as an `rgba` holding the setup alpha is **not** caught and cannot be from the file: it is a correct `rgba`, and the difference shows only under another track that moves the alpha. The loaded timeline class and the channels a separable timeline leaves alone are measured in the selftest (`S83`–`S85`) rather than here, because against the linked parser neither can come out wrong. The channel table is `SLOT_COLOR_CHANNELS` in `src/timelines.ts`, shared with the compiler's refusal and held to the runtime's own property ids (`S89`). **SKIP** when no animation keys an `rgb` or `alpha` — there is then no separable slot colour to read back |
|
|
5474
|
+
| `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | both | a **numbered series** (§3.4.3, §4.13) that the runtime does not show as the file states it. Every shape below loads without a word, measured on spine-core 4.3.13 ([#729](https://github.com/firejune/rigc/issues/729)). **The block**: a `sequence` with no `count` (`readSequence` reads 0, and the attachment holds no region) or a `setup` at or past `count` (`Sequence.resolveIndex` clamps it to the last frame). **The keys**: a `mode` outside the seven — `hold`, `once`, `loop`, `pingpong`, `onceReverse`, `loopReverse`, `pingpongReverse` — loads as `hold`; an `index` that is fractional (`index << 4` truncates it) or past the end (clamped); an advancing mode at an effective delay of 0 (the parser carries a key's `delay` from the key before; `(time - keyTime) / 0` is Infinity and `Infinity \| 0` is 0, so it never advances); a timeline on an attachment that carries no block (the parser gives every region a one-region series, so every mode shows it). **The pose**: every key is stepped to mid-frame sample times — enough to wrap every mode, and a `hold` key to its own time as the runtime stores it, a 32-bit float ([#771](https://github.com/firejune/rigc/issues/771)) — and the region the slot shows is held to the frame the file's own statement gives, the arithmetic of `SequenceTimeline.applyToSlot` and the names of `Sequence.getPath` transcribed rather than read off the loaded timeline, so the check is not the runtime agreeing with itself. Before the first key the frame is `setup`. ⚠️ A sample where the slot shows another attachment is not compared, because the runtime writes nothing there; a timeline with no comparable sample is counted in `stats.sequenceSamplesUnshown`. `compile.ts` refuses every one of these shapes in a spec (§5.1); this is them held against a skeleton it did not write. **SKIP** when no attachment carries a `sequence` block and no animation keys a `sequence` timeline |
|
|
5475
|
+
| `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | an ik constraint resting at `mix` 0 that no animation keys **away from 0** (§4.9, §4.12). `IkConstraint.update` returns on `mix === 0`, so it sits in the update cache and moves nothing. The keys are read the way `A23`/`A36`/`A37` read theirs — every value the loaded timeline poses on its `mix` channel, Bezier samples included — so a timeline keying 0 only is no rescue: [measured] it poses every bone exactly where the same rig with no constraint does, and before [#765](https://github.com/firejune/rigc/issues/765) it passed. Live is the runtime's `!== 0`, so a negative mix is not refused. `ik constraint "C" has mix 0 at setup and none of the 1 animation keys its mix above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it above 0, or key its mix above 0 in an animation`. **SKIP** when the skeleton declares no ik constraint |
|
|
5476
|
+
| `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | a transform constraint none of whose mixes **for a property it drives** is away from 0 at setup or on any value an animation poses (§4.10, §4.12), or one whose `properties` name no `to` at all. A property is applied only when its own mix `!== 0`, and a key that omits a mix reads it as 1, so the six-mix early return of `TransformConstraint.update` would take a key of `mixRotate: 0` alone as a rescue — [measured] that key, and one keying `mixX` 1 on a rotate-only constraint, pose every bone exactly where no constraint does ([#765](https://github.com/firejune/rigc/issues/765)). A negative mix runs, and five transforms in the editor's example exports rest at −1. `transform constraint "C" drives rotate and has mixRotate 0 at setup, and none of the 1 animation keys its mix above 0; a mix is read only for a property the constraint drives, and update() skips each one at 0, so nothing ever moves "follower" — rest mixRotate above 0, or key its mix above 0 in an animation`. **SKIP** when the skeleton declares no transform constraint |
|
|
5420
5477
|
|
|
5421
5478
|
`both ◑` marks a mixed assertion: its validity half always runs and its policy
|
|
5422
5479
|
clauses are gated by profile.
|
package/docs/INGEST.md
CHANGED
|
@@ -531,14 +531,23 @@ at the policy rather than implying the file was read.
|
|
|
531
531
|
|
|
532
532
|
**Two values are not in a skeleton**, so `ingest` asks rather than guesses:
|
|
533
533
|
|
|
534
|
-
- **the stage** (`skeleton.width`/`height`) —
|
|
535
|
-
|
|
534
|
+
- **the stage** (`skeleton.width`/`height`) — a file that declares none is **carried as
|
|
535
|
+
declaring none**: the rig spec states `"width": null, "height": null` (§2.1 step 3's
|
|
536
|
+
spelling, [#578](https://github.com/firejune/rigc/issues/578)), the rebuild emits a
|
|
537
|
+
header with none of `x`/`y`/`width`/`height`, and it is the file that was read, byte
|
|
538
|
+
for byte. No finding is recorded, because nothing was lost and nobody decided
|
|
539
|
+
anything ([#714](https://github.com/firejune/rigc/issues/714)). `--stage x,y,w,h` is how
|
|
540
|
+
a caller *adds* a box to such a file, and that is a `NO_STAGE` **judgement**. Until
|
|
541
|
+
#714 the absence was a blocker and the flag the only road through it — a number the
|
|
542
|
+
source never stated, on the shape #714 counts in 48 of 48 production exports.
|
|
543
|
+
⚠️ **This page said an editor export carries none until
|
|
536
544
|
[#594](https://github.com/firejune/rigc/issues/594) measured it: all twelve exports in
|
|
537
545
|
the fetched corpus carry a stage**, `ingest` reads it straight through, and not one of
|
|
538
546
|
them needed the flag. What holds without qualification is that the box cannot be
|
|
539
547
|
*derived* — posing the rig gives the *animated* extent, which is a different number
|
|
540
|
-
from the setup box
|
|
541
|
-
|
|
548
|
+
from the setup box. 🔸 **Half a stage is still a `NO_STAGE` blocker**: an origin with no
|
|
549
|
+
extent, or one extent without the other, declares no stage and is not the absence
|
|
550
|
+
either, and the rig spec holds a stage as four fields or none. It is also the value that costs least to get wrong: `diff`
|
|
542
551
|
reports it as two measures of its own (`stage_present`, `stage_box`, since
|
|
543
552
|
[#578](https://github.com/firejune/rigc/issues/578)) and they are `(reported)`, so
|
|
544
553
|
nothing on the ladder reads them and an absurd box is green nearly everywhere. The
|
|
@@ -609,7 +618,7 @@ is the one failure a comparison of two sets cannot show you.
|
|
|
609
618
|
| `HEADER_ORIGIN` | `LOSS` | 0 | the source declares an extent and omits `x`/`y`. Inside a declared extent an omitted origin **is** 0, so the spec states it — and the rebuild then spells two fields the source did not | nothing. Same box, different bytes — which is why byte identity is not the claim for an export that takes this branch |
|
|
610
619
|
| `HEADER_REDERIVED` | `LOSS` | 0 | `skeleton.spine`: the rebuild writes the version of the runtime rigc links. The line says whether that is the same string the source states | nothing — but read the line: a 4.2 export rebuilds as 4.3 in that one field, and a source from another generation raises `GENERATION_UNSUPPORTED` beside it, which is the blocker about the DATA rather than about the string |
|
|
611
620
|
| `IK_KEY_FIELD` | `BLOCK` | 1 | a key field on an `ik` timeline that is not part of its shape | check the spelling; an unknown field is dropped from the rebuilt track |
|
|
612
|
-
| `NO_STAGE` | `BLOCK` `JUDGE` | 1 | the skeleton declares no stage
|
|
621
|
+
| `NO_STAGE` | `BLOCK` `JUDGE` | 1 | the skeleton declares no stage, and one of two things follows. A **judgement** — exit 0 — when `--stage x,y,w,h` supplied a box, because nothing measured the box you gave it. A **blocker** when the header states **half** a stage — an origin with no extent, or one extent without the other — which the rig spec cannot hold; the detail names the fields it states. A header with **none** of the four is not a finding at all: it is carried as `"width": null, "height": null` and rebuilds byte for byte ([#714](https://github.com/firejune/rigc/issues/714)) | for the judgement, nothing if the box came from the project the file came from. For the blocker, supply the box with `--stage`, or take the stray field(s) out of the source and the absence is carried. It cannot be derived: posing the rig gives the animated extent, which is a different number |
|
|
613
622
|
| `PATH_LENGTHS` | `LOSS` | 0 | the source states a path attachment's `lengths` and rigc re-measures it as `PathConstraint` does | nothing. Dropping it is the correct reading: the field is the runtime's own four-sample forward difference, not an arc length |
|
|
614
623
|
| `PATH_TIMELINE` | `BLOCK` | 1 | a path-constraint timeline the motion spec has no track for — it carries position, spacing and mix | transcribe it, or accept that the rebuild plays nothing there |
|
|
615
624
|
| `PHYSICS_DRIVES_NOTHING` | `LOSS` | 0 | a physics constraint none of whose `x`, `y`, `rotate`, `scaleX`, `shearX` is above 0 — absent, or stated at 0 or below. `PhysicsConstraint.update` applies a component only above 0 (`PhysicsConstraint.js:112`), so it moves no bone, and `build` refuses exactly that shape by name at `A23_PHYSICS_CONSTRAINT_EFFECTIVE` — which, until [#731](https://github.com/firejune/rigc/issues/731), meant the whole rebuild of a file an editor exports was refused over a constraint that did nothing in it. The rig spec **omits** it, together with every timeline keyed to it (a track naming it would be an unknown constraint to the rebuild, refused at compile) and its place on any skin's `physics` list; the detail names each, and the values it did state. Measured on a generated rig through spine-core, posing the source with and without such a constraint differs by **0** on every bone world value — and by at most 9e-8 when it sits on the root, which is the runtime's `modifyWorld` recomputing a local transform it had no reason to, not a component. ⚠️ **One thing does move:** a duration is the last key an animation has left, so an omitted timeline that held the last key shortens the rebuilt animation, and the detail says which animation and both lengths | nothing, if it was meant to do nothing. If it was meant to jiggle, the file never said so: give it the component it should drive and it is carried like any other. Where the detail names a shortened animation and the length matters to whatever loops it, key something at the length it had |
|
|
@@ -693,7 +702,8 @@ it, and the only differing paths were the name and the `note`.
|
|
|
693
702
|
the file: `attachment` simply absent.
|
|
694
703
|
|
|
695
704
|
⚠️ **If the export's `skeleton` block carries no `x`/`y`/`width`/`height`, write
|
|
696
|
-
`"width": null, "height": null` and do not invent one** (issue #578)
|
|
705
|
+
`"width": null, "height": null` and do not invent one** (issue #578) — which is
|
|
706
|
+
also what `rigc ingest` writes for such a file since #714. That shape is
|
|
697
707
|
common — the twelve exports in `examples/` all carry the four, and 37 of 37 exports
|
|
698
708
|
in one production corpus carry none of them — and until the `null` pair existed the
|
|
699
709
|
only two moves were a made-up stage or a file that could not be transcribed. The
|
|
@@ -1389,7 +1399,7 @@ clean page:
|
|
|
1389
1399
|
exists to make explicit".** The clause was not wrong that a decompiler meets an
|
|
1390
1400
|
invention — it was wrong about *which*, and wrong that it is unavoidable: a refusal
|
|
1391
1401
|
naming the field is what this repository does with a missing number everywhere else,
|
|
1392
|
-
and
|
|
1402
|
+
and a stated absence is what `ingest` writes where the skeleton has none (§2.0, #714). ⚠️ Not to be confused with the *atlas*
|
|
1393
1403
|
importer below, which is a different direction and also exists.
|
|
1394
1404
|
|
|
1395
1405
|
⚠️ **What `ingest` is still not.** It reads skeleton JSON and writes two spec files.
|
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -739,6 +739,8 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
|
|
|
739
739
|
| `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | validity | a linked mesh, in either spelling, that also states `uvs`, `triangles`, `vertices`, `hull` or `edges` — keys the `source` branch returns before reading, so the file says one mesh and every runtime draws its source's |
|
|
740
740
|
| `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | validity | an `rgb` or `alpha` timeline beside another colour timeline of the slot that poses the same channel — the later in the file overwrites the other at every time — or a key of one the pose does not reproduce |
|
|
741
741
|
| `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | validity | a numbered series that loads as something other than what the file states — a `sequence` block with no `count` (0 regions) or a `setup` past the end (clamped), or a `sequence` key whose `mode` is outside the seven (read as `hold`), whose `index` is fractional or past the end, whose advancing mode runs at an effective delay of 0, or that steps an attachment with no block — and then the pose: every key sampled mid-frame, the region shown held to the frame the file's statement gives ([#729](https://github.com/firejune/rigc/issues/729)) |
|
|
742
|
+
| `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | validity | an ik constraint at `mix` 0 at setup that no animation keys away from 0 — `update()` returns on it, and the rig parses and moves nothing ([#765](https://github.com/firejune/rigc/issues/765)) |
|
|
743
|
+
| `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | validity | a transform constraint whose mixes for the properties it drives are all 0 at setup and on every value a key poses, or one that drives no property ([#765](https://github.com/firejune/rigc/issues/765)) |
|
|
742
744
|
| `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
|
|
743
745
|
| `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
|
|
744
746
|
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | **renderer-profile** | an overlay page that can never be transparent — no alpha channel and no `tRNS` chunk |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
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/skills/ingest/SKILL.md
CHANGED
|
@@ -36,11 +36,12 @@ rigc build --rig specs/rig.json --motion specs/motion.json --images parts/ --out
|
|
|
36
36
|
It reads the skeleton — **only** the skeleton — and writes the rig spec and motion
|
|
37
37
|
spec that rebuild it: **byte for byte for a skeleton rigc emitted**, and for an editor
|
|
38
38
|
export the weaker claim `diff` measures, with three kinds of benign difference left —
|
|
39
|
-
INGEST §2.3. Two values it
|
|
40
|
-
**stage** (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
**duration** (the largest key time,
|
|
39
|
+
INGEST §2.3. Two values it will not guess: the
|
|
40
|
+
**stage** (a skeleton that declares none is carried as declaring none, and `--stage`
|
|
41
|
+
adds a box to one — an editor export *may* be such a file, though every one in the
|
|
42
|
+
example corpus carries a box, and passing the flag at a file that declares a box is
|
|
43
|
+
refused rather than ignored) and each animation's **duration** (the largest key time,
|
|
44
|
+
recorded as a finding).
|
|
44
45
|
Read `findings.json`: a `BLOCK` line means the rebuild will be missing something and
|
|
45
46
|
the command exits non-zero. Every code it can print — gutter, exit, meaning, what to
|
|
46
47
|
do — is the finding-code table in INGEST §2.0. Keep the `note` both specs carry.
|
package/src/check.ts
CHANGED
|
@@ -1059,6 +1059,18 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
1059
1059
|
const notes: string[] = [];
|
|
1060
1060
|
|
|
1061
1061
|
const posable = posableFromText(options.skeletonText, options.atlasText, options.atlasDir);
|
|
1062
|
+
// A candidate that declares no stage is framed exactly as one that does,
|
|
1063
|
+
// because no framing here reads a stage: the world box is fitted from what the
|
|
1064
|
+
// candidate draws. Said on the one candidate where a reader can ask what box
|
|
1065
|
+
// stood in for the absent one (issue #714) — `SkeletonJson` copies the header
|
|
1066
|
+
// fields across unconditionally (`SkeletonJson.js:70-73`), so an omitted
|
|
1067
|
+
// extent is `undefined` here, not 0.
|
|
1068
|
+
if (typeof posable.data.width !== 'number' || typeof posable.data.height !== 'number') {
|
|
1069
|
+
notes.push(
|
|
1070
|
+
'the candidate declares no stage (no `skeleton.width`/`height`), and nothing stands in for one: its world ' +
|
|
1071
|
+
'box is fitted from the pixels it draws, as it is for every candidate, so the absence moves no figure below.',
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1062
1074
|
// The substitution is loaded before anything is posed, because posing has to
|
|
1063
1075
|
// record each piece's original-art UVs for it and that is the one thing about
|
|
1064
1076
|
// this measure that cannot be added afterwards — see `PieceTexture`.
|
package/src/ingest.ts
CHANGED
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* something nobody wrote. Where the skeleton cannot answer, this records a
|
|
25
25
|
* **finding** with a code and writes nothing: `findings` is the product, not a
|
|
26
26
|
* log. Exactly two values are not in a skeleton at all (the stage and an
|
|
27
|
-
* animation's duration) and
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* animation's duration), and a value somebody supplies for either is a
|
|
28
|
+
* `judgement` finding — a stage the file does not declare is carried as the
|
|
29
|
+
* absence it is, and only a caller's `--stage` is somebody deciding (issue
|
|
30
|
+
* #714); every construct the spec format cannot hold is a `blocker`; everything
|
|
31
|
+
* rigc re-derives rather than carries is `lossy`. The one thing it refuses outright rather than recording is
|
|
30
32
|
* an OPTION that contradicts the file — see `IngestError`.
|
|
31
33
|
*
|
|
32
34
|
* ## What it does not read
|
|
@@ -120,8 +122,8 @@ export class IngestSpecRefused extends Error {
|
|
|
120
122
|
*
|
|
121
123
|
* - `blocker` — the spec format cannot say this, so the rebuilt skeleton will
|
|
122
124
|
* NOT be the one that was read. Non-zero exit.
|
|
123
|
-
* - `judgement` — the skeleton does not carry it and somebody
|
|
124
|
-
*
|
|
125
|
+
* - `judgement` — the skeleton does not carry it and somebody decided. There
|
|
126
|
+
* are exactly two: a stage the caller supplied, and an animation's duration.
|
|
125
127
|
* - `lossy` — the skeleton's spelling and rigc's differ, on purpose, and the
|
|
126
128
|
* difference is named: a value rigc re-derives rather than takes (`lengths`,
|
|
127
129
|
* the `spine` version), a field the spec has no home for (`hash`, `audio`), or
|
|
@@ -646,8 +648,8 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
|
646
648
|
// -- header ---------------------------------------------------------------
|
|
647
649
|
// 🚨 THE SEAM. One function decides the rig spec's `skeleton` block, and the
|
|
648
650
|
// stage is the only value in this whole module that a skeleton cannot answer
|
|
649
|
-
// for.
|
|
650
|
-
//
|
|
651
|
+
// for. Since #578 a rig spec can SAY that a skeleton declares no stage, and
|
|
652
|
+
// since #714 this is where a file that declares none is carried as saying so.
|
|
651
653
|
const rigHeader = ingestHeader(obj(root.skeleton), opts, note);
|
|
652
654
|
|
|
653
655
|
// -- physics constraints that drive nothing (issue #731) ------------------
|
|
@@ -1014,6 +1016,9 @@ function readGeneration(root: JsonObject, note: Note): void {
|
|
|
1014
1016
|
);
|
|
1015
1017
|
}
|
|
1016
1018
|
|
|
1019
|
+
/** The four fields a stage is, in the order the editor and `compile` write them. */
|
|
1020
|
+
const STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
|
|
1021
|
+
|
|
1017
1022
|
/**
|
|
1018
1023
|
* Does this header declare a stage?
|
|
1019
1024
|
*
|
|
@@ -1043,18 +1048,28 @@ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): st
|
|
|
1043
1048
|
/**
|
|
1044
1049
|
* The rig spec's `skeleton` block — and the one judgement in this module.
|
|
1045
1050
|
*
|
|
1046
|
-
* 🚨 **A skeleton JSON need not carry the stage, and it cannot be derived.**
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1051
|
+
* 🚨 **A skeleton JSON need not carry the stage, and it cannot be derived.**
|
|
1052
|
+
* Posing the rig gives the ANIMATED extent, which is a different number from the
|
|
1053
|
+
* editor's setup box. So a file that declares none is written as declaring none
|
|
1054
|
+
* — `"width": null, "height": null`, the rig spec's spelling for that claim since
|
|
1055
|
+
* issue #578 — and `compile` then emits a header with none of the four fields,
|
|
1056
|
+
* which is the file that was read, byte for byte. No finding: nothing was lost,
|
|
1057
|
+
* nothing re-derived and nobody decided anything, and a line saying so would be
|
|
1058
|
+
* a finding about a file that rebuilds exactly (issue #714).
|
|
1059
|
+
*
|
|
1060
|
+
* ⚠️ **That is the shape of a whole production corpus, not a corner.** Until
|
|
1061
|
+
* #714 this branch was a `NO_STAGE` blocker and the only road through it was a
|
|
1062
|
+
* caller's `--stage` — a number the source never stated. Issue #714 counts 48 of
|
|
1063
|
+
* 48 production exports at 4.3.26 carrying no box; all twelve exports under
|
|
1064
|
+
* `examples/` carry all four fields, and take the declared branch below.
|
|
1052
1065
|
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1066
|
+
* 🔸 **Half a stage is still a blocker, and keeps the code.** A header that
|
|
1067
|
+
* states an origin with no extent, or one extent without the other, declares no
|
|
1068
|
+
* stage — the extent is what declares one — but it is not the absence either:
|
|
1069
|
+
* the rig spec holds a stage as four fields or none (`parseRigSpec` refuses the
|
|
1070
|
+
* pair `null` beside an `x`, and one extent alone is `compile`'s `no stage size`),
|
|
1071
|
+
* so the rebuild cannot carry what the file states. No export measured here has
|
|
1072
|
+
* that shape; the blocker names the fields it does state.
|
|
1058
1073
|
*
|
|
1059
1074
|
* ⭐ It is still the judgement that costs least to get wrong. `diff` does report
|
|
1060
1075
|
* the box — `stage_present` and `stage_box`, since issue #578 — but they sit in
|
|
@@ -1135,13 +1150,28 @@ function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonOb
|
|
|
1135
1150
|
return out;
|
|
1136
1151
|
}
|
|
1137
1152
|
if (opts.stage === undefined) {
|
|
1153
|
+
const stated = STAGE_FIELDS.filter((field) => head[field] !== undefined);
|
|
1154
|
+
if (stated.length === 0) {
|
|
1155
|
+
// The absence, carried: the pair goes where `RIG_KEYS` orders it, so the
|
|
1156
|
+
// spec reads like one a transcriber would have written by hand.
|
|
1157
|
+
const carried: JsonObject = {};
|
|
1158
|
+
for (const field of RIG_KEYS.RigSkeletonHeader) {
|
|
1159
|
+
if (field === 'width' || field === 'height') carried[field] = null;
|
|
1160
|
+
else if (out[field] !== undefined) carried[field] = out[field];
|
|
1161
|
+
}
|
|
1162
|
+
return carried;
|
|
1163
|
+
}
|
|
1164
|
+
const unstated = STAGE_FIELDS.filter((field) => head[field] === undefined);
|
|
1138
1165
|
note(
|
|
1139
1166
|
'blocker',
|
|
1140
1167
|
'NO_STAGE',
|
|
1141
1168
|
'skeleton.width/height',
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
'
|
|
1169
|
+
`the skeleton states ${stated.map((field) => `"${field}"`).join(', ')} and no ` +
|
|
1170
|
+
`${unstated.map((field) => `"${field}"`).join(', ')}, so it declares no stage — a width and a height are ` +
|
|
1171
|
+
'what declare one — and it is not the absence either. A rig spec holds a stage as four fields or none, so ' +
|
|
1172
|
+
'the rebuild cannot carry what this header states. Give --stage x,y,w,h if the box is known — the value is ' +
|
|
1173
|
+
'the caller\'s, not derived: posing the rig would give the ANIMATED extent, which is a different number from ' +
|
|
1174
|
+
'the setup box — or take the stray field(s) out of the source, and the absence is then carried as it stands',
|
|
1145
1175
|
);
|
|
1146
1176
|
return out;
|
|
1147
1177
|
}
|
|
@@ -1151,8 +1181,9 @@ function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonOb
|
|
|
1151
1181
|
'NO_STAGE',
|
|
1152
1182
|
'skeleton.width/height',
|
|
1153
1183
|
`the skeleton declares no stage and the caller supplied ${opts.stage.x},${opts.stage.y},${opts.stage.width},` +
|
|
1154
|
-
`${opts.stage.height}. Nothing measured it
|
|
1155
|
-
'green everywhere
|
|
1184
|
+
`${opts.stage.height}. Nothing measured it against the art: \`A14\` and \`A19\` measure the art against it ` +
|
|
1185
|
+
'and `diff` reports it, so a wrong box is green everywhere. Without --stage the absence is carried instead, ' +
|
|
1186
|
+
'and the rebuild declares no stage either',
|
|
1156
1187
|
);
|
|
1157
1188
|
return out;
|
|
1158
1189
|
}
|
package/src/validate.ts
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
type ConstraintTimeline,
|
|
26
26
|
type CurveTimeline,
|
|
27
27
|
DeformTimeline,
|
|
28
|
+
IkConstraintData,
|
|
29
|
+
IkConstraintTimeline,
|
|
28
30
|
Inherit,
|
|
29
31
|
isBoneTimeline,
|
|
30
32
|
isConstraintTimeline,
|
|
@@ -48,6 +50,14 @@ import {
|
|
|
48
50
|
TextureAtlas,
|
|
49
51
|
type TextureAtlasRegion,
|
|
50
52
|
type Timeline,
|
|
53
|
+
ToRotate,
|
|
54
|
+
ToScaleX,
|
|
55
|
+
ToScaleY,
|
|
56
|
+
ToShearY,
|
|
57
|
+
ToX,
|
|
58
|
+
ToY,
|
|
59
|
+
TransformConstraintData,
|
|
60
|
+
TransformConstraintTimeline,
|
|
51
61
|
} from '@esotericsoftware/spine-core';
|
|
52
62
|
// ⚠️ `src/` reaches outside itself for exactly two modules and this is one of
|
|
53
63
|
// them, so it is already on `package.json`'s `files` allowlist — see CLAUDE.md.
|
|
@@ -208,6 +218,8 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
|
|
|
208
218
|
A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
|
|
209
219
|
A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
|
|
210
220
|
A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
|
|
221
|
+
A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
|
|
222
|
+
A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
|
|
211
223
|
};
|
|
212
224
|
|
|
213
225
|
/**
|
|
@@ -677,6 +689,31 @@ function isObj(v: unknown): v is Json {
|
|
|
677
689
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
678
690
|
}
|
|
679
691
|
|
|
692
|
+
/**
|
|
693
|
+
* The time to pose a key at so that the runtime is AT it: the later of the
|
|
694
|
+
* file's number and that number as spine-core stores it (issue #771).
|
|
695
|
+
*
|
|
696
|
+
* 🚨 Every timeline keeps its key times in a `Float32Array`
|
|
697
|
+
* (`Utils.newFloatArray`), and a key time the float cannot hold exactly is
|
|
698
|
+
* stored at the nearest one — for `0.2`, `0.20000000298…`, which is LATER than
|
|
699
|
+
* the double `0.2`. Stepped to the file's own number, a timeline is then just
|
|
700
|
+
* BEFORE its key: before a first key it writes the setup value (`time <
|
|
701
|
+
* frames[0]`), and past a stepped key it still holds the one before
|
|
702
|
+
* (`frames[i] > time`). That is a pose one float step from the key, not the
|
|
703
|
+
* key's — measured on the selftest's own `ingest_probe`, whose `alpha` key at
|
|
704
|
+
* 0.2 posed the setup 1.0 and was refused as `the key states value 0.4`.
|
|
705
|
+
*
|
|
706
|
+
* ⚠️ The later of the two rather than `Math.fround` alone: where the float
|
|
707
|
+
* rounds DOWN, the file's number is already past the stored key, and a runtime
|
|
708
|
+
* built without typed arrays stores the double itself — in both, the file's
|
|
709
|
+
* number is the one at or after the key. What a key time rounds to is the
|
|
710
|
+
* runtime's storage and not the file's statement, so a rule judging what a KEY
|
|
711
|
+
* states poses at the key; the rounding itself is nothing an author can repair.
|
|
712
|
+
*/
|
|
713
|
+
function atStoredKey(time: number): number {
|
|
714
|
+
return Math.max(time, Math.fround(time));
|
|
715
|
+
}
|
|
716
|
+
|
|
680
717
|
/**
|
|
681
718
|
* The atlas region names one raw skin entry will make the loader look up — or
|
|
682
719
|
* `null` when the file states a sequence this walk cannot predict.
|
|
@@ -3037,10 +3074,17 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3037
3074
|
* A timeline naming no constraint is the physics family's global form and
|
|
3038
3075
|
* `unnamedPhysicsReach` answers who it reaches; every other constraint
|
|
3039
3076
|
* timeline names its one constraint by index.
|
|
3077
|
+
*
|
|
3078
|
+
* `live` is handed the channel as well as the value, because not every
|
|
3079
|
+
* channel of every constraint timeline is a mix (issue #765): an ik frame is
|
|
3080
|
+
* mix, softness, bend direction, compress and stretch, so a bend direction of
|
|
3081
|
+
* +1 is not a key that switches anything on, and a transform frame carries
|
|
3082
|
+
* six mixes of which only the ones for a property the constraint drives are
|
|
3083
|
+
* ever read.
|
|
3040
3084
|
*/
|
|
3041
3085
|
const keyedLive = <T extends CurveTimeline & ConstraintTimeline>(
|
|
3042
3086
|
owns: (timeline: Timeline) => timeline is T,
|
|
3043
|
-
live: (timeline: T, value: number) => boolean,
|
|
3087
|
+
live: (timeline: T, value: number, channel: number) => boolean,
|
|
3044
3088
|
): Set<object> => {
|
|
3045
3089
|
const reached = new Set<object>();
|
|
3046
3090
|
for (const animation of data.animations) {
|
|
@@ -3048,7 +3092,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3048
3092
|
if (!owns(timeline)) continue;
|
|
3049
3093
|
let keysLive = false;
|
|
3050
3094
|
for (let channel = 0; channel < timeline.getFrameEntries() - 1 && !keysLive; channel++) {
|
|
3051
|
-
keysLive = curveChannelValues(timeline, channel).some((value) => live(timeline, value));
|
|
3095
|
+
keysLive = curveChannelValues(timeline, channel).some((value) => live(timeline, value, channel));
|
|
3052
3096
|
}
|
|
3053
3097
|
if (!keysLive) continue;
|
|
3054
3098
|
const reach =
|
|
@@ -3470,6 +3514,108 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3470
3514
|
stats.sliderConstraints = sliders.length;
|
|
3471
3515
|
});
|
|
3472
3516
|
|
|
3517
|
+
// --- A47 / A48: an ik or a transform constraint muted for good ----------
|
|
3518
|
+
//
|
|
3519
|
+
// The question A23, A36 and A37 ask of their own kinds, asked of the two
|
|
3520
|
+
// kinds editor exports use most (issue #765): a constraint that rests muted
|
|
3521
|
+
// and that no animation switches on parses, sits in the update cache and
|
|
3522
|
+
// moves nothing. [measured] on generated fixtures, 61 steps at 60 fps: an ik
|
|
3523
|
+
// at `mix` 0 that nothing keys, one keyed to 0 only, a transform at every mix
|
|
3524
|
+
// 0 that nothing keys and one keyed to 0 only each pose every bone exactly
|
|
3525
|
+
// where the same rig with no constraint does (max |Δ| 0.000000), and all four
|
|
3526
|
+
// gated green with 0 failures before these two existed.
|
|
3527
|
+
//
|
|
3528
|
+
// 🔑 **Live is the runtime's own test, `!== 0`, and not `mixLive`'s `> 0`.**
|
|
3529
|
+
// `IkConstraint.update` returns on `mix === 0` and a transform's inner loop
|
|
3530
|
+
// applies a property only when `to.mix(pose) !== 0`, so a negative mix runs.
|
|
3531
|
+
// That is not a corner: [measured] five transform constraints across four of
|
|
3532
|
+
// the editor's own example exports rest at mixX = mixY = −1, nothing keys
|
|
3533
|
+
// them, and each moves its bones at setup against the same constraint with
|
|
3534
|
+
// every mix 0. A `> 0` reading refuses all five. (`A36`/`A37` still read
|
|
3535
|
+
// `> 0` — a path or slider resting negative is a question for their own card.)
|
|
3536
|
+
//
|
|
3537
|
+
// 🔑 **A transform mix is read only for a property the constraint drives.**
|
|
3538
|
+
// The early return in `TransformConstraint.update` is over all six mixes, but
|
|
3539
|
+
// it is not what decides whether anything moves: each `to` entry reads its own
|
|
3540
|
+
// mix (`ToRotate.mix` is `mixRotate`, …). At setup the parser only reads a mix
|
|
3541
|
+
// whose property is declared, so the two tests agree there — but a timeline
|
|
3542
|
+
// key that omits a mix is read as 1 (`SkeletonJson.js`, every `getValue(…, 1)`),
|
|
3543
|
+
// so a key of `mixRotate: 0` alone on a rotate-only constraint passes the
|
|
3544
|
+
// six-mix test on five mixes nothing reads. [measured] that key poses every
|
|
3545
|
+
// bone exactly where no constraint does, and so does one keying `mixX` 1 on
|
|
3546
|
+
// the same constraint. So this reads the mixes of the declared `to` kinds,
|
|
3547
|
+
// at setup and on every value a key poses.
|
|
3548
|
+
const ikLive = (value: number): boolean => value !== 0;
|
|
3549
|
+
/** A transform timeline's six channels, in frame order, and the `to` kind each one is the mix of. */
|
|
3550
|
+
const TRANSFORM_MIXES = [
|
|
3551
|
+
['mixRotate', ToRotate],
|
|
3552
|
+
['mixX', ToX],
|
|
3553
|
+
['mixY', ToY],
|
|
3554
|
+
['mixScaleX', ToScaleX],
|
|
3555
|
+
['mixScaleY', ToScaleY],
|
|
3556
|
+
['mixShearY', ToShearY],
|
|
3557
|
+
] as const;
|
|
3558
|
+
/** Which of the six channels `constraint` reads at all: the ones whose `to` kind it declares. */
|
|
3559
|
+
const transformReads = (constraint: TransformConstraintData): boolean[] =>
|
|
3560
|
+
TRANSFORM_MIXES.map(([, kind]) => constraint.properties.some((from) => from.to.some((to) => to instanceof kind)));
|
|
3561
|
+
const ikSwitchedOn = keyedLive(
|
|
3562
|
+
(timeline): timeline is IkConstraintTimeline => timeline instanceof IkConstraintTimeline,
|
|
3563
|
+
// Channel 0 is `mix`; the other four are softness, bend direction, compress and stretch.
|
|
3564
|
+
(_timeline, value, channel) => channel === 0 && ikLive(value),
|
|
3565
|
+
);
|
|
3566
|
+
const transformSwitchedOn = keyedLive(
|
|
3567
|
+
(timeline): timeline is TransformConstraintTimeline => timeline instanceof TransformConstraintTimeline,
|
|
3568
|
+
(timeline, value, channel) => {
|
|
3569
|
+
const constraint = data.constraints[timeline.constraintIndex];
|
|
3570
|
+
return constraint instanceof TransformConstraintData && transformReads(constraint)[channel] && value !== 0;
|
|
3571
|
+
},
|
|
3572
|
+
);
|
|
3573
|
+
|
|
3574
|
+
check('A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', () => {
|
|
3575
|
+
const constraints = data.constraints.filter((c) => c instanceof IkConstraintData);
|
|
3576
|
+
if (!constraints.length) return skip('A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', 'the skeleton declares no ik constraint');
|
|
3577
|
+
for (const constraint of constraints) {
|
|
3578
|
+
const mix = constraint.setupPose.mix;
|
|
3579
|
+
if (ikLive(mix) || ikSwitchedOn.has(constraint)) continue;
|
|
3580
|
+
fail(
|
|
3581
|
+
'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT',
|
|
3582
|
+
`ik constraint "${constraint.name}" has mix ${mix} at setup and ${noneKeysItsMixAbove0(data.animations.length)}; ` +
|
|
3583
|
+
`update() returns on mix 0, so ${constraint.bones.map((bone) => `"${bone.name}"`).join(' and ')} never ` +
|
|
3584
|
+
`reach${constraint.bones.length === 1 ? 'es' : ''} for "${constraint.target.name}" — ${REST_OR_KEY_ITS_MIX}`,
|
|
3585
|
+
);
|
|
3586
|
+
}
|
|
3587
|
+
});
|
|
3588
|
+
|
|
3589
|
+
check('A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT', () => {
|
|
3590
|
+
const NAME = 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT';
|
|
3591
|
+
const constraints = data.constraints.filter((c) => c instanceof TransformConstraintData);
|
|
3592
|
+
if (!constraints.length) return skip(NAME, 'the skeleton declares no transform constraint');
|
|
3593
|
+
for (const constraint of constraints) {
|
|
3594
|
+
const where = `transform constraint "${constraint.name}"`;
|
|
3595
|
+
const reads = transformReads(constraint);
|
|
3596
|
+
const pose = constraint.setupPose;
|
|
3597
|
+
const read = TRANSFORM_MIXES.filter((_, i) => reads[i]).map(([field]) => field);
|
|
3598
|
+
if (read.length === 0) {
|
|
3599
|
+
// No `to` at all: no mix is ever read, so neither remedy below applies.
|
|
3600
|
+
fail(
|
|
3601
|
+
NAME,
|
|
3602
|
+
`${where} drives no property — its \`properties\` name no \`to\` — so no mix it carries is ever read and it ` +
|
|
3603
|
+
'moves nothing; declare the property it should drive',
|
|
3604
|
+
);
|
|
3605
|
+
continue;
|
|
3606
|
+
}
|
|
3607
|
+
if (read.some((field) => pose[field] !== 0) || transformSwitchedOn.has(constraint)) continue;
|
|
3608
|
+
fail(
|
|
3609
|
+
NAME,
|
|
3610
|
+
`${where} drives ${read.map((field) => field.slice(3).replace(/^./, (c) => c.toLowerCase())).join(', ')} and has ` +
|
|
3611
|
+
`${read.map((field) => `${field} ${pose[field]}`).join(', ')} at setup, and ` +
|
|
3612
|
+
`${noneKeysItsMixAbove0(data.animations.length)}; a mix is read only for a property the constraint drives, and ` +
|
|
3613
|
+
`update() skips each one at 0, so nothing ever moves ${constraint.bones.map((bone) => `"${bone.name}"`).join(', ')} — ` +
|
|
3614
|
+
`rest ${read.length === 1 ? read[0] : `one of ${read.join(', ')}`} above 0, or key its mix above 0 in an animation`,
|
|
3615
|
+
);
|
|
3616
|
+
}
|
|
3617
|
+
});
|
|
3618
|
+
|
|
3473
3619
|
// --- A40: two sliders on one property, and the later one erases the other -
|
|
3474
3620
|
//
|
|
3475
3621
|
// 🚨 The hole A37 leaves. Every clause above is INTRA-slider — it asks
|
|
@@ -4300,9 +4446,10 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4300
4446
|
skeleton.setupPose();
|
|
4301
4447
|
skeleton.update(0);
|
|
4302
4448
|
skeleton.updateWorldTransform(Physics.reset);
|
|
4303
|
-
|
|
4449
|
+
// At the key as the runtime stores it — see `atStoredKey` (#771).
|
|
4450
|
+
state.update(atStoredKey(time));
|
|
4304
4451
|
state.apply(skeleton);
|
|
4305
|
-
skeleton.update(time);
|
|
4452
|
+
skeleton.update(atStoredKey(time));
|
|
4306
4453
|
skeleton.updateWorldTransform(Physics.update);
|
|
4307
4454
|
const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
|
|
4308
4455
|
const light = posed?.color;
|
|
@@ -4472,7 +4619,9 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4472
4619
|
skeleton.setupPose();
|
|
4473
4620
|
skeleton.update(0);
|
|
4474
4621
|
skeleton.updateWorldTransform(Physics.reset);
|
|
4475
|
-
|
|
4622
|
+
// At the key as the runtime stores it, not one float step before
|
|
4623
|
+
// it — see `atStoredKey` (issue #771).
|
|
4624
|
+
state.update(atStoredKey(time));
|
|
4476
4625
|
state.apply(skeleton);
|
|
4477
4626
|
const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
|
|
4478
4627
|
if (!posed) {
|
|
@@ -4792,7 +4941,10 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4792
4941
|
skeleton.setupPose();
|
|
4793
4942
|
skeleton.update(0);
|
|
4794
4943
|
skeleton.updateWorldTransform(Physics.reset);
|
|
4795
|
-
|
|
4944
|
+
// A `hold` sample is AT its key, so it is posed at the key as
|
|
4945
|
+
// the runtime stores it (`atStoredKey`); a mid-frame sample is
|
|
4946
|
+
// half a delay from any key and is posed where it is.
|
|
4947
|
+
state.update(sample.key >= 0 && sample.steps === 0 ? atStoredKey(sample.time) : sample.time);
|
|
4796
4948
|
state.apply(skeleton);
|
|
4797
4949
|
const pose = skeleton.slots[slotIndex].appliedPose;
|
|
4798
4950
|
const shown = pose.attachment;
|