spine-rigc 0.26.0 → 0.28.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 +4 -4
- package/cli.ts +159 -10
- package/docs/AUTHORING.md +208 -6
- package/docs/INGEST.md +58 -2
- package/docs/SPEC_COVERAGE.md +1 -0
- package/package.json +1 -1
- package/src/diff.ts +235 -9
- package/src/generation.ts +138 -0
- package/src/ingest.ts +187 -0
- package/src/pose.ts +195 -21
- package/src/validate.ts +226 -24
package/docs/INGEST.md
CHANGED
|
@@ -237,7 +237,9 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
|
|
|
237
237
|
`diff` takes two compiled skeletons and reports 49 measures in eight groups, plus two
|
|
238
238
|
blocks that report and gate nothing: the `(reported)` measures beside `attachments`
|
|
239
239
|
and `animations`, and the `skeleton` header block at the top, which measures the stage
|
|
240
|
-
(issue #578).
|
|
240
|
+
(issue #578). A ninth group of six joins them when something has paired the two sides'
|
|
241
|
+
animations — `--as <candidate>=<reference>`, or one animation each side, which pairs by
|
|
242
|
+
position (§1.3.1). Both sides may be foreign; the interesting pairing during ingest is
|
|
241
243
|
**your transcription against the export it came from**:
|
|
242
244
|
|
|
243
245
|
```bash
|
|
@@ -326,6 +328,44 @@ attachment types and bone-binding shapes by draw-order position. That is how you
|
|
|
326
328
|
*"the same rig with a different vocabulary"* from *"a different rig"*, and §4.2 is the
|
|
327
329
|
recipe built on it.
|
|
328
330
|
|
|
331
|
+
#### 1.3.1 Animations differ by name too — `--as`
|
|
332
|
+
|
|
333
|
+
`bones` and `slots` are matched name-agnostically by their own shape. Animations have
|
|
334
|
+
none: the candidate's `take01` and the reference's `arcs` are the same shot only
|
|
335
|
+
because somebody says they are. Two things say it —
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
rigc diff work/t6/skeleton.json examples/6-arcs/export/6-arcs-pro.json --as take01=arcs
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
— and, with no flag, **one animation each side**, which pairs by position because there
|
|
342
|
+
is exactly one reading of which shot is which. `--as` is repeatable, one pair each, and
|
|
343
|
+
the candidate's name goes on the left, as it does in `bonedist`'s correspondence file.
|
|
344
|
+
|
|
345
|
+
With the pairing in hand the `animations` section reports two figures like the other
|
|
346
|
+
two sections, the second over the paired shots — `duration`, `timeline_kinds`,
|
|
347
|
+
`key_counts`, `curve_kinds`, `draw_order`, `deform` — and the heading says which pairing
|
|
348
|
+
it used:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
animations mean 0.222 over 9 measures
|
|
352
|
+
1.000 count 1/1 how many animations
|
|
353
|
+
0.000 names 0/2 the animation names
|
|
354
|
+
…
|
|
355
|
+
animations (name-agnostic) mean 1.000 over 6 measures — the same two skeletons compared with names thrown away, paired by position: take01=arcs, the one animation each side carries
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Read that pair exactly as you read `bones`'s: **1.000 beside `names` 0.000** says the
|
|
359
|
+
shot is right and its name is yours. ⛔ `names` never moves into the second block, and
|
|
360
|
+
with two shots on each side and no `--as`, the block is **absent** rather than paired by
|
|
361
|
+
declaration order — a candidate that declares its two shots the other way round would
|
|
362
|
+
then read 0.000 across it and the report would be calling a guess a measurement.
|
|
363
|
+
|
|
364
|
+
⚠️ An `--as` naming an animation a side does not have is **refused** with what that side
|
|
365
|
+
does have, and so is one that pairs the same animation twice. Neither is dropped
|
|
366
|
+
quietly: a typo that measured less than you asked for is a report about a pairing you
|
|
367
|
+
did not state.
|
|
368
|
+
|
|
329
369
|
### 1.4 `check` — the instrument that does see coordinates
|
|
330
370
|
|
|
331
371
|
```bash
|
|
@@ -476,6 +516,19 @@ a default the source left to the format and the rebuild writes out). A blocker e
|
|
|
476
516
|
non-zero and still writes both files. **Every code it can print has a row at the end
|
|
477
517
|
of this section**, with its gutter, its effect on the exit code and what to do.
|
|
478
518
|
|
|
519
|
+
⛔ **And it reads one generation.** Spine data is locked to the generation that
|
|
520
|
+
exported it, and a mismatch is silent rather than loud: 4.3 takes constraints from the
|
|
521
|
+
top-level `constraints` array alone, so a 4.0–4.2 file's `ik`/`transform`/`path`/
|
|
522
|
+
`physics` arrays load as nothing at all — 1,302 shipped skeletons parsed on a 4.3
|
|
523
|
+
runtime and loaded 0 of 8,672 constraints
|
|
524
|
+
([#706](https://github.com/firejune/rigc/issues/706) row 1). So `ingest` reads
|
|
525
|
+
`skeleton.spine` before it reads a field of the file, and a file from another
|
|
526
|
+
generation is a blocker naming that generation and counting, **on that file**, what a
|
|
527
|
+
4.3 reader loses by it. Reading such a file with *that generation's own* defaults is a
|
|
528
|
+
different job — #706's item 2, a per-generation table extracted by machine from each
|
|
529
|
+
runtime's `SkeletonJson` — and it is not in this tool, which is why the finding points
|
|
530
|
+
at the policy rather than implying the file was read.
|
|
531
|
+
|
|
479
532
|
**Two values are not in a skeleton**, so `ingest` asks rather than guesses:
|
|
480
533
|
|
|
481
534
|
- **the stage** (`skeleton.width`/`height`) — `--stage x,y,w,h` is how you supply one
|
|
@@ -540,6 +593,7 @@ is the one failure a comparison of two sets cannot show you.
|
|
|
540
593
|
| --- | --- | --- | --- | --- |
|
|
541
594
|
| `ANIMATION_GROUP` | `BLOCK` | 1 | the animation carries a group the motion spec has no home for. The detail names the ten it does carry. `drawOrderFolder` is the group to know about: the runtime reads it and builds a timeline from it, and no export in this corpus carries one | transcribe that group by hand (§2), or accept that the rebuild does not carry it |
|
|
542
595
|
| `ATTACHMENT_<TYPE>` | `BLOCK` | 1 | an attachment of a type rigc does not emit; the code is composed from the type, so on the one type left it reads `ATTACHMENT_POINT`. rigc emits region, mesh, linkedmesh, boundingbox, clipping and path — `linkedmesh` since [#691](https://github.com/firejune/rigc/issues/691), and `point` is the remaining deferred type | the rebuild will not have that attachment at all. `docs/SPEC_COVERAGE.md` part 1-6 says what a deferred type would carry |
|
|
596
|
+
| `ATTACHMENT_LINK_GEOMETRY` | `LOSS` | 0 | a **linked mesh** 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 the attachment draws the geometry its `source` names; the rig spec has no home for them either, because `build` refuses geometry on a link by name. The detail lists the keys and the source. Until [#710](https://github.com/firejune/rigc/issues/710) the rebuild dropped them with no line at all, so an `ingest` that normalised somebody's file said nothing about it | nothing. The rebuild is the mesh the runtime was already drawing — and if those keys were the geometry you meant, take `source` off and author it as a mesh of its own. `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` is the same fact at the gate |
|
|
543
597
|
| `ATTACHMENT_NAME` | `LOSS` | 0 | the attachment states a `name` and only **one** skin fills the placeholder, so rigc writes none — it composes `<skin>/<placeholder>` exactly where a placeholder is contested | nothing, unless something downstream looks that attachment up by the name the source gave it |
|
|
544
598
|
| `ATTACHMENT_SEQUENCE` | `BLOCK` | 1 | the attachment carries a `sequence` block — a numbered image series — which the rig spec cannot say | the rebuild draws the single region the attachment names; the frames have to be driven some other way |
|
|
545
599
|
| `ATTACHMENT_TIMELINE` | `BLOCK` | 1 | an attachment timeline other than `deform`, which is the only one the motion spec carries | transcribe it, or accept that the rebuild does not play it |
|
|
@@ -549,9 +603,11 @@ is the one failure a comparison of two sets cannot show you.
|
|
|
549
603
|
| `CONSTRAINT_KEY_RESTATED` | `LOSS` | 0 | an `ik` or `transform` track whose keys do not all state the same fields. The motion spec takes one field set per track, so a field **any** key states is written on **every** key at the value the parser would have read there | nothing. Same values, larger file — the rebuild plays what the source plays |
|
|
550
604
|
| `CONSTRAINT_TYPE` | `BLOCK` | 1 | a constraint whose `type` is none rigc knows, so the whole constraint is dropped rather than approximated | the rebuild has no such constraint; check the spelling before assuming the type is unsupported |
|
|
551
605
|
| `DURATION` | `JUDGE` | 0 | skeleton JSON has no duration field at all. The largest key time is used, which is what a runtime plays to — and wrong for an animation that holds its last pose past its last key | if you know the real number, edit `duration` in the motion spec. It costs nothing: the declared duration is checked against the compiled keys |
|
|
606
|
+
| `GENERATION_UNKNOWN` | `BLOCK` | 1 | `skeleton.spine` names no generation rigc knows, or the header states none at all. A version is read as its LEADING `major.minor` token — a down-export writes `4.0-from-4.1.24`, which is 4.0 data from a 4.1 editor — and it is never rounded to the nearest generation: a catalog that rounded handed 19 skeletons labelled `3.8.99` a 4.2 runtime and every one posed as NaN ([#706](https://github.com/firejune/rigc/issues/706) row 7) | check the string against the file you were handed. A real generation rigc does not list belongs on #706 item 1, with the string beside it |
|
|
607
|
+
| `GENERATION_UNSUPPORTED` | `BLOCK` | 1 | the file is Spine data from another generation and this reader reads 4.3. The detail names the generation, the string it was read from, and what a 4.3 reader loses on **this** file: constraints parked in the top-level `ik` / `transform` / `path` / `physics` / `slider` arrays 4.3 folded into `constraints` and this reader never opens (row 1), bones carrying 4.2's `transform` where 4.3 spells `inherit` (row 6), and physics constraints omitting `inertia` / `damping`, whose default is not the same number in 4.2 as in 4.3 (row 4) | re-export the file as 4.3 from an editor of its own generation, or transcribe it by hand (§2). Reading it with **that generation's** defaults is #706 item 2 and is not in this tool |
|
|
552
608
|
| `HEADER_BOOKKEEPING` | `LOSS` | 0 | a header field the editor writes and the rig spec has no home for — `hash`, `audio`. Dropped, and nothing reads it back | nothing. It is one of the three differences §2.3 measures on every editor export |
|
|
553
609
|
| `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 |
|
|
554
|
-
| `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 |
|
|
610
|
+
| `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 |
|
|
555
611
|
| `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 |
|
|
556
612
|
| `NO_STAGE` | `BLOCK` `JUDGE` | 1 | the skeleton declares no stage. It is a blocker with no `--stage`, and a **judgement** — exit 0 — when `--stage x,y,w,h` supplies one, because nothing measured the box you gave it | supply the box from the project the file came from. It cannot be derived: posing the rig gives the animated extent, which is a different number |
|
|
557
613
|
| `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 |
|
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -736,6 +736,7 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
|
|
|
736
736
|
| `A11_NO_CLIPPING_ATTACHMENTS` | **renderer-profile** | clipping attachments — "the renderer skips them silently" |
|
|
737
737
|
| `A12_NO_DARK_COLOR` | **renderer-profile** | slot `dark`, `rgba2`/`rgb2` timelines — "parsed, then ignored". ⚠️ rigc **emits** the first two; a renderer that drops a construct is what a profile is for, not a reason not to emit it |
|
|
738
738
|
| `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | validity | a slot `dark` the parser drops or reads as NaN, an `rgba2` timeline on a slot with no dark colour to pose, or a key whose posed light/dark is not what it states |
|
|
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 |
|
|
739
740
|
| `A13_MESH_BUDGET` | **renderer-profile** | >4 mesh slots, >80 triangles per mesh |
|
|
740
741
|
| `A14_NO_FULL_FRAME_MESH` | **renderer-profile** | a mesh spanning the whole stage |
|
|
741
742
|
| `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.28.0",
|
|
4
4
|
"description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/diff.ts
CHANGED
|
@@ -46,6 +46,34 @@
|
|
|
46
46
|
* wrong; name-agnostic low alone is impossible, since a wrong shape cannot
|
|
47
47
|
* have right names.
|
|
48
48
|
*
|
|
49
|
+
* ⭐ `animations` carries the same second comparison, and it arrived last
|
|
50
|
+
* because it needs something the other two do not: a PAIRING. A bone is
|
|
51
|
+
* paired with a bone by its depth and its child count, which the file
|
|
52
|
+
* states; two animations have no such shape to be matched on, so the
|
|
53
|
+
* candidate's `take01` and the reference's `arcs` are the same shot only
|
|
54
|
+
* because somebody says they are. Until that was said, every animation
|
|
55
|
+
* measure was keyed on the name — and a candidate that followed a brief
|
|
56
|
+
* withholding it read `count` 1/1 and **0.000 on all eight measures below**,
|
|
57
|
+
* on a shot with the same duration, the same timeline families and the same
|
|
58
|
+
* key counts (issue #720). That is the *gate that cannot be passed* shape on
|
|
59
|
+
* the measuring instrument rather than on the gate.
|
|
60
|
+
*
|
|
61
|
+
* Two things say it, and nothing else does. `--as <candidate>=<reference>`
|
|
62
|
+
* pairs them outright; failing that, **one animation each side** pairs by
|
|
63
|
+
* position, because there is exactly one reading of which shot is which and
|
|
64
|
+
* no name is consulted to reach it. Anything else — two against two, three
|
|
65
|
+
* against one — has several readings, so the block is ABSENT rather than
|
|
66
|
+
* guessed at, which is what `DiffSection.nameAgnostic` means by *"should say
|
|
67
|
+
* so by having none"*. ⚠️ Guessing there is the failure this file is built
|
|
68
|
+
* against: pairing two-against-two by position would score a candidate whose
|
|
69
|
+
* two shots are declared in the other order 0.000 across the block and call
|
|
70
|
+
* it a measurement.
|
|
71
|
+
*
|
|
72
|
+
* 🔒 `names` stays in the name-matched block alone, and that is the whole
|
|
73
|
+
* point of the split rather than an oversight: the pair is read as *agnostic
|
|
74
|
+
* 1.000 with `names` 0.000*, which says the shot is right and its name is the
|
|
75
|
+
* author's own.
|
|
76
|
+
*
|
|
49
77
|
* 4. **A measure that cannot gate is not in the mean.** `section.reported`
|
|
50
78
|
* carries the measures `docs/GATE.md`'s *What never gates* calls
|
|
51
79
|
* unobservable by construction — *"could any reading of the frames have
|
|
@@ -121,6 +149,18 @@ export interface DiffAgnostic {
|
|
|
121
149
|
/** Unweighted mean of the measures below. NOT a quality score either. */
|
|
122
150
|
ratio: number;
|
|
123
151
|
measures: DiffMeasure[];
|
|
152
|
+
/**
|
|
153
|
+
* How the two sides were put against each other, for a block that had to
|
|
154
|
+
* choose — `animations` alone today. Absent where the correspondence is the
|
|
155
|
+
* elements themselves and there was nothing to decide.
|
|
156
|
+
*
|
|
157
|
+
* ⚠️ It is data rather than a caption. A block whose figures depend on a
|
|
158
|
+
* pairing, printed without the pairing beside it, is a measurement of
|
|
159
|
+
* something the reader cannot name — and the two pairings say different
|
|
160
|
+
* things: `--as` is the caller's claim, position is this file's reading of a
|
|
161
|
+
* one-against-one roster.
|
|
162
|
+
*/
|
|
163
|
+
pairedBy?: string;
|
|
124
164
|
}
|
|
125
165
|
|
|
126
166
|
/**
|
|
@@ -279,12 +319,21 @@ function sectionOf(
|
|
|
279
319
|
measures: DiffMeasure[],
|
|
280
320
|
nameAgnostic?: DiffMeasure[],
|
|
281
321
|
reported?: DiffMeasure[],
|
|
322
|
+
pairedBy?: string,
|
|
282
323
|
): DiffSection {
|
|
283
324
|
return {
|
|
284
325
|
name,
|
|
285
326
|
ratio: meanRatio(measures),
|
|
286
327
|
measures,
|
|
287
|
-
...(nameAgnostic === undefined
|
|
328
|
+
...(nameAgnostic === undefined
|
|
329
|
+
? {}
|
|
330
|
+
: {
|
|
331
|
+
nameAgnostic: {
|
|
332
|
+
ratio: meanRatio(nameAgnostic),
|
|
333
|
+
measures: nameAgnostic,
|
|
334
|
+
...(pairedBy === undefined ? {} : { pairedBy }),
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
288
337
|
...(reported === undefined ? {} : { reported: { measures: reported } }),
|
|
289
338
|
};
|
|
290
339
|
}
|
|
@@ -905,11 +954,154 @@ function keyingTotals(f: AnimationFacts): KeyingTotals {
|
|
|
905
954
|
return { keys: sum(f.keys), timelines: sum(f.kinds), seconds: sum(f.duration) };
|
|
906
955
|
}
|
|
907
956
|
|
|
908
|
-
|
|
957
|
+
/** One animation on each side, said to be the same shot. */
|
|
958
|
+
export interface DiffAnimationPair {
|
|
959
|
+
candidate: string;
|
|
960
|
+
reference: string;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* What a caller may tell `diffSkeletons` that neither file can say itself.
|
|
965
|
+
*
|
|
966
|
+
* Only the animation pairing today, and it is an INPUT in the sense
|
|
967
|
+
* `bonedist`'s correspondence file is one: two skeletons cannot derive which of
|
|
968
|
+
* their shots are the same shot, so a value worked out here would be a guess
|
|
969
|
+
* reported as a measurement.
|
|
970
|
+
*/
|
|
971
|
+
export interface DiffOptions {
|
|
972
|
+
/** `--as <candidate>=<reference>`, in the order the caller stated them. */
|
|
973
|
+
animationPairs?: readonly DiffAnimationPair[];
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* The pairing used for `animations.agnostic.*`, or `null` when there is none.
|
|
978
|
+
*
|
|
979
|
+
* ⚠️ A stated pair naming an animation a side does not have is DROPPED and said
|
|
980
|
+
* so in `pairedBy`, rather than silently making the block narrower. `cmdDiff`
|
|
981
|
+
* refuses one by name before this is reached, so through the CLI the branch is
|
|
982
|
+
* unreachable; it exists because this module is exported and a caller of the
|
|
983
|
+
* API can state one.
|
|
984
|
+
*/
|
|
985
|
+
function pairAnimations(
|
|
986
|
+
a: AnimationFacts,
|
|
987
|
+
b: AnimationFacts,
|
|
988
|
+
stated: readonly DiffAnimationPair[],
|
|
989
|
+
): { pairs: DiffAnimationPair[]; pairedBy: string } | null {
|
|
990
|
+
const spell = (p: DiffAnimationPair): string => `${p.candidate}=${p.reference}`;
|
|
991
|
+
if (stated.length > 0) {
|
|
992
|
+
const usable = stated.filter((p) => a.duration.has(p.candidate) && b.duration.has(p.reference));
|
|
993
|
+
if (usable.length === 0) return null;
|
|
994
|
+
const dropped = stated.filter((p) => !usable.includes(p));
|
|
995
|
+
return {
|
|
996
|
+
pairs: [...usable],
|
|
997
|
+
pairedBy:
|
|
998
|
+
`paired by --as: ${usable.map(spell).join(', ')}` +
|
|
999
|
+
(dropped.length === 0 ? '' : `; ${dropped.map(spell).join(', ')} named an animation a side does not have and was dropped`),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
if (a.names.length === 1 && b.names.length === 1) {
|
|
1003
|
+
const pair = { candidate: a.names[0], reference: b.names[0] };
|
|
1004
|
+
return { pairs: [pair], pairedBy: `paired by position: ${spell(pair)}, the one animation each side carries` };
|
|
1005
|
+
}
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* `f` restricted to the animations `label` names, with each one's name replaced
|
|
1011
|
+
* by the label — `#0` for the first pair, `#1` for the second.
|
|
1012
|
+
*
|
|
1013
|
+
* That substitution is the whole of what makes the block name-agnostic: the
|
|
1014
|
+
* measures below are the name-matched ones run again over facts whose keys are
|
|
1015
|
+
* positions in the pairing. Everything else about them — the tolerance, the
|
|
1016
|
+
* denominators, the histogram — is unchanged, which is what lets the two blocks
|
|
1017
|
+
* be read against each other.
|
|
1018
|
+
*/
|
|
1019
|
+
function underLabels(f: AnimationFacts, label: ReadonlyMap<string, string>): AnimationFacts {
|
|
1020
|
+
const keyed = <T>(m: Map<string, T>): Map<string, T> => {
|
|
1021
|
+
const out = new Map<string, T>();
|
|
1022
|
+
for (const [anim, v] of m) {
|
|
1023
|
+
const to = label.get(anim);
|
|
1024
|
+
if (to !== undefined) out.set(to, v);
|
|
1025
|
+
}
|
|
1026
|
+
return out;
|
|
1027
|
+
};
|
|
1028
|
+
// `kinds`, `keys` and `curves` are keyed `<anim>|<rest>`, so only the head is
|
|
1029
|
+
// relabelled and the tail — the timeline's kind, the curve's shape — is what
|
|
1030
|
+
// the histogram then intersects on.
|
|
1031
|
+
const prefixed = (m: Map<string, number>): Map<string, number> => {
|
|
1032
|
+
const out = new Map<string, number>();
|
|
1033
|
+
for (const [k, v] of m) {
|
|
1034
|
+
const bar = k.indexOf('|');
|
|
1035
|
+
const to = label.get(k.slice(0, bar));
|
|
1036
|
+
if (to === undefined) continue;
|
|
1037
|
+
const id = `${to}${k.slice(bar)}`;
|
|
1038
|
+
out.set(id, (out.get(id) ?? 0) + v);
|
|
1039
|
+
}
|
|
1040
|
+
return out;
|
|
1041
|
+
};
|
|
1042
|
+
return {
|
|
1043
|
+
names: [...label.values()],
|
|
1044
|
+
duration: keyed(f.duration),
|
|
1045
|
+
kinds: prefixed(f.kinds),
|
|
1046
|
+
keys: prefixed(f.keys),
|
|
1047
|
+
curves: prefixed(f.curves),
|
|
1048
|
+
events: keyed(f.events),
|
|
1049
|
+
hasDrawOrder: keyed(f.hasDrawOrder),
|
|
1050
|
+
hasDeform: keyed(f.hasDeform),
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The six measures of `animations.agnostic.*`.
|
|
1056
|
+
*
|
|
1057
|
+
* ⛔ `names` is not among them, by construction — a block that threw the names
|
|
1058
|
+
* away cannot then compare them. ⛔ Neither is `count`, which `bones` and
|
|
1059
|
+
* `slots` do carry, and the difference is what the block is OVER: those two
|
|
1060
|
+
* compare whole rosters, so their agnostic half has the same subject as their
|
|
1061
|
+
* name-matched half and restates the count for a reader with one block open.
|
|
1062
|
+
* This one is over the PAIRS. A `count` in it would either restate the roster
|
|
1063
|
+
* figure — a different subject under the same heading — or count the pairs,
|
|
1064
|
+
* which measures the flag rather than the two rigs. The roster figure is
|
|
1065
|
+
* `animations.count`, and it is already name-free.
|
|
1066
|
+
*/
|
|
1067
|
+
function agnosticAnimationMeasures(a: AnimationFacts, b: AnimationFacts, pairs: readonly DiffAnimationPair[]): DiffMeasure[] {
|
|
1068
|
+
const slot = (i: number): string => `#${i}`;
|
|
1069
|
+
const ca = underLabels(a, new Map(pairs.map((p, i) => [p.candidate, slot(i)])));
|
|
1070
|
+
const rb = underLabels(b, new Map(pairs.map((p, i) => [p.reference, slot(i)])));
|
|
1071
|
+
return [
|
|
1072
|
+
agreement(
|
|
1073
|
+
'animations.agnostic.duration',
|
|
1074
|
+
'each paired animation runs as long (last key time, within one frame)',
|
|
1075
|
+
ca.duration,
|
|
1076
|
+
rb.duration,
|
|
1077
|
+
(x, y) => Math.abs(x - y) <= FRAME,
|
|
1078
|
+
),
|
|
1079
|
+
histogram('animations.agnostic.timeline_kinds', 'the same timelines exist in the paired animations', ca.kinds, rb.kinds),
|
|
1080
|
+
histogram('animations.agnostic.key_counts', 'those timelines carry as many keys', ca.keys, rb.keys),
|
|
1081
|
+
histogram('animations.agnostic.curve_kinds', 'as many linear / stepped / bezier keys', ca.curves, rb.curves),
|
|
1082
|
+
agreement(
|
|
1083
|
+
'animations.agnostic.draw_order',
|
|
1084
|
+
'a draw-order timeline is present or absent alike',
|
|
1085
|
+
ca.hasDrawOrder,
|
|
1086
|
+
rb.hasDrawOrder,
|
|
1087
|
+
(x, y) => x === y,
|
|
1088
|
+
),
|
|
1089
|
+
agreement(
|
|
1090
|
+
'animations.agnostic.deform',
|
|
1091
|
+
'a deform timeline is present or absent alike',
|
|
1092
|
+
ca.hasDeform,
|
|
1093
|
+
rb.hasDeform,
|
|
1094
|
+
(x, y) => x === y,
|
|
1095
|
+
),
|
|
1096
|
+
];
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPair[]): DiffSection {
|
|
909
1100
|
const a = animationFacts(c);
|
|
910
1101
|
const b = animationFacts(r);
|
|
911
1102
|
const at = keyingTotals(a);
|
|
912
1103
|
const bt = keyingTotals(b);
|
|
1104
|
+
const paired = pairAnimations(a, b, pairsStated);
|
|
913
1105
|
const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
|
|
914
1106
|
const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
|
|
915
1107
|
return sectionOf('animations', [
|
|
@@ -929,7 +1121,14 @@ function diffAnimations(c: Json, r: Json): DiffSection {
|
|
|
929
1121
|
agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
|
|
930
1122
|
agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
|
|
931
1123
|
],
|
|
932
|
-
|
|
1124
|
+
// ── the same two skeletons' shots, paired rather than named (issue #720) ──
|
|
1125
|
+
//
|
|
1126
|
+
// Absent unless something pairs them — see `pairAnimations` and the header's
|
|
1127
|
+
// point 3. `undefined` and not `[]`: a block with no measures in it prints a
|
|
1128
|
+
// vacuous `mean 1.000 over 0 measures`, which is the false green this whole
|
|
1129
|
+
// file is built to refuse, and `movedAgnosticMeasures` cannot tell it from a
|
|
1130
|
+
// block that agreed about everything.
|
|
1131
|
+
paired === null ? undefined : agnosticAnimationMeasures(a, b, paired.pairs),
|
|
933
1132
|
// ── reported (issue #20) ────────────────────────────────────────────────
|
|
934
1133
|
//
|
|
935
1134
|
// 🔍 What #20 asked and what was actually wrong. The issue proposed making key
|
|
@@ -981,7 +1180,8 @@ function diffAnimations(c: Json, r: Json): DiffSection {
|
|
|
981
1180
|
`(${at.timelines} vs ${bt.timelines}), compared as min/max at ${RATE_PLACES} decimal places. Read beside ` +
|
|
982
1181
|
'`key_density`: this one alone moving means the same keying spread over a different number of timelines.',
|
|
983
1182
|
),
|
|
984
|
-
]
|
|
1183
|
+
],
|
|
1184
|
+
paired?.pairedBy);
|
|
985
1185
|
}
|
|
986
1186
|
|
|
987
1187
|
function eventFacts(root: Json): Map<string, string> {
|
|
@@ -1168,11 +1368,19 @@ function orientation(root: Json): Record<string, number> {
|
|
|
1168
1368
|
};
|
|
1169
1369
|
}
|
|
1170
1370
|
|
|
1171
|
-
export function diffSkeletons(candidate: unknown, reference: unknown): DiffReport {
|
|
1371
|
+
export function diffSkeletons(candidate: unknown, reference: unknown, options?: DiffOptions): DiffReport {
|
|
1172
1372
|
const c = isObj(candidate) ? candidate : {};
|
|
1173
1373
|
const r = isObj(reference) ? reference : {};
|
|
1374
|
+
const animationPairs = options?.animationPairs ?? [];
|
|
1174
1375
|
return {
|
|
1175
|
-
sections: [
|
|
1376
|
+
sections: [
|
|
1377
|
+
diffBones(c, r),
|
|
1378
|
+
diffSlots(c, r),
|
|
1379
|
+
diffAttachments(c, r),
|
|
1380
|
+
diffConstraints(c, r),
|
|
1381
|
+
diffAnimations(c, r, animationPairs),
|
|
1382
|
+
diffEvents(c, r),
|
|
1383
|
+
],
|
|
1176
1384
|
header: diffHeader(c, r),
|
|
1177
1385
|
candidate: orientation(c),
|
|
1178
1386
|
reference: orientation(r),
|
|
@@ -1466,8 +1674,18 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
|
|
|
1466
1674
|
);
|
|
1467
1675
|
lines.push(...measureLines(report.header.measures, 'skeleton.'.length));
|
|
1468
1676
|
lines.push('');
|
|
1469
|
-
// Wide enough for
|
|
1470
|
-
//
|
|
1677
|
+
// Wide enough for `bones (name-agnostic)` and `animations (reported)`, both
|
|
1678
|
+
// exactly 21, so that most of a section's headings line their figures up
|
|
1679
|
+
// under each other and read as a pair.
|
|
1680
|
+
//
|
|
1681
|
+
// ⚠️ Two headings are longer and push their own figure right instead:
|
|
1682
|
+
// `attachments (reported)`, which has done so since that block existed, and
|
|
1683
|
+
// `animations (name-agnostic)` (issue #720). Widening the column is the
|
|
1684
|
+
// obvious repair and it is the wrong one — it moves every heading line of
|
|
1685
|
+
// every report, and those lines are quoted verbatim in `docs/LADDER.md` and
|
|
1686
|
+
// in the landed run records under `bench/runs/`, which are sealed. A
|
|
1687
|
+
// cosmetic alignment is not worth a byte change in every transcript already
|
|
1688
|
+
// written, and the overflow is visible rather than silent.
|
|
1471
1689
|
const head = (label: string, ratio: number, n: number): string =>
|
|
1472
1690
|
` ${label.padEnd(21)} mean ${fmt(ratio)} over ${n} measures`;
|
|
1473
1691
|
for (const section of report.sections) {
|
|
@@ -1478,7 +1696,9 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
|
|
|
1478
1696
|
lines.push('');
|
|
1479
1697
|
lines.push(
|
|
1480
1698
|
`${head(`${section.name} (name-agnostic)`, agnostic.ratio, agnostic.measures.length)}` +
|
|
1481
|
-
' — the same two skeletons compared with names thrown away'
|
|
1699
|
+
' — the same two skeletons compared with names thrown away' +
|
|
1700
|
+
// The pairing is part of the figure, not decoration: see `DiffAgnostic.pairedBy`.
|
|
1701
|
+
(agnostic.pairedBy === undefined ? '' : `, ${agnostic.pairedBy}`),
|
|
1482
1702
|
);
|
|
1483
1703
|
lines.push(...measureLines(agnostic.measures, section.name.length + '.agnostic.'.length));
|
|
1484
1704
|
}
|
|
@@ -1506,6 +1726,12 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
|
|
|
1506
1726
|
lines.push(' comparisons, not two halves of one: name-agnostic 1.000 beside a low');
|
|
1507
1727
|
lines.push(' name-matched figure means the shape is right and the vocabulary differs.');
|
|
1508
1728
|
lines.push('');
|
|
1729
|
+
lines.push(' `animations` carries the same pair, and only once something has PAIRED the two');
|
|
1730
|
+
lines.push(' sides\' shots: `--as <candidate>=<reference>`, or one animation each side, which');
|
|
1731
|
+
lines.push(' pairs by position. With neither there is no reading of which shot is which, so');
|
|
1732
|
+
lines.push(' the block is absent rather than guessed — and its absence beside `names` 0.000');
|
|
1733
|
+
lines.push(' is the report saying the candidate named its shots itself and nothing said how.');
|
|
1734
|
+
lines.push('');
|
|
1509
1735
|
lines.push(' `skeleton` is the file\'s own header block and reports two measures for the stage.');
|
|
1510
1736
|
lines.push(' It has no mean for the reason a `(reported)` block never does, and it never');
|
|
1511
1737
|
lines.push(' gates for two: no reading of the frames recovers a setup-pose bounding box, and');
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Spine data generation a skeleton's own version string names.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is one function and not a regex per caller
|
|
5
|
+
*
|
|
6
|
+
* Spine data is locked to the generation that exported it, and a mismatch fails
|
|
7
|
+
* **silently**: 4.3 takes constraints from the top-level `constraints` array
|
|
8
|
+
* alone, so a 4.0–4.2 file parses clean with none of them — measured across
|
|
9
|
+
* 1,302 shipped skeletons that loaded 0 of 8,672 constraints
|
|
10
|
+
* ([#706](https://github.com/firejune/rigc/issues/706) row 1). rigc is where
|
|
11
|
+
* that knowledge lives for the tools around it (#706 *Ownership*), and living
|
|
12
|
+
* in one place means one reader of `skeleton.spine`:
|
|
13
|
+
* `A16_SKELETON_VERSION_4_3` asks this function for its verdict, and `ingest`
|
|
14
|
+
* asks it before it reads a field of the file.
|
|
15
|
+
*
|
|
16
|
+
* ## What is here, and what is deliberately not
|
|
17
|
+
*
|
|
18
|
+
* This is #706's item **1**, and nothing else. Item **2** — the per-generation
|
|
19
|
+
* table of key renames, array shapes and `getValue(map, key, default)` defaults,
|
|
20
|
+
* extracted by machine from each runtime branch's `SkeletonJson` — is not here,
|
|
21
|
+
* and neither is item **4**'s conversion utility. So this module reads a string
|
|
22
|
+
* and names a generation. It does not read a file, apply another generation's
|
|
23
|
+
* defaults, or convert anything, and a caller that meets data from another
|
|
24
|
+
* generation has to say so out loud rather than read it anyway.
|
|
25
|
+
*
|
|
26
|
+
* ## The grammar, and the two rules in it
|
|
27
|
+
*
|
|
28
|
+
* A version string is `MAJOR.MINOR`, an optional chain of `-from-MAJOR.MINOR`,
|
|
29
|
+
* then an optional `.PATCH` and an optional `-SUFFIX` after that. The shipped
|
|
30
|
+
* strings #706 lists are `3.8.99`, `4.0.33`, `4.0-from-4.1.24`,
|
|
31
|
+
* `4.0-from-4.1-from-4.2.29`, `4.1-from-4.2.33`, `4.2.09-beta`, `4.2.43`,
|
|
32
|
+
* `4.3.26` and `4.3.75-beta`.
|
|
33
|
+
*
|
|
34
|
+
* 1. **The leading token is the generation.** `4.0-from-4.1.24` is 4.0 data
|
|
35
|
+
* written by a 4.1 editor — a *down-export* — and all 418 such-or-plain 4.0
|
|
36
|
+
* files and 101 4.1 files in #706's corpus load and render on the 4.0 / 4.1
|
|
37
|
+
* runtimes with 0 failures. Reading the trailing token would hand them the
|
|
38
|
+
* wrong runtime, which is row 7's defect with extra steps.
|
|
39
|
+
* 2. **Every token has to be a generation this module knows, and the chain has
|
|
40
|
+
* to ascend.** A down-export comes *from* a newer editor, so `4.0-from-4.1`
|
|
41
|
+
* is a down-export and `4.3-from-4.2` is not a version string this reader
|
|
42
|
+
* can account for. Both rules answer `null`, which is rule 3.
|
|
43
|
+
* 3. **Unknown is `null`, never the nearest.** A catalog builder gave 19
|
|
44
|
+
* skeletons labelled `3.8.99` the nearest runtime it had; they loaded, and
|
|
45
|
+
* posed 238 of 248 bones as NaN (#706 row 7). `null` is what a caller has to
|
|
46
|
+
* act on, and it is why this returns a union rather than a number to compare.
|
|
47
|
+
*
|
|
48
|
+
* ⭐ Rules 2 and 3 together are also what keeps `A16`'s accepted set **exactly**
|
|
49
|
+
* what its own regex accepted before this module existed: 4.3 is the highest
|
|
50
|
+
* generation here, nothing can ascend above it, so no `-from-` string is ever
|
|
51
|
+
* read as 4.3 and `A16` still accepts `4.3`, `4.3.<patch>` and
|
|
52
|
+
* `4.3.<patch>-<suffix>` and those alone. The cost is stated rather than hidden:
|
|
53
|
+
* a future editor down-exporting as `4.3-from-4.4.1` reads as `null` here and is
|
|
54
|
+
* refused by name until that generation is added — which is #706 policy 1's own
|
|
55
|
+
* answer ("an unknown generation gets no runtime") rather than a gap in this one.
|
|
56
|
+
*
|
|
57
|
+
* ## Purity
|
|
58
|
+
*
|
|
59
|
+
* No clock, no randomness, no filesystem, no network, no `spine-core`. It reads
|
|
60
|
+
* strings and small plain objects and nothing else.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/** A generation of Spine data — the `MAJOR.MINOR` pair a runtime is locked to. */
|
|
64
|
+
export type SpineGeneration = '3.8' | '4.0' | '4.1' | '4.2' | '4.3';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every generation this module knows, oldest first.
|
|
68
|
+
*
|
|
69
|
+
* The order is load-bearing twice: a down-export chain has to ascend through it,
|
|
70
|
+
* and a caller listing "the generations rigc knows" reads it here rather than
|
|
71
|
+
* typing five strings next to a sixth.
|
|
72
|
+
*/
|
|
73
|
+
export const SPINE_GENERATIONS: readonly SpineGeneration[] = ['3.8', '4.0', '4.1', '4.2', '4.3'];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `MAJOR.MINOR`, then the `-from-` chain, then the patch and its suffix.
|
|
77
|
+
*
|
|
78
|
+
* Anchored at both ends on purpose: `4.30` and `4.3.1.2` are not version strings
|
|
79
|
+
* and a partial match would read them as 4.3. The suffix shape is the one the
|
|
80
|
+
* editor writes for a pre-release (`4.3.75-beta`, which every one of the twelve
|
|
81
|
+
* official example exports declares) and is the same one `A16`'s own regex
|
|
82
|
+
* carried, character for character, before this module took it over.
|
|
83
|
+
*/
|
|
84
|
+
const VERSION_STRING = /^(\d+\.\d+)((?:-from-\d+\.\d+)*)(?:\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.+-]*)?)?$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The generation a `skeleton.spine` string names, or `null` for one this module
|
|
88
|
+
* cannot account for.
|
|
89
|
+
*
|
|
90
|
+
* `null` is never the nearest generation and never a guess — see rule 3 above.
|
|
91
|
+
*/
|
|
92
|
+
export function spineGeneration(version: string): SpineGeneration | null {
|
|
93
|
+
const match = VERSION_STRING.exec(version);
|
|
94
|
+
if (match === null) return null;
|
|
95
|
+
const chain = match[2] === '' ? [] : match[2].split('-from-').slice(1);
|
|
96
|
+
const known = SPINE_GENERATIONS as readonly string[];
|
|
97
|
+
const steps = [match[1], ...chain].map((token) => known.indexOf(token));
|
|
98
|
+
if (steps.some((at) => at < 0)) return null;
|
|
99
|
+
for (let i = 1; i < steps.length; i++) if (steps[i] <= steps[i - 1]) return null;
|
|
100
|
+
return SPINE_GENERATIONS[steps[0]];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The constraint kinds a skeleton can carry as a **top-level array**, which 4.3
|
|
105
|
+
* folded into one `constraints` array with a `type` on each entry.
|
|
106
|
+
*
|
|
107
|
+
* 4.3 reads `constraints` and nothing else, so an array under any of these names
|
|
108
|
+
* loads without an error and the constraints in it are simply not there — #706
|
|
109
|
+
* row 1, and what `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` refuses in emitted
|
|
110
|
+
* data. `slider` never had a top-level form (sliders are 4.3's own), and it is
|
|
111
|
+
* on this list for the same reason as the other four: a constraint parked
|
|
112
|
+
* outside `constraints` vanishes whatever its kind, and a list with a hole in it
|
|
113
|
+
* is a rule with a hole in it.
|
|
114
|
+
*/
|
|
115
|
+
export const TOPLEVEL_CONSTRAINT_ARRAYS: readonly string[] = ['ik', 'transform', 'path', 'physics', 'slider'];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The bone key 4.2 spelled `transform` and 4.3 spells `inherit`.
|
|
119
|
+
*
|
|
120
|
+
* The old key does not throw and does not warn — it is an unknown field, so the
|
|
121
|
+
* bone falls back to Normal inheritance (#706 row 6, and
|
|
122
|
+
* `A02_NO_BONE_TRANSFORM_KEY`).
|
|
123
|
+
*/
|
|
124
|
+
export const LEGACY_BONE_INHERIT_KEY = 'transform';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The physics-constraint fields whose **omitted default** is not the same number
|
|
128
|
+
* in 4.2 as in 4.3.
|
|
129
|
+
*
|
|
130
|
+
* JSON omits a field equal to the parser's default, so the same file means two
|
|
131
|
+
* different constraints under two readers: `inertia` and `damping` both default
|
|
132
|
+
* to 1 in 4.2's `SkeletonJson` and to 0.5 and 0.85 in 4.3's (#706 row 4, which
|
|
133
|
+
* also counts 111 shipped constraints omitting `inertia`). The numbers are not
|
|
134
|
+
* repeated here deliberately — a hand-copied table is what #706 policy 3 exists
|
|
135
|
+
* to refuse, and item 2's generated table is where they belong. What a caller
|
|
136
|
+
* needs from this list is which fields to *count*, and that is what it is.
|
|
137
|
+
*/
|
|
138
|
+
export const PHYSICS_FIELDS_WHOSE_DEFAULT_MOVED: readonly string[] = ['inertia', 'damping'];
|