spine-rigc 0.24.0 → 0.25.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 +7 -2
- package/cli.ts +9 -6
- package/docs/AUTHORING.md +38 -10
- package/docs/INGEST.md +59 -6
- package/docs/SPEC_COVERAGE.md +1 -1
- package/package.json +1 -1
- package/skills/ingest/SKILL.md +4 -3
- package/src/diff.ts +265 -6
- package/src/validate.ts +268 -0
- package/tools/editor_roundtrip.ts +186 -8
package/README.md
CHANGED
|
@@ -503,7 +503,7 @@ commands take it and what its default is.
|
|
|
503
503
|
| `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 |
|
|
504
504
|
| `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 |
|
|
505
505
|
| `validate <dir>` | re-gates artifacts already on disk |
|
|
506
|
-
| `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` supplies the
|
|
506
|
+
| `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` supplies the stage for a skeleton that declares none, 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 |
|
|
507
507
|
| `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 |
|
|
508
508
|
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
|
|
509
509
|
| `preview --candidate <dir>` | one self-contained `.html` that plays it |
|
|
@@ -613,7 +613,12 @@ export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
|
|
|
613
613
|
animation **for each skin the build declares** — one render-and-check block per
|
|
614
614
|
skin, with a per-skin roll-up under them, because a rig's contested art lives in
|
|
615
615
|
its named skins and a single un-skinned check draws none of it — and a
|
|
616
|
-
field-by-field list of what the editor rewrote.
|
|
616
|
+
field-by-field list of what the editor rewrote. Every step quotes what its child
|
|
617
|
+
said when that child did not do what it was for, the renderers included; a skin
|
|
618
|
+
**neither** side can draw — a hit-box rig, say — is a **SKIP** naming that, not a
|
|
619
|
+
red, because `check` had nothing to compare and `diff` and `validate` have
|
|
620
|
+
already measured the rig. One side drawing where the other does not is the
|
|
621
|
+
divergence the trip exists to find and stays a failure. On its first
|
|
617
622
|
run it found three emitter defects — [#368](https://github.com/firejune/rigc/issues/368),
|
|
618
623
|
[#369](https://github.com/firejune/rigc/issues/369),
|
|
619
624
|
[#370](https://github.com/firejune/rigc/issues/370) — and then showed that a
|
package/cli.ts
CHANGED
|
@@ -2961,9 +2961,11 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
2961
2961
|
'--images <dir>` on every rebuild), `none` states width/height only for `build --atlas-in <pack>` to ' +
|
|
2962
2962
|
'resolve (default: loose)',
|
|
2963
2963
|
stage:
|
|
2964
|
-
|
|
2965
|
-
'
|
|
2966
|
-
"caller's value and
|
|
2964
|
+
'the setup bounding box — `skeleton.x,y,width,height` — for a skeleton that declares none. It cannot be ' +
|
|
2965
|
+
'derived: posing the rig gives the ANIMATED extent, which is a different number from the setup box, so this ' +
|
|
2966
|
+
"is the caller's value, and without it the missing stage is reported as a blocker. ⚠️ An editor export MAY " +
|
|
2967
|
+
'carry none; every editor export measured for this project carries one and ingest reads it straight through, ' +
|
|
2968
|
+
'so the flag is for a file that really has none rather than for editor exports as a class',
|
|
2967
2969
|
help: "show this command's flags and exit",
|
|
2968
2970
|
};
|
|
2969
2971
|
|
|
@@ -3312,9 +3314,10 @@ const USAGE = [
|
|
|
3312
3314
|
'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
|
|
3313
3315
|
'It reads the skeleton and nothing else — no .spine project, no binary .skel, no',
|
|
3314
3316
|
'atlas — so two things are the caller\'s and are refused rather than guessed: the',
|
|
3315
|
-
'setup stage (--stage
|
|
3316
|
-
'(--art). --images <dir> is the third and the only optional one: it
|
|
3317
|
-
'spec\'s own images directory, relative to --out, so the rebuild needs
|
|
3317
|
+
'setup stage (--stage, only when the skeleton itself declares none) and how the spec',
|
|
3318
|
+
'reaches the art (--art). --images <dir> is the third and the only optional one: it',
|
|
3319
|
+
'WRITES the rig spec\'s own images directory, relative to --out, so the rebuild needs',
|
|
3320
|
+
'no flag.',
|
|
3318
3321
|
'Everything the spec format cannot hold is printed as a named finding and',
|
|
3319
3322
|
'exits non-zero, with both files still written, because a spec plus a list of what',
|
|
3320
3323
|
'is missing from it beats no spec at all.',
|
package/docs/AUTHORING.md
CHANGED
|
@@ -397,7 +397,7 @@ repository builds on every run.
|
|
|
397
397
|
| `--art loose` (default) | name an `image` per attachment — `<path or placeholder>.png` — so the rebuild resolves loose PNGs and rigc measures them |
|
|
398
398
|
| `--art none` | state `width`/`height` only, so the rebuild is `build --atlas-in <pack.atlas>` and every part resolves out of the pack |
|
|
399
399
|
| `--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 |
|
|
400
|
-
| `--stage x,y,w,h` | the setup bounding box
|
|
400
|
+
| `--stage x,y,w,h` | the setup bounding box, **for a skeleton that declares none**. An editor export *may* be one; every export under `examples/` carries a box and `ingest` reads it straight through |
|
|
401
401
|
| `--name <n>` | the rig spec's `name`, which the motion spec's `archetype` must equal (default: the file's basename) |
|
|
402
402
|
|
|
403
403
|
⚠️ **`ingest --images` and `build --images` point opposite ways.** `build --images`
|
|
@@ -407,13 +407,26 @@ they name the same field — and `ingest` spells the value with the same functio
|
|
|
407
407
|
`build` spells `skeleton.images` with, so a spec and the skeleton it came from say
|
|
408
408
|
where the parts are in one convention.
|
|
409
409
|
|
|
410
|
-
🚨 **The stage is the
|
|
411
|
-
`skeleton.width`/`height
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
410
|
+
🚨 **The stage is one of the two values `ingest` will not guess.** A skeleton JSON
|
|
411
|
+
*need not* carry `skeleton.width`/`height`, and when it does not rigc cannot derive
|
|
412
|
+
one — posing the rig gives the *animated* extent, which is a different number from the
|
|
413
|
+
editor's setup box. So a file that declares none is a **blocker**, named, unless
|
|
414
|
+
`--stage x,y,w,h` supplies it; supply it from the project the file came from, or from
|
|
415
|
+
the editor's own canvas.
|
|
416
|
+
|
|
417
|
+
⚠️ **This said an editor export "never" carries one until
|
|
418
|
+
[#594](https://github.com/firejune/rigc/issues/594) measured the corpus.** All twelve
|
|
419
|
+
exports under `examples/` declare `x`, `y`, `width` and `height`, `ingest` takes the
|
|
420
|
+
early return on every one of them, and not one needs the flag. What an editor export
|
|
421
|
+
*may* do is carry none: a rigc build that declares no stage
|
|
422
|
+
([#578](https://github.com/firejune/rigc/issues/578)) came back from a Spine 4.3.26
|
|
423
|
+
round trip with a header of `hash`, `spine`, `images`, `audio` and **no box at all** —
|
|
424
|
+
the editor preserves the absence rather than inventing a stage
|
|
425
|
+
([#616](https://github.com/firejune/rigc/issues/616)). So `--stage` is for a file that
|
|
426
|
+
really has none, and this repository's corpus holds no example of one. It is still the
|
|
427
|
+
value that costs least to get wrong: `diff` reports the box as `stage_present` and
|
|
428
|
+
`stage_box` ([#578](https://github.com/firejune/rigc/issues/578)) and both are
|
|
429
|
+
`(reported)`, so nothing on the ladder consults them.
|
|
417
430
|
|
|
418
431
|
⚠️ **The duration is a convention, and it is recorded as one.** Skeleton JSON has no
|
|
419
432
|
duration field. The largest key time is the only derivable answer and it is what a
|
|
@@ -822,7 +835,17 @@ Three readings stay apart, and the middle one is the point of the other two:
|
|
|
822
835
|
reports **SKIP** on one, because there is no full frame for a mesh to span. And
|
|
823
836
|
`rigc diff` reports it — `skeleton.stage_present` and `skeleton.stage_box`, in the
|
|
824
837
|
header block at the top of the report — so a stage somebody invented now reads
|
|
825
|
-
below 1.000 against a source that has none.
|
|
838
|
+
below 1.000 against a source that has none.
|
|
839
|
+
|
|
840
|
+
⭐ **A stage at `0,0` is not a stage-less one, and an editor export spells it by
|
|
841
|
+
saying nothing.** The editor omits a header field that is at its default, so a
|
|
842
|
+
skeleton whose box sits at the origin exports as a `width` and a `height` with no
|
|
843
|
+
`x`/`y`; `stage_box` reads that omission as the `0` it means, and its line says so
|
|
844
|
+
— *the extent as stated, an omitted origin as the 0 it means*. So `4/4` on a build
|
|
845
|
+
of yours against an export of that same build is the right answer rather than a
|
|
846
|
+
tolerance, and an origin that really did move still reads below 1.000. The extent
|
|
847
|
+
is the half that is read exactly as stated: omit a `width` and you have declared no
|
|
848
|
+
stage, which `stage_present` is the measure of. Both are reported and gate nothing, for
|
|
826
849
|
the reason every reported measure is: no reading of the rendered frames could have
|
|
827
850
|
decided a setup-pose bounding box. The measure inventory that says so lives in
|
|
828
851
|
[BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md), which
|
|
@@ -927,7 +950,12 @@ pointing into it is not.
|
|
|
927
950
|
⚠️ **`rigc render` still refuses such a build**, by name and before it draws
|
|
928
951
|
anything: `… posed no drawable attachment in any animation or in its setup pose —
|
|
929
952
|
there is nothing to draw`. That is the honest division — the rig is valid Spine
|
|
930
|
-
data, and there is no picture of it.
|
|
953
|
+
data, and there is no picture of it. `rigc check` says the same thing one step
|
|
954
|
+
on, since it has no frames to compare; `tools/editor_roundtrip.ts` quotes both
|
|
955
|
+
renderers and reports its step 5 as a **SKIP** naming that, and the round trip
|
|
956
|
+
comes back green on the strength of `validate` and `diff`
|
|
957
|
+
([#621](https://github.com/firejune/rigc/issues/621)). A skin only **one** side
|
|
958
|
+
can draw is the other case entirely, and stays red.
|
|
931
959
|
|
|
932
960
|
**Region attachment** ([Spine: region attachments](http://esotericsoftware.com/spine-regions)),
|
|
933
961
|
the default `type`:
|
package/docs/INGEST.md
CHANGED
|
@@ -253,7 +253,7 @@ rigc diff
|
|
|
253
253
|
|
|
254
254
|
skeleton (reported) (no mean) over 2 measures — the stage, which no reading of the frames could decide
|
|
255
255
|
1.000 stage_present 1/1 both sides declare a setup-pose stage, or neither does — …
|
|
256
|
-
1.000 stage_box 4/4 the stage is the same box (x, y, width, height,
|
|
256
|
+
1.000 stage_box 4/4 the stage is the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means) — …
|
|
257
257
|
|
|
258
258
|
bones mean 1.000 over 8 measures
|
|
259
259
|
1.000 count 3/3 how many bones
|
|
@@ -288,12 +288,31 @@ units and every one of the 49 measures still reads **1.000**. ⇒ Never take a g
|
|
|
288
288
|
`diff` as evidence that a geometric edit did not land, and never take it as evidence
|
|
289
289
|
that one did.
|
|
290
290
|
|
|
291
|
+
⚠️ **The corpus gate has a value-level measure and this command does not expose it**
|
|
292
|
+
(§2.3, and `docs/BENCHMARK.md`'s *The nine value measures*). The reason is an input
|
|
293
|
+
rather than a policy: comparing values means reading both files through `spine-core`,
|
|
294
|
+
and a skeleton whose attachments carry a `sequence` cannot be parsed without the atlas
|
|
295
|
+
that resolves it — so the measure takes two skeletons **and two packs**, which
|
|
296
|
+
`rigc diff <a.json> <b.json>` does not have. The sentence above is about this command
|
|
297
|
+
and stays true of it.
|
|
298
|
+
|
|
291
299
|
⚠️ **The one exception is the skeleton's own declared box**, and it is an exception to
|
|
292
300
|
the sentence and not to the rule: `skeleton.stage_box` compares four world numbers,
|
|
293
|
-
but they are numbers an exporter *
|
|
301
|
+
but they are numbers an exporter *declared in the header* rather than a pose anything
|
|
294
302
|
measured, and the block they sit in gates nothing. Moving a pivot does not move them
|
|
295
303
|
either.
|
|
296
304
|
|
|
305
|
+
⭐ **Declared is not the same as written down, and for the origin it is the
|
|
306
|
+
difference between a green round trip and a false finding**
|
|
307
|
+
([#620](https://github.com/firejune/rigc/issues/620)). The editor omits a header
|
|
308
|
+
field at its default, so a stage sitting at `0,0` exports as a `width` and a
|
|
309
|
+
`height` and no `x`/`y` at all — there is no other spelling for it. The measure
|
|
310
|
+
reads that omission as the `0` it means, which is why a rigc build whose stage is at
|
|
311
|
+
the origin and its own export of that build read `stage_box` **4/4**; reading the
|
|
312
|
+
four "exactly as stated" scored the same box **2/4**. The extent is still read
|
|
313
|
+
exactly as stated: it is what decides whether there is a stage at all, so a missing
|
|
314
|
+
`width` is an absent stage rather than a stage of width zero.
|
|
315
|
+
|
|
297
316
|
⛔ **And its ratios are not a score.** [`src/diff.ts`](../src/diff.ts) says so in the
|
|
298
317
|
type itself (*"Unweighted mean of the measures below. NOT a quality score"*), and the
|
|
299
318
|
report repeats it at the foot. It measures *agreement with a particular reference*,
|
|
@@ -437,9 +456,12 @@ a file rigc did not write.
|
|
|
437
456
|
them** ([#594](https://github.com/firejune/rigc/issues/594)). Every
|
|
438
457
|
`examples/*/export/*.json` is ingested with `--art none`, rebuilt through the pack
|
|
439
458
|
beside it, and `diff`ed against the file it was read from: **12 of 12 come back with 0
|
|
440
|
-
blockers and 1.000 on all 49 ratio-bearing measures and all 5 reported ones
|
|
459
|
+
blockers and 1.000 on all 49 ratio-bearing measures and all 5 reported ones** — and,
|
|
460
|
+
since [#615](https://github.com/firejune/rigc/issues/615), on all **nine value
|
|
461
|
+
measures** too, over **193,927** compared values. Byte
|
|
441
462
|
identity is not the claim there and the reason is the input, not the round trip — §2.3
|
|
442
|
-
has the three kinds of difference, measured
|
|
463
|
+
has the three kinds of difference, measured, and what the value measures do and do not
|
|
464
|
+
reach. ⚠️ Which pack is "the one beside it" is
|
|
443
465
|
resolved rather than guessed, for §0.2's reason: `spineboy/export` holds two, and
|
|
444
466
|
`spineboy-run.atlas` covers neither skeleton in it.
|
|
445
467
|
|
|
@@ -605,8 +627,39 @@ rebuild against its source produces differences of exactly three kinds, in every
|
|
|
605
627
|
worth being exact about what that does and does not cover. `diff` compares structure —
|
|
606
628
|
counts, names, parentage, order, timeline kinds, key counts, curve kinds — and **not
|
|
607
629
|
the values inside the keys**, which is why the precision row above is invisible to it.
|
|
608
|
-
On rigc's own rigs byte identity covers both; on a foreign export
|
|
609
|
-
|
|
630
|
+
On rigc's own rigs byte identity covers both; on a foreign export it used to be
|
|
631
|
+
`check` (pixels) or nothing, depending on what you render.
|
|
632
|
+
|
|
633
|
+
⭐ **The values are gated now, and by a second measure rather than by `diff`**
|
|
634
|
+
([issue #615](https://github.com/firejune/rigc/issues/615)). Structure at 1.000 is
|
|
635
|
+
silent about the numbers inside it: a decompiler that halved every rotation, dropped
|
|
636
|
+
every bone's `length` or mirrored every vertex would read 1.000 on all 49 measures and
|
|
637
|
+
on every `(reported)` one. So the corpus round trip also compares **value by value**,
|
|
638
|
+
with the format's defaults taken from the parser rather than from a table — both files
|
|
639
|
+
are read through `spine-core` and the parsed forms are compared path by path, under a
|
|
640
|
+
tolerance that is the sum of rigc's own 1e-6 emitted grid and one float32 step of the
|
|
641
|
+
runtime's storage. Nine measures, printed on `IG16`'s own line and gated there — here
|
|
642
|
+
is the `6-arcs` export's, wrapped to fit this page:
|
|
643
|
+
|
|
644
|
+
```
|
|
645
|
+
values: 9/9 measure(s) at 1.000 over 13865 compared value(s); skeleton 1.000 ·
|
|
646
|
+
bones 1.000 · slots 1.000 · attachments 1.000 · constraints 1.000 · events 1.000 ·
|
|
647
|
+
key_times 1.000 · key_values 1.000 · curves 1.000
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
Over the whole corpus that is **193,927 values** compared, and the twelve read 1.000
|
|
651
|
+
on all nine.
|
|
652
|
+
|
|
653
|
+
`docs/BENCHMARK.md`'s *The nine value measures* is the full statement. What it still
|
|
654
|
+
does **not** cover, in the same breath:
|
|
655
|
+
|
|
656
|
+
| Still uncovered | Why |
|
|
657
|
+
| --- | --- |
|
|
658
|
+
| `version` and `hash` | the rig spec has no field for either, and `ingest` reports both as findings — the header row above, unchanged |
|
|
659
|
+
| anything below one float32 step | the parser stores frames, curves and vertices in a `Float32Array`, so a difference it cannot represent is invisible to any reading of the parsed form |
|
|
660
|
+
| a Bezier's handles *as written* | the parser samples them into the curve, so a moved handle arrives as moved samples rather than as the handle it was |
|
|
661
|
+
| how the file is **spelled** | field order, an omitted default written out, six decimals against eight — the second and third rows of the table above are values that agree, and this measure says so |
|
|
662
|
+
| how it **looks** | that is `check`, and `--texture-from` is how its figure is attributed |
|
|
610
663
|
|
|
611
664
|
The geometric row needs a real number, because a naive reading of `check` makes an
|
|
612
665
|
exact transcription look wrong. Here is the 3-timing transcription against frames
|
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -1052,7 +1052,7 @@ Nothing structurally new (attachment timeline, blend, inherit all arrived by run
|
|
|
1052
1052
|
| (a) | **Transform constraints** — 🔴 first appearance (4). Full 4.3 `source` + `properties{from→to}` model (§1.4), which is the least-documented constraint in the format. ✅ Expressible and round-trips exactly; `RigTransformConstraint` already carried the 4.3 shape |
|
|
1053
1053
|
| (a) | **Weighted meshes from authored geometry** — 🔴 first appearance. ~~rigc can emit weighted meshes, but only from `buildRingMesh`/`buildRibbonMesh`. An arbitrary 40-vertex/38-triangle mesh cannot be expressed~~ ⚠️ **This was wrong.** `RigMeshAttachment` takes authored `uvs`/`triangles`/`vertices`/`hull`, and `buildRigMesh` copies them verbatim. Both of 6-arcs' meshes round-trip to 1e-5 |
|
|
1054
1054
|
| (a) | Mesh **`edges`** key ~~(rigc emits `hull` but not `edges`)~~ ✅ emitted from `RigMeshAttachment.edges` and byte-identical to the reference. 🚨 But **nothing measures it** — deleting `edges` from the rig still scores 1.000 on all nine attachment measures, so this rung's own gating feature is invisible to `bench` (issue #46) |
|
|
1055
|
-
| (b) | Mesh geometry as data — vertices, triangles, uvs, per-vertex bone weights — instead of a generator name plus a polygon. ✅ Present. ~~🚨 But the weights bind bones by **index into the emitted bone array**, not by name — inserting a bone rebinds every vertex with the gate still green (issue #45)~~ ✅ **Fixed.** Weights bind **by name** (`weights: [[{ bone, x, y, weight }, …], …]`) and the compiler resolves them at emit; an unknown name is a `CompileError` (selftest `
|
|
1055
|
+
| (b) | Mesh geometry as data — vertices, triangles, uvs, per-vertex bone weights — instead of a generator name plus a polygon. ✅ Present. ~~🚨 But the weights bind bones by **index into the emitted bone array**, not by name — inserting a bone rebinds every vertex with the gate still green (issue #45)~~ ✅ **Fixed.** Weights bind **by name** (`weights: [[{ bone, x, y, weight }, …], …]`) and the compiler resolves them at emit; an unknown name is a `CompileError` (selftest `RF08`). Spine's index run survives behind an explicit `"boneIndexing": "raw"`, whose cost — silence — `MR07` still measures |
|
|
1056
1056
|
| (c) | **A20** (unweighted forbidden) is satisfied here. ~~Under `--profile spine-html` its extra clause fires 11 times instead — the editor writes zero-weight bindings and that profile forbids them~~ ✅ **Fixed with #44**: both of A20's policy clauses are statements about what a rigc *generator* produces, so neither applies to authored geometry. Its coherence clauses — present, in range, summing to 1 — still do, in every profile |
|
|
1057
1057
|
| (c) | **A21_MESH_RIM_PINNED** and **A28** encode ring/ribbon topology and will fire on an arbitrary mesh. ~~✅ **Confirmed**: A21 fires 40 times on the `tail` mesh under the default profile, because `meshKinds` has no entry for an authored mesh and the lookup falls back to `'ring'`~~ ✅ **Fixed** (issue #44): `meshKinds` has a third state, `authored`, and both assertions SKIP on one with that as the reason. The whole transcription is green under the default profile; selftest `MR08` holds it |
|
|
1058
1058
|
| (c) | A13's mesh budget (≤4 slots, ≤80 tris) is **satisfied** at this rung (2 slots, max 38 tris) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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/skills/ingest/SKILL.md
CHANGED
|
@@ -34,9 +34,10 @@ rigc build --rig specs/rig.json --motion specs/motion.json --images parts/ --out
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
It reads the skeleton — **only** the skeleton — and writes the rig spec and motion
|
|
37
|
-
spec that rebuild it, byte for byte. Two values
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
spec that rebuild it, byte for byte. Two values it refuses rather than guessing: the
|
|
38
|
+
**stage** (`--stage`, for a skeleton that declares none — an editor export *may* be
|
|
39
|
+
one, though every one in the example corpus carries a box) and each animation's
|
|
40
|
+
**duration** (the largest key time, recorded as a finding).
|
|
40
41
|
Read `findings.json`: a `BLOCK` line means the rebuild will be missing something and
|
|
41
42
|
the command exits non-zero. Keep the `note` both specs carry. INGEST §2.0.
|
|
42
43
|
|
package/src/diff.ts
CHANGED
|
@@ -66,6 +66,11 @@
|
|
|
66
66
|
* instrument — [`bonedist.ts`](bonedist.ts), the ladder's stage 3.
|
|
67
67
|
*/
|
|
68
68
|
import { walkTimelines } from './timelines.ts';
|
|
69
|
+
// Type only, and erased: the values themselves are read by `skeletonValues` in
|
|
70
|
+
// `src/validate.ts`, which is one of the three modules CLAUDE.md allows to link
|
|
71
|
+
// spine-core. This file stays what its header says it is — see
|
|
72
|
+
// `diffSkeletonValues` for why the reading and the comparing are split there.
|
|
73
|
+
import type { SkeletonValue } from './validate.ts';
|
|
69
74
|
|
|
70
75
|
type Json = Record<string, unknown>;
|
|
71
76
|
|
|
@@ -1021,7 +1026,11 @@ function diffEvents(c: Json, r: Json): DiffSection {
|
|
|
1021
1026
|
*/
|
|
1022
1027
|
interface StageFacts {
|
|
1023
1028
|
present: boolean;
|
|
1024
|
-
/**
|
|
1029
|
+
/**
|
|
1030
|
+
* The four fields as the header MEANS them: the extent exactly as stated, and
|
|
1031
|
+
* the origin of a declared stage as stated or `0` where it is omitted. See
|
|
1032
|
+
* `stageFacts` for why the second half is a reading and not a fallback.
|
|
1033
|
+
*/
|
|
1025
1034
|
box: Array<number | null>;
|
|
1026
1035
|
}
|
|
1027
1036
|
|
|
@@ -1035,11 +1044,48 @@ const STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
|
|
|
1035
1044
|
* own. It is also what the compiler requires and what `A14_NO_FULL_FRAME_MESH`
|
|
1036
1045
|
* and `A19_OVERLAY_PNGS_HAVE_ALPHA` measure against, so the three agree on the
|
|
1037
1046
|
* word by construction rather than by memory.
|
|
1047
|
+
*
|
|
1048
|
+
* ⭐ **Inside a declared stage, an omitted `x`/`y` IS `0`** (issue #620). That is
|
|
1049
|
+
* a reading of the format, not a value invented for a gap — the distinction this
|
|
1050
|
+
* file lives or dies by — and four measurements carry it, none of them anybody's
|
|
1051
|
+
* word for the convention:
|
|
1052
|
+
*
|
|
1053
|
+
* - **This tree had already decided it, one file over.** `compile.ts` assembles
|
|
1054
|
+
* the header as `header.x = rig.skeleton?.x ?? 0`, under the same guard: only
|
|
1055
|
+
* when the extent is there. So a rig spec that omits its origin emits `0`, and
|
|
1056
|
+
* reading the same omission in a file as absence made the compiler and the
|
|
1057
|
+
* comparison disagree about one value in one header. This measure is not
|
|
1058
|
+
* adopting the editor's convention so much as stopping contradicting the
|
|
1059
|
+
* emitter it is pointed at.
|
|
1060
|
+
* - **The header's writer omits a field at its default.** All twelve exports
|
|
1061
|
+
* under `examples/` omit `referenceScale`, whose default the parser itself
|
|
1062
|
+
* spells two lines below the four raw assignments (`SkeletonJson.js:74`,
|
|
1063
|
+
* `getValue(skeletonMap, "referenceScale", 100)`), and none of the 389 bone
|
|
1064
|
+
* `x`/`y`/`rotation`/`shearX`/`shearY` values those same files DO write is an
|
|
1065
|
+
* explicit `0`. A stage at the origin therefore has no spelling but the
|
|
1066
|
+
* omission, so reading the omission as absence reads a value the format cannot
|
|
1067
|
+
* express.
|
|
1068
|
+
* - **The binary reader supplies it unconditionally.** `SkeletonBinary.js:69-72`
|
|
1069
|
+
* reads the four as four floats with no key to be missing, so one skeleton's
|
|
1070
|
+
* origin is `0` in a `.skel` and absent in a `.json`. A measure that called
|
|
1071
|
+
* those two different boxes would be reporting the container.
|
|
1072
|
+
* - **The JSON reader's silence is an oversight rather than a meaning.**
|
|
1073
|
+
* `SkeletonJson.js:70-73` is `skeletonData.x = skeletonMap.x`, a raw
|
|
1074
|
+
* assignment that overwrites `SkeletonData`'s own `0` with `undefined`. The
|
|
1075
|
+
* line beside it does the same to `fps`, whose default is `30` and which every
|
|
1076
|
+
* one of the twelve omits — so taking `undefined` for a meaning would say the
|
|
1077
|
+
* editor has never exported a frame rate.
|
|
1078
|
+
*
|
|
1079
|
+
* ⚠️ **The default is the ORIGIN's alone**, and the guard is the paragraph above
|
|
1080
|
+
* it: the extent is what declares a stage, so defaulting it would turn the
|
|
1081
|
+
* stage-less header of issue #578 into a `0x0` stage at `0,0` and answer the
|
|
1082
|
+
* question instead of reading it.
|
|
1038
1083
|
*/
|
|
1039
1084
|
function stageFacts(root: Json): StageFacts {
|
|
1040
1085
|
const header = isObj(root.skeleton) ? root.skeleton : {};
|
|
1041
1086
|
const box = STAGE_FIELDS.map((k) => num(header[k]));
|
|
1042
|
-
|
|
1087
|
+
const present = box[2] !== null && box[3] !== null;
|
|
1088
|
+
return { present, box: present ? [box[0] ?? 0, box[1] ?? 0, box[2], box[3]] : box };
|
|
1043
1089
|
}
|
|
1044
1090
|
|
|
1045
1091
|
/**
|
|
@@ -1047,13 +1093,20 @@ function stageFacts(root: Json): StageFacts {
|
|
|
1047
1093
|
*
|
|
1048
1094
|
* ⚠️ **The box is compared EXACTLY, and that is a measurement rather than a
|
|
1049
1095
|
* choice.** The brief this was built from asked for "the tolerance the other
|
|
1050
|
-
* measures use";
|
|
1051
|
-
* sixtieth of a second, used once, for `animations.duration` — and
|
|
1052
|
-
* one anywhere, because
|
|
1096
|
+
* measures use"; the structural measures in this file have exactly one —
|
|
1097
|
+
* `FRAME`, one sixtieth of a second, used once, for `animations.duration` — and
|
|
1098
|
+
* no spatial one anywhere, because they compare no position at all (that is
|
|
1053
1099
|
* `bonedist.ts`). A stage is a box an exporter *wrote down*, not a pose anybody
|
|
1054
1100
|
* measured, so there is nothing for it to be within a tolerance *of*; inventing
|
|
1055
1101
|
* a spatial epsilon here would be a number nobody measured, in the file whose
|
|
1056
1102
|
* whole job is to report measured ones.
|
|
1103
|
+
*
|
|
1104
|
+
* ⭐ `diffSkeletonValues` (issue #615) is the second tolerance in the file and
|
|
1105
|
+
* it does not weaken this one. It compares positions, so it needs one; both its
|
|
1106
|
+
* terms are read off other code — rigc's own 1e-6 quantiser and the parser's
|
|
1107
|
+
* float32 storage — rather than chosen here; and the four numbers above are the
|
|
1108
|
+
* one place the two overlap, where `stage_box` stays the stricter reading and
|
|
1109
|
+
* says so by staying exact.
|
|
1057
1110
|
*/
|
|
1058
1111
|
function diffHeader(c: Json, r: Json): DiffReported {
|
|
1059
1112
|
const a = stageFacts(c);
|
|
@@ -1072,7 +1125,7 @@ function diffHeader(c: Json, r: Json): DiffReported {
|
|
|
1072
1125
|
),
|
|
1073
1126
|
measure(
|
|
1074
1127
|
'skeleton.stage_box',
|
|
1075
|
-
'the stage is the same box (x, y, width, height,
|
|
1128
|
+
'the stage is the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means)',
|
|
1076
1129
|
agreed,
|
|
1077
1130
|
both ? STAGE_FIELDS.length : 0,
|
|
1078
1131
|
both
|
|
@@ -1148,6 +1201,212 @@ export function movedReportedMeasures(report: DiffReport): string[] {
|
|
|
1148
1201
|
.map((m) => m.id);
|
|
1149
1202
|
}
|
|
1150
1203
|
|
|
1204
|
+
// ---------------------------------------------------------------------------
|
|
1205
|
+
// The value level — what the structural measures above are blind to
|
|
1206
|
+
// ---------------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* rigc's own emitted grid. `r6` in [`compile.ts`](compile.ts) rounds every
|
|
1210
|
+
* emitted number to 1e-6 and `keyTime` rounds a key time DOWN over the same
|
|
1211
|
+
* step, so a rebuild of a file written with more decimals than that may sit up
|
|
1212
|
+
* to one whole step from where it started — through no fault of anything this
|
|
1213
|
+
* measure is looking for.
|
|
1214
|
+
*/
|
|
1215
|
+
export const VALUE_EMITTED_GRID = 1e-6;
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* One float32 ULP, relative. `spine-core` holds every frame, curve sample and
|
|
1219
|
+
* vertex in a `Float32Array` (`Utils.newFloatArray`), so two decimals that
|
|
1220
|
+
* differ by the grid above can land on adjacent float32s, and the difference
|
|
1221
|
+
* the walk sees is the decimal gap plus that step.
|
|
1222
|
+
*
|
|
1223
|
+
* ⚠️ It is also the floor of what this measure can see AT ALL: a difference
|
|
1224
|
+
* smaller than one float32 step is invisible to the parser and therefore to
|
|
1225
|
+
* this. The tolerance states that rather than hiding it.
|
|
1226
|
+
*/
|
|
1227
|
+
export const VALUE_PARSED_ULP = 2 ** -23;
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* What two readings of one number are allowed to differ by, and nothing more.
|
|
1231
|
+
*
|
|
1232
|
+
* Both terms are derived rather than fitted: the first is rigc's own quantiser,
|
|
1233
|
+
* the second is the parser's storage. Measured over the twelve editor exports
|
|
1234
|
+
* in `examples/`, the widest gap between a rebuild and the file it was read
|
|
1235
|
+
* from reaches **0.81** of this — so the corpus sits inside a bound that was
|
|
1236
|
+
* not drawn around it.
|
|
1237
|
+
*/
|
|
1238
|
+
export function valueTolerance(magnitude: number): number {
|
|
1239
|
+
return VALUE_EMITTED_GRID + VALUE_PARSED_ULP * magnitude;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Whether two readings of one path agree. `NaN` on both sides is agreement: neither file states it. */
|
|
1243
|
+
function valuesAgree(a: number | string, b: number | string): boolean {
|
|
1244
|
+
if (typeof a !== 'number' || typeof b !== 'number') return a === b;
|
|
1245
|
+
if (Number.isNaN(a) && Number.isNaN(b)) return true;
|
|
1246
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
|
|
1247
|
+
return Math.abs(a - b) <= valueTolerance(Math.max(Math.abs(a), Math.abs(b)));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* The measures `diffSkeletonValues` defines, in the order it prints them, and
|
|
1252
|
+
* what each one is. The set is fixed rather than derived from the paths in
|
|
1253
|
+
* front of it, so that a run can say a measure compared NOTHING — the same
|
|
1254
|
+
* distinction `DiffMeasure.total` draws everywhere else in this file.
|
|
1255
|
+
*/
|
|
1256
|
+
const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }> = [
|
|
1257
|
+
{ id: 'values.skeleton', what: 'the header and the setup-pose stage', prefix: 'skeleton/' },
|
|
1258
|
+
{ id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
|
|
1259
|
+
{ id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
|
|
1260
|
+
{ id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
|
|
1261
|
+
{ id: 'values.constraints', what: 'every constraint pose field and flag', prefix: 'constraints/' },
|
|
1262
|
+
{ id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
|
|
1263
|
+
{ id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
|
|
1264
|
+
{ id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },
|
|
1265
|
+
{ id: 'values.curves', what: 'every curve type and the Bezier samples the parser built from its handles', prefix: 'animations/' },
|
|
1266
|
+
];
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Which measure a path belongs to. The first path segment decides it, except
|
|
1270
|
+
* under `animations/`, where the three arms are the three questions that fail
|
|
1271
|
+
* differently: a timing that moved, a value that moved, and an easing that
|
|
1272
|
+
* moved. A segment nothing here names becomes a measure of its own rather than
|
|
1273
|
+
* disappearing into one of these — a walk that grows a top-level kind should
|
|
1274
|
+
* arrive as a new line, not as a silently wider denominator.
|
|
1275
|
+
*/
|
|
1276
|
+
function valueMeasureFor(path: string): string {
|
|
1277
|
+
const head = path.slice(0, path.indexOf('/') + 1);
|
|
1278
|
+
if (head === 'animations/') {
|
|
1279
|
+
if (path.endsWith('/duration') || /\/key\/\d+\/time$/.test(path)) return 'values.key_times';
|
|
1280
|
+
if (/\/curves\/\d+$/.test(path)) return 'values.curves';
|
|
1281
|
+
return 'values.key_values';
|
|
1282
|
+
}
|
|
1283
|
+
const known = VALUE_MEASURES.find((m) => m.prefix === head);
|
|
1284
|
+
return known === undefined ? `values.${head.slice(0, -1) || path}` : known.id;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** How many offending paths a note spells out before it counts the rest. */
|
|
1288
|
+
const VALUE_OFFENDERS_SPELLED_OUT = 3;
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* The value-level comparison: are the numbers inside the structure the same
|
|
1292
|
+
* numbers?
|
|
1293
|
+
*
|
|
1294
|
+
* ## Why it is a separate call rather than a section of `diffSkeletons`
|
|
1295
|
+
*
|
|
1296
|
+
* Because its inputs are not JSON. Every default in the format — `time` absent
|
|
1297
|
+
* is 0, `scaleX` absent is 1, a physics key's `value` absent is 0 while its
|
|
1298
|
+
* `mix` is 1 — has to be applied before two files can be compared value by
|
|
1299
|
+
* value, and writing that table here would be a second spelling of the format
|
|
1300
|
+
* inside the gate. So the defaults come from the parser, through
|
|
1301
|
+
* `skeletonValues` in [`validate.ts`](validate.ts), and this function compares
|
|
1302
|
+
* what it returns: an ordered list of `path → value` with every default already
|
|
1303
|
+
* applied by the one reader that owns them.
|
|
1304
|
+
*
|
|
1305
|
+
* ⚠️ **It reports; it does not gate the ladder.** A rung's brief withholds
|
|
1306
|
+
* coordinates on purpose, so no reading of the reference frames could decide
|
|
1307
|
+
* these — `docs/GATE.md`'s *What never gates* applies word for word. The corpus
|
|
1308
|
+
* gate is the one place they DO decide a verdict, and for the reason `IG16`
|
|
1309
|
+
* already gives about `mesh_edges`: there the reference is the very file the
|
|
1310
|
+
* specs were read from, so a value that moved is a decompiler loss rather than
|
|
1311
|
+
* a candidate's entitlement.
|
|
1312
|
+
*
|
|
1313
|
+
* ## What a difference means, and what it cannot mean
|
|
1314
|
+
*
|
|
1315
|
+
* A path missing on one side is counted as unmatched and named as such: it is a
|
|
1316
|
+
* structural finding too, and the measures above it are where that is
|
|
1317
|
+
* diagnosed. A number present on both sides is compared under
|
|
1318
|
+
* `valueTolerance`, which is derived from rigc's quantiser and the parser's
|
|
1319
|
+
* float32 storage — never from the corpus.
|
|
1320
|
+
*/
|
|
1321
|
+
export function diffSkeletonValues(candidate: SkeletonValue[], reference: SkeletonValue[]): DiffMeasure[] {
|
|
1322
|
+
const left = new Map<string, number | string>();
|
|
1323
|
+
for (const v of candidate) left.set(v.path, v.value);
|
|
1324
|
+
const right = new Map<string, number | string>();
|
|
1325
|
+
for (const v of reference) right.set(v.path, v.value);
|
|
1326
|
+
|
|
1327
|
+
interface Tally {
|
|
1328
|
+
matched: number;
|
|
1329
|
+
total: number;
|
|
1330
|
+
offenders: string[];
|
|
1331
|
+
unpaired: number;
|
|
1332
|
+
}
|
|
1333
|
+
const tallies = new Map<string, Tally>();
|
|
1334
|
+
const order: string[] = VALUE_MEASURES.map((m) => m.id);
|
|
1335
|
+
const tallyFor = (id: string): Tally => {
|
|
1336
|
+
const held = tallies.get(id);
|
|
1337
|
+
if (held !== undefined) return held;
|
|
1338
|
+
const fresh: Tally = { matched: 0, total: 0, offenders: [], unpaired: 0 };
|
|
1339
|
+
tallies.set(id, fresh);
|
|
1340
|
+
if (!order.includes(id)) order.push(id);
|
|
1341
|
+
return fresh;
|
|
1342
|
+
};
|
|
1343
|
+
for (const id of order) tallyFor(id);
|
|
1344
|
+
|
|
1345
|
+
// The candidate's paths in the candidate's own order, then whatever the
|
|
1346
|
+
// reference has that the candidate does not — an iteration over the two
|
|
1347
|
+
// lists as they were built, never over a set.
|
|
1348
|
+
for (const { path, value } of candidate) {
|
|
1349
|
+
const tally = tallyFor(valueMeasureFor(path));
|
|
1350
|
+
tally.total++;
|
|
1351
|
+
const other = right.get(path);
|
|
1352
|
+
if (other === undefined) {
|
|
1353
|
+
tally.unpaired++;
|
|
1354
|
+
if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (candidate only)`);
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
if (valuesAgree(value, other)) {
|
|
1358
|
+
tally.matched++;
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) {
|
|
1362
|
+
const gap =
|
|
1363
|
+
typeof value === 'number' && typeof other === 'number'
|
|
1364
|
+
? `, off by ${Math.abs(value - other).toExponential(3)} against ${valueTolerance(
|
|
1365
|
+
Math.max(Math.abs(value), Math.abs(other)),
|
|
1366
|
+
).toExponential(3)} allowed`
|
|
1367
|
+
: '';
|
|
1368
|
+
tally.offenders.push(`${path} ${JSON.stringify(value)} vs ${JSON.stringify(other)}${gap}`);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
for (const { path } of reference) {
|
|
1372
|
+
if (left.has(path)) continue;
|
|
1373
|
+
const tally = tallyFor(valueMeasureFor(path));
|
|
1374
|
+
tally.total++;
|
|
1375
|
+
tally.unpaired++;
|
|
1376
|
+
if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (reference only)`);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return order.map((id) => {
|
|
1380
|
+
const tally = tallyFor(id);
|
|
1381
|
+
const defined = VALUE_MEASURES.find((m) => m.id === id);
|
|
1382
|
+
const missed = tally.total - tally.matched;
|
|
1383
|
+
const note =
|
|
1384
|
+
tally.total === 0
|
|
1385
|
+
? 'neither side carries one'
|
|
1386
|
+
: missed === 0
|
|
1387
|
+
? undefined
|
|
1388
|
+
: `${missed} moved` +
|
|
1389
|
+
(tally.unpaired === 0 ? '' : `, ${tally.unpaired} of them on one side only`) +
|
|
1390
|
+
`: ${tally.offenders.join('; ')}` +
|
|
1391
|
+
(missed > tally.offenders.length ? `; …and ${missed - tally.offenders.length} more` : '');
|
|
1392
|
+
return measure(id, defined?.what ?? 'values under a path kind this report does not define', tally.matched, tally.total, note);
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Every value measure that is not a perfect match, by id. What a test asserts on. */
|
|
1397
|
+
export function movedValueMeasures(measures: DiffMeasure[]): string[] {
|
|
1398
|
+
return measures.filter((m) => m.ratio < 1).map((m) => m.id);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* `skeleton 1.000 · bones 1.000 · …`, the same one-line shape `reportedFigures`
|
|
1403
|
+
* has, and with no roll-up for the same reason: a mean over "did the times move"
|
|
1404
|
+
* and "did the vertices move" is a number with no referent.
|
|
1405
|
+
*/
|
|
1406
|
+
export function valueFigures(measures: DiffMeasure[]): string {
|
|
1407
|
+
return measures.map((m) => `${m.id.slice(m.id.indexOf('.') + 1)} ${fmt(m.ratio)}`).join(' · ');
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1151
1410
|
const fmt = (n: number): string => n.toFixed(3);
|
|
1152
1411
|
|
|
1153
1412
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -3952,3 +3952,271 @@ export function reportLines(report: ValidateReport): string[] {
|
|
|
3952
3952
|
export function atlasDirOf(atlasPath: string): string {
|
|
3953
3953
|
return dirname(resolve(atlasPath));
|
|
3954
3954
|
}
|
|
3955
|
+
|
|
3956
|
+
// ---------------------------------------------------------------------------
|
|
3957
|
+
// skeletonValues — the round trip read as VALUES rather than as assertions
|
|
3958
|
+
// ---------------------------------------------------------------------------
|
|
3959
|
+
|
|
3960
|
+
/**
|
|
3961
|
+
* One value the parser read out of a skeleton file, at a path that names it.
|
|
3962
|
+
*
|
|
3963
|
+
* `bones/hip/setup/rotation`, `skins/default/head/head/regionUVs/12`,
|
|
3964
|
+
* `animations/walk/RotateTimeline/bone:hip/key/3/v1`. Numbers stay numbers so
|
|
3965
|
+
* that a comparison can state a tolerance; everything else — a blend mode the
|
|
3966
|
+
* runtime holds as an enum, an attachment name, a boolean, an absent object —
|
|
3967
|
+
* is a string, and is compared exactly.
|
|
3968
|
+
*/
|
|
3969
|
+
export interface SkeletonValue {
|
|
3970
|
+
/** Stable, name-keyed, and never an index into an emitted array (issue #45). */
|
|
3971
|
+
path: string;
|
|
3972
|
+
value: number | string;
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
/**
|
|
3976
|
+
* Keys the walk below does not read, each with the reason it is not a value the
|
|
3977
|
+
* file carries. It is a SKIP list rather than an include list on purpose: the
|
|
3978
|
+
* field names come from the parser, so a field spine-core starts reading is
|
|
3979
|
+
* compared the day it starts reading it, and the only hand-kept part is the
|
|
3980
|
+
* short list of things that are demonstrably not in the file.
|
|
3981
|
+
*
|
|
3982
|
+
* ⚠️ `id` is the sharp one. `VertexAttachment.id` is a process-wide counter, so
|
|
3983
|
+
* two parses in one process disagree about it by construction — and it reaches
|
|
3984
|
+
* `DeformTimeline.getPropertyIds()`, which is why the timeline key below is
|
|
3985
|
+
* built from the resolved owner rather than from the property ids.
|
|
3986
|
+
*/
|
|
3987
|
+
const NOT_A_VALUE_IN_THE_FILE: Record<string, string> = {
|
|
3988
|
+
a: 'the derived world matrix',
|
|
3989
|
+
b: 'the derived world matrix',
|
|
3990
|
+
c: 'the derived world matrix',
|
|
3991
|
+
d: 'the derived world matrix',
|
|
3992
|
+
world: 'derived by the runtime',
|
|
3993
|
+
local: 'derived by the runtime',
|
|
3994
|
+
worldX: 'derived by the runtime',
|
|
3995
|
+
worldY: 'derived by the runtime',
|
|
3996
|
+
region: 'the atlas, which is not the skeleton',
|
|
3997
|
+
regions: 'the atlas, which is not the skeleton',
|
|
3998
|
+
uvs: 'computed from the region',
|
|
3999
|
+
offsets: 'computed from the region',
|
|
4000
|
+
tempColor: 'runtime scratch',
|
|
4001
|
+
deform: 'runtime scratch on the setup pose',
|
|
4002
|
+
id: 'a process-wide counter, not a value in the file',
|
|
4003
|
+
index: 'the array position this walk already iterates by name',
|
|
4004
|
+
timelineIds: 'derived from the timelines',
|
|
4005
|
+
timelineSlots: 'derived from the skin',
|
|
4006
|
+
properties: 'derived from the constraint',
|
|
4007
|
+
propertyIds: 'carries an attachment id, which is a counter (see above)',
|
|
4008
|
+
};
|
|
4009
|
+
|
|
4010
|
+
/** How deep a chain of unnamed objects may go before the walk says so and stops. */
|
|
4011
|
+
const VALUE_WALK_DEPTH = 10;
|
|
4012
|
+
|
|
4013
|
+
/**
|
|
4014
|
+
* Reflection over the parsed form: numbers and strings as they are, arrays by
|
|
4015
|
+
* index, objects by their own keys in **sorted** order, and any nested object
|
|
4016
|
+
* that carries a `name` by that name rather than by expansion — which is what
|
|
4017
|
+
* makes a cross-reference (`parent`, `boneData`, `endSlot`, a constraint's
|
|
4018
|
+
* `bones`) a name and terminates every cycle the runtime's back-references
|
|
4019
|
+
* would otherwise walk forever.
|
|
4020
|
+
*
|
|
4021
|
+
* Sorted rather than insertion-ordered because `A18_DETERMINISTIC_EMIT`'s rule
|
|
4022
|
+
* applies here too: nothing whose order is the runtime's business may decide
|
|
4023
|
+
* what this function emits.
|
|
4024
|
+
*/
|
|
4025
|
+
function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: number): void {
|
|
4026
|
+
if (value === null || value === undefined) {
|
|
4027
|
+
out.push({ path, value: '(none)' });
|
|
4028
|
+
return;
|
|
4029
|
+
}
|
|
4030
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
4031
|
+
out.push({ path, value });
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
if (typeof value === 'boolean') {
|
|
4035
|
+
out.push({ path, value: value ? 'true' : 'false' });
|
|
4036
|
+
return;
|
|
4037
|
+
}
|
|
4038
|
+
if (typeof value === 'function') return;
|
|
4039
|
+
if (ArrayBuffer.isView(value) || Array.isArray(value)) {
|
|
4040
|
+
const list = value as ArrayLike<unknown>;
|
|
4041
|
+
for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${i}`, out, depth + 1);
|
|
4042
|
+
return;
|
|
4043
|
+
}
|
|
4044
|
+
if (typeof value !== 'object') return;
|
|
4045
|
+
const obj = value as Record<string, unknown>;
|
|
4046
|
+
if (depth > 0 && typeof obj.name === 'string') {
|
|
4047
|
+
out.push({ path, value: obj.name });
|
|
4048
|
+
return;
|
|
4049
|
+
}
|
|
4050
|
+
if (depth > VALUE_WALK_DEPTH) {
|
|
4051
|
+
out.push({ path, value: '(deeper than the walk goes)' });
|
|
4052
|
+
return;
|
|
4053
|
+
}
|
|
4054
|
+
for (const key of Object.keys(obj).sort()) {
|
|
4055
|
+
if (key in NOT_A_VALUE_IN_THE_FILE) continue;
|
|
4056
|
+
pushValue(obj[key], `${path}/${key}`, out, depth + 1);
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
/**
|
|
4061
|
+
* Which timeline this is, in words that both sides of a comparison can reach.
|
|
4062
|
+
*
|
|
4063
|
+
* The class name and the OWNER'S NAME — never the property ids, which carry
|
|
4064
|
+
* array indices and, for a deform timeline, a counter. A rig that reorders its
|
|
4065
|
+
* bones is a structural finding `diff` already makes; it must not also arrive
|
|
4066
|
+
* here as a timeline nobody can pair.
|
|
4067
|
+
*/
|
|
4068
|
+
function timelineKey(timeline: Timeline, data: ReturnType<SkeletonJson['readSkeletonData']>): string {
|
|
4069
|
+
const kind = timeline.constructor?.name ?? 'Timeline';
|
|
4070
|
+
const asBone = timeline as Timeline & Partial<{ boneIndex: number }>;
|
|
4071
|
+
if (isBoneTimeline(asBone)) return `${kind}/bone:${data.bones[asBone.boneIndex]?.name ?? `#${asBone.boneIndex}`}`;
|
|
4072
|
+
const asSlot = timeline as Timeline & Partial<{ slotIndex: number }>;
|
|
4073
|
+
if (isSlotTimeline(asSlot)) {
|
|
4074
|
+
const slot = data.slots[asSlot.slotIndex]?.name ?? `#${asSlot.slotIndex}`;
|
|
4075
|
+
const attachment = (timeline as Timeline & Partial<{ attachment: { name: string } }>).attachment;
|
|
4076
|
+
return `${kind}/slot:${slot}${attachment === undefined ? '' : `:${attachment.name}`}`;
|
|
4077
|
+
}
|
|
4078
|
+
const asConstraint = timeline as Timeline & Partial<{ constraintIndex: number }>;
|
|
4079
|
+
if (isConstraintTimeline(asConstraint)) {
|
|
4080
|
+
return `${kind}/constraint:${data.constraints[asConstraint.constraintIndex]?.name ?? `#${asConstraint.constraintIndex}`}`;
|
|
4081
|
+
}
|
|
4082
|
+
return `${kind}/skeleton`;
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
/**
|
|
4086
|
+
* Every value a skeleton file carries, as the **parser** understands it.
|
|
4087
|
+
*
|
|
4088
|
+
* ## Why this is here and not in `diff.ts`
|
|
4089
|
+
*
|
|
4090
|
+
* `diff` compares structure over raw JSON and says so in its own header:
|
|
4091
|
+
* *"Pure JSON reading — no spine-core, no filesystem."* Comparing the values
|
|
4092
|
+
* inside that structure needs the format's per-field defaults — `time` absent
|
|
4093
|
+
* is 0, `scaleX` absent is 1, a physics key's `value` absent is 0 but its `mix`
|
|
4094
|
+
* is 1 — and a second spelling of those inside the gate is exactly what this
|
|
4095
|
+
* repository refuses (CLAUDE.md, *The compiler never invents a value*). So the
|
|
4096
|
+
* defaults are taken from the one reader that owns them, which means the
|
|
4097
|
+
* runtime, which means this file: `CLAUDE.md`'s *Conventions* names the three
|
|
4098
|
+
* modules allowed to link spine-core and `src/validate.ts` is the one that
|
|
4099
|
+
* "owns the round trip". `src/bonedist.ts` is the precedent for the other half
|
|
4100
|
+
* — an instrument that needs the runtime and reaches it through `render.ts`
|
|
4101
|
+
* rather than linking it itself. `diff.ts` compares what this returns.
|
|
4102
|
+
*
|
|
4103
|
+
* ## What it covers
|
|
4104
|
+
*
|
|
4105
|
+
* Everything on `SkeletonData` that is not on the skip list above: the header
|
|
4106
|
+
* and stage, every bone's setup pose and `length`, every slot's colours, blend
|
|
4107
|
+
* and setup attachment, every attachment in every skin (a region's offsets,
|
|
4108
|
+
* rotation, scale and size; a mesh's vertices, weights, `regionUVs`,
|
|
4109
|
+
* triangles, hull and edges; a bounding box's, path's and clipping shape's
|
|
4110
|
+
* vertices), every constraint's pose and flags, every event's payload, and for
|
|
4111
|
+
* every timeline every frame — time and values, from the runtime's own
|
|
4112
|
+
* `getFrameEntries()` — its curve type and Bezier samples, and its deform
|
|
4113
|
+
* vertices, attachment names, draw orders and event payloads.
|
|
4114
|
+
*
|
|
4115
|
+
* ## What it does not
|
|
4116
|
+
*
|
|
4117
|
+
* - **`version` and `hash`.** The rig spec has no field for either; `ingest`
|
|
4118
|
+
* reports them as `HEADER_REDERIVED` and `HEADER_BOOKKEEPING` findings, and
|
|
4119
|
+
* a rebuild restating the runtime rigc links is the whole reason the corpus
|
|
4120
|
+
* gate is `diff` at 1.000 rather than a byte comparison (`docs/INGEST.md`
|
|
4121
|
+
* §2.3). They are named here so that skipping them is a decision a reader
|
|
4122
|
+
* can see rather than an omission.
|
|
4123
|
+
* - **Anything below one float32 step.** `spine-core` stores frames, curves and
|
|
4124
|
+
* vertices in `Float32Array`, so two values that round to the same float32
|
|
4125
|
+
* are equal to this walk whatever the file says. The comparison's tolerance
|
|
4126
|
+
* states that bound rather than hiding it.
|
|
4127
|
+
* - **A Bezier's control points as such.** The parser samples them into
|
|
4128
|
+
* `curves` (`setBezier`), so what is compared is the sampled curve; a moved
|
|
4129
|
+
* handle moves the samples, but by a different amount than it moved the
|
|
4130
|
+
* handle.
|
|
4131
|
+
*/
|
|
4132
|
+
export function skeletonValues(skeletonText: string, atlasText: string): SkeletonValue[] {
|
|
4133
|
+
const data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(
|
|
4134
|
+
JSON.parse(skeletonText),
|
|
4135
|
+
);
|
|
4136
|
+
const out: SkeletonValue[] = [];
|
|
4137
|
+
const header = data as unknown as Record<string, unknown>;
|
|
4138
|
+
for (const field of ['x', 'y', 'width', 'height', 'referenceScale', 'fps', 'imagesPath', 'audioPath', 'name']) {
|
|
4139
|
+
pushValue(header[field], `skeleton/${field}`, out, 1);
|
|
4140
|
+
}
|
|
4141
|
+
for (const bone of data.bones) {
|
|
4142
|
+
const at = `bones/${bone.name}`;
|
|
4143
|
+
const rec = bone as unknown as Record<string, unknown>;
|
|
4144
|
+
for (const key of Object.keys(rec).sort()) {
|
|
4145
|
+
if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
|
|
4146
|
+
pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
for (const slot of data.slots) {
|
|
4150
|
+
const at = `slots/${slot.name}`;
|
|
4151
|
+
const rec = slot as unknown as Record<string, unknown>;
|
|
4152
|
+
for (const key of Object.keys(rec).sort()) {
|
|
4153
|
+
if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
|
|
4154
|
+
pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
for (const skin of data.skins) {
|
|
4158
|
+
const at = `skins/${skin.name}`;
|
|
4159
|
+
pushValue(skin.color, `${at}/color`, out, 1);
|
|
4160
|
+
pushValue(skin.bones, `${at}/bones`, out, 1);
|
|
4161
|
+
pushValue(skin.constraints, `${at}/constraints`, out, 1);
|
|
4162
|
+
for (const [slotIndex, held] of skin.attachments.entries()) {
|
|
4163
|
+
if (held === undefined || held === null) continue;
|
|
4164
|
+
const slot = data.slots[slotIndex]?.name ?? `#${slotIndex}`;
|
|
4165
|
+
const byName = held as unknown as Record<string, unknown>;
|
|
4166
|
+
for (const placeholder of Object.keys(byName).sort()) {
|
|
4167
|
+
const attachment = byName[placeholder] as Record<string, unknown>;
|
|
4168
|
+
const where = `${at}/${slot}/${placeholder}`;
|
|
4169
|
+
// The attachment's TYPE, which is the one thing reflection cannot see:
|
|
4170
|
+
// a region and a mesh differ in their fields, and a walk that only read
|
|
4171
|
+
// the fields would call a swap a pile of unpaired paths rather than a
|
|
4172
|
+
// kind that changed.
|
|
4173
|
+
pushValue(attachment.constructor?.name ?? '(unknown)', `${where}/kind`, out, 1);
|
|
4174
|
+
pushValue(attachment, where, out, 0);
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
for (const constraint of data.constraints) {
|
|
4179
|
+
const at = `constraints/${constraint.name}`;
|
|
4180
|
+
pushValue(constraint.constructor?.name ?? '(unknown)', `${at}/kind`, out, 1);
|
|
4181
|
+
pushValue(constraint, at, out, 0);
|
|
4182
|
+
}
|
|
4183
|
+
for (const event of data.events) pushValue(event, `events/${event.name}`, out, 0);
|
|
4184
|
+
for (const animation of data.animations) {
|
|
4185
|
+
const at = `animations/${animation.name}`;
|
|
4186
|
+
pushValue(animation.duration, `${at}/duration`, out, 1);
|
|
4187
|
+
// Timelines are grouped by their key and numbered within the group, so that
|
|
4188
|
+
// two timelines a runtime distinguishes by object identity — one deform per
|
|
4189
|
+
// skin over the same slot and attachment — still pair up in file order
|
|
4190
|
+
// rather than colliding on one path.
|
|
4191
|
+
const grouped = new Map<string, Timeline[]>();
|
|
4192
|
+
for (const timeline of animation.timelines) {
|
|
4193
|
+
const key = timelineKey(timeline, data);
|
|
4194
|
+
const held = grouped.get(key);
|
|
4195
|
+
if (held === undefined) grouped.set(key, [timeline]);
|
|
4196
|
+
else held.push(timeline);
|
|
4197
|
+
}
|
|
4198
|
+
for (const key of [...grouped.keys()].sort()) {
|
|
4199
|
+
const group = grouped.get(key) ?? [];
|
|
4200
|
+
for (const [n, timeline] of group.entries()) {
|
|
4201
|
+
const where = `${at}/${key}${group.length > 1 ? `#${n}` : ''}`;
|
|
4202
|
+
// The frames, split into time and values by the runtime's own count of
|
|
4203
|
+
// entries per frame rather than by a table of what each timeline kind
|
|
4204
|
+
// keys. Entry 0 is the time for every timeline spine-core defines.
|
|
4205
|
+
const entries = timeline.getFrameEntries();
|
|
4206
|
+
for (let i = 0; i < timeline.frames.length; i++) {
|
|
4207
|
+
const slot = i % entries;
|
|
4208
|
+
pushValue(timeline.frames[i], `${where}/key/${Math.floor(i / entries)}/${slot === 0 ? 'time' : `v${slot}`}`, out, 1);
|
|
4209
|
+
}
|
|
4210
|
+
const rec = timeline as unknown as Record<string, unknown>;
|
|
4211
|
+
for (const field of Object.keys(rec).sort()) {
|
|
4212
|
+
if (field in NOT_A_VALUE_IN_THE_FILE || field === 'frames') continue;
|
|
4213
|
+
// The owner is in the path already; comparing the index as well would
|
|
4214
|
+
// report one reordering twice, in a measure that is not about order.
|
|
4215
|
+
if (field === 'boneIndex' || field === 'slotIndex' || field === 'constraintIndex') continue;
|
|
4216
|
+
pushValue(rec[field], `${where}/${field}`, out, 1);
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
return out;
|
|
4222
|
+
}
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* emitter defects (#368 `hull`/`edges`, #369 hold curves, #370
|
|
10
10
|
* `skeleton.images`) before proving that a human edit survives the trip.
|
|
11
11
|
*
|
|
12
|
+
* ⭐ Every step quotes what its child said when that child did not do what it was
|
|
13
|
+
* for (#541, and step 5 since #621). A skin NEITHER side can draw is a SKIP
|
|
14
|
+
* naming that rather than a red — `check` had nothing to compare, and `validate`
|
|
15
|
+
* and `diff` have already measured the rig — while a skin only ONE side draws is
|
|
16
|
+
* the divergence this whole file exists to find and stays a failure.
|
|
17
|
+
*
|
|
12
18
|
* It ran from a shell script in a local scratch directory. A tool nobody can
|
|
13
19
|
* find is not a tool, hence this file.
|
|
14
20
|
*
|
|
@@ -308,6 +314,107 @@ function editorSaid(ran: Ran): string[] {
|
|
|
308
314
|
return lines;
|
|
309
315
|
}
|
|
310
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The line a rigc child REFUSED on, out of the stream it printed it on.
|
|
319
|
+
*
|
|
320
|
+
* ⚠️ Not `editorSaid`, which quotes both streams whole. A rigc refusal is a
|
|
321
|
+
* `UsageError` and `cli.ts` prints the entire usage under one — some sixty lines
|
|
322
|
+
* of correct prose that would bury the one sentence somebody needs. Every
|
|
323
|
+
* refusal rigc prints starts, at column zero, with its own name and a colon:
|
|
324
|
+
* `rigc:`, `rigc check error:`, `rigc compile error:`. The usage block has
|
|
325
|
+
* neither shape — its `rigc <command> …` lines are indented, and its unindented
|
|
326
|
+
* ones carry no colon — so none of them is mistaken for one.
|
|
327
|
+
*/
|
|
328
|
+
function refusalLines(text: string): string[] {
|
|
329
|
+
return text.split('\n').filter((line) => /^rigc\b[^\n]*:/.test(line));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* What one rigc child said, under the step that ran it.
|
|
334
|
+
*
|
|
335
|
+
* 🚨 Issue #621, and it is `editorSaid`'s defect one surface over. Step 5 was
|
|
336
|
+
* the one step that printed a child's exit code and threw its words away: on a
|
|
337
|
+
* rig whose only attachment is a `boundingbox` it reported a bare `exit=1`, and
|
|
338
|
+
* the renderer's own refusal — *"posed no drawable attachment in any animation
|
|
339
|
+
* or in its setup pose — there is nothing to draw"* — reached nobody. A reader
|
|
340
|
+
* of that log cannot tell a crashed renderer from a rig with no frames, which is
|
|
341
|
+
* the same silence this file already has a judgment about.
|
|
342
|
+
*
|
|
343
|
+
* ⭐ The fallback is the half that keeps the fix from being the defect again one
|
|
344
|
+
* level down: a child that dies with a stack trace prints no `rigc…:` line at
|
|
345
|
+
* all, so when nothing matched, the tail of stderr is quoted rather than
|
|
346
|
+
* nothing.
|
|
347
|
+
*/
|
|
348
|
+
function rigcSaid(what: string, ran: Ran): string[] {
|
|
349
|
+
const said = [
|
|
350
|
+
...refusalLines(ran.stderr),
|
|
351
|
+
// `check` reports its failures on stdout, in the FAIL lines `verdictLines`
|
|
352
|
+
// keeps for the green path.
|
|
353
|
+
...ran.stdout.split('\n').filter((line) => /^\s*FAIL/.test(line)),
|
|
354
|
+
];
|
|
355
|
+
if (said.length === 0) {
|
|
356
|
+
said.push(...ran.stderr.replace(/\s+$/, '').split('\n').slice(-6).filter((line) => line !== ''));
|
|
357
|
+
}
|
|
358
|
+
const head = ` ${what} exit=${String(ran.status)}${ran.timedOut ? ' TIMED OUT' : ''}`;
|
|
359
|
+
// Silence is a finding, for the reason `editorSaid` states: "it said nothing"
|
|
360
|
+
// and "this harness threw its words away" look identical from the outside.
|
|
361
|
+
if (said.length === 0) return [head, ' | it printed nothing on stdout or stderr'];
|
|
362
|
+
return [head, ...said.map((line) => ` | ${line.trimEnd()}`)];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The clause `rigc render` refuses on when the skeleton draws nothing, and the
|
|
367
|
+
* exit code it leaves it with.
|
|
368
|
+
*
|
|
369
|
+
* ⚠️ The clause is the part of that message that does not move: `cli.ts` splices
|
|
370
|
+
* ` under skin "x"` in after "setup pose" when `--skin` was passed, and ends on
|
|
371
|
+
* `— there is nothing to draw` either way.
|
|
372
|
+
*/
|
|
373
|
+
const NOTHING_TO_DRAW = 'posed no drawable attachment in any animation or in its setup pose';
|
|
374
|
+
|
|
375
|
+
/** `cli.ts` exits 2 on a `UsageError`, which is what that refusal is. */
|
|
376
|
+
const RIGC_USAGE_EXIT = 2;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Did this `render` call refuse because there was nothing to draw?
|
|
380
|
+
*
|
|
381
|
+
* 🔒 **Both signals, and the second one is why** (issue #621). The exit code
|
|
382
|
+
* alone is every `UsageError` there is — an unknown flag, a candidate that is
|
|
383
|
+
* not there, a `--skin` the skeleton does not declare — so reading a 2 as
|
|
384
|
+
* "nothing to draw" would turn the export dropping a skin the build declares
|
|
385
|
+
* into a SKIP, which is the one outcome this must never produce. The sentence
|
|
386
|
+
* alone is a string found on a stream however the child exited, including a path
|
|
387
|
+
* that echoed it and then did something else.
|
|
388
|
+
*
|
|
389
|
+
* ⛔ What was rejected: reading the two skeletons here and deciding for
|
|
390
|
+
* ourselves whether either draws. That is a second implementation of
|
|
391
|
+
* `framingViewport` — atlas resolution, the setup pose and every animation — and
|
|
392
|
+
* two answers that need not agree is a checker agreeing with itself. The verdict
|
|
393
|
+
* belongs to the child that refused.
|
|
394
|
+
*
|
|
395
|
+
* ⛔ Also rejected, and it is the more structural signal: an exit code of its own
|
|
396
|
+
* from `cli.ts` for this refusal. `src/render.ts` emits no code at all —
|
|
397
|
+
* `framingViewport` returns null and `cli.ts` turns that into a `UsageError` — so
|
|
398
|
+
* that is a change to rigc's CLI contract rather than to this harness, and it is
|
|
399
|
+
* wider than the card it would be landing under.
|
|
400
|
+
*/
|
|
401
|
+
function nothingToDraw(ran: Ran): boolean {
|
|
402
|
+
return !ran.timedOut && ran.status === RIGC_USAGE_EXIT && ran.stderr.includes(NOTHING_TO_DRAW);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Step 5's verdict when NEITHER side draws (issue #621).
|
|
407
|
+
*
|
|
408
|
+
* The question this step asks is *does what comes back still play the same*. A
|
|
409
|
+
* rig that draws nothing on both sides gives `check` nothing to compare, and the
|
|
410
|
+
* honest answer to a question with no measurement behind it is the one this file
|
|
411
|
+
* gives everywhere else: SKIP by name, never a pass and never a red. `validate`
|
|
412
|
+
* and `diff` have already measured the rig — this is the shape #608 removed from
|
|
413
|
+
* `build`, one tool further out.
|
|
414
|
+
*/
|
|
415
|
+
const NOTHING_MEASURED =
|
|
416
|
+
'neither side draws a frame, so the check is not measured; `diff` and `validate` carry this rig';
|
|
417
|
+
|
|
311
418
|
function run(cmd: string, args: string[], timeoutS: number): Ran {
|
|
312
419
|
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutS * 1000 });
|
|
313
420
|
return {
|
|
@@ -497,6 +604,16 @@ export function skinsDeclaredBy(path: string): string[] {
|
|
|
497
604
|
return out;
|
|
498
605
|
}
|
|
499
606
|
|
|
607
|
+
/**
|
|
608
|
+
* How one step-5 block ended (issue #621).
|
|
609
|
+
*
|
|
610
|
+
* Three states rather than an exit code, because `check` not having run and
|
|
611
|
+
* `check` having returned 0 are different facts and used to print the same:
|
|
612
|
+
* `measured` is the only one that carries a figure, and `not measured` is the
|
|
613
|
+
* only one that does not fail the run.
|
|
614
|
+
*/
|
|
615
|
+
export type BlockVerdict = 'measured' | 'not measured' | 'no frames';
|
|
616
|
+
|
|
500
617
|
/** One render-and-check block of step 5: which skin, where its frames go. */
|
|
501
618
|
export interface SkinBlock {
|
|
502
619
|
/** The skin this block poses under — `null` when the skeleton declares none. */
|
|
@@ -901,21 +1018,63 @@ function main(): void {
|
|
|
901
1018
|
// export dropped altogether then reads as a block whose check fails by name,
|
|
902
1019
|
// where enumerating the export's own skins would quietly stop looking for it.
|
|
903
1020
|
const blocks = skinBlocks(skinsDeclaredBy(source), opts.out, opts.fps);
|
|
904
|
-
const checks: Array<{ skin: string | null; status: number | null; mae: number | null }> = [];
|
|
1021
|
+
const checks: Array<{ skin: string | null; verdict: BlockVerdict; status: number | null; mae: number | null }> = [];
|
|
905
1022
|
for (const block of blocks) {
|
|
906
1023
|
emit('');
|
|
907
1024
|
emit(block.heading);
|
|
908
|
-
|
|
909
|
-
run(rigc.cmd, [...rigc.prefix, 'render', '--candidate',
|
|
1025
|
+
const args = ['--fps', String(opts.fps), ...block.args];
|
|
1026
|
+
const drawBuild = run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', opts.build, ...args, '--out', block.buildFrames], 900);
|
|
1027
|
+
const drawExport = run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', cand, ...args, '--out', block.exportFrames], 900);
|
|
1028
|
+
// 🚨 Both renderers are quoted the moment either did not do what it was for
|
|
1029
|
+
// (issue #621) — the rule steps 1 and 2 have followed since #541, and step 5
|
|
1030
|
+
// was the one step that did not.
|
|
1031
|
+
for (const [what, ran] of [
|
|
1032
|
+
['render (the build)', drawBuild],
|
|
1033
|
+
['render (the export)', drawExport],
|
|
1034
|
+
] as const) {
|
|
1035
|
+
if (ran.status !== 0 || ran.timedOut) for (const line of rigcSaid(what, ran)) emit(line);
|
|
1036
|
+
}
|
|
1037
|
+
const buildBlank = nothingToDraw(drawBuild);
|
|
1038
|
+
const exportBlank = nothingToDraw(drawExport);
|
|
1039
|
+
const buildDrew = drawBuild.status === 0 && !drawBuild.timedOut;
|
|
1040
|
+
const exportDrew = drawExport.status === 0 && !drawExport.timedOut;
|
|
1041
|
+
|
|
1042
|
+
if (buildBlank && exportBlank) {
|
|
1043
|
+
emit(` SKIP ${NOTHING_MEASURED}`);
|
|
1044
|
+
checks.push({ skin: block.skin, verdict: 'not measured', status: null, mae: null });
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
// 🔒 One side only, and it stays red. "Nothing to draw" is an honest answer
|
|
1048
|
+
// about a RIG; about one side of a round trip it is the loss the trip exists
|
|
1049
|
+
// to find, so the SKIP above is guarded by `&&` and never by `||`.
|
|
1050
|
+
if ((buildBlank && exportDrew) || (exportBlank && buildDrew)) {
|
|
1051
|
+
const blank = buildBlank ? 'the build' : 'the export';
|
|
1052
|
+
const drawn = buildBlank ? 'the export' : 'the build';
|
|
1053
|
+
emit(
|
|
1054
|
+
` FAIL ${blank} has nothing to draw and ${drawn} draws — one side drawing where the other does not IS ` +
|
|
1055
|
+
'the divergence this step measures, so it is a failure and never a SKIP',
|
|
1056
|
+
);
|
|
1057
|
+
checks.push({ skin: block.skin, verdict: 'no frames', status: null, mae: null });
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (!buildDrew || !exportDrew) {
|
|
1061
|
+
emit(
|
|
1062
|
+
' FAIL a render did not do what it was for, so there is no frame set to compare — `check` is not run, ' +
|
|
1063
|
+
'because with a frame set missing its message would name the directory rather than the refusal above',
|
|
1064
|
+
);
|
|
1065
|
+
checks.push({ skin: block.skin, verdict: 'no frames', status: null, mae: null });
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
910
1068
|
const check = run(
|
|
911
1069
|
rigc.cmd,
|
|
912
1070
|
[...rigc.prefix, 'check', '--candidate', cand, '--frames', block.buildFrames, ...block.args, '--json', block.checkJson],
|
|
913
1071
|
900,
|
|
914
1072
|
);
|
|
915
1073
|
emit(` exit=${check.status}`);
|
|
1074
|
+
if (check.status !== 0 || check.timedOut) for (const line of rigcSaid('check', check)) emit(line);
|
|
916
1075
|
for (const line of verdictLines(check.stdout)) emit(` ${line}`);
|
|
917
1076
|
for (const line of checkFigures(block.checkJson)) emit(` ${line}`);
|
|
918
|
-
checks.push({ skin: block.skin, status: check.status, mae: worstMeanMae(block.checkJson) });
|
|
1077
|
+
checks.push({ skin: block.skin, verdict: 'measured', status: check.status, mae: worstMeanMae(block.checkJson) });
|
|
919
1078
|
}
|
|
920
1079
|
// The roll-up, so a loss in one skin of many is a line somebody reads rather
|
|
921
1080
|
// than a row buried in the block above it. The mark is on the LARGEST figure
|
|
@@ -923,22 +1082,41 @@ function main(): void {
|
|
|
923
1082
|
// against and this tool does not get to invent one, but "these skins did not
|
|
924
1083
|
// come back the same" is a fact the run itself produced.
|
|
925
1084
|
if (blocks.length > 1) {
|
|
926
|
-
|
|
1085
|
+
// ⚠️ Off the MEASURED blocks only (issue #621). A skin nobody could render
|
|
1086
|
+
// has no figure, and folding it in as one would put a number in the column a
|
|
1087
|
+
// reader looks at for a block where nothing was compared.
|
|
1088
|
+
const figures = checks.filter((c) => c.verdict === 'measured').map((c) => c.mae).filter((v): v is number => v !== null);
|
|
927
1089
|
const worst = figures.length === 0 ? null : Math.max(...figures);
|
|
928
1090
|
const agreed = figures.length === checks.length && new Set(figures).size === 1;
|
|
929
1091
|
emit('');
|
|
930
1092
|
emit(` per skin ${checks.length} block(s)`);
|
|
931
|
-
for (const { skin, status, mae } of checks) {
|
|
1093
|
+
for (const { skin, verdict, status, mae } of checks) {
|
|
1094
|
+
// 🔒 Never a pass and never an MAE of 0: a block that measured nothing
|
|
1095
|
+
// says so in the column the figures would have been in.
|
|
1096
|
+
if (verdict !== 'measured') {
|
|
1097
|
+
emit(
|
|
1098
|
+
` ${String(skin).padEnd(20)} ` +
|
|
1099
|
+
(verdict === 'not measured'
|
|
1100
|
+
? 'NOT MEASURED — neither side draws a frame under this skin'
|
|
1101
|
+
: '⚠️ NOT MEASURED — a render did not do what it was for, and this skin is red for it'),
|
|
1102
|
+
);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
932
1105
|
emit(
|
|
933
1106
|
` ${String(skin).padEnd(20)} check exit=${status} worst mean MAE ` +
|
|
934
1107
|
`${mae === null ? '(no report)' : mae.toFixed(4)}` +
|
|
935
1108
|
`${status === 0 ? '' : ' ⚠️ this skin did not come back'}` +
|
|
936
|
-
|
|
1109
|
+
// The mark compares figures, so it needs two of them: crowning the one
|
|
1110
|
+
// skin that WAS measured "the worst" says nothing and reads as a loss.
|
|
1111
|
+
`${!agreed && figures.length > 1 && mae !== null && mae === worst ? ` ⚠️ the worst of the ${figures.length} measured skins` : ''}`,
|
|
937
1112
|
);
|
|
938
1113
|
}
|
|
939
1114
|
if (agreed) emit(` ⤷ every skin came back at the same figure, so no skin is carrying a difference the others are not.`);
|
|
940
1115
|
}
|
|
941
|
-
|
|
1116
|
+
// 🔒 `not measured` is the one verdict that does not fail the run: nothing was
|
|
1117
|
+
// compared, and a round trip that ends red on a correct rig is the shape #608
|
|
1118
|
+
// removed from `build` (issue #621).
|
|
1119
|
+
const checksClean = checks.every((c) => c.verdict === 'not measured' || (c.verdict === 'measured' && c.status === 0));
|
|
942
1120
|
|
|
943
1121
|
emit('');
|
|
944
1122
|
emit('## 6 what the editor rewrote');
|