spine-rigc 2.0.2 → 2.0.3
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 +3 -1
- package/docs/AUTHORING.md +18 -0
- package/docs/INGEST.md +29 -20
- package/package.json +1 -1
- package/src/compile.ts +4 -1
- package/src/diff.ts +327 -5
- package/src/validate.ts +90 -7
- package/tools/editor_roundtrip.ts +75 -1
package/README.md
CHANGED
|
@@ -680,7 +680,9 @@ none of it; a skin only one side declares is a FAIL naming it as **lost** (or
|
|
|
680
680
|
**added**) **by the export**, with `diff`'s `attachments.skins` beside it, and is
|
|
681
681
|
rendered on neither side — and a
|
|
682
682
|
field-by-field list of what the editor rewrote. Every step quotes what its child
|
|
683
|
-
said when that child did not do what it was for, the renderers included
|
|
683
|
+
said when that child did not do what it was for, the renderers included — the
|
|
684
|
+
editor's words in full and in order, except its `Licensed to:` line, which names
|
|
685
|
+
the licence holder and is replaced by a line saying it was withheld; a skin
|
|
684
686
|
**neither** side can draw — a hit-box rig, say — is a **SKIP** naming that, not a
|
|
685
687
|
red, because `check` had nothing to compare and `diff` and `validate` have
|
|
686
688
|
already measured the rig. One side drawing where the other does not is the
|
package/docs/AUTHORING.md
CHANGED
|
@@ -1008,6 +1008,24 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
|
|
|
1008
1008
|
[INGEST.md](INGEST.md) §1.3.1, and *The measure inventory* in
|
|
1009
1009
|
[BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md),
|
|
1010
1010
|
which is repository material rather than part of this package.
|
|
1011
|
+
|
|
1012
|
+
🔍 **The wiring is compared by name too, not only the rosters** (issue #1085).
|
|
1013
|
+
`constraints.order` reads the order of the one constraint array, which is the
|
|
1014
|
+
order the runtime applies it in; `animations.targets` reads what each timeline
|
|
1015
|
+
keys and `animations.keyed_names` what each key names — an attachment key's
|
|
1016
|
+
attachment, a draw-order offset's slot, an event key's event; `attachments.refs`
|
|
1017
|
+
reads the region an attachment draws, a clipping polygon's `end` and a linked
|
|
1018
|
+
mesh's `source`; `attachments.skin_members` reads what each skin activates.
|
|
1019
|
+
Every one of those, changed alone, poses or draws differently, and every one read
|
|
1020
|
+
1.000 before. Each note names what differs, candidate first —
|
|
1021
|
+
`candidate only: "heavy|bones|root"; reference only: "heavy|bones|bone"` is a
|
|
1022
|
+
rotate timeline moved from `bone` to `root`, and `attachment
|
|
1023
|
+
"default/clipping/clipping": end "portal-flare6" vs "head-bb"` is a clipping that
|
|
1024
|
+
stops at another slot. A region's `path` is read as the runtime resolves it —
|
|
1025
|
+
`path`, else `name`, else the key — so stating the path an attachment already
|
|
1026
|
+
resolves is no difference. These are name-matched: your own names read low here
|
|
1027
|
+
exactly as they do on `names`, and a low figure with `names` at 1.000 is the
|
|
1028
|
+
wiring that moved.
|
|
1011
1029
|
- **`check`** renders your candidate into the reference frames' own pixel grid and
|
|
1012
1030
|
compares pixels — the only thing here that can see a wrong animation. **§9.**
|
|
1013
1031
|
🚨 What it certifies is the **default skin** unless you pass `--skin <name>`:
|
package/docs/INGEST.md
CHANGED
|
@@ -64,7 +64,7 @@ an upstream `license.txt` (Appendix, and [NOTICE.md](../NOTICE.md)).
|
|
|
64
64
|
| **`preview --candidate <skeleton.json>`** | ✅ **yes** | one self-contained `.html` that plays it in the official Spine Web Player. Needs a network the first time it is opened ([NOTICE.md](../NOTICE.md)) |
|
|
65
65
|
| **`vote --candidate <a> --candidate <b>`** | ✅ **yes, on either side** | a ballot page. Pairing a foreign export against your own transcription is a legitimate ballot, and the panes carry no paths |
|
|
66
66
|
| **`check --candidate <skeleton.json> --frames <dir>`** | ✅ **yes** | ⭐ it reads **frames and never a reference skeleton**, so a foreign export enters this one *twice over*: as the candidate, or — via `render` — as the source of the frames. §1.4 |
|
|
67
|
-
| **`diff <candidate.json> <reference.json>`** | ✅ **yes, both sides** |
|
|
67
|
+
| **`diff <candidate.json> <reference.json>`** | ✅ **yes, both sides** | 54 structural measures over bones, slots, attachments, constraints, animations and events. ⛔ **Blind to every coordinate** — §1.3 |
|
|
68
68
|
| **`ingest <skeleton.json> --out <dir>`** | ✅ **yes — and it is the only reader that WRITES specs** | the `.json` alone; no atlas, no art, no project file. Out come `rig.json`, `motion.json` and a findings report, such that `build`ing them reproduces the skeleton it read **byte for byte — for a skeleton rigc emitted**. ⚠️ For an editor export the claim is identity in canonical form apart from `hash` and `spine`, which §2.3 states in full. The seventh reader, and the one that ends §2's hand work — §2.0 and §5 |
|
|
69
69
|
| **`pose --images <dir> --frame <png>`** | ⛔ **not the skeleton** | loose part PNGs and one picture. A packed atlas page is not loose parts, and pointing it at one produces a confident answer about nothing — §5 |
|
|
70
70
|
| **`explain --rig … --motion … --out …`** | ⛔ **no** | rig spec + motion spec. It explains **what you wrote**, which makes it a transcription instrument rather than a reading one — §1.5 |
|
|
@@ -215,7 +215,7 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
|
|
|
215
215
|
|
|
216
216
|
### 1.3 `diff` — and the two things it cannot see
|
|
217
217
|
|
|
218
|
-
`diff` takes two compiled skeletons and reports
|
|
218
|
+
`diff` takes two compiled skeletons and reports 54 measures in eight groups, plus two
|
|
219
219
|
blocks that report and gate nothing: the `(reported)` measures beside `attachments`
|
|
220
220
|
and `animations`, and the `skeleton` header block at the top, which measures the stage.
|
|
221
221
|
A ninth group of six joins them when something has paired the two sides'
|
|
@@ -250,7 +250,7 @@ rigc diff
|
|
|
250
250
|
|
|
251
251
|
bones (name-agnostic) mean 1.000 over 5 measures — the same two skeletons compared with names thrown away
|
|
252
252
|
…
|
|
253
|
-
animations mean 1.000 over
|
|
253
|
+
animations mean 1.000 over 11 measures
|
|
254
254
|
1.000 count 2/2 how many animations
|
|
255
255
|
1.000 names 2/2 the animation names
|
|
256
256
|
1.000 duration 2/2 each animation runs as long (last key time, within one frame)
|
|
@@ -267,7 +267,7 @@ it does not say *which* side has three. The `..` line above is where you read th
|
|
|
267
267
|
⛔ **`diff` is blind to every coordinate a bone, an attachment or a key carries.** No
|
|
268
268
|
measure reads a bone's `x`/`y`/`rotation`, an attachment's offset, or a key's value —
|
|
269
269
|
only *presence*, *names*, *counts*, *order* and *kinds*. §4.1 moves a pivot 236.5
|
|
270
|
-
units and every one of the
|
|
270
|
+
units and every one of the 54 measures still reads **1.000**. ⇒ Never take a green
|
|
271
271
|
`diff` as evidence that a geometric edit did not land, and never take it as evidence
|
|
272
272
|
that one did.
|
|
273
273
|
|
|
@@ -325,7 +325,7 @@ two sections, the second over the paired shots — `duration`, `timeline_kinds`,
|
|
|
325
325
|
it used:
|
|
326
326
|
|
|
327
327
|
```
|
|
328
|
-
animations mean 0.
|
|
328
|
+
animations mean 0.182 over 11 measures
|
|
329
329
|
1.000 count 1/1 how many animations
|
|
330
330
|
0.000 names 0/2 the animation names
|
|
331
331
|
…
|
|
@@ -475,8 +475,8 @@ no field of the file).
|
|
|
475
475
|
📊 **For an editor export the claim is weaker, and measured.** Every
|
|
476
476
|
`examples/*/export/*.json` ingested with `--art none`, rebuilt through the pack
|
|
477
477
|
beside it, and `diff`ed against the file it was read from: **12 of 12 come back with 0
|
|
478
|
-
blockers and 1.000 on all
|
|
479
|
-
all **nine value measures** too, over **
|
|
478
|
+
blockers and 1.000 on all 54 ratio-bearing measures and all 6 reported ones** — and on
|
|
479
|
+
all **nine value measures** too, over **195,363** compared values. Byte
|
|
480
480
|
identity is not the claim there and the reason is the input, not the round trip — §2.3
|
|
481
481
|
has the pass line, and what the value measures do and do not reach. ⚠️ Which pack is "the one beside it" is
|
|
482
482
|
resolved rather than guessed, for §0.2's reason: `spineboy/export` holds two, and
|
|
@@ -702,7 +702,7 @@ State the ambition in the right units, because three different things get called
|
|
|
702
702
|
|
|
703
703
|
| Ambition | Reachable? | What it costs, and what it proves |
|
|
704
704
|
| --- | --- | --- |
|
|
705
|
-
| **Structural agreement** — same bones, slots, attachments, timelines, key counts, curve kinds | ✅ yes, and `diff` measures it | the 3-timing transcription reads **1.000 on all
|
|
705
|
+
| **Structural agreement** — same bones, slots, attachments, timelines, key counts, curve kinds | ✅ yes, and `diff` measures it | the 3-timing transcription reads **1.000 on all 54 measures**. Aim here first |
|
|
706
706
|
| **Geometric agreement** — the same drawn pixels, allowing for the atlas | ✅ yes, and `check` measures it | see below |
|
|
707
707
|
| **Byte-identical JSON** | ✅ **for a rebuild, in canonical form, apart from `hash` and `spine`** — the pass line below | a **rebuild** of an editor export (`ingest`, then `build`) is the export. A **transcription** by hand is not held to it: what differs there is what a person chose to write, not the emitter |
|
|
708
708
|
|
|
@@ -745,7 +745,7 @@ keys**, which is why a moved value is invisible to it.
|
|
|
745
745
|
⭐ **The values are measured by a second comparison rather than by `diff`.**
|
|
746
746
|
Structure at 1.000 is silent about the numbers inside it: a decompiler that halved
|
|
747
747
|
every rotation, dropped every bone's `length` or mirrored every vertex would read
|
|
748
|
-
1.000 on all
|
|
748
|
+
1.000 on all 54 measures and on every `(reported)` one. So the example round trip is
|
|
749
749
|
also compared **value by value**,
|
|
750
750
|
with the format's defaults taken from the parser rather than from a table — both files
|
|
751
751
|
are read through `spine-core` and the parsed forms are compared path by path, under a
|
|
@@ -755,12 +755,12 @@ storage. Nine measures — here is the `6-arcs` export's, wrapped to fit this
|
|
|
755
755
|
page:
|
|
756
756
|
|
|
757
757
|
```
|
|
758
|
-
values: 9/9 measure(s) at 1.000 over
|
|
758
|
+
values: 9/9 measure(s) at 1.000 over 13977 compared value(s); skeleton 1.000 ·
|
|
759
759
|
bones 1.000 · slots 1.000 · attachments 1.000 · constraints 1.000 · events 1.000 ·
|
|
760
760
|
key_times 1.000 · key_values 1.000 · curves 1.000
|
|
761
761
|
```
|
|
762
762
|
|
|
763
|
-
Over the twelve exports that is **
|
|
763
|
+
Over the twelve exports that is **195,363 values** compared, and all twelve read
|
|
764
764
|
1.000 on all nine.
|
|
765
765
|
|
|
766
766
|
`docs/BENCHMARK.md`'s *The nine value measures* is the full statement. What it still
|
|
@@ -1082,7 +1082,7 @@ same displacement evaluated across the bone's own rotation:
|
|
|
1082
1082
|
**What the instruments say about it** — and this pair is why §1.3 carries its warning:
|
|
1083
1083
|
|
|
1084
1084
|
- **`diff` sees nothing.** `rigc diff work/t3b/skeleton.json <the export>` reads
|
|
1085
|
-
**1.000 on all
|
|
1085
|
+
**1.000 on all 54 measures**: same bones, names, parents, order, slots, draw order,
|
|
1086
1086
|
attachments, animations, timelines, key counts, curve kinds. All true, and all silent
|
|
1087
1087
|
about a 236.5-unit move.
|
|
1088
1088
|
- **`check` sees it loudly**, with the framing pinned so the comparison is of pictures
|
|
@@ -1192,28 +1192,35 @@ slots to `arm-art`/`block-art` and their attachments to match:
|
|
|
1192
1192
|
1.000 bone_binding_shape 2/2 as many slots hang off a bone of each shape (`?` = no such bone is declared)
|
|
1193
1193
|
1.000 order_shape 2/2 the draw order is the same order of `<attachment type>@<bone shape>`
|
|
1194
1194
|
|
|
1195
|
-
attachments mean 0.
|
|
1195
|
+
attachments mean 0.818 over 11 measures
|
|
1196
1196
|
1.000 skins 1/1 the skin names
|
|
1197
1197
|
1.000 count 2/2 how many attachments
|
|
1198
1198
|
0.000 names 0/4 skin/slot/attachment keys
|
|
1199
1199
|
1.000 type_counts 2/2 as many of each attachment type
|
|
1200
1200
|
…
|
|
1201
|
-
|
|
1201
|
+
0.000 refs 0/2 each attachment resolves the same region, clipping end and linked-mesh source — 2 on one side only, which `attachments.names` names
|
|
1202
|
+
1.000 skin_members 1/1 each skin activates the same skin-required bones and constraints
|
|
1203
|
+
…
|
|
1204
|
+
animations mean 0.909 over 11 measures
|
|
1205
|
+
…
|
|
1206
|
+
0.000 targets 0/8 each timeline keys the same bone, slot, constraint or attachment — …
|
|
1202
1207
|
```
|
|
1203
1208
|
|
|
1204
1209
|
Three things to read out of that, in order:
|
|
1205
1210
|
|
|
1206
1211
|
- **The name-keyed collapse is the task, not a defect.** `bones` 0.567, `slots` 0.143,
|
|
1207
|
-
`attachments` 0.
|
|
1212
|
+
`attachments` 0.818, `animations` 0.909. Note that several *non*-name measures fall with them —
|
|
1208
1213
|
`slots.bone`, `slots.blend`, `bones.parent_by_name` — because they are keyed **by**
|
|
1209
1214
|
the name that changed. They are not saying the binding changed.
|
|
1210
1215
|
- 🚨 **`bones (name-agnostic)` and `slots (name-agnostic)` must stay 1.000.** They
|
|
1211
1216
|
measure the rig with the vocabulary thrown away, so a rename that changed only names
|
|
1212
1217
|
leaves them untouched. **A drop there is a structural mistake wearing a rename's
|
|
1213
1218
|
clothes**, and it is the only assertion this recipe really has.
|
|
1214
|
-
- **`animations`
|
|
1215
|
-
|
|
1216
|
-
|
|
1219
|
+
- **`animations` moves on one measure only, `targets`**, because animation *names*
|
|
1220
|
+
were not part of the ask but every timeline keys a renamed bone — `targets` is keyed
|
|
1221
|
+
by the name that changed, as `slots.bone` is. Every other `animations` measure stays
|
|
1222
|
+
1.000. Under the same edit `bones.names` reads `1/5` — `root` survived, and the total
|
|
1223
|
+
is the union of both vocabularies.
|
|
1217
1224
|
|
|
1218
1225
|
⛔ And remember §1.3: `diff` reads no coordinates either way. A rename that also moved
|
|
1219
1226
|
something is invisible to every measure in that report. Pair it with a `check` against
|
|
@@ -1255,7 +1262,7 @@ that already round-trips.
|
|
|
1255
1262
|
4. **Read `diff` knowing what it is about to say.**
|
|
1256
1263
|
|
|
1257
1264
|
```
|
|
1258
|
-
animations mean 0.
|
|
1265
|
+
animations mean 0.821 over 11 measures
|
|
1259
1266
|
0.667 count 2/3 how many animations
|
|
1260
1267
|
0.667 names 2/3 the animation names
|
|
1261
1268
|
0.667 duration 2/3 each animation runs as long (last key time, within one frame)
|
|
@@ -1265,10 +1272,12 @@ that already round-trips.
|
|
|
1265
1272
|
1.000 event_keys 0/0 as many event firings — neither side has any
|
|
1266
1273
|
0.667 draw_order 2/3 a draw-order timeline is present or absent alike
|
|
1267
1274
|
0.667 deform 2/3 a deform timeline is present or absent alike
|
|
1275
|
+
0.889 targets 8/9 each timeline keys the same bone, slot, constraint or attachment — …
|
|
1276
|
+
1.000 keyed_names 0/0 each key names the same attachment, draw-order slot or event — neither side has any
|
|
1268
1277
|
```
|
|
1269
1278
|
|
|
1270
1279
|
🚨 **Every one of those got worse, and that is the correct result.** The
|
|
1271
|
-
`animations` section went 1.000 → 0.
|
|
1280
|
+
`animations` section went 1.000 → 0.821 because the candidate now has something the
|
|
1272
1281
|
reference does not. `diff` measures agreement with a reference; you were asked to
|
|
1273
1282
|
*disagree* with it, in one specific way. ⇒ **Check that the drop is confined to the
|
|
1274
1283
|
`animations` section and is the size the addition explains** — one animation of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/compile.ts
CHANGED
|
@@ -7482,7 +7482,10 @@ function buildMesh(
|
|
|
7482
7482
|
);
|
|
7483
7483
|
|
|
7484
7484
|
return {
|
|
7485
|
-
kind
|
|
7485
|
+
// `kind` is what chose the builder above, so it is the claim; `geometry.kind`
|
|
7486
|
+
// is the wider `MeshKind`, which a strict tsc refuses here (and a dependant
|
|
7487
|
+
// with `strict: true` reads this file through `src/render.ts`).
|
|
7488
|
+
kind,
|
|
7486
7489
|
attachment: {
|
|
7487
7490
|
kind: 'mesh',
|
|
7488
7491
|
uvs: geometry.uvs.map(f32),
|
package/src/diff.ts
CHANGED
|
@@ -689,6 +689,43 @@ interface AttachmentFact {
|
|
|
689
689
|
* — see `attachments.runtime_name`.
|
|
690
690
|
*/
|
|
691
691
|
runtimeName: string;
|
|
692
|
+
/**
|
|
693
|
+
* Every name the attachment resolves, as `<field>:<name>` and sorted — see
|
|
694
|
+
* `attachmentRefTokens` for which fields and why.
|
|
695
|
+
*/
|
|
696
|
+
refs: string;
|
|
697
|
+
/** The same tokens as a list, for the note that spells a disagreement out. */
|
|
698
|
+
refList: string[];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The names an attachment resolves (issue #1085): the atlas region a region or
|
|
703
|
+
* a mesh draws, a clipping polygon's end slot, and the mesh a linked mesh takes
|
|
704
|
+
* its geometry from — with the skin and slot it is found under, where stated.
|
|
705
|
+
*
|
|
706
|
+
* ⭐ The region is read as the runtime resolves it, `path`, else the stated
|
|
707
|
+
* `name`, else the placeholder (SPEC_COVERAGE §1.5's three-level indirection),
|
|
708
|
+
* for the reason `runtime_name` reads its name that way: a file that spells the
|
|
709
|
+
* path out and one that leaves it to the parser name the same region, and a
|
|
710
|
+
* measure that called those different would score every export against every
|
|
711
|
+
* writer that states its paths. Every other field is a token only when the file
|
|
712
|
+
* states it — absent is no token, never a default, as in `constraints.refs`.
|
|
713
|
+
*
|
|
714
|
+
* Measured on public builds through spine-core: a region's `path` moved to the
|
|
715
|
+
* other eye of `spineboy-pro` changes 94 of its 99 sampled draws, its clipping
|
|
716
|
+
* polygon's `end` moved six slots on changes the clipping of 9, and a linked
|
|
717
|
+
* mesh on `gallery/nod` taking `ear_r` instead of `ear_l` as its source changes
|
|
718
|
+
* all 18 of its sampled poses. `diff` compared none of the three, and two of them occur in
|
|
719
|
+
* none of the twelve editor exports (a stated `path` and a linked mesh).
|
|
720
|
+
*/
|
|
721
|
+
function attachmentRefTokens(type: string, key: string, att: Json): string[] {
|
|
722
|
+
const out: string[] = [];
|
|
723
|
+
if (type === 'region' || type === 'mesh' || type === 'linkedmesh') out.push(`path:${str(att.path) ?? str(att.name) ?? key}`);
|
|
724
|
+
for (const field of ['end', 'source', 'skin', 'slot'] as const) {
|
|
725
|
+
const s = str(att[field]);
|
|
726
|
+
if (s !== null) out.push(`${field}:${s}`);
|
|
727
|
+
}
|
|
728
|
+
return out.sort();
|
|
692
729
|
}
|
|
693
730
|
|
|
694
731
|
function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
|
|
@@ -725,6 +762,8 @@ function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, A
|
|
|
725
762
|
edgesPresent: type === 'mesh' ? 'edges' in att : null,
|
|
726
763
|
size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
|
|
727
764
|
runtimeName: str(att.name) ?? attName,
|
|
765
|
+
refs: attachmentRefTokens(type, attName, att).join(' '),
|
|
766
|
+
refList: attachmentRefTokens(type, attName, att),
|
|
728
767
|
});
|
|
729
768
|
}
|
|
730
769
|
}
|
|
@@ -795,6 +834,22 @@ function diffAttachments(c: Json, r: Json): DiffSection {
|
|
|
795
834
|
counted([...ar.values()].map((f) => f.size)),
|
|
796
835
|
counted([...br.values()].map((f) => f.size)),
|
|
797
836
|
),
|
|
837
|
+
wiringAgreement(
|
|
838
|
+
'attachments.refs',
|
|
839
|
+
'each attachment resolves the same region, clipping end and linked-mesh source',
|
|
840
|
+
a.byKey,
|
|
841
|
+
b.byKey,
|
|
842
|
+
(key) => `attachment ${JSON.stringify(key)}`,
|
|
843
|
+
'`attachments.names`',
|
|
844
|
+
),
|
|
845
|
+
wiringAgreement(
|
|
846
|
+
'attachments.skin_members',
|
|
847
|
+
'each skin activates the same skin-required bones and constraints',
|
|
848
|
+
skinMembers(c),
|
|
849
|
+
skinMembers(r),
|
|
850
|
+
(skin) => `skin ${JSON.stringify(skin)}`,
|
|
851
|
+
'`attachments.skins`',
|
|
852
|
+
),
|
|
798
853
|
],
|
|
799
854
|
undefined,
|
|
800
855
|
// ── reported (issue #46) ────────────────────────────────────────────────
|
|
@@ -853,6 +908,67 @@ function diffAttachments(c: Json, r: Json): DiffSection {
|
|
|
853
908
|
]);
|
|
854
909
|
}
|
|
855
910
|
|
|
911
|
+
/**
|
|
912
|
+
* What each skin activates (issue #1085): the `bones` and the per-kind
|
|
913
|
+
* constraint lists a skin declares, as `<list>:<name>` tokens, sorted — a skin's
|
|
914
|
+
* MEMBERSHIP, which is what decides whether a bone or constraint marked
|
|
915
|
+
* `skin: true` is active under it.
|
|
916
|
+
*
|
|
917
|
+
* Measured on public builds through spine-core: a `gallery/look` bone made
|
|
918
|
+
* skin-required and left out of the skin's `bones` moves every one of its 27
|
|
919
|
+
* sampled poses, and `gallery/walk`'s `leg_b_ik` left out of the skin's `ik`
|
|
920
|
+
* moves all 9. None of the twelve editor exports declares a membership list, so
|
|
921
|
+
* every one of them reads `1/1` per skin here — two empty lists agree — which is
|
|
922
|
+
* the comparison being made rather than an absence of one.
|
|
923
|
+
*/
|
|
924
|
+
function skinMembers(root: Json): Map<string, { refs: string; refList: string[] }> {
|
|
925
|
+
const out = new Map<string, { refs: string; refList: string[] }>();
|
|
926
|
+
for (const skin of objs(root.skins)) {
|
|
927
|
+
const name = str(skin.name) ?? 'default';
|
|
928
|
+
const refList: string[] = [];
|
|
929
|
+
for (const list of ['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const) {
|
|
930
|
+
for (const member of arr(skin[list])) {
|
|
931
|
+
const s = str(member);
|
|
932
|
+
if (s !== null) refList.push(`${list}:${s}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
refList.sort();
|
|
936
|
+
out.set(name, { refs: refList.join(' '), refList });
|
|
937
|
+
}
|
|
938
|
+
return out;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* `agreement` over the larger roster on a `refs` string, with the note
|
|
943
|
+
* `constraints.refs` writes: each entry both sides hold whose names differ,
|
|
944
|
+
* field by field and candidate first, then a count of the entries one side holds
|
|
945
|
+
* alone, which `elsewhere` is the measure that names.
|
|
946
|
+
*/
|
|
947
|
+
function wiringAgreement<T extends { refs: string; refList: string[] }>(
|
|
948
|
+
id: string,
|
|
949
|
+
what: string,
|
|
950
|
+
a: Map<string, T>,
|
|
951
|
+
b: Map<string, T>,
|
|
952
|
+
label: (key: string) => string,
|
|
953
|
+
elsewhere: string,
|
|
954
|
+
): DiffMeasure {
|
|
955
|
+
const base = agreement(id, what, a, b, (x, y) => x.refs === y.refs);
|
|
956
|
+
const missed = base.total - base.matched;
|
|
957
|
+
if (missed === 0) return base;
|
|
958
|
+
const differ: string[] = [];
|
|
959
|
+
for (const [key, fact] of a) {
|
|
960
|
+
const other = b.get(key);
|
|
961
|
+
if (other !== undefined && other.refs !== fact.refs) differ.push(refsDisagreement(label(key), fact, other));
|
|
962
|
+
}
|
|
963
|
+
const oneSided = missed - differ.length;
|
|
964
|
+
const spelled = differ.slice(0, REFS_SPELLED_OUT);
|
|
965
|
+
const note =
|
|
966
|
+
(differ.length === 0 ? '' : `${differ.length} differ: ${spelled.join('; ')}`) +
|
|
967
|
+
(differ.length > spelled.length ? `; …and ${differ.length - spelled.length} more` : '') +
|
|
968
|
+
(oneSided === 0 ? '' : `${differ.length === 0 ? '' : '; '}${oneSided} on one side only, which ${elsewhere} names`);
|
|
969
|
+
return { ...base, note };
|
|
970
|
+
}
|
|
971
|
+
|
|
856
972
|
/**
|
|
857
973
|
* `attachments.runtime_name`: of the skin/slot/placeholder keys both sides
|
|
858
974
|
* hold, how many answer to the same runtime name — see the note at its call.
|
|
@@ -921,7 +1037,8 @@ interface ConstraintFact {
|
|
|
921
1037
|
* property fields were blinder still: measured on `gallery/look` and `6-arcs-pro`,
|
|
922
1038
|
* a slider moved from `rotate` to `x` and a transform remapped from `x>x` to
|
|
923
1039
|
* `x>y` moved no measure here and no value in the value walk either (its skip
|
|
924
|
-
* list
|
|
1040
|
+
* list named `properties`), so nothing in the tree compared them at all. Since
|
|
1041
|
+
* issue #1084 the walk reads both, by class (`property/kind`, `properties/FromX/to/ToY`).
|
|
925
1042
|
*
|
|
926
1043
|
* ⚠️ Absent is no token, never a default: a transform with no `properties` maps
|
|
927
1044
|
* nothing, and two sides that both omit a field agree about it. And the tokens
|
|
@@ -967,8 +1084,8 @@ const REFS_SPELLED_OUT = 3;
|
|
|
967
1084
|
* "5" vs "-a"` — the candidate's names for each field that differs, then the
|
|
968
1085
|
* reference's, `none` where a side has none.
|
|
969
1086
|
*/
|
|
970
|
-
function refsDisagreement(at: string, a:
|
|
971
|
-
const byField = (f:
|
|
1087
|
+
function refsDisagreement(at: string, a: { refList: string[] }, b: { refList: string[] }): string {
|
|
1088
|
+
const byField = (f: { refList: string[] }): Map<string, string[]> => {
|
|
972
1089
|
const m = new Map<string, string[]>();
|
|
973
1090
|
for (const token of f.refList) {
|
|
974
1091
|
const colon = token.indexOf(':');
|
|
@@ -1024,9 +1141,43 @@ function constraintRefs(a: Map<string, ConstraintFact>, b: Map<string, Constrain
|
|
|
1024
1141
|
return { ...base, note };
|
|
1025
1142
|
}
|
|
1026
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* The constraints in the order the file declares them, by kind and name — the
|
|
1146
|
+
* key `constraintFacts` uses, so that `leg` as an ik and as a transform are two
|
|
1147
|
+
* entries here as well.
|
|
1148
|
+
*
|
|
1149
|
+
* 🔍 **Why the order is data** (issue #1085). 4.3 folds every constraint into one
|
|
1150
|
+
* array and applies them in its order, so two files with the same constraints
|
|
1151
|
+
* wired the same way can pose differently. Measured by reversing the array and
|
|
1152
|
+
* posing both through spine-core: `spineboy-pro` moves 98 of its 99 sampled
|
|
1153
|
+
* poses, `6-arcs-pro` 8 of 9 and `sack-pro` 8 of 36 (33 of 36 with physics
|
|
1154
|
+
* stepped); swapping `aim-torso-ik` and `aim-torso-transform` alone moves the 9
|
|
1155
|
+
* samples of `aim`. Before this nothing in the tree compared it — the value walk
|
|
1156
|
+
* keys constraints by name, so it is blind to the order by construction.
|
|
1157
|
+
*
|
|
1158
|
+
* ⚠️ Not every swap poses differently, and the measure does not pretend to know
|
|
1159
|
+
* which do: `gallery/look`'s two sliders and `gallery/walk`'s two legs drive
|
|
1160
|
+
* disjoint bones and read the same pose either way. A reordering is reported as
|
|
1161
|
+
* what the file states, the way `bones.order` reports one.
|
|
1162
|
+
*/
|
|
1163
|
+
function constraintOrder(root: Json): string[] {
|
|
1164
|
+
const out: string[] = [];
|
|
1165
|
+
for (const con of objs(root.constraints)) {
|
|
1166
|
+
const name = str(con.name);
|
|
1167
|
+
if (name !== null) out.push(constraintAt(str(con.type) ?? '(none)', name));
|
|
1168
|
+
}
|
|
1169
|
+
return out;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1027
1172
|
function diffConstraints(c: Json, r: Json): DiffSection {
|
|
1028
1173
|
const a = constraintFacts(c);
|
|
1029
1174
|
const b = constraintFacts(r);
|
|
1175
|
+
const ao = constraintOrder(c);
|
|
1176
|
+
const bo = constraintOrder(r);
|
|
1177
|
+
const shared = ao.filter((n) => b.has(n));
|
|
1178
|
+
const sharedOther = bo.filter((n) => a.has(n));
|
|
1179
|
+
const inOrder = lcs(shared, sharedOther);
|
|
1180
|
+
const max = Math.max(a.size, b.size);
|
|
1030
1181
|
return sectionOf('constraints', [
|
|
1031
1182
|
measure('constraints.count', 'how many constraints', Math.min(a.size, b.size), Math.max(a.size, b.size)),
|
|
1032
1183
|
jaccard('constraints.names', 'the constraint names', new Set(a.keys()), new Set(b.keys())),
|
|
@@ -1038,9 +1189,32 @@ function diffConstraints(c: Json, r: Json): DiffSection {
|
|
|
1038
1189
|
),
|
|
1039
1190
|
agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
|
|
1040
1191
|
constraintRefs(a, b),
|
|
1192
|
+
measure(
|
|
1193
|
+
'constraints.order',
|
|
1194
|
+
'the constraints are declared, and so applied, in the same order',
|
|
1195
|
+
inOrder,
|
|
1196
|
+
max,
|
|
1197
|
+
[
|
|
1198
|
+
...(inOrder === shared.length ? [] : [outOfOrder(shared, sharedOther)]),
|
|
1199
|
+
...(shared.length === max ? [] : [`${max - shared.length} of the larger roster not on both sides, which \`constraints.names\` names`]),
|
|
1200
|
+
].join('; ') || undefined,
|
|
1201
|
+
),
|
|
1041
1202
|
]);
|
|
1042
1203
|
}
|
|
1043
1204
|
|
|
1205
|
+
/**
|
|
1206
|
+
* `constraints.order`'s note when the shared constraints are not in one order:
|
|
1207
|
+
* both sides' order of them, candidate first, spelled out to a limit.
|
|
1208
|
+
*/
|
|
1209
|
+
function outOfOrder(a: string[], b: string[]): string {
|
|
1210
|
+
const spell = (list: string[]): string =>
|
|
1211
|
+
list.slice(0, ORDER_SPELLED_OUT).join(', ') + (list.length > ORDER_SPELLED_OUT ? `, …and ${list.length - ORDER_SPELLED_OUT} more` : '');
|
|
1212
|
+
return `${a.length - lcs(a, b)} out of order: candidate ${spell(a)}; reference ${spell(b)}`;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** How many constraints `constraints.order`'s note lists per side before it counts the rest. */
|
|
1216
|
+
const ORDER_SPELLED_OUT = 6;
|
|
1217
|
+
|
|
1044
1218
|
interface AnimationFacts {
|
|
1045
1219
|
names: string[];
|
|
1046
1220
|
/** `<anim>` -> last key time. Skeleton JSON has no duration field. */
|
|
@@ -1098,6 +1272,136 @@ function animationFacts(root: Json): AnimationFacts {
|
|
|
1098
1272
|
return facts;
|
|
1099
1273
|
}
|
|
1100
1274
|
|
|
1275
|
+
/**
|
|
1276
|
+
* The names the animations resolve, as two multisets of tokens (issue #1085).
|
|
1277
|
+
*
|
|
1278
|
+
* `animationFacts` keeps each timeline's kind and drops its target on purpose —
|
|
1279
|
+
* a candidate's own vocabulary would otherwise be counted against it a second
|
|
1280
|
+
* time inside `timeline_kinds` (#21) — and reads no key's contents beyond its
|
|
1281
|
+
* time and curve. So until this, a bone timeline moved onto another bone, an
|
|
1282
|
+
* attachment key naming another attachment, a draw-order key naming another slot
|
|
1283
|
+
* and an event key firing another event each moved no measure in this file, and
|
|
1284
|
+
* every one of them poses or draws differently: measured on public builds
|
|
1285
|
+
* through spine-core, a retargeted bone timeline moves 7 of `gallery/look`'s 27
|
|
1286
|
+
* sampled poses, two slots' attachment keys swapped move 9 of `spineboy-pro`'s
|
|
1287
|
+
* 99, a draw-order offset moved to the next slot moves 9 of `spineboy-ess`'s 72
|
|
1288
|
+
* and an event key renamed to a second event moves the event block of one sample.
|
|
1289
|
+
*
|
|
1290
|
+
* - `targets`: one token per timeline, `<animation>|<group>|<target>` — the
|
|
1291
|
+
* bone, slot or constraint it keys, a deform's or sequence's
|
|
1292
|
+
* `skin/slot/attachment`, and each slot a draw-order folder holds. The
|
|
1293
|
+
* timeline's own kind is left out on purpose: it is `timeline_kinds`'
|
|
1294
|
+
* subject, and a rotate keyed as a scale on the same bone moves that measure
|
|
1295
|
+
* and must not move this one too.
|
|
1296
|
+
* - `keyed`: one token per name a KEY carries, with its position in the
|
|
1297
|
+
* timeline — an attachment key's attachment (`(none)` for a key that clears
|
|
1298
|
+
* the slot), each draw-order offset's slot, an event key's event.
|
|
1299
|
+
*
|
|
1300
|
+
* Both are names, so both are name-matched, and a candidate with its own names
|
|
1301
|
+
* reads low here exactly as it does on `names` — the name-agnostic block is
|
|
1302
|
+
* where that rig is read. ⚠️ No key VALUE is read: a key's time, an offset's
|
|
1303
|
+
* distance and an event's payload are the value walk's.
|
|
1304
|
+
*/
|
|
1305
|
+
interface AnimationRefs {
|
|
1306
|
+
targets: Map<string, number>;
|
|
1307
|
+
keyed: Map<string, number>;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** The physics group's empty name, which applies a timeline to every physics constraint. */
|
|
1311
|
+
const EVERY_PHYSICS_CONSTRAINT = '(every physics constraint)';
|
|
1312
|
+
|
|
1313
|
+
function animationRefs(root: Json): AnimationRefs {
|
|
1314
|
+
const targets = new Map<string, number>();
|
|
1315
|
+
const keyed = new Map<string, number>();
|
|
1316
|
+
const bump = (m: Map<string, number>, k: string): void => {
|
|
1317
|
+
m.set(k, (m.get(k) ?? 0) + 1);
|
|
1318
|
+
};
|
|
1319
|
+
if (!isObj(root.animations)) return { targets, keyed };
|
|
1320
|
+
for (const [anim, body] of Object.entries(root.animations)) {
|
|
1321
|
+
if (!isObj(body)) continue;
|
|
1322
|
+
for (const group of ['bones', 'slots', 'path', 'physics', 'slider'] as const) {
|
|
1323
|
+
const held = body[group];
|
|
1324
|
+
if (!isObj(held)) continue;
|
|
1325
|
+
for (const [target, timelines] of Object.entries(held)) {
|
|
1326
|
+
if (!isObj(timelines)) continue;
|
|
1327
|
+
const named = group === 'physics' && target === '' ? EVERY_PHYSICS_CONSTRAINT : target;
|
|
1328
|
+
for (const [timeline, keys] of Object.entries(timelines)) {
|
|
1329
|
+
if (!Array.isArray(keys)) continue;
|
|
1330
|
+
bump(targets, `${anim}|${group}|${named}`);
|
|
1331
|
+
if (group === 'slots' && timeline === 'attachment') {
|
|
1332
|
+
keys.forEach((key, i) => {
|
|
1333
|
+
if (isObj(key)) bump(keyed, `${anim}|attachment key ${i}|${target}: ${str(key.name) ?? '(none)'}`);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
for (const group of ['ik', 'transform'] as const) {
|
|
1340
|
+
const held = body[group];
|
|
1341
|
+
if (!isObj(held)) continue;
|
|
1342
|
+
for (const [target, keys] of Object.entries(held)) if (Array.isArray(keys)) bump(targets, `${anim}|${group}|${target}`);
|
|
1343
|
+
}
|
|
1344
|
+
if (isObj(body.attachments)) {
|
|
1345
|
+
for (const [skin, bySlot] of Object.entries(body.attachments)) {
|
|
1346
|
+
if (!isObj(bySlot)) continue;
|
|
1347
|
+
for (const [slot, byAttachment] of Object.entries(bySlot)) {
|
|
1348
|
+
if (!isObj(byAttachment)) continue;
|
|
1349
|
+
for (const [attachment, timelines] of Object.entries(byAttachment)) {
|
|
1350
|
+
if (!isObj(timelines)) continue;
|
|
1351
|
+
for (const keys of Object.values(timelines)) {
|
|
1352
|
+
if (Array.isArray(keys)) bump(targets, `${anim}|attachments|${skin}/${slot}/${attachment}`);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const offsets = (label: string, keys: unknown): void => {
|
|
1359
|
+
arr(keys).forEach((key, i) => {
|
|
1360
|
+
if (!isObj(key)) return;
|
|
1361
|
+
for (const offset of objs(key.offsets)) bump(keyed, `${anim}|${label} key ${i}|${str(offset.slot) ?? '(none)'}`);
|
|
1362
|
+
});
|
|
1363
|
+
};
|
|
1364
|
+
offsets('drawOrder', body.drawOrder);
|
|
1365
|
+
objs(body.drawOrderFolder).forEach((folder, f) => {
|
|
1366
|
+
for (const slot of arr(folder.slots)) bump(targets, `${anim}|drawOrderFolder ${f}|${str(slot) ?? '(none)'}`);
|
|
1367
|
+
offsets(`drawOrderFolder ${f}`, folder.keys);
|
|
1368
|
+
});
|
|
1369
|
+
arr(body.events).forEach((key, i) => {
|
|
1370
|
+
if (isObj(key)) bump(keyed, `${anim}|event key ${i}|${str(key.name) ?? '(none)'}`);
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
return { targets, keyed };
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** How many tokens a names histogram's note spells out per side before it counts the rest. */
|
|
1377
|
+
const TOKENS_SPELLED_OUT = 3;
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* `histogram`, with a note naming what each side holds that the other does not —
|
|
1381
|
+
* the figure alone says some name moved, and a reader acting on it needs which.
|
|
1382
|
+
*/
|
|
1383
|
+
function namesHistogram(id: string, what: string, a: Map<string, number>, b: Map<string, number>): DiffMeasure {
|
|
1384
|
+
const base = histogram(id, what, a, b);
|
|
1385
|
+
if (base.matched === base.total) return base;
|
|
1386
|
+
// A token one side holds more often than the other, with how many more — a
|
|
1387
|
+
// bone keyed by three timelines on one side and one on the other is `×2`.
|
|
1388
|
+
const only = (x: Map<string, number>, y: Map<string, number>): string[] => {
|
|
1389
|
+
const out: string[] = [];
|
|
1390
|
+
for (const [k, v] of x) {
|
|
1391
|
+
const extra = v - (y.get(k) ?? 0);
|
|
1392
|
+
if (extra > 0) out.push(`${JSON.stringify(k)}${extra > 1 ? ` ×${extra}` : ''}`);
|
|
1393
|
+
}
|
|
1394
|
+
return out;
|
|
1395
|
+
};
|
|
1396
|
+
const spell = (side: string, list: string[]): string =>
|
|
1397
|
+
list.length === 0
|
|
1398
|
+
? ''
|
|
1399
|
+
: `${side} only: ${list.slice(0, TOKENS_SPELLED_OUT).join(', ')}` +
|
|
1400
|
+
(list.length > TOKENS_SPELLED_OUT ? `, …and ${list.length - TOKENS_SPELLED_OUT} more` : '');
|
|
1401
|
+
const parts = [spell('candidate', only(a, b)), spell('reference', only(b, a))].filter((p) => p !== '');
|
|
1402
|
+
return { ...base, note: `${base.total - base.matched} differ — ${parts.join('; ')}` };
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1101
1405
|
/**
|
|
1102
1406
|
* What a side's keying costs, in the two shapes that separate the two ways a
|
|
1103
1407
|
* key total can differ: more timelines, or more keys inside a timeline.
|
|
@@ -1266,6 +1570,8 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
|
|
|
1266
1570
|
const at = keyingTotals(a);
|
|
1267
1571
|
const bt = keyingTotals(b);
|
|
1268
1572
|
const paired = pairAnimations(a, b, pairsStated);
|
|
1573
|
+
const refsA = animationRefs(c);
|
|
1574
|
+
const refsB = animationRefs(r);
|
|
1269
1575
|
const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
|
|
1270
1576
|
const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
|
|
1271
1577
|
return sectionOf('animations', [
|
|
@@ -1284,6 +1590,18 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
|
|
|
1284
1590
|
histogram('animations.event_keys', 'as many event firings', a.events, b.events),
|
|
1285
1591
|
agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
|
|
1286
1592
|
agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
|
|
1593
|
+
namesHistogram(
|
|
1594
|
+
'animations.targets',
|
|
1595
|
+
'each timeline keys the same bone, slot, constraint or attachment',
|
|
1596
|
+
refsA.targets,
|
|
1597
|
+
refsB.targets,
|
|
1598
|
+
),
|
|
1599
|
+
namesHistogram(
|
|
1600
|
+
'animations.keyed_names',
|
|
1601
|
+
'each key names the same attachment, draw-order slot or event',
|
|
1602
|
+
refsA.keyed,
|
|
1603
|
+
refsB.keyed,
|
|
1604
|
+
),
|
|
1287
1605
|
],
|
|
1288
1606
|
// ── the same two skeletons' shots, paired rather than named (issue #720) ──
|
|
1289
1607
|
//
|
|
@@ -1628,7 +1946,7 @@ export const VALUE_PARSED_ULP = 2 ** -23;
|
|
|
1628
1946
|
* Both terms are derived rather than fitted: the first is rigc's models' grid,
|
|
1629
1947
|
* the second is the parser's storage. Measured over the twelve editor exports
|
|
1630
1948
|
* in `examples/`, the widest gap between a rebuild and the file it was read
|
|
1631
|
-
* from is **0** — none of
|
|
1949
|
+
* from is **0** — none of 189,699 numeric values differs at all, since issue
|
|
1632
1950
|
* #716 made every emitted number its float's own name (it was 0.81 of this
|
|
1633
1951
|
* bound, over 56,951 values that differed, while rigc emitted six decimals) —
|
|
1634
1952
|
* so the corpus sits inside a bound that was not drawn around it, and no
|
|
@@ -1657,7 +1975,11 @@ const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }
|
|
|
1657
1975
|
{ id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
|
|
1658
1976
|
{ id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
|
|
1659
1977
|
{ id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
|
|
1660
|
-
{
|
|
1978
|
+
{
|
|
1979
|
+
id: 'values.constraints',
|
|
1980
|
+
what: 'every constraint field the parser reads: pose, flags, offsets, and the property map and its kinds',
|
|
1981
|
+
prefix: 'constraints/',
|
|
1982
|
+
},
|
|
1661
1983
|
{ id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
|
|
1662
1984
|
{ id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
|
|
1663
1985
|
{ id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },
|
package/src/validate.ts
CHANGED
|
@@ -3466,10 +3466,70 @@ const NOT_A_VALUE_IN_THE_FILE: Record<string, string> = {
|
|
|
3466
3466
|
index: 'the array position this walk already iterates by name',
|
|
3467
3467
|
timelineIds: 'derived from the timelines',
|
|
3468
3468
|
timelineSlots: 'derived from the skin',
|
|
3469
|
-
properties: 'derived from the constraint',
|
|
3470
3469
|
propertyIds: 'carries an attachment id, which is a counter (see above)',
|
|
3471
3470
|
};
|
|
3472
3471
|
|
|
3472
|
+
/**
|
|
3473
|
+
* The keys above that ARE a value the file carries on these owners, by the
|
|
3474
|
+
* owner's class name (issue #1084).
|
|
3475
|
+
*
|
|
3476
|
+
* The skip list is keyed by field name alone, and four of its names are also
|
|
3477
|
+
* the name of a field the parser reads off the file on a different class. Each
|
|
3478
|
+
* owner below was found by walking the parsed form of the twelve editor exports
|
|
3479
|
+
* and the seven gallery builds for every class carrying a key the list names,
|
|
3480
|
+
* and each field was planted and posed before it was let back in:
|
|
3481
|
+
*
|
|
3482
|
+
* - `a`, `b` — a `Color`'s alpha and blue channels, which share their names with
|
|
3483
|
+
* two entries of a bone's world matrix. Every slot colour, dark colour, bone
|
|
3484
|
+
* colour and attachment colour lost half its channels to that: a slot's alpha
|
|
3485
|
+
* `ff` → `80` moved no value at all, and posed, it changes every slot row.
|
|
3486
|
+
* - `local` — a slider's `local` flag, which shares its name with a bone pose's
|
|
3487
|
+
* derived `local`. Flipped on `gallery/look`'s `yaw`, it moves the posed bones.
|
|
3488
|
+
* - `offsets` — a transform constraint's six offsets (`rotation`, `x`, `y`,
|
|
3489
|
+
* `scaleX`, `scaleY`, `shearY` in the file), which share their name with a
|
|
3490
|
+
* sequence's region offsets. A `y` offset moved on `6-arcs-pro`'s `tail`
|
|
3491
|
+
* moved no value and moves the posed bones.
|
|
3492
|
+
*
|
|
3493
|
+
* A fifth entry, `properties` (*derived from the constraint*), is gone rather
|
|
3494
|
+
* than excepted: the one class measured carrying it is the transform constraint,
|
|
3495
|
+
* where it is the file's from/to map, so the reason was not true of anything.
|
|
3496
|
+
*
|
|
3497
|
+
* ⚠️ An exception rather than a rewrite of the list: the list's design — a
|
|
3498
|
+
* field the parser starts reading is compared the day it starts — is right,
|
|
3499
|
+
* and what was wrong was that a name stood for every class carrying it.
|
|
3500
|
+
*/
|
|
3501
|
+
const VALUE_ON_THESE_OWNERS: Record<string, readonly string[]> = {
|
|
3502
|
+
a: ['Color'],
|
|
3503
|
+
b: ['Color'],
|
|
3504
|
+
local: ['SliderData'],
|
|
3505
|
+
offsets: ['TransformConstraintData'],
|
|
3506
|
+
};
|
|
3507
|
+
|
|
3508
|
+
/** Whether the walk leaves `key` out on an object of class `owner`. */
|
|
3509
|
+
function notAValue(owner: string, key: string): boolean {
|
|
3510
|
+
return key in NOT_A_VALUE_IN_THE_FILE && !(VALUE_ON_THESE_OWNERS[key] ?? []).includes(owner);
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
/**
|
|
3514
|
+
* The class an object was parsed into when that class is one of several sharing
|
|
3515
|
+
* a shape — it extends another class — or `null`.
|
|
3516
|
+
*
|
|
3517
|
+
* Reflection reads fields, and two classes with the same fields are the same
|
|
3518
|
+
* object to it: a slider reading `rotate` holds a `FromRotate` and one reading
|
|
3519
|
+
* `x` a `FromX`, both `{ offset, to }`, so moving a slider to another property
|
|
3520
|
+
* moved no value (issue #1084). Measured over the parsed corpus, the only
|
|
3521
|
+
* unnamed objects the walk expands whose class extends another are the
|
|
3522
|
+
* transform and slider property classes (`From*`, `To*`); a pose, a colour and a
|
|
3523
|
+
* sequence are classes of their own, so this adds nothing under them.
|
|
3524
|
+
*/
|
|
3525
|
+
function sharedShapeKind(obj: object): string | null {
|
|
3526
|
+
const proto = Object.getPrototypeOf(obj) as object | null;
|
|
3527
|
+
if (proto === null || proto === Object.prototype) return null;
|
|
3528
|
+
const parent = Object.getPrototypeOf(proto) as object | null;
|
|
3529
|
+
if (parent === null || parent === Object.prototype) return null;
|
|
3530
|
+
return (obj.constructor as { name?: string } | undefined)?.name ?? null;
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3473
3533
|
/** How deep a chain of unnamed objects may go before the walk says so and stops. */
|
|
3474
3534
|
const VALUE_WALK_DEPTH = 10;
|
|
3475
3535
|
|
|
@@ -3501,7 +3561,24 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
|
|
|
3501
3561
|
if (typeof value === 'function') return;
|
|
3502
3562
|
if (ArrayBuffer.isView(value) || Array.isArray(value)) {
|
|
3503
3563
|
const list = value as ArrayLike<unknown>;
|
|
3504
|
-
|
|
3564
|
+
// A list whose every entry is an unnamed object of its own shared-shape
|
|
3565
|
+
// class — a transform's `properties` and each one's `to` — is keyed by those
|
|
3566
|
+
// classes rather than by position: the file writes them as an object keyed
|
|
3567
|
+
// by property name, any writer may key it in its own order, and the
|
|
3568
|
+
// parser's list follows that order. By position, the same map written
|
|
3569
|
+
// backwards would read as every property moved (issue #1084).
|
|
3570
|
+
const kinds: string[] = [];
|
|
3571
|
+
for (let i = 0; i < list.length; i++) {
|
|
3572
|
+
const entry = list[i];
|
|
3573
|
+
const kind =
|
|
3574
|
+
typeof entry === 'object' && entry !== null && typeof (entry as Record<string, unknown>).name !== 'string'
|
|
3575
|
+
? sharedShapeKind(entry)
|
|
3576
|
+
: null;
|
|
3577
|
+
if (kind === null || kinds.includes(kind)) break;
|
|
3578
|
+
kinds.push(kind);
|
|
3579
|
+
}
|
|
3580
|
+
const byKind = list.length > 0 && kinds.length === list.length;
|
|
3581
|
+
for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${byKind ? kinds[i] : i}`, out, depth + 1);
|
|
3505
3582
|
return;
|
|
3506
3583
|
}
|
|
3507
3584
|
if (typeof value !== 'object') return;
|
|
@@ -3514,8 +3591,12 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
|
|
|
3514
3591
|
out.push({ path, value: '(deeper than the walk goes)' });
|
|
3515
3592
|
return;
|
|
3516
3593
|
}
|
|
3594
|
+
const owner = (obj.constructor as { name?: string } | undefined)?.name ?? '';
|
|
3595
|
+
// The class, where it is the one thing the fields cannot say — see `sharedShapeKind`.
|
|
3596
|
+
const kind = depth > 0 ? sharedShapeKind(obj) : null;
|
|
3597
|
+
if (kind !== null) out.push({ path: `${path}/kind`, value: kind });
|
|
3517
3598
|
for (const key of Object.keys(obj).sort()) {
|
|
3518
|
-
if (key
|
|
3599
|
+
if (notAValue(owner, key)) continue;
|
|
3519
3600
|
pushValue(obj[key], `${path}/${key}`, out, depth + 1);
|
|
3520
3601
|
}
|
|
3521
3602
|
}
|
|
@@ -3570,7 +3651,9 @@ function timelineKey(timeline: Timeline, data: ReturnType<SkeletonJson['readSkel
|
|
|
3570
3651
|
* and setup attachment, every attachment in every skin (a region's offsets,
|
|
3571
3652
|
* rotation, scale and size; a mesh's vertices, weights, `regionUVs`,
|
|
3572
3653
|
* triangles, hull and edges; a bounding box's, path's and clipping shape's
|
|
3573
|
-
* vertices), every constraint's pose and
|
|
3654
|
+
* vertices), every constraint's pose, flags and modes — a transform's offsets
|
|
3655
|
+
* and its property map, each entry by its class, and a slider's property by its
|
|
3656
|
+
* class (issue #1084) — every event's payload, and for
|
|
3574
3657
|
* every timeline every frame — time and values, from the runtime's own
|
|
3575
3658
|
* `getFrameEntries()` — its curve type and Bezier samples, and its deform
|
|
3576
3659
|
* vertices, attachment names, draw orders and event payloads.
|
|
@@ -3605,7 +3688,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
|
|
|
3605
3688
|
const at = `bones/${bone.name}`;
|
|
3606
3689
|
const rec = bone as unknown as Record<string, unknown>;
|
|
3607
3690
|
for (const key of Object.keys(rec).sort()) {
|
|
3608
|
-
if (key
|
|
3691
|
+
if (notAValue('BoneData', key) || key === 'name') continue;
|
|
3609
3692
|
pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
|
|
3610
3693
|
}
|
|
3611
3694
|
}
|
|
@@ -3613,7 +3696,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
|
|
|
3613
3696
|
const at = `slots/${slot.name}`;
|
|
3614
3697
|
const rec = slot as unknown as Record<string, unknown>;
|
|
3615
3698
|
for (const key of Object.keys(rec).sort()) {
|
|
3616
|
-
if (key
|
|
3699
|
+
if (notAValue('SlotData', key) || key === 'name') continue;
|
|
3617
3700
|
pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
|
|
3618
3701
|
}
|
|
3619
3702
|
}
|
|
@@ -3681,7 +3764,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
|
|
|
3681
3764
|
}
|
|
3682
3765
|
const rec = timeline as unknown as Record<string, unknown>;
|
|
3683
3766
|
for (const field of Object.keys(rec).sort()) {
|
|
3684
|
-
if (
|
|
3767
|
+
if (notAValue(timeline.constructor?.name ?? '', field) || field === 'frames') continue;
|
|
3685
3768
|
// The owner is in the path already; comparing the index as well would
|
|
3686
3769
|
// report one reordering twice, in a measure that is not about order.
|
|
3687
3770
|
if (field === 'boneIndex' || field === 'slotIndex' || field === 'constraintIndex') continue;
|
|
@@ -308,6 +308,61 @@ interface Ran {
|
|
|
308
308
|
timedOut: boolean;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The indices of the lines in one stream of editor output that carry the
|
|
313
|
+
* licence holder, and nothing else (issue #1082).
|
|
314
|
+
*
|
|
315
|
+
* Measured on a licensed 4.3 editor on macOS 2026-10-02, through this tool, on
|
|
316
|
+
* four failing calls. Every one exited 1, wrote everything to stdout and nothing
|
|
317
|
+
* to stderr, and printed one of two shapes:
|
|
318
|
+
*
|
|
319
|
+
* an input path that does not exist (import, and export) — refused by the
|
|
320
|
+
* LAUNCHER before the editor starts, and no licence line at all:
|
|
321
|
+
*
|
|
322
|
+
* the launcher's banner, the copyright line, the operating system
|
|
323
|
+
* (blank) `Parameter: --input <path>` (blank)
|
|
324
|
+
* `ERROR: Input path does not exist:`
|
|
325
|
+
* the path
|
|
326
|
+
*
|
|
327
|
+
* a file the editor cannot read (a skeleton JSON naming a parent bone it does
|
|
328
|
+
* not declare; a `.spine` that is not a project) — the editor starts, names
|
|
329
|
+
* itself and its licence holder, and then the failure:
|
|
330
|
+
*
|
|
331
|
+
* the launcher's banner, the copyright line, the operating system
|
|
332
|
+
* `Starting: Spine <x.y.z> Professional`
|
|
333
|
+
* `Spine <x.y.z> Professional`
|
|
334
|
+
* `Licensed to: <name> <<e-mail>>` <- ONE line, withheld
|
|
335
|
+
* import: `Project import: …`, `ERROR: Unable to import skeleton.`, then
|
|
336
|
+
* export: `ERROR: Unable to export.`, then
|
|
337
|
+
* `[error] …` lines, a stack, and `Cause: …` lines
|
|
338
|
+
*
|
|
339
|
+
* 🚨 So on a failure the block is one line, not the three `--version` prints
|
|
340
|
+
* (`Licensed to:` alone, then the name, then the e-mail — issue #1077), and
|
|
341
|
+
* the lines straight after it are the editor's own error: a rule that took
|
|
342
|
+
* "the header and the two lines after it" would have quoted the licence holder
|
|
343
|
+
* not at all and the reason for the failure not at all either.
|
|
344
|
+
*
|
|
345
|
+
* ⚠️ Both shapes are held, each by what bounds it. A header with a value on the
|
|
346
|
+
* same line is that one line. A bare header takes the two lines after it only
|
|
347
|
+
* when the second of them is an e-mail (`@`), which is the `--version` shape;
|
|
348
|
+
* anything else after a bare header is the editor talking and is quoted.
|
|
349
|
+
*/
|
|
350
|
+
function licenceLines(lines: readonly string[]): Set<number> {
|
|
351
|
+
const withheld = new Set<number>();
|
|
352
|
+
for (let i = 0; i < lines.length; i++) {
|
|
353
|
+
const header = /^[ \t]*Licensed to:(.*)$/.exec(lines[i]);
|
|
354
|
+
if (header === null) continue;
|
|
355
|
+
withheld.add(i);
|
|
356
|
+
if (header[1].trim() !== '') continue;
|
|
357
|
+
if (i + 2 < lines.length && lines[i + 2].includes('@')) {
|
|
358
|
+
withheld.add(i + 1);
|
|
359
|
+
withheld.add(i + 2);
|
|
360
|
+
i += 2;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return withheld;
|
|
364
|
+
}
|
|
365
|
+
|
|
311
366
|
/**
|
|
312
367
|
* Everything the editor printed on a step that did not do what it was for.
|
|
313
368
|
*
|
|
@@ -336,6 +391,11 @@ interface Ran {
|
|
|
336
391
|
* softening the verdict. What it prints is the editor's own words, quoted and
|
|
337
392
|
* attributed to the stream they came off, and never rewritten — a harness that
|
|
338
393
|
* summarised them would be the same defect with a smaller radius.
|
|
394
|
+
*
|
|
395
|
+
* 🚨 With one exception, which is not the editor's words but the licence
|
|
396
|
+
* holder's (issue #1082): the licence line, which `licenceLines` names and
|
|
397
|
+
* which is replaced by one line saying it was withheld. Every other line is
|
|
398
|
+
* quoted as printed, in order.
|
|
339
399
|
*/
|
|
340
400
|
function editorSaid(ran: Ran): string[] {
|
|
341
401
|
const lines: string[] = [];
|
|
@@ -346,7 +406,21 @@ function editorSaid(ran: Ran): string[] {
|
|
|
346
406
|
const body = text.replace(/\s+$/, '');
|
|
347
407
|
if (body === '') continue;
|
|
348
408
|
lines.push(` the editor's ${stream}:`);
|
|
349
|
-
|
|
409
|
+
const printed = body.split('\n');
|
|
410
|
+
const withheld = licenceLines(printed);
|
|
411
|
+
for (let i = 0; i < printed.length; i++) {
|
|
412
|
+
if (!withheld.has(i)) {
|
|
413
|
+
lines.push(` | ${printed[i]}`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
// One note per run of withheld lines, in the place they were printed, so
|
|
417
|
+
// the quotation still reads in the editor's order and the omission is
|
|
418
|
+
// stated rather than left to look like an editor that said less.
|
|
419
|
+
let span = 1;
|
|
420
|
+
while (withheld.has(i + span)) span++;
|
|
421
|
+
lines.push(` ~ ${span} line(s) withheld here: the editor's licence block names the licence holder (issue #1082)`);
|
|
422
|
+
i += span - 1;
|
|
423
|
+
}
|
|
350
424
|
}
|
|
351
425
|
// Silence is a finding too, and it has to be stated rather than left to look
|
|
352
426
|
// like a harness that forgot to print. The card above is what an unstated one
|